Skip to content

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

Open
askalf wants to merge 4 commits into
nodejs:mainfrom
askalf:fix/websocket-stale-compressed-flag
Open

askalf wants to merge 4 commits into
nodejs:mainfrom
askalf:fix/websocket-stale-compressed-flag

Conversation

@askalf

@askalf askalf commented Sep 15, 2026

Copy link
Copy Markdown

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

Decisions

consumeFragments() is the correct place to clear #info.compressed: 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 #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.

Not run: the borp-based test:websocket script (the full websocket suite, needs node_modules) and the Autobahn conformance suite — node --test on the touched file was used instead, covering the same code path unmodified.

AI assistance: this bug was found and the fix and tests were drafted with AI tooling in my workflow; the tests and checks above were executed as pasted. I'm responsible for the change and will handle review feedback.

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.
…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.

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@codecov-commenter

codecov-commenter commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.53%. Comparing base (bfea020) to head (d505281).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #5827   +/-   ##
=======================================
  Coverage   93.53%   93.53%           
=======================================
  Files         110      110           
  Lines       39773    39778    +5     
=======================================
+ Hits        37200    37207    +7     
+ Misses       2573     2571    -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants