From 369bf5f93e93f556d0e079d54f2a173c8efb440b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:26:52 +0000 Subject: [PATCH 1/7] worker: survive terminate() while Bun.serve is streaming a type:"direct" body A Worker running Bun.serve() that streams a type:"direct" ReadableStream body, terminated mid-stream, SIGABRTed debug/ASAN builds with ASSERTION FAILED: vm.hasTerminationRequest() VMTraps.cpp(539) : void JSC::VMTraps::deferTerminationSlow(DeferAction) do_render_stream drives the async pull() loop via drain_microtasks(). When the termination trap fires inside it, the outermost VMEntryScope's executeEntryScopeServicesOnExit clears hasTerminationRequest while the TerminationException stays pending. do_render_stream then kept going and lazy-initialized NativePromiseContextStructure; LazyProperty::callFunc opens a DeferTerminationForAWhile scope unconditionally, whose deferTerminationSlow asserts hasTerminationRequest() whenever a termination exception is pending. WebWorker::shutdown had the same inconsistency: it cleared only the request flag before on_exit / close_all_socket_groups, leaving the sticky exception in place. Fix: - do_render_stream: bail out after drain_microtasks() when an exception is pending instead of re-entering JS; worker shutdown tears down the sink. - drainMicrotasks: re-set hasTerminationRequest before returning the termination signal so the DeferTermination invariant holds for any caller that proceeds to touch a LazyProperty. - WebWorker::shutdown: clear the pending TerminationException along with the request flag so on_exit handlers and close_all_socket_groups can re-enter JS. --- src/jsc/bindings/ZigGlobalObject.cpp | 21 ++++++- src/jsc/web_worker.rs | 12 +++- src/runtime/server/RequestContext.rs | 17 ++++++ .../workers/worker-terminate-lifetime.test.ts | 57 +++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index b98da54b52e4..e16a1208f074 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3230,9 +3230,24 @@ uint8_t GlobalObject::drainMicrotasks() auto& vm = this->vm(); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + // Each `return 1` below observes the TerminationException still pending. + // The outermost VMEntryScope that raised it has already run + // executeEntryScopeServicesOnExit(), which clears hasTerminationRequest() + // while leaving the exception in place. Re-set the flag so JSC's + // DeferTermination invariant (hasPendingTerminationException() implies + // hasTerminationRequest(); see VMTraps::deferTerminationSlow) holds for a + // caller that does not consult the return value and proceeds to touch a + // LazyProperty (LazyPropertyInlines.h callFunc opens + // DeferTerminationForAWhile unconditionally). setHasTerminationRequest() + // is idempotent. + auto returnTerminated = [&]() -> uint8_t { + vm.setHasTerminationRequest(); + return 1; + }; + if (auto* exception = scope.exception()) [[unlikely]] { if (vm.isTerminationException(exception)) [[unlikely]] { - return 1; + return returnTerminated(); } #if ASSERT_ENABLED @@ -3254,7 +3269,7 @@ uint8_t GlobalObject::drainMicrotasks() nextTickQueue->drain(vm, this); if (auto* exception = scope.exception()) { if (vm.isTerminationException(exception)) { - return 1; + return returnTerminated(); } (void)scope.tryClearException(); this->reportUncaughtExceptionAtEventLoop(this, exception); @@ -3264,7 +3279,7 @@ uint8_t GlobalObject::drainMicrotasks() vm.drainMicrotasks(); if (auto* exception = scope.exception()) { if (vm.isTerminationException(exception)) { - return 1; + return returnTerminated(); } (void)scope.tryClearException(); this->reportUncaughtExceptionAtEventLoop(this, exception); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index d4e1759d2f7a..f8b463761577 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1244,9 +1244,15 @@ impl WebWorker { // other thread can dereference it now — `&mut` is exclusive. let vm = unsafe { &mut *vm_ptr }; // terminate() set the JSC termination flag to interrupt running JS; - // clear it so process.on('exit') handlers can run. teardownJSCVM - // re-sets it for the JSC VM teardown. - vm.jsc_vm().clear_has_termination_request(); + // clear both the request flag and the already-thrown + // TerminationException so process.on('exit') handlers and + // close_all_socket_groups' on_close callbacks can re-enter JS. + // Clearing only the flag would leave the sticky exception pending, + // and JSC's DeferTermination scope asserts + // `hasTerminationRequest()` whenever a termination exception is + // pending (VMTraps::deferTerminationSlow). teardownJSCVM re-sets + // the flag for the JSC VM teardown. + vm.global().clear_termination_exception(); vm.is_shutting_down = true; vm.on_exit(); if let Some(hooks) = runtime_hooks() { diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 87bd776e2c12..3b6ef7d2c572 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2129,6 +2129,23 @@ where stream_log!("returned a promise"); this.drain_microtasks(); + // A worker terminate() arriving while drain_microtasks() was + // driving the stream's async pull leaves the + // TerminationException pending here. Every branch below would + // then re-enter JS (NativePromiseContext lazy-init / + // then_with_value / handle_resolve_stream / + // handle_reject_stream), tripping JSC's DeferTermination or + // executeCallImpl assertNoException under debug/ASAN. The + // worker is shutting down, so the sink already in `this.sink` + // is torn down by the server abort path during + // close_all_socket_groups; nothing to wire up. + if global_this.has_exception() { + response_stream.sink.on_first_write = None; + response_stream.sink.ctx = None; + this.flags.set_has_marked_pending(true); + return; + } + // `MarkHandled` matters for the Rejected arm: the promise // settled before any reaction was attached, so without the // flag the VM would report it as an unhandled rejection even diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 374246033f82..c12a54b4198d 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -11,6 +11,63 @@ const rounds = slow ? 4 : 8; const perRound = slow ? 12 : 32; const timeout = slow ? 60_000 : 20_000; +// Regression: Bun.serve() inside a worker streaming a type:"direct" body, +// then worker.terminate() mid-stream. drain_microtasks() drives the async +// pull loop; when the termination trap fires inside it, the outermost +// VMEntryScope's executeEntryScopeServicesOnExit clears hasTerminationRequest +// while the TerminationException stays pending. do_render_stream then kept +// going and (a) lazy-initialized NativePromiseContextStructure inside a +// DeferTerminationForAWhile scope, asserting hasTerminationRequest() in +// VMTraps::deferTerminationSlow, and (b) re-entered JS via then_with_value, +// asserting !exception() in executeCallImpl. Separately, WebWorker::shutdown +// cleared only the request flag before on_exit / close_all_socket_groups, +// leaving the sticky exception in place for the same class of assert. Both +// asserts are debug/ASAN-only; release builds compile them out. +test.skipIf(!isDebug)( + "terminate() while Bun.serve is streaming a type:\"direct\" body does not trip DeferTermination / assertNoException", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("node:worker_threads"); + const src = 'const server = Bun.serve({ port: 0, fetch() {' + + ' return new Response(new ReadableStream({ type: "direct", async pull(ctrl) {' + + ' for (let i = 0; i < 20000; i++) {' + + ' ctrl.write(new Uint8Array(8192).fill(i));' + + ' await ctrl.flush();' + + ' }' + + ' ctrl.close();' + + ' }, cancel() {} }));' + + '} });' + + 'require("node:worker_threads").parentPort.postMessage(server.port);'; + for (let i = 0; i < ${rounds}; i++) { + const w = new Worker(src, { eval: true }); + const port = await new Promise(r => w.once("message", r)); + const res = await fetch("http://127.0.0.1:" + port + "/"); + const rd = res.body.getReader(); + await rd.read(); + const done = new Promise(r => w.once("exit", r)); + w.terminate(); + await done; + await rd.cancel().catch(() => {}); + } + console.log("survived"); + `, + ], + env: bunEnv, + 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("survived\n"); + expect(exitCode).toBe(0); + }, + timeout, +); + // Regression: `new Worker(url, { ref: false })` was silently ignored — the // Zig-side `user_keep_alive` field was set from it but never read, and the // parent keep-alive was taken unconditionally in `create()`. `.unref()` after From bc829572d29ea1a069b1f519a28d29c6b3488d96 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:29:28 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- test/js/web/workers/worker-terminate-lifetime.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index c12a54b4198d..3a1e25299ccf 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -24,7 +24,7 @@ const timeout = slow ? 60_000 : 20_000; // leaving the sticky exception in place for the same class of assert. Both // asserts are debug/ASAN-only; release builds compile them out. test.skipIf(!isDebug)( - "terminate() while Bun.serve is streaming a type:\"direct\" body does not trip DeferTermination / assertNoException", + 'terminate() while Bun.serve is streaming a type:"direct" body does not trip DeferTermination / assertNoException', async () => { await using proc = Bun.spawn({ cmd: [ From c3ca20e95991e991b1701b2a05bcc96415cbd6e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:19:31 +0000 Subject: [PATCH 3/7] address review: move clear_termination_exception after on_exit, drop drainMicrotasks change, cover on_response sibling, delete dead clear_has_termination_request --- src/jsc/VM.rs | 4 ---- src/jsc/bindings/ZigGlobalObject.cpp | 21 +++---------------- src/jsc/bindings/bindings.cpp | 5 ----- src/jsc/web_worker.rs | 16 ++++++-------- src/runtime/server/RequestContext.rs | 20 +++++++++--------- .../workers/worker-terminate-lifetime.test.ts | 8 +++---- 6 files changed, 23 insertions(+), 51 deletions(-) diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index 41f780c67f5b..77e7f8e0bc76 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -118,10 +118,6 @@ impl VM { JSC__VM__notifyNeedTermination(self) } - pub(crate) fn clear_has_termination_request(&self) { - crate::cpp::JSC__VM__clearHasTerminationRequest(self) - } - #[track_caller] pub fn throw_error(&self, global_object: &JSGlobalObject, value: JSValue) -> JsError { crate::validation_scope!(scope, global_object); diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index e16a1208f074..b98da54b52e4 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3230,24 +3230,9 @@ uint8_t GlobalObject::drainMicrotasks() auto& vm = this->vm(); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - // Each `return 1` below observes the TerminationException still pending. - // The outermost VMEntryScope that raised it has already run - // executeEntryScopeServicesOnExit(), which clears hasTerminationRequest() - // while leaving the exception in place. Re-set the flag so JSC's - // DeferTermination invariant (hasPendingTerminationException() implies - // hasTerminationRequest(); see VMTraps::deferTerminationSlow) holds for a - // caller that does not consult the return value and proceeds to touch a - // LazyProperty (LazyPropertyInlines.h callFunc opens - // DeferTerminationForAWhile unconditionally). setHasTerminationRequest() - // is idempotent. - auto returnTerminated = [&]() -> uint8_t { - vm.setHasTerminationRequest(); - return 1; - }; - if (auto* exception = scope.exception()) [[unlikely]] { if (vm.isTerminationException(exception)) [[unlikely]] { - return returnTerminated(); + return 1; } #if ASSERT_ENABLED @@ -3269,7 +3254,7 @@ uint8_t GlobalObject::drainMicrotasks() nextTickQueue->drain(vm, this); if (auto* exception = scope.exception()) { if (vm.isTerminationException(exception)) { - return returnTerminated(); + return 1; } (void)scope.tryClearException(); this->reportUncaughtExceptionAtEventLoop(this, exception); @@ -3279,7 +3264,7 @@ uint8_t GlobalObject::drainMicrotasks() vm.drainMicrotasks(); if (auto* exception = scope.exception()) { if (vm.isTerminationException(exception)) { - return returnTerminated(); + return 1; } (void)scope.tryClearException(); this->reportUncaughtExceptionAtEventLoop(this, exception); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 59947724bd44..aba1efd1ce2b 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -4923,11 +4923,6 @@ bool JSC__VM__isTerminationException(JSC::VM* vm, JSC::Exception* exception) return vm->isTerminationException(exception); } -[[ZIG_EXPORT(nothrow)]] -void JSC__VM__clearHasTerminationRequest(JSC::VM* vm) -{ - vm->clearHasTerminationRequest(); -} [[ZIG_EXPORT(nothrow)]] bool JSC__VM__hasTerminationRequest(JSC::VM* vm) { diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index f8b463761577..675a25f755bf 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1243,18 +1243,14 @@ impl WebWorker { // SAFETY: vm_ptr valid; unpublished above under vm_lock, so no // other thread can dereference it now — `&mut` is exclusive. let vm = unsafe { &mut *vm_ptr }; - // terminate() set the JSC termination flag to interrupt running JS; - // clear both the request flag and the already-thrown - // TerminationException so process.on('exit') handlers and - // close_all_socket_groups' on_close callbacks can re-enter JS. - // Clearing only the flag would leave the sticky exception pending, - // and JSC's DeferTermination scope asserts - // `hasTerminationRequest()` whenever a termination exception is - // pending (VMTraps::deferTerminationSlow). teardownJSCVM re-sets - // the flag for the JSC VM teardown. - vm.global().clear_termination_exception(); vm.is_shutting_down = true; + // dispatchExitInternal's termination guard skips process.on('exit') + // on terminate() (node semantics), so the pending + // TerminationException may stay in place across on_exit. vm.on_exit(); + // Now clear it so close_all_socket_groups' on_close callbacks can + // re-enter JS. teardownJSCVM re-sets the flag for the VM teardown. + vm.global().clear_termination_exception(); if let Some(hooks) = runtime_hooks() { (hooks.cron_clear_all_teardown)(vm); // Drain `TimeoutObject`s from this worker's timer heap before diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 3b6ef7d2c572..9ccb5ce4479f 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2129,16 +2129,9 @@ where stream_log!("returned a promise"); this.drain_microtasks(); - // A worker terminate() arriving while drain_microtasks() was - // driving the stream's async pull leaves the - // TerminationException pending here. Every branch below would - // then re-enter JS (NativePromiseContext lazy-init / - // then_with_value / handle_resolve_stream / - // handle_reject_stream), tripping JSC's DeferTermination or - // executeCallImpl assertNoException under debug/ASAN. The - // worker is shutting down, so the sink already in `this.sink` - // is torn down by the server abort path during - // close_all_socket_groups; nothing to wire up. + // worker.terminate() landing inside drain_microtasks leaves the + // TerminationException pending; every branch below re-enters + // JS. Leave the sink on `this` for the server abort path. if global_this.has_exception() { response_stream.sink.on_first_write = None; response_stream.sink.ctx = None; @@ -2696,6 +2689,13 @@ where response_value.ensure_still_alive(); ctx.drain_microtasks(); + // Same guard as do_render_stream: worker.terminate() landing inside the + // drain leaves the TerminationException pending, and the paths below + // re-enter JS. + if this.global_this().has_exception() { + return; + } + if ctx.is_aborted_or_ended() { return; } diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 3a1e25299ccf..6e1fa4567172 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -20,10 +20,10 @@ const timeout = slow ? 60_000 : 20_000; // DeferTerminationForAWhile scope, asserting hasTerminationRequest() in // VMTraps::deferTerminationSlow, and (b) re-entered JS via then_with_value, // asserting !exception() in executeCallImpl. Separately, WebWorker::shutdown -// cleared only the request flag before on_exit / close_all_socket_groups, -// leaving the sticky exception in place for the same class of assert. Both -// asserts are debug/ASAN-only; release builds compile them out. -test.skipIf(!isDebug)( +// cleared only the request flag before close_all_socket_groups, leaving the +// sticky exception in place for the same class of assert. Both asserts are +// compiled out outside debug/ASAN builds. +test.skipIf(!(isDebug || isASAN))( 'terminate() while Bun.serve is streaming a type:"direct" body does not trip DeferTermination / assertNoException', async () => { await using proc = Bun.spawn({ From 8a48b1cbd8d1ed20ed5c4afe674f4e8b09c26bf5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:22:40 +0000 Subject: [PATCH 4/7] trim comments to single lines for comment-cop --- src/jsc/web_worker.rs | 7 ++----- src/runtime/server/RequestContext.rs | 8 ++------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 675a25f755bf..ffd99b6ce6f8 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1244,12 +1244,9 @@ impl WebWorker { // other thread can dereference it now — `&mut` is exclusive. let vm = unsafe { &mut *vm_ptr }; vm.is_shutting_down = true; - // dispatchExitInternal's termination guard skips process.on('exit') - // on terminate() (node semantics), so the pending - // TerminationException may stay in place across on_exit. + // on_exit first: dispatchExitInternal skips 'exit' on terminate (node semantics). vm.on_exit(); - // Now clear it so close_all_socket_groups' on_close callbacks can - // re-enter JS. teardownJSCVM re-sets the flag for the VM teardown. + // Cleared here so close_all_socket_groups can re-enter JS; teardownJSCVM re-sets it. vm.global().clear_termination_exception(); if let Some(hooks) = runtime_hooks() { (hooks.cron_clear_all_teardown)(vm); diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 9ccb5ce4479f..5f5be1fa6f93 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2129,9 +2129,7 @@ where stream_log!("returned a promise"); this.drain_microtasks(); - // worker.terminate() landing inside drain_microtasks leaves the - // TerminationException pending; every branch below re-enters - // JS. Leave the sink on `this` for the server abort path. + // terminate() inside the drain leaves a pending TerminationException; every branch below re-enters JS. if global_this.has_exception() { response_stream.sink.on_first_write = None; response_stream.sink.ctx = None; @@ -2689,9 +2687,7 @@ where response_value.ensure_still_alive(); ctx.drain_microtasks(); - // Same guard as do_render_stream: worker.terminate() landing inside the - // drain leaves the TerminationException pending, and the paths below - // re-enter JS. + // Same as do_render_stream: terminate() inside the drain leaves a pending TerminationException. if this.global_this().has_exception() { return; } From a8c960d652119e5f8889262cb356d601a9430a28 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:43:37 +0000 Subject: [PATCH 5/7] add coverage for on_response guard and process.on('exit') skip on terminate --- .../workers/worker-terminate-lifetime.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 6e1fa4567172..fcbf72619fe8 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -68,6 +68,89 @@ test.skipIf(!(isDebug || isASAN))( timeout, ); +// Sibling of the above: on_response's drain_microtasks() driving an async +// fetch handler that never yields past `await 1`. Same DeferTermination / +// assertNoException pair, same fix shape. +test.skipIf(!(isDebug || isASAN))( + "terminate() while Bun.serve's async fetch is looping in drain_microtasks does not trip DeferTermination", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("node:worker_threads"); + const sab = new SharedArrayBuffer(4); + const flag = new Int32Array(sab); + const src = 'const { workerData } = require("node:worker_threads");' + + 'const flag = new Int32Array(workerData.sab);' + + 'const server = Bun.serve({ port: 0, async fetch() {' + + ' for (;;) { Atomics.add(flag, 0, 1); await 1; }' + + '} });' + + 'require("node:worker_threads").parentPort.postMessage(server.port);'; + for (let i = 0; i < ${rounds}; i++) { + Atomics.store(flag, 0, 0); + const w = new Worker(src, { eval: true, workerData: { sab } }); + const port = await new Promise(r => w.once("message", r)); + const ac = new AbortController(); + fetch("http://127.0.0.1:" + port + "/", { signal: ac.signal }).catch(() => {}); + while (Atomics.load(flag, 0) < 100) await Bun.sleep(0); + const done = new Promise(r => w.once("exit", r)); + w.terminate(); + await done; + ac.abort(); + } + console.log("survived"); + `, + ], + env: bunEnv, + 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("survived\n"); + expect(exitCode).toBe(0); + }, + timeout, +); + +// Node skips the worker's process.on('exit') listeners on worker.terminate(). +// WebWorker::shutdown must leave the TerminationException in place across +// on_exit so dispatchExitInternal's guard trips; clearing it earlier would let +// the handler run. +test("process.on('exit') does not fire on worker.terminate()", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("node:worker_threads"); + const sab = new SharedArrayBuffer(4); + const counter = new Int32Array(sab); + const src = 'const { workerData, parentPort } = require("node:worker_threads");' + + 'const counter = new Int32Array(workerData.sab);' + + 'process.on("exit", () => Atomics.add(counter, 0, 1));' + + 'parentPort.postMessage("ready");' + + 'setInterval(() => {}, 100000);'; + const w = new Worker(src, { eval: true, workerData: { sab } }); + await new Promise(r => w.once("message", r)); + const done = new Promise(r => w.once("exit", r)); + w.terminate(); + await done; + console.log("exitCalls=" + Atomics.load(counter, 0)); + `, + ], + env: bunEnv, + 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("exitCalls=0\n"); + expect(exitCode).toBe(0); +}); + // Regression: `new Worker(url, { ref: false })` was silently ignored — the // Zig-side `user_keep_alive` field was set from it but never read, and the // parent keep-alive was taken unconditionally in `create()`. `.unref()` after From 65d6b7d3a88f71ebf4bceda1ea1c6ff747f0b98d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:17:25 +0000 Subject: [PATCH 6/7] make process.on('exit') skip check load-bearing by folding into mid-JS terminate cell --- .../workers/worker-terminate-lifetime.test.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index fcbf72619fe8..db5765a9277b 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -70,7 +70,11 @@ test.skipIf(!(isDebug || isASAN))( // Sibling of the above: on_response's drain_microtasks() driving an async // fetch handler that never yields past `await 1`. Same DeferTermination / -// assertNoException pair, same fix shape. +// assertNoException pair, same fix shape. Also asserts Node's +// "process.on('exit') is skipped on terminate()" when terminate lands while +// JS is on the stack (sab[1] stays 0): in that state the NeedTermination trap +// has already been consumed, so only the sticky TerminationException guards +// dispatchExitInternal; clearing it before on_exit would let the handler run. test.skipIf(!(isDebug || isASAN))( "terminate() while Bun.serve's async fetch is looping in drain_microtasks does not trip DeferTermination", async () => { @@ -80,16 +84,18 @@ test.skipIf(!(isDebug || isASAN))( "-e", ` const { Worker } = require("node:worker_threads"); - const sab = new SharedArrayBuffer(4); + const sab = new SharedArrayBuffer(8); const flag = new Int32Array(sab); const src = 'const { workerData } = require("node:worker_threads");' + 'const flag = new Int32Array(workerData.sab);' + + 'process.on("exit", () => Atomics.add(flag, 1, 1));' + 'const server = Bun.serve({ port: 0, async fetch() {' + ' for (;;) { Atomics.add(flag, 0, 1); await 1; }' + '} });' + 'require("node:worker_threads").parentPort.postMessage(server.port);'; for (let i = 0; i < ${rounds}; i++) { Atomics.store(flag, 0, 0); + Atomics.store(flag, 1, 0); const w = new Worker(src, { eval: true, workerData: { sab } }); const port = await new Promise(r => w.once("message", r)); const ac = new AbortController(); @@ -99,6 +105,7 @@ test.skipIf(!(isDebug || isASAN))( w.terminate(); await done; ac.abort(); + if (Atomics.load(flag, 1) !== 0) throw new Error("process.on('exit') fired on terminate"); } console.log("survived"); `, @@ -115,11 +122,11 @@ test.skipIf(!(isDebug || isASAN))( timeout, ); -// Node skips the worker's process.on('exit') listeners on worker.terminate(). -// WebWorker::shutdown must leave the TerminationException in place across -// on_exit so dispatchExitInternal's guard trips; clearing it earlier would let -// the handler run. -test("process.on('exit') does not fire on worker.terminate()", async () => { +// Node skips the worker's process.on('exit') listeners on worker.terminate() +// when the worker is idle (the NeedTermination trap bit is still armed here, +// so this is the simpler half of the invariant; the mid-JS half is checked in +// the async-fetch test above). +test("process.on('exit') does not fire on worker.terminate() when idle", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), From 2183b9d3d41e5bab34305cbf9c914add2f50610c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:25:24 +0000 Subject: [PATCH 7/7] cover on_node_http_request_with_upgrade_ctx sibling (same drain_microtasks -> then2 shape) --- src/runtime/server/mod.rs | 5 +++ .../workers/worker-terminate-lifetime.test.ts | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 61044580f1db..d630754e4021 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1313,6 +1313,11 @@ impl NewServer { needs_to_drain = false; // SAFETY: `vm` is the process-static VirtualMachine. unsafe { (*vm).drain_microtasks() }; + // Same as RequestContext::on_response: terminate() inside the drain leaves a pending TerminationException. + if global.has_exception() { + strong_promise.deinit(); + break 'brk HttpResult::Success; + } status = promise.status(); } diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index db5765a9277b..02a9559aa5a1 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -122,6 +122,51 @@ test.skipIf(!(isDebug || isASAN))( timeout, ); +// Same again for the node:http-compat path (on_node_http_request_with_upgrade_ctx). +test.skipIf(!(isDebug || isASAN))( + "terminate() while a node:http server's async handler is looping in drain_microtasks does not trip DeferTermination", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("node:worker_threads"); + const sab = new SharedArrayBuffer(4); + const flag = new Int32Array(sab); + const src = 'const { workerData } = require("node:worker_threads");' + + 'const flag = new Int32Array(workerData.sab);' + + 'const http = require("node:http");' + + 'const server = http.createServer(async (req, res) => {' + + ' for (;;) { Atomics.add(flag, 0, 1); await 1; }' + + '}).listen(0, () => require("node:worker_threads").parentPort.postMessage(server.address().port));'; + for (let i = 0; i < ${rounds}; i++) { + Atomics.store(flag, 0, 0); + const w = new Worker(src, { eval: true, workerData: { sab } }); + const port = await new Promise(r => w.once("message", r)); + const ac = new AbortController(); + fetch("http://127.0.0.1:" + port + "/", { signal: ac.signal }).catch(() => {}); + while (Atomics.load(flag, 0) < 100) await Bun.sleep(0); + const done = new Promise(r => w.once("exit", r)); + w.terminate(); + await done; + ac.abort(); + } + console.log("survived"); + `, + ], + env: bunEnv, + 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("survived\n"); + expect(exitCode).toBe(0); + }, + timeout, +); + // Node skips the worker's process.on('exit') listeners on worker.terminate() // when the worker is idle (the NeedTermination trap bit is still armed here, // so this is the simpler half of the invariant; the mid-JS half is checked in