Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions dev/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1653,3 +1653,24 @@ a release yet except the first two, which are fixed.
Left open: `errors_summed` in `telemetry/012` has never had a spread guard and
still has none, so its summation is proved only on a run where the six bad
requests did land on more than one worker. The file's header comment says so.

## A WebSocket close is lost when the peer is still sending (#303 fallout)

- [ ] **The 1013 close never reaches a client that overflowed the inbound
cap.** `websocket/035-recv-queue-overflow` fails on Windows with
`client saw close: NULL` while the handler side reports 1013 correctly, so
the decision is right and the frame does not arrive. The shape is the one
#287 named for HTTP/1: the client writes 24 frames, about 16 KiB, the server
caps at 8 KiB and stops reading, queues the close and tears the transport
down at `ws_session.c:1345` — and a socket closed with unread bytes in its
receive buffer is reset rather than finished, which discards what this side
has written. #288 gave HTTP/1 a lingering close for exactly this; the
WebSocket overflow path has none.

Not proved yet, and the cheap proof is the same one that settled #287: cut
the client's write to just past the cap, leaving nothing unread, and see
whether the close arrives. Windows shows it because a reset there is
immediate; Linux may be hiding it behind timing rather than avoiding it.

Found while making the group build on Windows at all (#303), where it is the
single failure out of 61 executed WebSocket tests.
32 changes: 24 additions & 8 deletions tests/phpt/websocket/035-recv-queue-overflow.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use function Async\await;
use function Async\delay;

require_once __DIR__ . '/../server/_free_port.inc';
require_once __DIR__ . '/_ws_client.inc';

$port = tas_free_port();
$config = (new HttpServerConfig())
Expand Down Expand Up @@ -85,18 +86,33 @@ $client = spawn(function () use ($port, $server) {
fwrite($fp, $frame);
}

/* Server must answer with CLOSE 1013 and drop the connection. */
/* Server must answer with CLOSE 1013 and drop the connection. The close
* is not promised to be the next thing on the wire — frames accepted
* before the cap are still being drained — so the stream is walked frame
* by frame rather than sampled at byte 0. */
$close_code = null;
$buf = '';
while (!feof($fp)) {
$chunk = fread($fp, 4096);
if ($chunk === false || $chunk === '') break;
$buf .= $chunk;
if (strlen($buf) >= 4 && (ord($buf[0]) & 0x0f) === 0x8) {
$close_code = unpack('n', substr($buf, 2, 2))[1];
$deadline = microtime(true) + 5;

while (microtime(true) < $deadline) {
$frame = ws_read_frame($fp);

if ($frame === null) {
if (feof($fp)) {
break;
}

delay(20);
continue;
}

if ($frame['opcode'] === 0x8) {
$close_code = strlen($frame['data']) >= 2
? unpack('n', substr($frame['data'], 0, 2))[1]
: 0;
break;
}
}

fclose($fp);

echo "client saw close: ", $close_code === 1013 ? '1013' : var_export($close_code, true), "\n";
Expand Down
9 changes: 8 additions & 1 deletion tests/phpt/websocket/_ws_client.inc
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@ function ws_write($fp, string $payload, bool $binary = false): void
function ws_read_frame($fp): ?array
{
$hdr = ws_take($fp, 2, false);
if (strlen($hdr) < 2) { return null; }

if (strlen($hdr) < 2) {
/* One byte of a header is still part of the next frame: without the
* pushback it is dropped and every frame after it reads as garbage. */
ws_pushback($fp, $hdr);

return null;
}

$opcode = ord($hdr[0]) & 0x0f;
$len = ord($hdr[1]) & 0x7f;
Expand Down
Loading