From 88fbb17f524df8caa3d86fc61149b5d79dd25fe2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:55:10 +0000 Subject: [PATCH 1/2] RuntimeTranspilerStore: don't report TerminationException as uncaught on worker.terminate() A worker dynamic-importing in a loop and terminated mid-iteration has an in-flight TranspilerJob whose JS-thread completion (AsyncModule::fulfill) raises the TerminationException at the from_js_host_call_generic trap check. run_from_js_thread handed that to report_uncaught_exception_from_error, which has no termination filter, so Bun__handleUncaughtException ran process->get("_fatalException") on a terminating VM. The static-hash-table reification inside that lookup transitions process's Structure mid-walk and trips ASSERT(object->structure() == this) in Structure::storedPrototype (debug-build SIGABRT). Route the error through report_error_or_terminate instead (the same helper the AnyTask/ManagedTask/CppTask dispatch arms use), which recognises both JsError::Terminated and a pending TerminationException and propagates JsTerminated to unwind the tick loop. run_from_js_thread now returns Result<(), JsTerminated> so dispatch can short-circuit. --- src/jsc/RuntimeTranspilerStore.rs | 15 ++-- src/runtime/dispatch.rs | 2 +- .../workers/worker-terminate-lifetime.test.ts | 72 ++++++++++++++++++- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index b025baa3e846..03e214ced8d3 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -237,19 +237,19 @@ impl RuntimeTranspilerStore { event_loop: NonNull, global: &JSGlobalObject, vm: NonNull, - ) { + ) -> Result<(), crate::JsTerminated> { let batch = self.queue.pop_batch(); // SAFETY: `vm` is the live owning VM (caller is the JS-thread tick loop). let jsc_vm = unsafe { (*vm.as_ptr()).jsc_vm() }; let mut iter = batch.iterator(); let first = iter.next(); if first.is_null() { - return; + return Ok(()); } // we run just one job first to see if there are more // SAFETY: `first` is a live job popped from the intrusive queue. if let Err(err) = unsafe { (*first).run_from_js_thread() } { - global.report_uncaught_exception_from_error(err); + crate::task::report_error_or_terminate(global, err)?; } loop { let job = iter.next(); @@ -258,18 +258,15 @@ impl RuntimeTranspilerStore { } // if there are more, we need to drain the microtasks from the previous run // SAFETY: `event_loop` is the VM's live event-loop self-pointer. - if unsafe { (*event_loop.as_ptr()).drain_microtasks_with_global(global, jsc_vm) } - .is_err() - { - return; - } + unsafe { (*event_loop.as_ptr()).drain_microtasks_with_global(global, jsc_vm) }?; // SAFETY: `job` is a live job popped from the intrusive queue. if let Err(err) = unsafe { (*job).run_from_js_thread() } { - global.report_uncaught_exception_from_error(err); + crate::task::report_error_or_terminate(global, err)?; } } // immediately after this is called, the microtasks will be drained again. + Ok(()) } pub fn transpile( diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 3dbffa4cb078..9904f3cc1882 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -349,7 +349,7 @@ pub(crate) fn run_task( } task_tag::RuntimeTranspilerStore => { let store = cast!(RuntimeTranspilerStore); - store.run_from_js_thread(el.into(), global, vm.into()); + store.run_from_js_thread(el.into(), global, vm.into())?; } // ── hot-reload (early-returns from the drain loop) ─────────────── diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 374246033f82..3e6673ea8717 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug, tls } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir, tls } from "harness"; import { join } from "path"; // Worker VM startup/teardown is much slower under debug and/or ASAN; these @@ -121,6 +121,76 @@ test( timeout, ); +// Regression: RuntimeTranspilerStore::run_from_js_thread reported the +// TerminationException as an uncaught exception. A worker dynamic-importing +// in a loop and terminated mid-iteration has an in-flight TranspilerJob whose +// JS-thread completion (AsyncModule::fulfill -> promise resolve/reject) raises +// the TerminationException; the old report_uncaught_exception_from_error path +// then reached Bun__handleUncaughtException -> process->get("_fatalException"), +// whose static-property reification transitions process's Structure mid-walk +// and trips ASSERT(object->structure() == this) in Structure::storedPrototype. +// +// Modules have a syntax error so the fetch promise rejects: the rejected +// ModuleLoadTopSettled branch does not call loadModule -> hostLoadImportedModule, +// whose separate scope.assertNoException() termination bug (WebKit-side) would +// otherwise also fire here. +test.skipIf(!isDebug)( + "terminate() while dynamic-import transpiler jobs are in flight does not report TerminationException as uncaught", + async () => { + using dir = tempDir("worker-terminate-dynimport", { + "mod0.mjs": "export default 0; ++++;", + "mod1.mjs": "export default 1; ++++;", + "mod2.mjs": "export default 2; ++++;", + "mod3.mjs": "export default 3; ++++;", + }); + const rounds = 100; + // Uses the global Web Worker (no preloads); node:worker_threads injects a + // "node:worker_threads" preload whose load_preloads spin uses the non- + // termination-aware wait_for_promise and independently hits the WebKit + // assertNoException() termination bug. + const code = ` + import { writeFileSync } from "node:fs"; + import { join } from "node:path"; + const D = ${JSON.stringify(String(dir) + "/")}; + writeFileSync(join(D, "w.mjs"), + 'const D = ' + JSON.stringify(D) + ';' + + 'postMessage("ready");' + + 'let k = 0;' + + 'while (true) {' + + ' const i = k % 4;' + + ' try { await import(D + "mod" + i + ".mjs?v=" + ((k / 4) | 0)); } catch {}' + + ' k++;' + + '}'); + function one(delay) { + return new Promise((resolve) => { + const w = new Worker(join(D, "w.mjs")); + w.onmessage = () => {}; w.onerror = () => {}; + setTimeout(() => { w.terminate(); setTimeout(resolve, 5); }, delay); + }); + } + async function lane(offset) { + for (let i = 0; i < ${rounds}; i++) await one(20 + ((i * 37 + offset) % 120)); + } + await Promise.all([lane(0), lane(17)]); + console.log("survived"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, stdout, exitCode, signalCode: proc.signalCode }).toEqual({ + stderr: "", + stdout: "survived\n", + exitCode: 0, + signalCode: null, + }); + }, + timeout * 2, +); + // Regression: the per-VM c-ares channel was destroyed in deinit_runtime_state // (RuntimeState drop) AFTER JSC teardown and RareData.file_polls drop. // ares_destroy() synchronously fires EDESTRUCTION query callbacks and socket- From 0b45bbe3a7377fc85def1272a329bd26414cb2dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:43:46 +0000 Subject: [PATCH 2/2] test: key terminate off ready signal and move fixtures into tempDir Addresses review: the previous version started the terminate timer at new Worker() with delays capped at 139ms, while debug+ASAN worker startup measured 112-137ms, so on a slower host no round would reach the import loop. Waiting for the ready postMessage guarantees every round is inside the while(true){await import()} when terminate lands, which lets the round count drop (100 -> 60) and the delay sweep shrink (20-139 -> 0-29). Also moves w.mjs and main.mjs into the tempDir object with relative ./modN imports instead of writeFileSync from inside the -e child. --- .../workers/worker-terminate-lifetime.test.ts | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 3e6673ea8717..893f703f4108 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -137,46 +137,47 @@ test( test.skipIf(!isDebug)( "terminate() while dynamic-import transpiler jobs are in flight does not report TerminationException as uncaught", async () => { + // w.mjs lives in the temp dir so it can use relative ./modN imports; + // main.mjs uses the global Web Worker (no preloads): node:worker_threads + // injects a "node:worker_threads" preload whose load_preloads spin uses the + // non-termination-aware wait_for_promise and independently hits the WebKit + // assertNoException() termination bug. using dir = tempDir("worker-terminate-dynimport", { "mod0.mjs": "export default 0; ++++;", "mod1.mjs": "export default 1; ++++;", "mod2.mjs": "export default 2; ++++;", "mod3.mjs": "export default 3; ++++;", + "w.mjs": ` + postMessage("ready"); + let k = 0; + while (true) { + const i = k % 4; + try { await import("./mod" + i + ".mjs?v=" + ((k / 4) | 0)); } catch {} + k++; + } + `, + "main.mjs": ` + const rounds = 60; + function one(delay) { + return new Promise((resolve) => { + const w = new Worker(new URL("./w.mjs", import.meta.url).href); + w.onerror = () => {}; + // Key terminate off the ready signal so every round is past + // startup and inside the import loop. + w.onmessage = () => setTimeout(() => { w.terminate(); setTimeout(resolve, 5); }, delay); + }); + } + async function lane(offset) { + for (let i = 0; i < rounds; i++) await one((i * 7 + offset) % 30); + } + await Promise.all([lane(0), lane(3)]); + console.log("survived"); + `, }); - const rounds = 100; - // Uses the global Web Worker (no preloads); node:worker_threads injects a - // "node:worker_threads" preload whose load_preloads spin uses the non- - // termination-aware wait_for_promise and independently hits the WebKit - // assertNoException() termination bug. - const code = ` - import { writeFileSync } from "node:fs"; - import { join } from "node:path"; - const D = ${JSON.stringify(String(dir) + "/")}; - writeFileSync(join(D, "w.mjs"), - 'const D = ' + JSON.stringify(D) + ';' + - 'postMessage("ready");' + - 'let k = 0;' + - 'while (true) {' + - ' const i = k % 4;' + - ' try { await import(D + "mod" + i + ".mjs?v=" + ((k / 4) | 0)); } catch {}' + - ' k++;' + - '}'); - function one(delay) { - return new Promise((resolve) => { - const w = new Worker(join(D, "w.mjs")); - w.onmessage = () => {}; w.onerror = () => {}; - setTimeout(() => { w.terminate(); setTimeout(resolve, 5); }, delay); - }); - } - async function lane(offset) { - for (let i = 0; i < ${rounds}; i++) await one(20 + ((i * 37 + offset) % 120)); - } - await Promise.all([lane(0), lane(17)]); - console.log("survived"); - `; await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", code], + cmd: [bunExe(), "main.mjs"], env: bunEnv, + cwd: String(dir), stdout: "pipe", stderr: "pipe", });