Preserve AsyncLocalStorage context in WebSocket client event handlers

Closed
RO
1mo ago
+322-19

Problem

Client WebSocket event handlers lose the AsyncLocalStorage context that was active when the socket was constructed. This affects onopen, onmessage, onerror, onclose and their addEventListener equivalents, so the usual APM pattern (open an outbound WebSocket inside a request, tag its messages with the request's trace id) silently loses the request context.

als.run("request-7", () => {
  const ws = new WebSocket(url);
  ws.onopen = () => console.log(als.getStore());    // node: "request-7"   bun: undefined
  ws.onmessage = () => console.log(als.getStore()); // node: "request-7"   bun: undefined
  ws.addEventListener("close", () => console.log(als.getStore())); // same
});

Node reports "request-7" in all of them, Bun reports undefined in all of them.

Cause

The client WebSocket's events are dispatched from the native socket callbacks (didConnect, didReceiveMessage, didReceiveBinaryData, didReceiveClose, didClose) and from the event-loop tasks those queue. None of them enter an AsyncContextFrame, so listeners run with whatever async context the event loop last had (none). Node treats the WebSocket as an async resource created at construction, so its handlers observe the construction context.

Fix

  • AsyncContextFrame::captureCurrentContext() snapshots the active async context into a JSValueInWrappedObject, and the new AsyncContextFrameScope RAII type installs a captured context for the duration of a block, restoring the previous one afterwards.
  • WebSocket captures the context in its constructor into m_creationAsyncContext and routes every network-originated event (open, text/binary message, ping, pong, handshake, error, close, including the queued-task variants and the connect-failure paths) through dispatchEventInCreationContext().
  • The snapshot is GC-visited from JSWebSocket::visitChildrenImpl (it is set before the wrapper exists and only cleared afterwards, so no output constraint is needed) and released after the close event, the last event a socket can dispatch, so a long-lived ws object does not pin the store.

Synchronous user-initiated dispatches (ws.dispatchEvent(...)) are not affected.

Verification

  • test/js/web/websocket/websocket-client.test.ts: three new tests, the normal open/message/close flow, a GC stress case, and the connection-error path (error + close). On an unfixed build every captured store is undefined and all three fail; with the fix the file is green (40 tests).
  • The GC test is the guard for the marking path: it builds the socket in a scope that returns, runs Bun.gc(true) twice, then exercises a late message and the close. Removing the creationAsyncContext().visit(visitor) line makes exactly that test fail. All three also pass under BUN_JSC_collectContinuously=1 on the ASAN debug build.
  • test/js/node/async_hooks/async-context/async-context-websocket.js: new fixture in the AsyncLocalStorage tracking suite, which runs every fixture under both bun and node and requires exit code 0 from both (76 fixtures green).
Notes

Rebased onto current main on 2026-09-07. The conflicts came from main's WebSocket refactor and were resolved by re-applying the change onto the new shapes:

  • WebSocket moved from ContextDestructionObserver plus manual pending-activity counting to ActiveDOMObject, and deferred dispatches moved from context->postTask([this, protectedThis = Ref { *this }] ...) to queueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [](WebSocket& ws) ...). Every dispatch site moved with it, so each one now calls ws.dispatchEventInCreationContext(...).
  • Main added a handshake event (didReceiveHandshakeResponse, used by the ws shim). It is network-originated too, so it goes through the same helper.
  • Main added ActiveDOMObject::stop(). It is a terminal path, so it clears the snapshot.
  • The earlier branch also carried a commit that ran prettier over two docs pages. Main is clean there now, so that commit is dropped.

Also addressed in review: the comments on the new code are one line each, and the GC test's error handler rejects both of its promises so an early error fails the test instead of hanging.


[human-review] gate passed · iteration 3 · 8 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/web/websocket/websocket-client.test.ts
bun test v1.4.3 (f42e98025)

test/js/web/websocket/websocket-client.test.ts:
Listening: ws://127.0.0.1:36707/
(pass) WebSocket > url [8.26ms]
Received client error: Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20) {
  errno: -104,
  code: 'ECONNRESET',
  syscall: 'read'
}
(pass) WebSocket > binaryType > (default) [2.29ms]
Received client error: Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20) {
  errno: -104,
  code: 'ECONNRESET',
  syscall: 'read'
}
(pass) WebSocket > binaryType > (invalid) [3.76ms]
Received client error: Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20) {
  errno: -104,
  code: 'ECONNRESET',
  syscall: 'read'
}
Received upgrade: {
  host: '127.0.0.1:36707',
  connection: 'Upgrade',
  upgrade: 'websocket',
  'sec-websocket-version': '13',
  'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits',
  'sec-websocket-key': 'RRi8aZSKaHQjOgcw
... (truncated)

release without fix: 3 FAILED
bun test v1.4.3-canary.1 (f42e98025)

test/js/web/websocket/websocket-client.test.ts:
Listening: ws://127.0.0.1:34397/
(pass) WebSocket > url [0.43ms]
(pass) WebSocket > binaryType > (default) [0.08ms]
(pass) WebSocket > binaryType > (invalid) [0.11ms]
Received client error: Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20) {
  errno: -104,
  code: 'ECONNRESET',
  syscall: 'read'
}
Received upgrade: {
  host: '127.0.0.1:34397',
  connection: 'Upgrade',
  upgrade: 'websocket',
  'sec-websocket-version': '13',
  'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits',
  'sec-websocket-key': 'c02/pzRcAus8dPJn5/1SEg=='
}
(pass) WebSocket > readyState [4.99ms]
Received connection: 127.0.0.1
Received bytes: <Buffer 88 82 38 a7 34 fa 3b 4f>
Received client error: Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20) {
  errno: -104,
  code: 'ECONNRESET',
  syscall: 'read'
}
Received client error: Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20) {
  errno: -104,
  code: 'ECONNRESET',
  syscall: 'read'
}
Received close: 1000 <Buffer >
Received
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/web/websocket/websocket-client.test.ts
bun test v1.4.3 (f42e98025)

test/js/web/websocket/websocket-client.test.ts:
Listening: ws://127.0.0.1:39135/
(pass) WebSocket > url [8.41ms]
Received client error: Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20) {
  errno: -104,
  code: 'ECONNRESET',
  syscall: 'read'
}
(pass) WebSocket > binaryType > (default) [2.18ms]
Received client error: Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20) {
  errno: -104,
  code: 'ECONNRESET',
  syscall: 'read'
}
(pass) WebSocket > binaryType > (invalid) [3.89ms]
Received client error: Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20) {
  errno: -104,
  code: 'ECONNRESET',
  syscall: 'read'
}
Received upgrade: {
  host: '127.0.0.1:39135',
  connection: 'Upgrade',
  upgrade: 'websocket',
  'sec-websocket-version': '13',
  'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits',
  'sec-websocket-key': 'U3cogCBKL7IRXR5a
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     b4d46961c4
  features     baseline

23 deps, 131 codegen, 1172 objects in 650ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (f42e98025)

Checked 22 installs across 61 packages (no changes) [9.00ms]
[2/1244] gen ErrorCode+*.h
[3/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (f42e98025)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1244] fetch tinycc
[tinycc] up to date
[5/1243] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (f42e98025)

Checked 111 installs across 104 packages (no changes) [13.00ms]
[6/1243] gen bindgenv2
[7/1243] fetch zlib
[zlib] up to date
[8/1243] gen node-fallbacks/react-refresh.js
Bundled 1 module in 11ms

  react-refresh.js  4.81 KB  (entry point)

[9/1243] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1216] gen .bind.ts → GeneratedBindings.cpp
[11/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.serve
... (truncated)
diff hotspot
src/jsc/bindings/AsyncContextFrame.cpp             |  27 ++++
 src/jsc/bindings/AsyncContextFrame.h               |  21 +++
 src/jsc/bindings/webcore/JSWebSocket.cpp           |  12 ++
 src/jsc/bindings/webcore/JSWebSocket.h             |   1 +
 src/jsc/bindings/webcore/WebSocket.cpp             |  56 +++++---
 src/jsc/bindings/webcore/WebSocket.h               |   9 ++
 .../async-context/async-context-websocket.js       |  69 ++++++++++
 test/js/web/websocket/websocket-client.test.ts     | 146 +++++++++++++++++++++
 8 files changed, 322 insertions(+), 19 deletions(-)

gate history · 1 passed · 0 rejected · iteration 3

evidence per changed file
file                                                      reads  edits  tests
src/jsc/bindings/AsyncContextFrame.cpp                        2      4     35
src/jsc/bindings/AsyncContextFrame.h                          3      7     35
src/jsc/bindings/webcore/JSWebSocket.cpp                      3      8     35
src/jsc/bindings/webcore/JSWebSocket.h                        2      2     35
src/jsc/bindings/webcore/WebSocket.cpp                       11     27     35
src/jsc/bindings/webcore/WebSocket.h                          6     10     35
…de/async_hooks/async-context/async-context-websocket.js      1      2     37
test/js/web/websocket/websocket-client.test.ts                6      6     35