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..893f703f4108 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,77 @@ 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 () => { + // 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"); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + 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-