From 77c4c7bdae02e4fd437702d849642578d6142fad Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:03:04 +0300 Subject: [PATCH] test(ws): walk the frames instead of sampling byte zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `035-recv-queue-overflow` read the socket into one buffer and looked for the close opcode at its first byte, so anything the server sent ahead of the close left it parsing a data frame's header as a control frame. And `ws_read_frame` dropped a header it read only half of: `ws_take` returns what it got, the caller discarded it, and every frame after a torn read came out as garbage — `ws_pushback` exists for that and was not used. Neither is why the test fails on Windows. It still reads `NULL` there while the handler reports 1013, and the reason is in the server: the overflow path tears the transport down with the peer's bytes still unread, which resets the connection and discards the close it had queued. That is the shape #288 gave HTTP/1 a lingering close for, and the WebSocket path has none. Recorded in dev/PLAN.md as open. --- dev/PLAN.md | 21 ++++++++++++ .../websocket/035-recv-queue-overflow.phpt | 32 ++++++++++++++----- tests/phpt/websocket/_ws_client.inc | 9 +++++- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/dev/PLAN.md b/dev/PLAN.md index efa43d1..50ea471 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -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. diff --git a/tests/phpt/websocket/035-recv-queue-overflow.phpt b/tests/phpt/websocket/035-recv-queue-overflow.phpt index a80dc1d..71ab8cb 100644 --- a/tests/phpt/websocket/035-recv-queue-overflow.phpt +++ b/tests/phpt/websocket/035-recv-queue-overflow.phpt @@ -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()) @@ -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"; diff --git a/tests/phpt/websocket/_ws_client.inc b/tests/phpt/websocket/_ws_client.inc index 6f17b0e..6831dcd 100644 --- a/tests/phpt/websocket/_ws_client.inc +++ b/tests/phpt/websocket/_ws_client.inc @@ -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;