Skip to content

fix(websocket): defer close()/fail() events to a queued task when CONNECTING - #5741

Open
yfwmaniish wants to merge 2 commits into
nodejs:mainfrom
yfwmaniish:fix/websocket-close-connecting-async
Open

fix(websocket): defer close()/fail() events to a queued task when CONNECTING#5741
yfwmaniish wants to merge 2 commits into
nodejs:mainfrom
yfwmaniish:fix/websocket-close-connecting-async

Conversation

@yfwmaniish

Copy link
Copy Markdown

Fixes #4741.

The bug

close() (and the internal "fail the WebSocket connection" path) calls
handler.onSocketClose() synchronously when the socket is still
CONNECTING, so error/close fire during close() instead of after it
returns. For an already-established connection this doesn't happen —
onSocketClose is wired to the real socket's 'close' event, which is
naturally async. CONNECTING is the one path with no real socket yet, so it
took a synchronous shortcut instead.

The fix

lib/web/websocket/connection.js, failWebsocketConnection: when
isConnecting(handler.readyState), queue the onSocketClose() call with
setImmediate instead of calling it inline. This is the single call site
that both WebSocket and WebSocketStream share for this path, so one
change fixes both.

I also removed the // TODO: process.nextTick comment that was sitting
directly above the close event dispatch in websocket.js — with the fix
now deferring the whole onSocketClose() call at its one synchronous
trigger point, that per-line TODO no longer applies.

Why setImmediate, not process.nextTick

I 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 same
issue) doesn't really carry that semantics — process.nextTick drains
before the event loop's I/O/check phases, so it's still effectively "this
turn." setImmediate is the macrotask primitive, so it matches "queue a
task" correctly.

I deliberately didn't take on rewiring establishWebSocketConnection,
closeWebSocketConnection, or message-received handling the way @domenic's
comment 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 failWebsocketConnection call site this PR
touches; 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's
abort handling (stream/websocketstream.js). Its abort listener set
#handshakeAborted = true after calling failWebsocketConnection, so
when onSocketClose used to run synchronously (nested inside that same
call), the flag wasn't set yet, and onSocketClose's own "was never
connected" logic would settle opened/closed first — with a generic
WebSocketError, not the abort signal's actual reason, contradicting the
spec comment directly above it ("reject...with signal's abort reason").
Deferring onSocketClose flipped which side won that race, exposing it.
I moved the #handshakeAborted = true assignment before the
failWebsocketConnection call so the intended guard is honored regardless
of timing, and updated test/websocket/stream/abort-before-open.js
accordingly (it was asserting the old, incorrect race outcome).

Testing

  • Added a regression test in test/websocket/close.js that connects to a
    TCP server which accepts the connection but never responds (so the
    handshake never completes and the client stays CONNECTING
    deterministically), calls close(), and asserts the close event only
    fires after close() has returned.
  • Negative control: reverted the fix locally and confirmed the new test
    fails with the exact reported symptom (event handler running inside the
    close() call stack), then restored the fix.
  • Updated test/websocket/issue-4628.js, which asserted the old
    synchronous-firing behavior via t.plan() on a non-async test function —
    converted it to async/await the close event instead of assuming
    same-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 lint clean.

…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.
@mcollina
mcollina requested a review from KhafraDev August 31, 2026 10:12
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (fc3450d) to head (11b94cd).
⚠️ Report is 3 commits behind head on main.

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

@yfwmaniish

Copy link
Copy Markdown
Author

The Node.js 26 job failures across all platforms are test/http2-request-never-settles.js wedging — unrelated to this change (HTTP/2, not WebSocket) and tracked as a known, pre-existing nightly-runner flake in #5671. Node 22/24/25 all pass.

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.

WebSocket events fire synchronously during close() instead of asynchronously

2 participants