Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
6 changes: 3 additions & 3 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1074,7 +1074,7 @@ impl VirtualMachine {
}

pub fn handled_promise(&self, global_object: &JSGlobalObject, promise: JSValue) -> bool {
if self.is_shutting_down() {
if self.script_execution_status() != crate::ScriptExecutionStatus::Running {
return true;
}
Bun__emitHandledPromiseEvent(global_object, promise)
Expand Down Expand Up @@ -1348,7 +1348,7 @@ impl VirtualMachine {
err: JSValue,
is_rejection: bool,
) -> bool {
if self.is_shutting_down() {
if self.script_execution_status() != crate::ScriptExecutionStatus::Running {
return true;
}

Expand Down Expand Up @@ -3287,7 +3287,7 @@ impl VirtualMachine {
) {
use bun_options_types::schema::api::UnhandledRejections as Mode;

if self.is_shutting_down() {
if self.script_execution_status() != crate::ScriptExecutionStatus::Running {
Comment thread
robobun marked this conversation as resolved.
bun_core::debug_warn!("unhandledRejection during shutdown.");
return;
}
Expand Down
11 changes: 10 additions & 1 deletion src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1212,9 +1212,14 @@ extern "C" int Bun__handleUncaughtException(JSC::JSGlobalObject* lexicalGlobalOb
if (!lexicalGlobalObject->inherits(Zig::GlobalObject::info()))
return false;
auto* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject);
auto& vm = JSC::getVM(globalObject);

// Stopped == a worker with has_requested_terminate(); the process->get / emit / call walk below asserts under a terminate() race.
if (Zig::GlobalObject::scriptExecutionStatus(globalObject, globalObject) != JSC::ScriptExecutionStatus::Running) [[unlikely]]
return true;
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

auto* process = globalObject->processObject();
auto& wrapped = process->wrapped();
auto& vm = JSC::getVM(globalObject);

// node parity (exitWithUndefinedFatalException): the internal fatal-exception
// handler is monkey-patchable as process._fatalException. If user code
Expand Down Expand Up @@ -1346,6 +1351,8 @@ extern "C" int Bun__handleUnhandledRejection(JSC::JSGlobalObject* lexicalGlobalO
if (!lexicalGlobalObject->inherits(Zig::GlobalObject::info()))
return false;
auto* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject);
if (Zig::GlobalObject::scriptExecutionStatus(globalObject, globalObject) != JSC::ScriptExecutionStatus::Running) [[unlikely]]
return false;
Comment thread
claude[bot] marked this conversation as resolved.
auto* process = globalObject->processObject();

auto eventType = Identifier::fromString(JSC::getVM(globalObject), "unhandledRejection"_s);
Expand All @@ -1369,6 +1376,8 @@ extern "C" bool Bun__emitHandledPromiseEvent(JSC::JSGlobalObject* lexicalGlobalO
if (!lexicalGlobalObject->inherits(Zig::GlobalObject::info()))
return false;
auto* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject);
if (Zig::GlobalObject::scriptExecutionStatus(globalObject, globalObject) != JSC::ScriptExecutionStatus::Running) [[unlikely]]
return false;
auto* process = globalObject->processObject();

auto eventType = Identifier::fromString(JSC::getVM(globalObject), "rejectionHandled"_s);
Expand Down
29 changes: 28 additions & 1 deletion src/jsc/web_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,12 @@
}
}

// terminate() may have landed during entrySettled / the status block above; skip dispatchOnline/tick().
if self.has_requested_terminate() && !self.exit_called.load(Ordering::Relaxed) {
self.flush_logs(vm);
return self.shutdown();
}

self.flush_logs(vm);
log!("[{}] event loop start", self.execution_context_id);
// dispatchOnline fires the parent-side 'open' event and flips the C++
Expand Down Expand Up @@ -1459,6 +1465,9 @@
if vm_log.msgs.is_empty() {
return;
}
if self.has_requested_terminate() {
return;
}

Check warning on line 1470 in src/jsc/web_worker.rs

View check run for this annotation

Claude / Claude Code Review

flush_logs early-return drops configure_defines() error; makes checkpoint flush_logs calls dead

This early-return makes every `flush_logs` call inside a `has_requested_terminate()`-gated block a provable no-op — the new call at :1140 and the two pre-existing checkpoints in `spin()` ("Terminated during startVM()…" and "Terminated while resolving…"). More than dead code: `start_vm()`'s `configure_defines()` failure path uses `set_requested_terminate()` as a same-thread self-signal and per its comment "vm.log carries the error for flushLogs" relies on the first checkpoint's `flush_logs` to di
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
let global = vm.global();
let result: jsc::JsResult<(JSValue, BunString)> = (|| {
let err = vm_log.to_js(global, "Error in worker")?;
Expand All @@ -1468,14 +1477,32 @@
let (err, str) = match result {
Ok(pair) => pair,
Err(JsError::OutOfMemory) => bun_core::out_of_memory(),
Err(JsError::Thrown | JsError::Terminated) => panic!("unhandled exception"),
Err(JsError::Terminated) => return,
Err(e @ JsError::Thrown) => {
if self.has_requested_terminate() {
return;
}
if let Some(exc) = global
.take_exception(e)
.as_exception(global.vm().as_mut_ptr())
{
let _ = jsc::js_global_object::report_uncaught_exception(
global,
jsc::Exception::opaque_ref(exc),
);
}
return;

Check warning on line 1494 in src/jsc/web_worker.rs

View check run for this annotation

Claude / Claude Code Review

New Err(Thrown) arm uses `if let Some` where sibling arm 20 lines below uses `.expect()`

The new `Err(e @ JsError::Thrown)` arm uses `if let Some(exc) = ...as_exception(...)` — silently returning on `None` — while the pre-existing sibling arm ~20 lines below in the same function handles the identical `take_exception → as_exception → report_uncaught_exception` sequence with `.expect("takeException returned non-Exception")` and an explicit comment stating "None is unreachable. Do not silently drop the error." Match the sibling's `.expect()` (or extract a shared helper covering both ar
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
}
};
let mut str = bun_core::OwnedString::new(str);
let dispatch = jsc::host_fn::from_js_host_call_generic(global, || {
// `cpp_worker` is the opaque C++-owned handle; `str` reffed for the call.
WebWorker__dispatchError(global, self.cpp_worker, &mut str, err)
});
if let Err(e) = dispatch {
if self.has_requested_terminate() {
return;
}
// `take_exception` on a `JsError` always returns an Exception
// cell; None is unreachable. Do not silently drop the error.
let exc = global
Expand Down
60 changes: 60 additions & 0 deletions test/js/web/workers/worker-terminate-lifetime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,66 @@ test(
timeout,
);

// Regression: a nested worker whose grandchild's module fails to load reports
// its uncaught MODULE_NOT_FOUND via flush_logs → report_uncaught_exception →
// Bun__handleUncaughtException. When worker.terminate() and process.exit()
// land mid-dispatch, that handler would still lazily create `process` and do
// `process->get("_fatalException")` on a VM already asked to terminate, which
// tripped ASSERT(object->structure() == this) in Structure::storedPrototype.
// Debug-assert-only; the race is non-deterministic (~2/14 on
// release-asan-cov), so loop it.
test.skipIf(!isASAN)(
"terminating a worker while its grandchildren are reporting load errors does not assert",
async () => {
// Each subprocess run spawns a middle worker that creates grandchildren
// whose module load fails, then main terminates the middle worker and
// exits. The race window is between the grandchild's error dispatch and
// terminate_all_and_wait arming TerminationException. The grandchild path
// is an absolute nonexistent file so MODULE_NOT_FOUND is independent of
// import.meta.url's value inside the data-URL middle worker.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const code = `
const { Worker } = require("node:worker_threads");
const bad = require("node:path").join(process.cwd(), "does-not-exist-xyzzy.mjs");
const middleSrc = \`
const { Worker, parentPort } = require("node:worker_threads");
for (let j = 0; j < 4; j++) {
const w = new Worker(\${JSON.stringify(bad)});
w.on("error", () => {});
}
parentPort.postMessage("spawned");
setInterval(() => {}, 1000);
\`;
const middle = new Worker(new URL(
"data:text/javascript;base64," + Buffer.from(middleSrc).toString("base64"),
));
middle.on("error", e => { console.error("middle error:", e.message); process.exit(1); });
middle.on("message", () => {
middle.terminate();
console.log("ok");
process.exit(0);
});
setTimeout(() => { console.error("timeout"); process.exit(1); }, 5000);
`;

for (let i = 0; i < 20; i++) {
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: "ok\n",
exitCode: 0,
signalCode: null,
});
}
},
timeout * 2,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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