Preserve AsyncLocalStorage context in WebSocket client event handlers
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 aJSValueInWrappedObject, and the newAsyncContextFrameScopeRAII type installs a captured context for the duration of a block, restoring the previous one afterwards.WebSocketcaptures the context in its constructor intom_creationAsyncContextand routes every network-originated event (open, text/binary message, ping, pong, handshake, error, close, including the queued-task variants and the connect-failure paths) throughdispatchEventInCreationContext().- 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-livedwsobject 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 isundefinedand 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 thecreationAsyncContext().visit(visitor)line makes exactly that test fail. All three also pass underBUN_JSC_collectContinuously=1on 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 bothbunandnodeand 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:
WebSocketmoved fromContextDestructionObserverplus manual pending-activity counting toActiveDOMObject, and deferred dispatches moved fromcontext->postTask([this, protectedThis = Ref { *this }] ...)toqueueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [](WebSocket& ws) ...). Every dispatch site moved with it, so each one now callsws.dispatchEventInCreationContext(...).- Main added a
handshakeevent (didReceiveHandshakeResponse, used by thewsshim). 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