From 3aabd8df82c8ba1cc59419a3598fe10bc4049d89 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:10:55 +0000 Subject: [PATCH 1/9] socket: upgrade the remaining NewSocket dispatch gates to script_execution_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 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. --- src/runtime/socket/socket_body.rs | 23 ++++--- .../workers/worker-terminate-lifetime.test.ts | 67 +++++++++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index ebdd1a879828..d22b4a3d02fe 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -109,7 +109,11 @@ extern "C" fn select_alpn_callback( { let handlers = this.get_handlers(); let callback = handlers.on_alpn_callback(); - if !callback.is_empty() && !handlers.vm.is_shutting_down() && !in_.is_null() && inlen > 0 { + if !callback.is_empty() + && handlers.vm.script_execution_status() == jsc::ScriptExecutionStatus::Running + && !in_.is_null() + && inlen > 0 + { let scope = handlers.enter(); let global = handlers.global_object; let this_value = this.get_this_value(&global); @@ -838,7 +842,7 @@ impl NewSocket { log!("handleError"); let handlers = self.get_handlers(); let vm = handlers.vm; - if vm.is_shutting_down() { + if vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { return; } // the handlers must be kept alive for the duration of the function call @@ -879,7 +883,7 @@ impl NewSocket { } let vm = handlers.vm; - if vm.is_shutting_down() { + if vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { return; } // Hold the socket alive for the rest of the dispatch: `internal_flush` @@ -971,7 +975,7 @@ impl NewSocket { if callback.is_empty() || this.flags.get().contains(Flags::FINALIZING) { return; } - if handlers.vm.is_shutting_down() { + if handlers.vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { return; } @@ -1615,7 +1619,8 @@ impl NewSocket { let callback = handlers.on_end(); let vm = handlers.vm; - if callback.is_empty() || vm.is_shutting_down() { + if callback.is_empty() || vm.script_execution_status() != jsc::ScriptExecutionStatus::Running + { this.poll_ref.with_mut(|p| p.unref(js_loop_ctx())); // If you don't handle TCP fin, we assume you're done. @@ -1758,8 +1763,8 @@ impl NewSocket { let mut is_open = false; if handlers.vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { - // `on_close` is single-JS-entry, so the native close it routes - // through here just takes the trap and returns. + // `on_close` skips its JS dispatch under the same gate, so the + // native close is still safe here. if reject_unauthorized { this.reject_unauthorized_connection(); } @@ -2036,7 +2041,7 @@ impl NewSocket { return Ok(()); } - if vm.is_shutting_down() { + if vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { drop(cleanup); return Ok(()); } @@ -2112,7 +2117,7 @@ impl NewSocket { if callback.is_empty() || this.flags.get().contains(Flags::FINALIZING) { return; } - if handlers.vm.is_shutting_down() { + if handlers.vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { return; } diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 6b9f7c1b3366..17108bf1dbc7 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -455,3 +455,70 @@ test.skipIf(!isDebug)( }, 120_000, ); + +// Regression: the remaining NewSocket dispatch entry points (on_end, +// on_writable, on_data, on_timeout, on_close, handle_error, ALPN) still gated +// on is_shutting_down(), so a worker terminate() raised inside one socket's +// callback left the TerminationException pending for the next socket's +// dispatch in the same uSockets tick. This shape fires on plain TCP via +// on_end: the worker accepts NCLI connections, the parent FINs them all in +// one burst (so multiple on_end dispatches land in one poll sweep), then +// terminates the worker mid-sweep. +test.skipIf(!isDebug)( + "terminate() while a worker's Bun.listen end handler is firing does not trip assertNoException()", + async () => { + const ROUNDS = 15; + const NCLI = 60; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("node:worker_threads"); + const net = require("node:net"); + const src = + "const { parentPort, workerData: d } = require('node:worker_threads');" + + "let opens = 0;" + + "const srv = Bun.listen({ hostname: '127.0.0.1', port: 0, allowHalfOpen: true, socket: {" + + " open(s) { if (++opens === d.ncli) parentPort.postMessage('ready'); }," + + " data() {}, end(s) { s.end(); }, close() {}, error() {} } });" + + "parentPort.postMessage(srv.port);"; + for (let r = 0; r < ${ROUNDS}; r++) { + const w = new Worker(src, { eval: true, workerData: { ncli: ${NCLI} } }); + const msgs = []; + const port = await new Promise((res, rej) => { + w.once("error", rej); + w.on("message", (m) => { msgs.push(m); if (msgs.length === 1) res(m); }); + }); + const clients = []; + for (let i = 0; i < ${NCLI}; i++) { + const c = net.connect(port, "127.0.0.1"); + c.on("error", () => {}); c.on("data", () => {}); + clients.push(c); + } + await new Promise((res, rej) => { + w.once("error", rej); + if (msgs.includes("ready")) return res(); + w.on("message", (m) => { if (m === "ready") res(); }); + }); + w.on("error", () => {}); + await Bun.sleep(r % 4); + for (const c of clients) c.end(); + await w.terminate(); + for (const c of clients) c.destroy(); + } + console.log("PASS"); + `, + ], + env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("PASS\n"); + expect(exitCode).toBe(0); + }, + 120_000, +); From f50ca6a93f622582c0f807f4489057cee175f91d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:27:10 +0000 Subject: [PATCH 2/9] server/shell: guard the ServerWebSocket / Bun.serve / Bun.$ dispatch 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. --- src/runtime/server/RequestContext.rs | 9 ++++++ src/runtime/server/ServerWebSocket.rs | 43 ++++++++++++++++----------- src/runtime/server/mod.rs | 7 +++++ src/runtime/shell/interpreter.rs | 7 +++++ 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 845980f5cb61..318328f32e1c 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3582,6 +3582,15 @@ where status: u16, ) { jsc::mark_binding!(); + if let Some(server) = self.server { + // The "error" may be the worker's TerminationException raised + // inside on_request's fetch-handler call; entering the user + // error() handler with it pending trips assertNoException(). + // SAFETY: BACKREF + if (*server).vm().script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { + return; + } + } if let Some(server) = self.server { // SAFETY: BACKREF let server = &*server; diff --git a/src/runtime/server/ServerWebSocket.rs b/src/runtime/server/ServerWebSocket.rs index c6acf0d4a4cb..b292f9f57790 100644 --- a/src/runtime/server/ServerWebSocket.rs +++ b/src/runtime/server/ServerWebSocket.rs @@ -420,7 +420,7 @@ impl ServerWebSocket { let global_object = handler.global_object(); let on_open_handler = handler.on_open; let on_error = handler.on_error; - if vm.is_shutting_down() { + if vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { bun_output::scoped_log!(WebSocketServer, "onOpen called after script execution"); ws.close(); return; @@ -493,7 +493,7 @@ impl ServerWebSocket { let global_object = self.handler().global_object(); // This is the start of a task. let vm = self.handler().vm(); - if vm.is_shutting_down() { + if vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { bun_output::scoped_log!(WebSocketServer, "onMessage called after script execution"); ws.close(); return; @@ -501,19 +501,18 @@ impl ServerWebSocket { let _loop_guard = vm.enter_event_loop_scope(); + let payload = match opcode { + Opcode::Text => jsc::bun_string_jsc::create_utf8_for_js(global_object, message), + Opcode::Binary => self.binary_to_js(global_object, message), + _ => unreachable!(), + }; + let Ok(payload) = payload else { return }; let arguments = [ self.this_value .get() .try_get() .unwrap_or(JSValue::UNDEFINED), - match opcode { - Opcode::Text => jsc::bun_string_jsc::create_utf8_for_js(global_object, message) - .unwrap_or(JSValue::ZERO), // TODO: properly propagate exception upwards - Opcode::Binary => self - .binary_to_js(global_object, message) - .unwrap_or(JSValue::ZERO), // TODO: properly propagate exception upwards - _ => unreachable!(), - }, + payload, ]; let mut corker = Corker { @@ -562,7 +561,9 @@ impl ServerWebSocket { bun_output::scoped_log!(WebSocketServer, "onDrain"); let handler = self.handler(); let vm = handler.vm(); - if self.is_closed() || vm.is_shutting_down() { + if self.is_closed() + || vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running + { return; } @@ -610,7 +611,9 @@ impl ServerWebSocket { let cb = handler.on_ping; let on_error = handler.on_error; let vm = handler.vm(); - if cb.is_empty_or_undefined_or_null() || vm.is_shutting_down() { + if cb.is_empty_or_undefined_or_null() + || vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running + { return; } let global_this = handler.global_object(); @@ -618,13 +621,15 @@ impl ServerWebSocket { // This is the start of a task. let _loop_guard = vm.enter_event_loop_scope(); + let Ok(payload) = self.binary_to_js(global_this, data) else { + return; + }; let args = [ self.this_value .get() .try_get() .unwrap_or(JSValue::UNDEFINED), - self.binary_to_js(global_this, data) - .unwrap_or(JSValue::ZERO), // TODO: properly propagate exception upwards + payload, ]; if let Err(e) = cb.call(global_this, JSValue::UNDEFINED, &args) { let err = global_this.take_exception(e); @@ -646,20 +651,22 @@ impl ServerWebSocket { let global_this = handler.global_object(); let vm = handler.vm(); - if vm.is_shutting_down() { + if vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return; } // This is the start of a task. let _loop_guard = vm.enter_event_loop_scope(); + let Ok(payload) = self.binary_to_js(global_this, data) else { + return; + }; let args = [ self.this_value .get() .try_get() .unwrap_or(JSValue::UNDEFINED), - self.binary_to_js(global_this, data) - .unwrap_or(JSValue::ZERO), // TODO: properly propagate exception upwards + payload, ]; if let Err(e) = cb.call(global_this, JSValue::UNDEFINED, &args) { let err = global_this.take_exception(e); @@ -723,7 +730,7 @@ impl ServerWebSocket { }); let vm = handler.vm(); - if vm.is_shutting_down() { + if vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return; } diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 9551413e57ed..72d212f380fd 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1082,6 +1082,13 @@ impl NewServer { server_body::respond_stopped_503(bun_opaque::opaque_deref_mut(resp)); return; }; + // SAFETY: `this` is the live server backref for this request. + if unsafe { &*this }.vm().script_execution_status() + != bun_jsc::ScriptExecutionStatus::Running + { + server_body::respond_stopped_503(bun_opaque::opaque_deref_mut(resp)); + return; + } let should_deinit_context = core::cell::Cell::new(false); let Some(prepared) = Self::prepare_js_request_context( this, diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 4e925f76cfb2..42789534f12e 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -1199,6 +1199,13 @@ impl Interpreter { let global_this = self .global_this_ref() .expect("global_this set on Js event-loop path"); + if global_this.bun_vm().script_execution_status() + != bun_jsc::ScriptExecutionStatus::Running + { + self.keep_alive.with_mut(|k| k.disable()); + self.deref_root_shell_and_io_if_needed(true); + return Yield::done(); + } let buffered_stdout = self.get_buffered_stdout(global_this); let buffered_stderr = self.get_buffered_stderr(global_this); self.keep_alive.with_mut(|k| k.disable()); From 2b4152ed32e37958d2e3ac284a39e70b2d081812 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:31:41 +0000 Subject: [PATCH 3/9] [autofix.ci] apply automated fixes --- src/runtime/socket/socket_body.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index d22b4a3d02fe..11a5a22e8575 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -1619,7 +1619,8 @@ impl NewSocket { let callback = handlers.on_end(); let vm = handlers.vm; - if callback.is_empty() || vm.script_execution_status() != jsc::ScriptExecutionStatus::Running + if callback.is_empty() + || vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { this.poll_ref.with_mut(|p| p.unref(js_loop_ctx())); From 74c0a240b8aeb059aaee47475196a8504f900f20 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:44:55 +0000 Subject: [PATCH 4/9] server: move the terminate gate into prepare_js_request_context{,_for} 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. --- src/runtime/server/mod.rs | 15 ++++++++------- src/runtime/server/server_body.rs | 6 ++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 72d212f380fd..43f7a8c25b98 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -672,6 +672,14 @@ impl NewServer { // S008: `Response` is a ZST opaque — safe `*mut → &mut` deref. let resp_ref = bun_opaque::opaque_deref_mut(resp); + // A worker terminate() raised inside a previous request's handler in + // the same poll sweep leaves the TerminationException pending; every + // on_request-family entry funnels here before its first JS call. + if server.vm().script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { + server_body::respond_stopped_503(resp_ref); + return None; + } + // We need to register the handler immediately since uSockets will not buffer. // // We first validate the self-reported request body length so that @@ -1082,13 +1090,6 @@ impl NewServer { server_body::respond_stopped_503(bun_opaque::opaque_deref_mut(resp)); return; }; - // SAFETY: `this` is the live server backref for this request. - if unsafe { &*this }.vm().script_execution_status() - != bun_jsc::ScriptExecutionStatus::Running - { - server_body::respond_stopped_503(bun_opaque::opaque_deref_mut(resp)); - return; - } let should_deinit_context = core::cell::Cell::new(false); let Some(prepared) = Self::prepare_js_request_context( this, diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 36d0aa29b2b6..e150ff6325b6 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3077,6 +3077,12 @@ where ) -> Option> { jsc::mark_binding!(); + // Same worker-terminate gate as `NewServer::prepare_js_request_context`. + if self.vm().script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { + respond_stopped_503(resp); + return None; + } + // We need to register the handler immediately since uSockets will not buffer. // // We first validate the self-reported request body length so that From 801fd3a0fd9381c2b2e512e9bcd5078f5bd001be Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:12:49 +0000 Subject: [PATCH 5/9] socket/server: sweep the remaining is_shutting_down() JS-entry gates 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. --- src/runtime/server/mod.rs | 3 +++ src/runtime/socket/Handlers.rs | 6 +++--- src/runtime/socket/Listener.rs | 7 ++++--- src/runtime/socket/UpgradedDuplex.rs | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 43f7a8c25b98..25e658b8299c 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -555,6 +555,9 @@ impl NewServer { /// `Finalized` means the slots are gone and the `config` shadows may point /// at freed cells. Dispatch trampolines answer 503+close on `None`. pub(crate) fn js_value_for_dispatch(&self) -> Option { + if self.vm().script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { + return None; + } self.js_value.try_get() } } diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index cde8a3463988..a0e9588ed555 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -215,7 +215,7 @@ impl Handlers { pub(crate) fn resolve_promise(&self, value: JSValue) -> JsResult<()> { let vm = self.vm; - if vm.is_shutting_down() { + if vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return Ok(()); } @@ -231,7 +231,7 @@ impl Handlers { pub(crate) fn reject_promise(&self, value: JSValue) -> JsResult { let vm = self.vm; - if vm.is_shutting_down() { + if vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return Ok(true); } @@ -277,7 +277,7 @@ impl Handlers { pub(crate) fn call_error_handler(&self, this_value: JSValue, args: &[JSValue; 2]) -> bool { let vm = self.vm; - if vm.is_shutting_down() { + if vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return false; } diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 6b2dbf01aba5..67534a039bce 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -1713,7 +1713,8 @@ impl WindowsNamedPipeListeningContext { // SAFETY: `this` is the `data` pointer libuv hands back; it was set to a // live heap `WindowsNamedPipeListeningContext` in `listen_named_pipe`. let this_ref = unsafe { &mut *this }; - let shutting_down = this_ref.vm.is_shutting_down(); + let shutting_down = + this_ref.vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running; if status != uv::ReturnCode::ZERO || shutting_down || this_ref.listener.is_none() { // connection dropped or vm is shutting down or we are deiniting/closing return; @@ -1915,7 +1916,7 @@ pub(crate) extern "C" fn us_dispatch_socket_server_name( return core::ptr::null_mut(); } let handlers = tls.get_handlers(); - if handlers.vm.is_shutting_down() { + if handlers.vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return core::ptr::null_mut(); } let callback = handlers.on_server_name(); @@ -2008,7 +2009,7 @@ extern "C" fn us_dispatch_server_name( // duration of this synchronous handshake dispatch. let listener = unsafe { bun_ptr::ThisPtr::new(listener_ptr) }; let handlers = &listener.handlers; - if handlers.vm.is_shutting_down() { + if handlers.vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return core::ptr::null_mut(); } let callback = handlers.on_server_name(); diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 5b5a364a17af..e765b81d922f 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -207,7 +207,7 @@ impl UpgradedDuplex { // `vm` is always set via `from()`; `None` only in the zeroed placeholder // state, which never reaches here. let Some(vm) = self.vm else { return }; - if vm.is_shutting_down() { + if vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return; } let duplex = self.origin; From d91033f1dfab051ef9a39298279f6d73bc9d0d16 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:36:50 +0000 Subject: [PATCH 6/9] server: consolidate the request-entry gate in js_value_for_dispatch 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. --- src/runtime/server/RequestContext.rs | 9 +++------ src/runtime/server/mod.rs | 10 +++++++--- src/runtime/server/server_body.rs | 6 ------ 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 318328f32e1c..1c52a7b92a7e 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3583,17 +3583,14 @@ where ) { jsc::mark_binding!(); if let Some(server) = self.server { + // SAFETY: BACKREF + let server = &*server; // The "error" may be the worker's TerminationException raised // inside on_request's fetch-handler call; entering the user // error() handler with it pending trips assertNoException(). - // SAFETY: BACKREF - if (*server).vm().script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { + if server.vm().script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return; } - } - if let Some(server) = self.server { - // SAFETY: BACKREF - let server = &*server; let on_error = server.config().on_error; if !on_error.is_empty() && !self.flags.has_called_error_handler() { self.flags.set_has_called_error_handler(true); diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 25e658b8299c..303ec81df23e 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -554,6 +554,11 @@ impl NewServer { /// still live (its WriteBarrier slots still root the handlers); only /// `Finalized` means the slots are gone and the `config` shadows may point /// at freed cells. Dispatch trampolines answer 503+close on `None`. + /// + /// Also `None` once a worker's terminate() has armed the trap: a previous + /// request's handler in the same poll sweep may have left the sticky + /// TerminationException pending, and every request-family entry calls this + /// before its first JS entry. pub(crate) fn js_value_for_dispatch(&self) -> Option { if self.vm().script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { return None; @@ -675,9 +680,8 @@ impl NewServer { // S008: `Response` is a ZST opaque — safe `*mut → &mut` deref. let resp_ref = bun_opaque::opaque_deref_mut(resp); - // A worker terminate() raised inside a previous request's handler in - // the same poll sweep leaves the TerminationException pending; every - // on_request-family entry funnels here before its first JS call. + // Belt-and-braces for `prepare_and_save_js_request_context` (bake), + // which reaches here without the `js_value_for_dispatch()` gate. if server.vm().script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { server_body::respond_stopped_503(resp_ref); return None; diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index e150ff6325b6..36d0aa29b2b6 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3077,12 +3077,6 @@ where ) -> Option> { jsc::mark_binding!(); - // Same worker-terminate gate as `NewServer::prepare_js_request_context`. - if self.vm().script_execution_status() != bun_jsc::ScriptExecutionStatus::Running { - respond_stopped_503(resp); - return None; - } - // We need to register the handler immediately since uSockets will not buffer. // // We first validate the self-reported request body length so that From 5880eb439f1f2a64fc1906972f8702881c4b880f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:53:49 +0000 Subject: [PATCH 7/9] server: drop stale 'only trips once finalized' remark at the downgrade site --- src/runtime/server/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 303ec81df23e..18890ee174f5 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1771,9 +1771,9 @@ impl NewServer { } if self.pending_requests == 0 && !self.has_listener() && !self.has_active_web_sockets() { // Make the wrapper collectible. Dispatch still works while it is - // `Weak` (its WriteBarrier slots still root the handlers); the - // `js_value_for_dispatch` gate only trips once the wrapper is - // actually finalized. + // `Weak` (its WriteBarrier slots still root the handlers); see + // `js_value_for_dispatch` for the conditions under which the gate + // trips. self.js_value.downgrade(); if let Some(ws) = self.config.websocket.as_mut() { ws.handler.app = None; From 66c07c1243cabeb5924bf28288c105390cb3dae7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:06:18 +0000 Subject: [PATCH 8/9] UpgradedDuplex/RequestContext: bail between JS entries when the first 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. --- src/runtime/server/RequestContext.rs | 3 +++ src/runtime/socket/UpgradedDuplex.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 1c52a7b92a7e..ba699a174b13 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3601,6 +3601,9 @@ where &[value], ) .unwrap_or_else(|err| server.global_this().take_exception(err)); + if server.global_this().has_exception() { + return; + } let _keep = jsc::EnsureStillAlive(result); if !result.is_empty_or_undefined_or_null() { if let Some(err) = result.to_error() { diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index e765b81d922f..aae797de204c 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -229,6 +229,9 @@ impl UpgradedDuplex { // Best-effort probe: consume the exception and fall through. Err(err) => drop(global.take_exception(err)), } + if global.has_exception() { + return; + } } let name = if msg_more { "write" } else { "end" }; From 1b9a1f85af7d05208dc149ce5ec47c5917211dc1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:10:19 +0000 Subject: [PATCH 9/9] Listener: genericize the vm field doc-comment example --- src/runtime/socket/Listener.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 67534a039bce..68f87a0b260e 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -1688,7 +1688,7 @@ pub struct WindowsNamedPipeListeningContext { pub(crate) listener: Option>, pub global_this: GlobalRef, /// JSC_BORROW: process-lifetime singleton; `&'static` so call sites read - /// `self.vm.is_shutting_down()` without a raw-pointer deref. + /// `self.vm.()` without a raw-pointer deref. pub(crate) vm: &'static VirtualMachine, pub ctx: Option>, // server reuses the same ctx }