worker: guard the remaining NewSocket / ServerWebSocket / Bun.serve / Bun.$ dispatch sites against a pending termination - #36808
Conversation
…ution_status() #36579 upgraded the multi-JS-entry callbacks (on_open/keylog/session/ handshake/handle_connect_error) from is_shutting_down() to the worker-aware script_execution_status() gate, leaving the single-entry siblings (on_writable/on_data/on_end/on_timeout/on_close/handle_error/ ALPN) on the weaker predicate. That split is insufficient: uSockets can dispatch several callbacks in one poll sweep, and a terminate() trap raised inside the first socket's handler leaves the TerminationException pending for the next socket's dispatch in the same tick, tripping Interpreter::executeCallImpl's scope.assertNoException(). Reproduces via on_end on plain TCP (worker Bun.listen with allowHalfOpen, parent FINs the whole batch then terminates) and via on_writable<true> through ssl_retry_parked_write under node:tls. Unify every remaining is_shutting_down() gate in the NewSocket dispatch table on the same predicate the rest of #36579 uses.
…sites against a pending worker termination
ServerWebSocket on_open/on_message/on_drain/on_ping/on_pong/on_close gated
on is_shutting_down() and on_message/on_ping/on_pong swallowed a failed
payload-buffer creation into JSValue::ZERO, which Bun__JSValue__call
asserts on ("arguments[i] is JSValue.zero"). Upgrade the gates and
early-return when the payload conversion fails.
Bun.serve: on_request's fetch-handler call can take the termination trap;
on_response then routes the pending TerminationException into the user's
error() handler at run_error_handler_with_status_code_dont_check_responded,
tripping assertNoException(). Gate both the on_request entry (respond 503,
like the stale-wrapper path) and the error-handler dispatch.
Bun.$: Interpreter::finish builds the stdout/stderr Buffers
(DECLARE_TOP_EXCEPTION_SCOPE in JSBuffer__bufferFromPointerAndLengthAndDeinit)
then resolve.call() with no gate; on a terminating worker, drop the
keepalive and root-io and skip the JS resolve.
|
Status: diff is green. The x64-asan lane in build 88022 is done and the new Scope (follow-up to #36579):
Between-entry bails use Fail-before (debug build): |
WalkthroughChangesThe PR gates server, WebSocket, socket, listener, promise, duplex, and shell callbacks on Runtime termination guards
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Found 6 issues this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/server/mod.rs (1)
1072-1091: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winGuard every remaining JS dispatch path against terminated scripts.
on_saved_request,on_user_route_request,server_body::on_request_for,server_body::on_user_route_request_for, andupgrade_web_socket_user_routecan dispatch into JS without checkingscript_execution_status(). The route dispatcher callsscope.assertNoException()before invoking the handler, so a pendingTerminationExceptioncan abort the process. Return 503 withrespond_stopped_503before preparing the request or dispatching into JS on each path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/server/mod.rs` around lines 1072 - 1091, Guard every listed dispatch path—on_saved_request, on_user_route_request, server_body::on_request_for, server_body::on_user_route_request_for, and upgrade_web_socket_user_route—with a script_execution_status() check before request preparation or any JS dispatch. When the status is not Running, immediately call server_body::respond_stopped_503 and return, matching the existing on_request guard behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/runtime/server/mod.rs`:
- Around line 1072-1091: Guard every listed dispatch path—on_saved_request,
on_user_route_request, server_body::on_request_for,
server_body::on_user_route_request_for, and upgrade_web_socket_user_route—with a
script_execution_status() check before request preparation or any JS dispatch.
When the status is not Running, immediately call
server_body::respond_stopped_503 and return, matching the existing on_request
guard behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 51f7217a-e86d-4a79-b5d1-5ff0171af3e5
📒 Files selected for processing (6)
src/runtime/server/RequestContext.rssrc/runtime/server/ServerWebSocket.rssrc/runtime/server/mod.rssrc/runtime/shell/interpreter.rssrc/runtime/socket/socket_body.rstest/js/web/workers/worker-terminate-lifetime.test.ts
Every on_request-family entry (on_request, on_user_route_request, on_saved_request, upgrade_web_socket_user_route, and the generic on_request_for / on_user_route_request_for) funnels through one of these two before its first JS call, so the gate there covers the whole family instead of only on_request.
|
Addressed the sibling-dispatch finding in 74c0a24: moved the |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/runtime/server/mod.rs:1085-1091— Thescript_execution_status()gate added toon_requestisn't applied to its sibling request-dispatch trampolines that also enter user JS in the same uSockets tick:on_user_route_request(mod.rs:1126,routes: { "/x": handler }),on_saved_request(mod.rs:880),on_node_http_request_with_upgrade_ctx(mod.rs:1216), and the server_body.rs h3/generic sites at ~2949/3037/3335/3418 — each still gates only onjs_value_for_dispatch(). A worker runningBun.serve({ routes: {...} })that receives a burst of requests in one poll sweep and isterminate()d inside the first handler will still tripassertNoException()on the next request viaBun__ServerRouteList__callRoute. Copy the same 5-line gate to those entry points (or fold it intojs_value_for_dispatch()/prepare_js_request_context).Extended reasoning...
What the bug is
This PR's
Bun.servehalf adds ascript_execution_status() != Runningcheck at the top ofNewServer::on_request(mod.rs:1085–1091), so a request arriving in the same uSockets poll tick as a pendingTerminationExceptiongets a 503 instead of entering the JSfetchhandler with the exception pending. Buton_requestis only one of several uWS request-dispatch trampolines onNewServerthat call into user JS. The siblings still gate only onjs_value_for_dispatch():on_user_route_request— mod.rs:1126–1183, callsBun__ServerRouteList__callRoute(theroutes: { "/x": handler }path)on_saved_request— mod.rs:880on_node_http_request_with_upgrade_ctx— mod.rs:1216 (node:http compat)- The server_body.rs generic paths:
on_user_route_request_for(~2949),on_request_for(~3037), the h3 user-route site (~3335), and the h3 request site (~3418) - (Also
on_client_errorat ~3625 andon_connection_callbackat ~3677, which call user JS behind the same gate.)
js_value_for_dispatch()is justself.js_value.try_get()(mod.rs:557–559) — it returnsNoneonly once the JS wrapper isFinalized. It does not consultscript_execution_status(), so a live wrapper on a VM whose termination trap has already fired still passes the gate.Code path that triggers it
Concrete step-through with
on_user_route_request:- A worker runs
Bun.serve({ port: 0, routes: { "/x": (req) => new Response(...) } }). - In one uSockets poll sweep, N pipelined/parallel requests to
/xbecome ready. uWS dispatches them back-to-back throughtrampoline::on_user_route_request<SSL,DEBUG>→NewServer::on_user_route_request. - Request A enters
on_user_route_request:js_value_for_dispatch()isSome, so it proceeds throughprepare_js_request_contextand callsBun__ServerRouteList__callRoute, which enters JS viaInterpreter::executeCallImpl. While inside the user's route handler, the parent thread'sworker.terminate()fires the VM's termination trap; the handler unwinds with the stickyTerminationExceptionpending. - Control returns to the uSockets loop, which immediately dispatches request B to
on_user_route_requestin the same tick. js_value_for_dispatch()is stillSome(the wrapper is notFinalized). There is noscript_execution_status()check on this path, and the PR placed theon_requestgate beforeprepare_js_request_contextrather than inside it, so nothing between the trampoline andBun__ServerRouteList__callRouteobserves the pending termination.Bun__ServerRouteList__callRoute→JSC::call→Interpreter::executeCallImplhitsscope.assertNoException()with theTerminationExceptionpending →ASSERTION FAILED: !exception()→ SIGABRT of the whole process.
This is the identical failure class the PR fixes for the default
fetchpath, and the identical shape it fixes forNewSocket("a burst of dispatches in one poll sweep, terminate() inside the first, next dispatch trips the assert").Why existing code doesn't prevent it
The new gate at
run_error_handler_with_status_code_dont_check_responded(RequestContext.rs:3585–3593) does help these siblings — they all funnel intohandle_request→on_response→ the error handler — but it only covers the after-call path (routing the pending exception into the user'serror()handler). It cannot prevent the before-callassertNoException()on entering the route/node-http handler itself, which is what theon_requestentry gate was added for.Impact
Same as the
on_requestcase this PR fixes: debug/ASAN-build SIGABRT (release WebKit compiles the assert out). It affects any worker usingBun.serve({ routes: {...} }), node:http-backed servers, or the h3 path — arguably more common than a barefetchhandler. Per REVIEW.md ("Fix the whole class in the same PR — grep for every sibling site sharing the pattern… If a site is intentionally excluded, say so in the PR"), and given this PR's own methodology forNewSocket/ServerWebSocketwas exactly "upgrade every sibling gate", leaving these ungated is a same-class miss in the same file.Fix
Copy the same 5-line
script_execution_status() != Running→respond_stopped_503gate into each sibling right after thejs_value_for_dispatch()check. Alternatively, fold the check intojs_value_for_dispatch()itself (returnNonewhen notRunning) or intoprepare_js_request_context, which every JS-entering path shares — that would coveron_request,on_user_route_request,on_saved_request,on_node_http_request_with_upgrade_ctx, and the server_body.rs generics in one place.
Socket module: the two SNI server_name callbacks (same BoringSSL-handshake
shape as ALPN), UpgradedDuplex::call_write_or_end, the Windows named-pipe
accept path, and Handlers::{resolve,reject}_promise/call_error_handler
still gated on is_shutting_down() before their JS entry.
Handlers::mark_inactive stays as-is (no JS entry; the check there is about
the wrapper being gone at process exit).
Server: fold the gate into NewServer::js_value_for_dispatch so the
node-http / client-error / connection-callback entry points (which do not
go through prepare_js_request_context) share it.
Every request-family trampoline (on_request, on_user_route_request, on_saved_request both arms, on_node_http_request_with_upgrade_ctx, the on_web_socket_upgrade id==0 fetch fallthrough, on_client_error, on_connection_callback, and the generic on_request_for / on_user_route_request_for / upgrade_web_socket_user_route) calls js_value_for_dispatch() before its first JS entry, so the gate there covers the class. prepare_js_request_context keeps a belt-and-braces check for the bake prepare_and_save_js_request_context path that bypasses js_value_for_dispatch. Also merge the duplicated option match in run_error_handler_with_status_code_dont_check_responded.
|
Re the |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/server/mod.rs`:
- Around line 557-565: Update js_value_for_dispatch to release the saved request
context when script_execution_status is not Running: invoke
SavedRequest::deinit() or the established cancellation cleanup before returning
None. Ensure this stopped path calls RequestContext::deinit() and ultimately
on_request_complete(), while preserving the existing None return behavior.
In `@src/runtime/server/RequestContext.rs`:
- Around line 3588-3593: Re-check script execution termination immediately after
the user-JavaScript on_error.call operation in RequestContext.rs before entering
finish_running_error_handler, process_on_error_promise, or render, returning
when the status is no longer Running. Also update UpgradedDuplex.rs after each
duplex.get operation to perform the same termination check before the next
JavaScript entry.
In `@src/runtime/socket/Handlers.rs`:
- Around line 218-220: Update the handler flow around script_execution_status
and take_promise so the pending promise is detached before returning for
non-Running statuses. Discard the detached promise when the VM is stopped or the
worker terminates, while preserving reject_promise’s true terminal-cancellation
result for callbacks that can still run.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: a4704161-0cfe-4fc7-b8b1-f7c115a5f221
📒 Files selected for processing (5)
src/runtime/server/RequestContext.rssrc/runtime/server/mod.rssrc/runtime/socket/Handlers.rssrc/runtime/socket/Listener.rssrc/runtime/socket/UpgradedDuplex.rs
… left a termination pending call_write_or_end's writableEnded probe and run_error_handler_*'s on_error.call both have a follow-up JS entry in the same function; a has_exception() check after take_exception prevents the assert when terminate() lands inside the first.
|
Updated 12:41 AM PT - Aug 3rd, 2026
❌ @robobun, your commit 1b9a1f8 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 36808That installs a local version of the PR into your bun-36808 --bun |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/socket/UpgradedDuplex.rs (1)
210-211: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winRe-check VM status before each subsequent JavaScript call.
duplex.get(...)performs JSC property access and can run a getter or proxy trap. A termination request is tracked byscript_execution_status(), notglobal.has_exception(). Check the status after thewritableEndedlookup and after thewrite/endlookup, beforewrite_or_end.call(...), including the non-teardown path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/socket/UpgradedDuplex.rs` around lines 210 - 211, Update the JavaScript call sequence in the upgraded duplex handling to re-check vm.script_execution_status() after the writableEnded lookup and again after the write/end lookup, before invoking write_or_end.call(...). Apply these checks to both teardown and non-teardown paths, returning immediately when the status is no longer Running.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/server/RequestContext.rs`:
- Around line 3604-3606: After on_error.call(), update the guard in the
surrounding error-handling flow to re-check script_execution_status() before
processing result, covering both pending exceptions and worker termination
requests. Return immediately when execution is no longer live, preventing
finish_running_error_handler, process_on_error_promise, or render from entering
JavaScript again.
---
Outside diff comments:
In `@src/runtime/socket/UpgradedDuplex.rs`:
- Around line 210-211: Update the JavaScript call sequence in the upgraded
duplex handling to re-check vm.script_execution_status() after the writableEnded
lookup and again after the write/end lookup, before invoking
write_or_end.call(...). Apply these checks to both teardown and non-teardown
paths, returning immediately when the status is no longer Running.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 61afec87-acda-4fe7-b834-da2646b9f3bd
📒 Files selected for processing (2)
src/runtime/server/RequestContext.rssrc/runtime/socket/UpgradedDuplex.rs
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/socket/Listener.rs (1)
1919-1919: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winHandle termination after both SNI callbacks.
After each
callback.call(), checkglobal.has_exception()before normal result decoding or any later JavaScript entry. A termination exception can remain pending and triggerassertNoException().🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/socket/Listener.rs` at line 1919, Update the SNI callback handling around handlers.vm.script_execution_status() so each callback.call() is immediately followed by a global.has_exception() check before result decoding or any further JavaScript entry; handle termination and return through the existing termination path, preventing a pending exception from reaching assertNoException().Source: Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/runtime/socket/Listener.rs`:
- Line 1919: Update the SNI callback handling around
handlers.vm.script_execution_status() so each callback.call() is immediately
followed by a global.has_exception() check before result decoding or any further
JavaScript entry; handle termination and return through the existing termination
path, preventing a pending exception from reaching assertNoException().
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 71493799-61a2-4726-a12c-0ac717b2efe5
📒 Files selected for processing (1)
src/runtime/socket/Listener.rs
|
Re the SNI |
There was a problem hiding this comment.
No issues found; every prior finding (mine and CodeRabbit's) has been addressed and resolved. Deferring to a human because the sweep touches Bun.serve request dispatch, the socket/WebSocket callback tables, and shell finish — critical paths where the non-mechanical early-return sites (run_error_handler_*, prepare_js_request_context, Interpreter::finish) deserve a maintainer's sign-off.
What was reviewed:
- The
is_shutting_down()→script_execution_status() != Runningswaps at every remainingsrc/runtime/socket/ServerWebSocketdispatch site — confirmed no JS-enteringis_shutting_down()gates remain in those modules (Handlers::mark_inactiveintentionally kept). Interpreter::finishearly return: cleanup (keep_alive.disable,deref_root_shell_and_io_if_needed(true),_decrguard) mirrors the success path; only the JS Buffer allocs andresolve.callare skipped.js_value_for_dispatchnewNonecondition: every caller already has the 503-and-return path from the pre-existingFinalizedgate;prepare_js_request_contextcovers the one bake caller that bypasses it.ServerWebSocketpayloadlet Ok(...) else { return }: the_loop_guardis already armed, so the pending exception surfaces on scope exit; noJSValue::ZEROreachesBun__JSValue__call.
Extended reasoning...
Overview
Follow-up to #36579 that upgrades the remaining is_shutting_down() JS-entry gates to the worker-aware script_execution_status() != Running predicate across NewSocket (socket_body.rs: on_writable/on_data/on_end/on_timeout/on_close/handle_error/ALPN), Handlers (resolve/reject_promise, call_error_handler), Listener (SNI ×2, Windows named-pipe accept), UpgradedDuplex::call_write_or_end, ServerWebSocket (on_open/message/drain/ping/pong/close), NewServer (js_value_for_dispatch, prepare_js_request_context), RequestContext::run_error_handler_*, and shell Interpreter::finish. Also fixes three unwrap_or(JSValue::ZERO) → early-return sites in ServerWebSocket that would trip Bun__JSValue__call's empty-argument assert. One new debug-only regression test in worker-terminate-lifetime.test.ts covering the on_end burst shape.
Security risks
None. This is defensive gating of JS re-entry during worker VM teardown; no auth, crypto, input parsing, or trust-boundary changes. The early-return paths run strictly less code than before.
Level of scrutiny
High — the touched functions are the hot dispatch paths for Bun.serve, Bun.listen/Bun.connect, ServerWebSocket, and Bun.$. About 15 sites are purely mechanical predicate swaps following the #36579 pattern; the remainder introduce new early-return branches with their own cleanup ordering (Interpreter::finish, prepare_js_request_context → 503, run_error_handler_* has_exception() bail, the ServerWebSocket payload-Err returns, and the between-JS-entry has_exception() rechecks in RequestContext/UpgradedDuplex added in 66c07c1). Those are the parts a maintainer should confirm.
Other factors
The PR has been through several review rounds: my earlier findings (missed SNI/Handlers/UpgradedDuplex/named-pipe siblings; on_node_http_request/on_saved_request/ws-upgrade coverage via js_value_for_dispatch; two stale doc-comments) were addressed in 801fd3a/d91033f/5880eb4/1b9a1f8. CodeRabbit's re-check-between-JS-entries finding was addressed in 66c07c1; its SavedRequest cleanup and take_promise ordering findings were withdrawn as pre-existing/out-of-scope (correctly — the early-return paths only fire on a terminating VM whose teardown releases the remaining state). The new test follows the file's existing debug-only pattern and the PR description records fail-before/pass-after. CI on 1b9a1f8 (build #88022) is still building per the timeline. Given the breadth across critical dispatch tables and the several non-mechanical early-return sites, this should get a human sign-off rather than a bot auto-approve.
There was a problem hiding this comment.
No bugs found this pass; the earlier inline findings are all addressed. Deferring to a human rather than approving because the change spans dispatch entry points in four subsystems (NewSocket, ServerWebSocket, Bun.serve request/error, Bun.$) and adds new early-return paths on hot code, and build #88022 shows test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js SIGABRTing on x64-asan — worth confirming that's pre-existing on main before merge.
Checked: the is_shutting_down() → script_execution_status() sweep now covers every JS-entering site in src/runtime/socket/ (SNI, ALPN, UpgradedDuplex, Handlers, named-pipe accept) and the request-family entries via js_value_for_dispatch().
Checked: the let Ok(payload) else { return } early returns in ServerWebSocket land inside _loop_guard, so a non-termination exception is reported (not left pending) on the way out.
Checked: the Interpreter::finish bail drops keep_alive and root-io before returning, matching the surrounding cleanup order.
Extended reasoning...
Overview
Follow-up to #36579: upgrades every remaining is_shutting_down() gate on a JS-dispatch entry to the worker-aware script_execution_status() != Running predicate, so a TerminationException raised inside one callback in a poll sweep does not reach the next callback's assertNoException(). Touches NewSocket (on_writable/on_data/on_end/on_timeout/on_close/handle_error/ALPN), the two SNI callbacks, Windows named-pipe accept, Handlers::{resolve,reject}_promise/call_error_handler, UpgradedDuplex::call_write_or_end, all six ServerWebSocket handlers (plus fixing the .unwrap_or(JSValue::ZERO) payload-argument aborts), Bun.serve's js_value_for_dispatch/prepare_js_request_context/error-handler dispatch, and Bun.$'s Interpreter::finish. Adds one debug-only regression test.
Security risks
None. No new user-visible surface, no auth/crypto/permissions changes; the gates are strictly more conservative than before.
Level of scrutiny
Medium-high. The individual edits are a mechanical predicate swap following #36579's proven pattern, but they add early-return paths across ~20 hot dispatch sites in four subsystems. The bail-out cleanup on each path matters (e.g. shell keep-alive, ServerWebSocket _loop_guard ordering, respond_stopped_503 on the request path), and the between-JS-entry has_exception() bails in RequestContext/UpgradedDuplex are new control flow.
Other factors
All four rounds of my earlier inline findings were addressed in 801fd3a → 1b9a1f8; CodeRabbit's three findings were withdrawn or fixed in 66c07c1. The one open nit (routing the binary_to_js Err through run_error_callback like on_close does) is non-blocking — _loop_guard drop reports it. Build #88022 shows test-worker-message-port-transfer-terminate.js SIGABRTing on x64-asan; this PR does not touch MessagePort, but it is in the worker-terminate area, so a human should check whether it also fails on main. bun-add.test.ts on Windows is unrelated to this diff.
…or TLS session/keylog dispatch fails (#37067) ### What Fuzzing (Fuzzilli) keeps hitting this debug assertion in processes exercising sockets: ``` ASSERTION FAILED: !scope.exception() || vm.hasPendingTerminationException() || !hasProperty JavaScriptCore/JSObjectInlines.h(137) : JSValue JSC::JSObject::get(JSGlobalObject *, PropertyName) ``` It fires when any native `JSObject::get` runs while a non-termination JS exception is pending. Several socket dispatch sites could leave the VM in exactly that state: they call a fallible operation whose `Err` means "a JS exception is pending", discard the `Err`, and return to the uSockets/libuv event loop. The next native JSC call in the same tick then runs with the stale exception. In debug/ASAN builds that trips the assert (`JSObject::get`, or `assertNoExceptionExceptTermination` at the microtask-drain entry); in release the exception gets attributed to whatever runs next. The sites, all in `src/runtime/socket/`: - `handle_connect_error` swallowed a failed promise reject with `handlers.reject_promise(err_val).unwrap_or(true)` and returned `Ok` with the exception still set (a `// TODO: properly propagate exception upwards` site), and its other two settle sites (`reject`, `reject_as_handled`) returned `Err` into callers that all discarded it: `connect()`'s synchronous `do_connect()` failure branch, the `on_connect_error` event-loop dispatch, the Windows named-pipe `on_error`/`fail_and_release`, and the duplex TLS upgrade teardown. - `on_open` swallowed a failed resolve of the connect promise (the other `// TODO: properly propagate exception upwards` site) and returned to the loop with the exception pending, and did the same `unwrap_or(true)` swallow when rejecting with an error returned from the `open` callback. - `us_dispatch_session` / `us_dispatch_keylog` (and the duplex and named-pipe equivalents) discarded `on_session` / `on_keylog` results; those return `Err` when allocating the session/keylog `Buffer` throws. ### Fix The pending exception has to be handled at the failure site, before the dispatch's scope guards drop: `ScopeExit` runs a microtask checkpoint on scope exit, and entering that checkpoint with a non-termination exception pending is itself the asserted condition, so an `Err` propagated or discarded past the guard is already too late. And the event-loop dispatch callers have no JS frame to propagate into in the first place. So each failed settle now reports its exception as unhandled right where it fails, via `report_active_exception_as_unhandled`: the same idiom the socket error handler, ipc, h2, fs-watcher, and dns (#37004) completions use. That clears and reports a real throw, and leaves a termination exception pending, which is what the event loop expects (`assertNoExceptionExceptTermination`). With every failure handled internally, `handle_connect_error`, `on_session`, and `on_keylog` become infallible, and `on_handshake`/`on_close` (whose `Err` paths were structurally dead: `verify_error_to_js` cannot fail) are made infallible with them, so no dispatch call site can discard an `Err` from this family again; every `let _ =` at those call sites is gone. ### Verification The promise-settle failures (`JSPromise::resolve/reject`) only occur on JSC heap OOM or termination mid-call, so those arms have no deterministic reproduction; they are covered by the behavior-level tests below plus the analysis above. The session/keylog `Buffer` allocation has the same trigger, but for that one this PR adds a `session_buffer` fault-injection rule (mirroring the existing `ssl_loop_buffer` rule for the one other allocation whose failure is unreachable without injection), which makes the fix deterministically testable: - `socket-session-oom-fixture.ts` arms the rule, completes a TLS handshake with a `session` handler, and asserts the injected allocation failure surfaces as an `uncaughtException` while the event loop survives (a TCP round trip completes afterwards and the process exits 0). With the hook but without the fix, the same fixture dies on `ASSERTION FAILED: ... !exception() || m_vm.hasPendingTerminationException()` in `ExceptionScope::assertNoExceptionExceptTermination`, the fuzzer's failure class. - New behavior tests cover every touched dispatch path: TLS `session`/`keylog` delivery (previously untested), throws from `session`/`keylog`/`open` handlers reaching the socket's (or listener's) `error` handler while `connect()` still resolves, a synchronous unix connect failure rejecting with `ENOENT` (run in a child with a relative socket path so macOS's 104-byte `sun_path` limit cannot clobber the errno), and a throw from `connectError` becoming the `connect()` rejection. - The Windows named-pipe paths were exercised directly (named-pipe client lifecycle tests, including the failed-connect branch this PR touches, and the Windows connect error-code tests): all pass on a Windows debug build. - The existing fault-injection suites (`socket-syscall-fault`, `tls-syscall-fault`) pass with the new rule added. - `test/js/bun/net/`, `test/js/node/net/`, and the node-tls connect suites show the identical failure set as a baseline build in this sandboxed environment (local DNS/IPv6 limitations), with no new failures. `cargo check` passes on all 10 platform targets. Related but distinct open work: #36808 gates these dispatch sites against an already-pending termination (entry-side), while this PR fixes exceptions the dispatch itself creates (exit-side); #35221 changes which errno the DNS-path `connectError` reports, not the exception discipline. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/net/socket.test.ts <!-- robobun:evidence:end -->
Follow-up to #36579. That PR upgraded the multi-JS-entry
NewSocketcallbacks and several C++ emit paths to the worker-awarescript_execution_status() != Runninggate; this one covers the remaining dispatch sites the fuzz ledger has since hit with the sameassertNoException()/ empty-argument aborts.NewSocketremaining callbacks (socket_body.rs)on_writable/on_data/on_end/on_timeout/on_close/handle_error/ the ALPN selector still gated onis_shutting_down(). A single uSockets poll sweep can dispatch several callbacks back to back (e.g.us_internal_ssl_on_data→on_handshake→ssl_retry_parked_write→on_writable, or a burst of FINs → N×on_end), so aterminate()trap raised inside one socket's handler leaves theTerminationExceptionpending for the next socket's dispatch in the same tick:Unify every remaining
is_shutting_down()gate in theNewSocketdispatch table on the predicate the rest of #36579 uses.ServerWebSocket(ServerWebSocket.rs)on_open/on_message/on_drain/on_ping/on_pong/on_closehad the sameis_shutting_down()gate, andon_message/on_ping/on_pongswallowed a failed payload-buffer creation into.unwrap_or(JSValue::ZERO), whichBun__JSValue__callasserts on:Upgrade the gates and early-return when the payload conversion fails.
Bun.serve(mod.rs / RequestContext.rs)on_request's fetch-handler call can take the termination trap;on_responsethen routes the pendingTerminationExceptioninto the user'serror()handler atrun_error_handler_with_status_code_dont_check_responded, trippingassertNoException(). Gate both theon_requestentry (respond 503 like the stale-wrapper path) and the error-handler dispatch.Bun.$(shell/interpreter.rs)Interpreter::finishbuilds the stdout/stderrBuffers (DECLARE_TOP_EXCEPTION_SCOPEinJSBuffer__bufferFromPointerAndLengthAndDeinit) thenresolve.call()with no gate; on a terminating worker, drop the keepalive and root-io and skip the JS resolve.Verification
One new
skipIf(!isDebug)test (release WebKit compiles these asserts out). On a debug build, without the src/ changes:With the src/ changes it passes (~33s on debug+ASAN). The
on_writable<true>(node:tls),ServerWebSocket::on_ping,Bun.serve error(), andBun.$sites each reproduce on a release-asan build (see the commit messages); they use the samescript_execution_status()gate proven by theon_endtest and by #36579's three tests, and their repros are heavy (self-signed TLS, raw WS PING frames,cat/headpipelines) for a committed test.no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/workers/worker-terminate-lifetime.test.ts