Skip to content

fix(websocket): implement receive backpressure for WebSocketStream#5539

Open
GiHoon1123 wants to merge 1 commit into
nodejs:mainfrom
GiHoon1123:fix-issue-5503-websocketstream-backpressure
Open

fix(websocket): implement receive backpressure for WebSocketStream#5539
GiHoon1123 wants to merge 1 commit into
nodejs:mainfrom
GiHoon1123:fix-issue-5503-websocketstream-backpressure

Conversation

@GiHoon1123

Copy link
Copy Markdown

This relates to...

Fixes #5503

Rationale

WebSocketStream's readable side never applied backpressure. The ReadableStream was constructed without a pull algorithm, so consumer demand (desiredSize) was never observable, and both spec steps that say "Apply backpressure to the WebSocket" (constructor step 7, and the message-receive algorithm step 4) were TODO/no-op. Every parsed message was enqueued unconditionally regardless of whether the consumer was reading, so a slow (or absent) reader let the socket buffer an unbounded number of messages in memory — the issue's repro shows RSS growing from 75 MiB to 337 MiB with 200k unread 1 KiB messages, and the same amount fully flushed on the wire even though the client had read almost nothing.

The existing onSocketDataparser.write() === falsesocket.pause() / onParserDrainsocket.resume() pair only reflects the parser's own internal buffer draining as fast as frames can be parsed — it has nothing to do with consumer demand.

Changes

Two backpressure sources now gate the socket independently:

  • Parser buffer pressure (pre-existing): onSocketData pauses when parser.write() returns false; onParserDrain clears that flag.
  • Consumer demand (new): a pull algorithm on the ReadableStream re-checks on every reader.read(); #onMessage pauses the socket once desiredSize <= 0 after enqueueing.

The socket only resumes once both are clear (parser not congested and desiredSize > 0) — either source alone is enough to keep it paused.

While testing this I found a second, related bug: once close()/reader.cancel() starts the closing handshake, it needs to keep reading off the socket to see the peer's own Close frame. If the socket happened to be paused for receive backpressure at that moment (a very reachable state — any unread backlog leaves it paused), the peer's Close frame was never read and closed hung forever. Fixed by unconditionally resuming the socket once closing starts (#resumeSocketForClose) — there's no more reader to protect from backpressure at that point, so ignoring both backpressure sources above is intentional, not an oversight.

Features

N/A

Bug Fixes

Breaking Changes and Deprecations

N/A — WebSocketStream is still experimental (UNDICI-WSS warning). Consumers that read promptly see no behavior change; slow/absent consumers now correctly experience backpressure instead of unbounded buffering.

Status

The readable stream never had a pull algorithm and every message was
enqueued unconditionally, so a slow consumer let the underlying socket
buffer an unbounded number of messages in memory instead of pausing.

Track both the parser's own buffer pressure and the readable stream's
desiredSize, and only resume the socket once neither is backpressured.
Also resume unconditionally when closing/cancelling, since the closing
handshake needs to keep reading off the socket to see the peer's Close
frame - otherwise a socket paused for receive backpressure would leave
`closed` unresolved forever.

Fixes: nodejs#5503
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.

WebSocketStream: receive backpressure is not implemented — a slow consumer buffers every received message in memory

1 participant