Visitar URL original
[oss-candidate] fix(websocket): reset the compressed flag when a message completes by askalf · Pull Request #1 · sprayberry-code/undici · GitHub
Skip to content
This repository was archived by the owner on Oct 20, 2026. It is now read-only.

[oss-candidate] fix(websocket): reset the compressed flag when a message completes - #1

Closed
askalf wants to merge 3 commits into
mainfrom
fix/websocket-stale-compressed-flag
Closed

askalf wants to merge 3 commits into
mainfrom
fix/websocket-stale-compressed-flag

Conversation

@askalf

@askalf askalf commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • ByteParser never cleared #info.compressed after delivering a permessage-deflate message, so the flag described the previous message while the next frame header was parsed.
  • The guard at lib/web/websocket/receiver.js:171 that rejects a continuation frame with no message in progress is conditioned on !this.#info.compressed, so it was skipped after any compressed message.
  • Result: a stray continuation frame arriving after a compressed message is decompressed and delivered to the application as an extra message, instead of failing the connection with 1002 as RFC 6455 §5.4 requires.
  • Fix: clear the flag in consumeFragments(), the one place where the message the flag describes is completed (both websocketMessageReceived call sites go through it).
  • 8 regression tests added to test/websocket/receiver-unit.js (plus 1 pre-existing test in the file, 9 total): 4 discriminate on this bug and 4 are declared controls.

Both arms below were measured at head 998d2a463b8c348f1c3029ba728a818e3bb423c7, with all 9 tests present in the file and only lib/web/websocket/receiver.js swapped between arms.

$ git checkout origin/main -- lib/web/websocket/receiver.js   # base arm: fix reverted, tests kept
$ node --test test/websocket/receiver-unit.js
✔ ByteParser rejects 64-bit payload lengths with a non-zero upper word (27.118782ms)
✖ ByteParser fails the connection on a continuation frame after a compressed message (119.98587ms)
✔ ByteParser fails the connection on a continuation frame after an uncompressed message (control) (105.086991ms)
✔ ByteParser still assembles a fragmented compressed message (control) (106.584768ms)
✖ ByteParser fails the connection on a continuation frame after a multi-fragment compressed message (157.444873ms)
✖ ByteParser fails the connection on a continuation frame separated from a compressed message by a control frame (155.700874ms)
✖ ByteParser fails the connection on a non-empty continuation frame after a compressed message (105.319193ms)
✔ ByteParser delivers two consecutive compressed messages (control) (106.370579ms)
✔ ByteParser keeps the compressed flag across a control frame inside a fragmented message (control) (154.134819ms)
ℹ tests 9
ℹ pass 5
ℹ fail 4
✖ failing tests:
✖ ByteParser fails the connection on a continuation frame after a compressed message (119.98587ms)
    actual: [ 'message:5', 'message:0' ],
    expected: [ 'message:5', 'abort' ],
✖ ByteParser fails the connection on a continuation frame after a multi-fragment compressed message (157.444873ms)
    actual: [ 'message:5', 'message:0' ],
    expected: [ 'message:5', 'abort' ],
✖ ByteParser fails the connection on a continuation frame separated from a compressed message by a control frame (155.700874ms)
    actual: [ 'message:5', 'ping', 'message:0' ],
    expected: [ 'message:5', 'ping', 'abort' ],
✖ ByteParser fails the connection on a non-empty continuation frame after a compressed message (105.319193ms)
    actual: [ 'message:5', 'message:5' ],
    expected: [ 'message:5', 'abort' ],

$ git checkout HEAD -- lib/web/websocket/receiver.js   # fixed arm, head 998d2a46
$ node --test test/websocket/receiver-unit.js
✔ ByteParser rejects 64-bit payload lengths with a non-zero upper word (17.284235ms)
✔ ByteParser fails the connection on a continuation frame after a compressed message (113.10889ms)
✔ ByteParser fails the connection on a continuation frame after an uncompressed message (control) (102.501077ms)
✔ ByteParser still assembles a fragmented compressed message (control) (104.682335ms)
✔ ByteParser fails the connection on a continuation frame after a multi-fragment compressed message (156.259085ms)
✔ ByteParser fails the connection on a continuation frame separated from a compressed message by a control frame (155.52651ms)
✔ ByteParser fails the connection on a non-empty continuation frame after a compressed message (108.682308ms)
✔ ByteParser delivers two consecutive compressed messages (control) (107.327427ms)
✔ ByteParser keeps the compressed flag across a control frame inside a fragmented message (control) (154.650232ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0
ℹ duration_ms 1309.719693

Upstream

  • Repository: nodejs/undici
  • Default branch: main
  • Base sha: 2a783e77dee2212366dc000e1fe0bd9fb6c919d4
  • Files: lib/web/websocket/receiver.js (ByteParser.consumeFragments, +5 lines), test/websocket/receiver-unit.js (+227 lines, 8 new tests)
  • Diff at head 998d2a46: 2 files changed, 232 insertions(+), 0 deletions(-)

Bug

When a permessage-deflate message is received, run() sets this.#info.compressed = rsv1 !== 0 at receiver.js:191 while parsing the frame header, and consumeFragments() hands the assembled message to the application. The flag is never reset, so it stays true until the next text or binary frame header overwrites it at line 191. A continuation frame (opcode 0x0) carries no such assignment, so between a completed compressed message and the next data frame, #info.compressed is stale.

The guard at receiver.js:171 reads isContinuationFrame(opcode) && this.#fragments.length === 0 && !this.#info.compressed. The !this.#info.compressed term exists because a compressed fragmented message legitimately drains #fragments on each decompressed chunk while remaining in progress. But with the flag stale, that same term suppresses the guard for a continuation frame that follows a completed message. The parser then treats the stray frame as message data, feeds it to the inflater, and emits a second message event. Trigger: any peer (or on-path attacker able to inject a frame) that sends a continuation frame after a compressed message on a connection where permessage-deflate was negotiated. Blast radius: every undici WebSocket client that negotiates permessage-deflate — the default when the server offers it — receives spurious application messages on a connection the spec requires to be failed. It is a protocol-conformance hole, and the extra message is attacker-chosen data delivered as though it were a real message.

Repro

repro.js at the repository root, driving ByteParser directly (no node_modules needed). Historical transcript, measured at the fix commit a3cf4645 against base 2a783e77 — the script is deliberately not part of the diff, and the identical scenario is pinned at the current head by the first regression test below, whose verbatim both-arm output is in ## Summary:

$ node repro.js   # on base 2a783e77
events: ["message(len=5)","message(len=0)"]
messages delivered: 2 (expected 1)
connection failed:  false (expected true)
RESULT: BUG — stray continuation frame accepted after a compressed message
exit=1

$ node repro.js   # with fix
events: ["message(len=5)","abort"]
messages delivered: 1 (expected 1)
connection failed:  true (expected true)
RESULT: OK — stray continuation frame rejected
exit=0

The repro writes one complete compressed text frame (FIN|RSV1, opcode 0x1, deflate-raw payload with the trailing 00 00 ff ff stripped) followed by a bare 80 00 continuation frame.

Fix

   consumeFragments () {
     const fragments = this.#fragments
 
+    // The compressed flag describes the message that is being completed here.
+    // It must not survive into the next message, otherwise a stray
+    // continuation frame is accepted instead of failing the connection.
+    this.#info.compressed = false
+
     if (fragments.length === 1) {

consumeFragments() is the correct place: it is called from exactly the two websocketMessageReceived sites (receiver.js:258 uncompressed, receiver.js:290 compressed), i.e. every point where a message completes, and it already owns the rest of the per-message state reset (#fragments, #fragmentsBytes). Clearing the flag there keeps the reset with the state it belongs to and costs one assignment per message.

Alternatives rejected:

  • Clear the flag at the two websocketMessageReceived call sites. Same effect, duplicated in two places, and a future third completion path would miss it.
  • Drop !this.#info.compressed from the guard at line 171. Reverts the fix from permessage-deflate decompression support in websocket nodejs/undici#3263 and breaks compressed fragmented messages, which drain #fragments while still in progress — the ...still assembles a fragmented compressed message (control) test is the control that would catch that.
  • Track a separate "message in progress" boolean. Larger change, new state to keep in sync, no behaviour this one-line reset does not already give.

Test evidence

9 tests now live in test/websocket/receiver-unit.js: 1 pre-existing, 4 added with the fix (3 in a3cf4645, 1 in ffb1247b), and 4 added by the independent verification pass (998d2a46). All follow the file's existing unit-level style: construct ByteParser with a stub handler, no server. The shared createRecordingHandler() records every onMessage/onPing/abort/close the parser produces, so the assertions compare full event sequences rather than a single flag.

# Test Origin Discriminates? Base arm Fixed arm
T1 ByteParser rejects 64-bit payload lengths with a non-zero upper word pre-existing no PASS PASS
T2 ...continuation frame after a compressed message fix commit yes FAIL — ['message:5','message:0'] vs expected ['message:5','abort'] PASS
T3 ...continuation frame after an uncompressed message (control) fix commit no — control PASS PASS
T4 ...still assembles a fragmented compressed message (control) fix commit no — control PASS PASS
T5 ...continuation frame after a multi-fragment compressed message fix commit ffb1247b yes FAIL — ['message:5','message:0'] vs expected ['message:5','abort'] PASS
T6 ...continuation frame separated from a compressed message by a control frame verification 998d2a46 yes FAIL — ['message:5','ping','message:0'] vs expected ['message:5','ping','abort'] PASS
T7 ...non-empty continuation frame after a compressed message verification 998d2a46 yes FAIL — ['message:5','message:5'] vs expected ['message:5','abort'] PASS
T8 ByteParser delivers two consecutive compressed messages (control) verification 998d2a46 no — control PASS PASS
T9 ByteParser keeps the compressed flag across a control frame inside a fragmented message (control) verification 998d2a46 no — control PASS PASS

Verbatim base/fixed output for all 9 is in the ## Summary console block above.

What each control controls for, one thing each:

  • T3 — the fragments.length === 0 term still fires when the flag was never set, so a future change cannot make the guard vacuous and still pass.
  • T4 — legal compressed fragmentation, the reason the !compressed term exists at all, is not broken by the reset.
  • T8 — the reverse direction: clearing the flag on completion must not stop the next frame from being read as compressed (two consecutive compressed messages both arrive intact).
  • T9 — the flag must survive a control frame interleaved into a message still in progress; it pins that the reset happens at completion, not at any control frame. T4 and T9 differ in that only T9 interleaves a control frame mid-message.

The four discriminating tests differ in the code path that reaches the guard: T2 the single-fragment consumeFragments() shortcut, T5 the concatenating branch, T6 a control frame between the message and the stray continuation, T7 a stray continuation carrying a decompressible payload (proving the failure is on the opcode, not incidentally on a zlib error).

Linter: run and clean. eslint@9 + neostandard@0.13 (the linter eslint.config.js configures, matching the repo's npm run lint) were installed into a scratch directory and linked in, then:

$ npx eslint --no-config-lookup --config <neostandard> test/websocket/receiver-unit.js lib/web/websocket/receiver.js
$ echo $?
0

Zero errors, zero warnings on both touched files. The scratch node_modules symlink was removed afterwards; git status --porcelain is empty at head.

Verification method

executed — Node.js v24.19.0 on Linux, via node --test (the repo's test:websocket script uses borp, which needs node_modules; node --test runs the same file unmodified). Both arms were run at the final head by reverting only lib/web/websocket/receiver.js to origin/main with all 9 tests in place, then restoring with git checkout HEAD -- <file> (not a cp backup, which would leave the index dirty). Each arm was guarded by grep -c 'compressed = false' lib/web/websocket/receiver.js (0 = base, 1 = head) in the same shell call as the test run, so no arm could silently run the wrong source.

Two passes at two heads:

Pass Head Tests Head arm Base arm
Hunt ffb1247b 5 5 pass 3 pass / 2 fail
Independent verification 998d2a46 9 9 pass 5 pass / 4 fail

In both passes the base-arm passers are exactly the declared controls, and every non-control test fails on base.

Production source is byte-identical across both heads: git diff ffb1247b 998d2a46 -- lib/ is empty. The verification pass added tests only.

Head integrity measured, not inferred: git status --porcelain empty and git diff HEAD --stat empty after the final commit; git diff origin/main..HEAD --stat = 2 files, +232/-0.

Fork CI: gh pr checks 1 --repo askalf/undici → no checks reported on the 'fix/websocket-stale-compressed-flag' branch. GitHub Actions has never been enabled on askalf/undici, so this is an absence of a signal, not a failure — there are no non-green jobs to explain. Upstream CI, including the Autobahn conformance suite, which is the authoritative check for a protocol-conformance change like this one, has not run against this branch and remains outstanding.

Prior art

  • gh search prs --repo nodejs/undici "continuation frame" --limit 15 → fix websocket empty fragments parsing nodejs/undici#5424 "fix websocket empty fragments parsing" (CLOSED, empty-fragment parsing, superseded by merged commit c5ed7875), feat: implement websockets nodejs/undici#1795 "feat: implement websockets" (merged, the original implementation). Neither touches the compressed-flag lifecycle.
  • gh search prs --repo nodejs/undici "permessage-deflate" --limit 15 → open fix(websocket): validate inline payload with current opcode nodejs/undici#5432 "validate inline payload with current opcode" and open fix(websocket): enforce deflate remaining payload budget nodejs/undici#5435 "enforce deflate remaining payload budget". Both are maxPayloadSize accounting in the same file; neither resets or reads #info.compressed outside the existing sites. No conflict, no overlap.
  • gh search prs --repo nodejs/undici "compressed flag reset" --limit 10 → [].
  • gh search issues --repo nodejs/undici "continuation frame" --limit 15 → []; gh search issues --repo nodejs/undici "unexpected continuation frame" → [].
  • git log -S'!this.#info.compressed' -- lib/web/websocket/receiver.js → 5a564bd9 "permessage-deflate decompression support in websocket (permessage-deflate decompression support in websocket nodejs/undici#3263)", which introduced the guard term. The stale-flag hole has been present since that commit.
  • git log --oneline -12 -- lib/web/websocket/receiver.js → the last twelve changes are fragment/limit/overflow fixes (c5ed7875, 32dbf0b3, b4c287b3, 84235c62, …), i.e. exactly this bug class, and several are by the maintainer who owns this file.

Policy

  • CONTRIBUTING.md, CODE_OF_CONDUCT.md and .github/PULL_REQUEST_TEMPLATE.md contain no mention of AI, LLM, generated or agent contributions — grep for ai|llm|generated|agent|copilot|chatgpt|claude returns only unrelated hits (deps/llhttp/...). Policy is silent, not a ban. There is no AGENTS.md and no .github/CONTRIBUTING*.
  • DCO, not a CLA. .github/PULL_REQUEST_TEMPLATE.md: "You will also need to ensure that your contribution complies with the Developer's Certificate of Origin, outlined in CONTRIBUTING.md", with the checkbox "- [ ] I have read and agreed to the [Developer's Certificate of Origin][cert]". CONTRIBUTING.md:354: "Developer's Certificate of Origin 1.1 — By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file...". 13 of the last 30 upstream commits carry a Signed-off-by trailer, so it is not mechanically enforced — the operator decides whether to add -s when submitting; that certification is theirs to make, not ours.
  • PR template requires: "1. Include tests for any bug fixes or new features. 2. Update documentation if relevant. 3. Ensure that tests and linting pass." Tests are included (9 in the touched file, all green at head); no documented behaviour changes, so no docs update; linting now runs clean — see ## Test evidence.
  • No changelog or changeset file is required by the repo (releases are automated from main per CONTRIBUTING.md:170).

Disclosure facts for the operator

Plain facts, for you to word your own disclosure:

  • An AI agent found this bug by reading lib/web/websocket/receiver.js and tracing the lifetime of #info.compressed against the guard at line 171. It was not reported by a user or taken from an issue.
  • The AI wrote the one-line fix, the comment above it, and all 8 regression tests.
  • A second, independent AI run adversarially verified the fix: it rebuilt the boundary ledger from the diff without reading the existing write-up, added 4 tests (2 of which fail on base and 2 of which are declared controls), and re-ran both arms. It found no defect in the fix. It did correct one claim in the earlier write-up: the row for "control frame between the compressed message and the stray continuation" had been dismissed as not varying with the diff, but it does vary — that case is now test T6 and it fails on base.
  • The AI executed both arms (base and fixed) on Linux with Node.js v24.19.0 and captured the transcripts quoted above verbatim.
  • The AI ran the repo's linter (eslint + neostandard) on both touched files: clean.
  • The AI did not run the borp-based test:websocket script (the full websocket suite), and did not run the Autobahn conformance suite.
  • Nothing was posted to nodejs/undici by the AI: no issues, comments, reviews or PRs. All work happened on the askalf/undici fork.
  • The DCO sign-off, if you add one, is your certification — the AI did not add a Signed-off-by trailer.

Boundaries

One row per predicate, comparison or truthiness check the diff adds or changes. The diff adds a single unconditional assignment, so the rows below enumerate the state that assignment feeds — the guard at receiver.js:171, the branch at receiver.js:248, the branch inside consumeFragments(), and the two ByteParser construction sites. Ledger rebuilt from the diff by the verification pass; rows 5, 8, 9 and 12–14 were re-derived and their previous dispositions re-tested rather than inherited.

# Predicate / input Value at boundary Fixed-code behaviour Pinned by
1 #info.compressed after an uncompressed message completes false (was already false) Unchanged; guard at :171 fires for a later stray continuation frame T3 (control)
2 #info.compressed after a compressed message completes false (was true) Guard at :171 now fires; connection failed 1002 T2
3 consumeFragments() single-fragment branch (fragments.length === 1, early return) assignment precedes the return, so it runs Flag cleared T2
4 consumeFragments() concat branch (fragments.length > 1) assignment precedes the branch Flag cleared T5
5 consumeFragments() with fragments.length === 0 Unreachable. Measured directly: calling consumeFragments() on a fresh parser returns an empty Buffer (fragments.length === 1 is false, so it takes the concat branch with #fragmentsBytes === 0) — no throw, no crash. Both internal call sites (:258, :290) are immediately preceded by a writeFragments() that pushes, so the count is ≥1 at every real call Flag cleared; benign either way Unreachable — probed, not assumed; rows 3 and 4 cover both reachable branch outcomes
6 Compressed message still in progress (!fin), guard at :171 consumeFragments() has not run, flag stays true Continuation frame accepted, message assembled — the behaviour the !compressed term exists for, preserved T4 (control)
7 Branch at :248 (!this.#info.compressed) for the frame after a compressed message Flag now false, but line 191 overwrites it from rsv1 before this branch is reached for any text/binary frame Unchanged for text/binary frames; for a continuation frame the guard at :171 now rejects first, so :248 is not reached T2, T5 (the frame never reaches :248); T8 pins that a following compressed frame still takes the :248 else-branch
8 Continuation frame with a non-empty payload after a compressed message Guard at :171 runs on the header, before payloadLength is consumed Connection failed 1002 before the payload is read — the failure is on the opcode, not a downstream zlib error T7 (previously "not separately pinned"; now a real test, and it fails on base)
9 Control frame (ping/pong/close) between a compressed message and a continuation frame Control frames take parseControlFrame at :244 and never assign #info.compressed (only the isTextBinaryFrame branch at :189–192 does) Flag stays false; the stray continuation frame is still rejected after the ping is answered T6 (previously dismissed as "no diff behaviour varies"; that was wrong — it fails on base, ['message:5','ping','message:0'])
10 permessage-deflate not negotiated, rsv1 set Rejected earlier at :135 ("Expected RSV1 to be clear"), never reaches the diff Unchanged Pre-existing guard at :135, unreachable from the diff
11 #info before any message ({}, compressed === undefined) consumeFragments() cannot run before a message completes Unreachable Unreachable — consumeFragments() is only called from the two completion sites
12 Two compressed messages back to back (reverse direction of the reset) Flag cleared at the end of message 1, re-set from rsv1 at :191 for message 2 Both messages delivered intact; the reset does not suppress later compression T8 (control)
13 Control frame interleaved inside a fragmented compressed message consumeFragments() has not run, so the flag must survive the control frame Flag stays true; the message completes normally after the ping T9 (control)
14 Second ByteParser construction site — WebSocketStream (lib/web/websocket/stream/websocketstream.js:265) vs WebSocket (lib/web/websocket/websocket.js:497) Both construct the same ByteParser class with the same parsedExtensions; the flag and consumeFragments() live entirely inside ByteParser Identically fixed — there is no per-consumer copy of this state to miss Covered by construction: the tests drive ByteParser directly, i.e. below both call sites, so they pin the behaviour both consumers get

Suggested upstream PR title

fix(websocket): reset the compressed flag when a message completes

ByteParser kept #info.compressed set after delivering a permessage-deflate
message. The guard that rejects a continuation frame arriving with no message
in progress is skipped when that flag is set, so a stray continuation frame
following a compressed message was decompressed and delivered as an extra
message instead of failing the connection with 1002.

Clear the flag in consumeFragments(), where the message it describes is
completed.
@askalf askalf added the oss-candidate Sprayberry Code candidate for upstream label Sep 15, 2026
@askalf
askalf marked this pull request as ready for review September 15, 2026 01:14
…pressed messages

Adds four cases around the compressed-flag reset: a stray continuation
separated from the completed message by a control frame, a stray
continuation that carries a decompressible payload, two consecutive
compressed messages, and a control frame interleaved into a fragmented
compressed message.
@askalf askalf added the verified Adversarially verified by a fresh run label Sep 15, 2026
@askalf

askalf commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Verification

Independent adversarial verification by a fresh run, at head 998d2a463b8c348f1c3029ba728a818e3bb423c7. The ## Boundaries ledger was rebuilt from the diff rather than read from the body, and every disposition of the form "not separately pinned" was re-derived and re-tested rather than inherited. Node.js v24.19.0 on Linux, node --test.

Result: the fix holds. Production source was not touched by this pass — git diff ffb1247b 998d2a46 -- lib/ is empty. Four tests were added, two of which discriminate on the bug.

One claim in the previous body was wrong, and it is now a test

Ledger row 9 — a control frame arriving between the completed compressed message and the stray continuation frame — had been dismissed as "not separately pinned — no diff behaviour varies on it". That is incorrect. Control frames never assign #info.compressed (only the isTextBinaryFrame branch at receiver.js:189-192 does), so an interleaved ping does not launder the stale flag: on base the stray continuation is still wrongly accepted after the ping is answered. It is now test T6 and it fails on base with ['message:5','ping','message:0']. Row 8 (a stray continuation carrying a non-empty, decompressible payload) was likewise promoted from a dismissal to test T7, which also fails on base — proving the connection fails on the frame's opcode rather than incidentally on a zlib error.

Row 5 (consumeFragments() with zero fragments) was probed, not assumed: calling it on a fresh parser returns an empty Buffer with no throw, and both internal call sites (:258, :290) are immediately preceded by a writeFragments() that pushes, so the count is ≥1 at every real call. Unreachable as claimed, and benign if it were reached.

Both arms at this head, verbatim

All 9 tests present in the file; only lib/web/websocket/receiver.js swapped between arms, each arm guarded by grep -c 'compressed = false' in the same shell call (0 = base, 1 = head) so neither arm could silently run the wrong source.

$ git checkout origin/main -- lib/web/websocket/receiver.js   # base arm
$ node --test test/websocket/receiver-unit.js
✔ ByteParser rejects 64-bit payload lengths with a non-zero upper word (27.118782ms)
✖ ByteParser fails the connection on a continuation frame after a compressed message (119.98587ms)
✔ ByteParser fails the connection on a continuation frame after an uncompressed message (control) (105.086991ms)
✔ ByteParser still assembles a fragmented compressed message (control) (106.584768ms)
✖ ByteParser fails the connection on a continuation frame after a multi-fragment compressed message (157.444873ms)
✖ ByteParser fails the connection on a continuation frame separated from a compressed message by a control frame (155.700874ms)
✖ ByteParser fails the connection on a non-empty continuation frame after a compressed message (105.319193ms)
✔ ByteParser delivers two consecutive compressed messages (control) (106.370579ms)
✔ ByteParser keeps the compressed flag across a control frame inside a fragmented message (control) (154.134819ms)
ℹ tests 9
ℹ pass 5
ℹ fail 4
✖ failing tests:
✖ ByteParser fails the connection on a continuation frame after a compressed message (119.98587ms)
    actual: [ 'message:5', 'message:0' ],
    expected: [ 'message:5', 'abort' ],
✖ ByteParser fails the connection on a continuation frame after a multi-fragment compressed message (157.444873ms)
    actual: [ 'message:5', 'message:0' ],
    expected: [ 'message:5', 'abort' ],
✖ ByteParser fails the connection on a continuation frame separated from a compressed message by a control frame (155.700874ms)
    actual: [ 'message:5', 'ping', 'message:0' ],
    expected: [ 'message:5', 'ping', 'abort' ],
✖ ByteParser fails the connection on a non-empty continuation frame after a compressed message (105.319193ms)
    actual: [ 'message:5', 'message:5' ],
    expected: [ 'message:5', 'abort' ],

$ git checkout HEAD -- lib/web/websocket/receiver.js   # fixed arm
$ node --test test/websocket/receiver-unit.js
✔ ByteParser rejects 64-bit payload lengths with a non-zero upper word (17.284235ms)
✔ ByteParser fails the connection on a continuation frame after a compressed message (113.10889ms)
✔ ByteParser fails the connection on a continuation frame after an uncompressed message (control) (102.501077ms)
✔ ByteParser still assembles a fragmented compressed message (control) (104.682335ms)
✔ ByteParser fails the connection on a continuation frame after a multi-fragment compressed message (156.259085ms)
✔ ByteParser fails the connection on a continuation frame separated from a compressed message by a control frame (155.52651ms)
✔ ByteParser fails the connection on a non-empty continuation frame after a compressed message (108.682308ms)
✔ ByteParser delivers two consecutive compressed messages (control) (107.327427ms)
✔ ByteParser keeps the compressed flag across a control frame inside a fragmented message (control) (154.650232ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0
ℹ duration_ms 1309.719693

The 5 base-arm passers are exactly the 4 declared controls plus the pre-existing test. Every non-control test fails on base.

Controls added by this pass

Both were run against base before being committed, confirmed to pass on both arms, and named (control) in the test name accordingly. They control for different things:

  • ByteParser delivers two consecutive compressed messages (control) — the reverse direction of the reset: clearing the flag on completion must not stop the next frame from being read as compressed.
  • ByteParser keeps the compressed flag across a control frame inside a fragmented message (control) — the flag must survive a control frame interleaved into a message still in progress, pinning that the reset happens at completion and not at any control frame.

Linter — the previous body's outstanding item, now closed

eslint@9 + neostandard@0.13 (the linter eslint.config.js configures) installed into a scratch directory and linked in:

$ npx eslint --no-config-lookup --config <neostandard> test/websocket/receiver-unit.js lib/web/websocket/receiver.js
$ echo $?
0

Zero errors, zero warnings on both touched files. The scratch symlink was removed; git status --porcelain is empty at head.

Behaviour outside the stated bug

The diff is a single unconditional assignment at the top of consumeFragments(). Both ByteParser construction sites — websocket.js:497 and stream/websocketstream.js:265 (WebSocketStream) — share the same class and the same private state, so both consumers are fixed identically; there is no per-consumer copy of the flag to miss. The tests drive ByteParser directly, i.e. below both call sites. No behaviour changes outside the guard at :171 and the completion path.

Not covered by this verification

  • Fork CI: gh pr checks 1 --repo askalf/undici → no checks reported. Actions have never been enabled on askalf/undici — absence of signal, not failure. There are no non-green jobs to explain.
  • The borp-based test:websocket script (full websocket suite) was not run — it requires node_modules.
  • The Autobahn conformance suite has not run against this branch and remains outstanding. For a protocol-conformance change it is the authoritative check, and only upstream CI runs it.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: APPROVE — ready for the operator to submit.

No blocking issues found in the live-head diff. I traced the changed reset at lib/web/websocket/receiver.js:372-376 through both completion paths: it clears stale per-message compression state before either the single-fragment early return or concatenating return, while preserving compression state for an incomplete fragmented message. The regression cases exercise both branches, a control-frame interleave, non-empty stray continuations, and the reverse consecutive-compressed-message case; the PR body supplies measured base/fixed evidence (4 discriminating failures on base, 9/9 passing at head).

What's good: the change is narrowly scoped, the tests validate the observable protocol outcome rather than internal state, and the facts sheet documents the unrun Autobahn/full-suite coverage candidly. Fork CI reports no checks for this branch (absence of a signal, not a failing check). I also checked the required facts sections, base implementation at 2a783e77, contribution policy, commit messages, and upstream prior-art searches; no duplicate open upstream PR was identified.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

Verdict: No blocking issues found; independently confirmed the bug and the fix at head 998d2a46.

What I checked

  • Cloned the fork, checked out 998d2a4 (HEAD), ran node --test test/websocket/receiver-unit.js: 9/9 pass, matching the PR body exactly.
  • Reverted only lib/web/websocket/receiver.js to origin/main (base arm, tests kept from HEAD): 5 pass / 4 fail, and the four failing tests are exactly the four the PR body names, with identical actual/expected arrays. This independently reproduces the bug — I did not just trust the pasted console output.
  • Read lib/web/websocket/receiver.js:171 (the guard), :189-191 (the only write site for #info.compressed, gated on isTextBinaryFrame(opcode)), and :369-397 (consumeFragments(), where the fix adds the reset at :375). The trace holds: a continuation frame never reaches :191, so nothing else clears the flag between one message completing and the next frame's header being parsed. consumeFragments() is the only code path executed at message completion for both the uncompressed branch (:258) and the compressed branch (:290), so it's the correct single choke point — the two websocketMessageReceived call sites the PR body considered as alternatives would have needed the same fix duplicated twice for no benefit.
  • Grepped nodejs/undici open/closed PRs and issues for "compressed flag", "continuation frame", "stale compressed" — no prior art on this exact defect. PR nodejs#5432 and nodejs#5435 touch the same file but are maxPayloadSize-accounting fixes; confirmed via diff that neither reads/writes #info.compressed.
  • Checked git log on lib/web/websocket/receiver.js and test/websocket/receiver-unit.js: both are actively maintained (12+ prior fragment/limit fixes on the source file, one prior fix on the unit-test file itself — 84235c62, "Fix websocket 64-bit length overflow" — which is the same file, same test/websocket/receiver-unit.js unit-style, and touches the exact same guard region). This PR's shape (isolated ByteParser unit test, no server/client harness) matches that precedent well.
  • gh pr checks 1 reports "no checks reported" — Actions are not enabled on this fork, consistent with prior candidates from this operator.

Boundaries ledger (rebuilt from the diff)

# Predicate/state at receiver.js:171,191,375 Input Fixed behavior Test that pins it
1 #info.compressed after a completed compressed message, next frame is continuation stray continuation, empty payload abort (1002) "...after a compressed message"
2 same, but message was multi-fragment compressed (concat branch of consumeFragments, not the fragments.length===1 shortcut) stray continuation after 2-fragment compressed msg abort "...after a multi-fragment compressed message"
3 same, but a control frame (ping) intervenes between message completion and the stray continuation ping, then stray continuation abort (ping does not launder the flag) "...separated...by a control frame"
4 same, but stray continuation carries a non-empty, decompressible payload inline payload deflated abort on opcode, not a zlib error "...non-empty continuation frame..."
5 #info.compressed after an uncompressed completed message (control, flag was never true) stray continuation abort (unchanged behavior — this predicate was never affected) "...after an uncompressed message (control)"
6 continuation during an in-progress fragmented compressed message (#fragments.length > 0, so the 171 guard's second conjunct is false regardless of the flag) legal continuation message delivered "still assembles a fragmented compressed message (control)"
7 two consecutive compressed messages — flag must clear at #1's completion and be re-set correctly for nodejs#2 by :191 2nd compressed message both delivered "delivers two consecutive compressed messages (control)"
8 control frame inside (not after) a fragmented compressed message — flag must not be cleared by an interior control frame, only by completion ping mid-fragment fragmented message still assembles "keeps the compressed flag across a control frame inside a fragmented message (control)"

All 8 reachable rows I could construct from the diff are covered, and I confirmed row 1–4 are genuinely discriminating (fail on base, per the reproduction above) and rows 5–8 are true controls (pass on both arms for a reason connected to the diff, not vacuously — e.g. row 5 exercises the same guard with compressed structurally false throughout, row 7 is the reverse direction of the fix, row 8 pins that the reset point is completion and not "any control frame").

One row not covered by a unit test, but not a gap I'd block on: WebSocketStream (lib/web/websocket/stream/websocketstream.js:265) and WebSocket (lib/web/websocket/websocket.js:497) both construct their own ByteParser, but the fixed state (#info.compressed) is private to ByteParser itself — neither call site can observe or influence it. The PR body's row 14 argument is correct on inspection; I don't think this needs its own test, since it would just be receiver-unit.js's existing tests run through a second constructor call site with no different code in between. This is a judgement call the ticket asked about explicitly, and I don't have a specific execution path where the two front-ends diverge.

Idiom / maintainer's-eye notes

  • Test style: receiver-unit.js is intentionally a bare ByteParser unit test (stub handler, no real socket/server), which matches the file's one pre-existing test and its only prior fix (84235c62). Good choice over adding this to test/websocket/fragments.js or continuation-frames.js, which use a real ws server and would make the discriminating assertions much slower and noisier.
  • The (control) suffix naming convention for both-arms-green tests is not an existing upstream idiom (I found no prior instance of it anywhere else in test/) — it's fork-local practice. Harmless and self-documenting, but a maintainer seeing it for the first time may ask about it or just accept it silently; not a blocking concern either way.
  • PR title fix(websocket): reset the compressed flag when a message completes matches the conventional-commit style used by the other recent websocket: fixes in this file's history (e.g. fix(websocket): enforce max payload size across fragments, fix(websocket): add maxDecompressedMessageSize limit...).
  • No CHANGELOG.md exists in this repo to update, and the PR body follows the repo's own PULL_REQUEST_TEMPLATE.md shape (Rationale / Changes / Bug Fixes) closely, matching nodejs#5432/nodejs#5435, two other currently-open PRs against this same file.
  • Autobahn (the authoritative conformance suite for a protocol-correctness change like this) has not run — the fork has GitHub Actions disabled, so only the added unit tests provide coverage. The PR body already discloses this; I'd flag it as the one thing I'd want resolved before treating this as fully validated, but it's an infrastructure gap, not a defect in the diff.

What's good

  • Single, minimal production line (receiver.js:375), correctly placed at the one code path both message-completion routes funnel through.
  • Every added test is deliberately labeled control vs. discriminating, and I verified both arms actually behave that way — no vacuous "both green" tests slipped through here.
  • The non-empty-payload continuation test (row 4) is a nice touch that most contributors wouldn't have bothered with; it rules out the alternative explanation ("fails because zlib chokes on garbage input" vs. "fails because the opcode is rejected outright").

SECOND READ: READY

@askalf askalf added the ready-for-operator Gated; operator submits upstream label Sep 15, 2026
@askalf

askalf commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Upstream submission complete.

@askalf askalf closed this Sep 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

oss-candidate Sprayberry Code candidate for upstream ready-for-operator Gated; operator submits upstream submitted verified Adversarially verified by a fresh run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants