fix(websocket): defer close()/fail() events to a queued task when CONNECTING - #5741
Open
yfwmaniish wants to merge 2 commits into
Open
fix(websocket): defer close()/fail() events to a queued task when CONNECTING#5741yfwmaniish wants to merge 2 commits into
yfwmaniish wants to merge 2 commits into
Conversation
…NECTING When close() (or an internal failure) is triggered while the socket is still CONNECTING, failWebsocketConnection() invoked handler.onSocketClose() synchronously, so the 'error' and 'close' events fired during the call to close() itself instead of afterward. For an already-established connection this doesn't happen, since onSocketClose is naturally invoked async via the underlying socket's 'close' event; CONNECTING was the one path that took a synchronous shortcut. Per the WHATWG WebSocket spec's "handle connection close" algorithm, the readyState transition and both events belong inside a single queued task. Defer the onSocketClose() call itself (via process.nextTick, matching the project's existing convention) at the one synchronous call site, which fixes both WebSocket and WebSocketStream (they share this code path) with a single centralized change. This also surfaces a related ordering bug in WebSocketStream's abort handling: #handshakeAborted was set *after* calling failWebsocketConnection, so it never actually gated the nested onSocketClose() call, and the opened/closed promises were settled by whichever synchronous rejection happened to run first rather than by the spec-mandated abort reason. Setting the flag before failing the connection makes that correct regardless of timing. Fixes nodejs#4741
… the task The WHATWG spec's "queue a task" is a macrotask, not a microtask. process.nextTick drains before the event loop's I/O/check phases, so it doesn't carry the same task-boundary semantics as a real queued task, per domenic's review comment on the earlier attempt at this fix (nodejs#4745). setImmediate matches that semantics correctly.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5741 +/- ##
==========================================
- Coverage 93.47% 93.45% -0.02%
==========================================
Files 110 110
Lines 38908 38916 +8
==========================================
+ Hits 36368 36370 +2
- Misses 2540 2546 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Author
|
The Node.js 26 job failures across all platforms are |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4741.
The bug
close()(and the internal "fail the WebSocket connection" path) callshandler.onSocketClose()synchronously when the socket is stillCONNECTING, soerror/closefire duringclose()instead of after itreturns. For an already-established connection this doesn't happen —
onSocketCloseis wired to the real socket's'close'event, which isnaturally async.
CONNECTINGis the one path with no real socket yet, so ittook a synchronous shortcut instead.
The fix
lib/web/websocket/connection.js,failWebsocketConnection: whenisConnecting(handler.readyState), queue theonSocketClose()call withsetImmediateinstead of calling it inline. This is the single call sitethat both
WebSocketandWebSocketStreamshare for this path, so onechange fixes both.
I also removed the
// TODO: process.nextTickcomment that was sittingdirectly above the
closeevent dispatch inwebsocket.js— with the fixnow deferring the whole
onSocketClose()call at its one synchronoustrigger point, that per-line TODO no longer applies.
Why
setImmediate, notprocess.nextTickI looked through prior attempts at this exact issue before writing this
(there have been a few: #4745, #4835, #5078, #5088, #5372). On #4745,
@domenic flagged that the spec's "queue a task" is a macrotask, and a
microtask-based fix (that PR used a Promise microtask; the maintainer TODO
this repo already had suggested
process.nextTick, which has the sameissue) doesn't really carry that semantics —
process.nextTickdrainsbefore the event loop's I/O/check phases, so it's still effectively "this
turn."
setImmediateis the macrotask primitive, so it matches "queue atask" correctly.
I deliberately didn't take on rewiring
establishWebSocketConnection,closeWebSocketConnection, or message-received handling the way @domenic'scomment on #4745 also suggested — those are already driven by the real
socket's async I/O callbacks, which already give correct task-boundary
semantics for free. The only place that was actually firing events
synchronously is the one
failWebsocketConnectioncall site this PRtouches; broadening the change to already-correct code seemed like
unnecessary scope for this specific, reported bug.
A related bug this surfaced
Fixing the timing exposed a latent ordering issue in
WebSocketStream'sabort handling (
stream/websocketstream.js). Its abort listener set#handshakeAborted = trueafter callingfailWebsocketConnection, sowhen
onSocketCloseused to run synchronously (nested inside that samecall), the flag wasn't set yet, and
onSocketClose's own "was neverconnected" logic would settle
opened/closedfirst — with a genericWebSocketError, not the abort signal's actual reason, contradicting thespec comment directly above it ("reject...with signal's abort reason").
Deferring
onSocketCloseflipped which side won that race, exposing it.I moved the
#handshakeAborted = trueassignment before thefailWebsocketConnectioncall so the intended guard is honored regardlessof timing, and updated
test/websocket/stream/abort-before-open.jsaccordingly (it was asserting the old, incorrect race outcome).
Testing
test/websocket/close.jsthat connects to aTCP server which accepts the connection but never responds (so the
handshake never completes and the client stays
CONNECTINGdeterministically), calls
close(), and asserts thecloseevent onlyfires after
close()has returned.fails with the exact reported symptom (event handler running inside the
close()call stack), then restored the fix.test/websocket/issue-4628.js, which asserted the oldsynchronous-firing behavior via
t.plan()on a non-async test function —converted it to
async/await thecloseevent instead of assumingsame-tick completion.
npm run test:websocket— 143/143 passing.npm run test:unit— 1516/1520 passing, 4 skipped; the one flaky failure(
test/http2-dispatcher.js, an HTTP/2 PING-frame-count timing assertion)is unrelated to this change, doesn't touch WebSocket code, and passes
cleanly when run in isolation.
npm run lintclean.