Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions src/jsc/RuntimeTranspilerStore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,19 +237,19 @@ impl RuntimeTranspilerStore {
event_loop: NonNull<EventLoop>,
global: &JSGlobalObject,
vm: NonNull<VirtualMachine>,
) {
) -> 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();
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ───────────────
Expand Down
73 changes: 72 additions & 1 deletion test/js/web/workers/worker-terminate-lifetime.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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-
Expand Down
Loading