Skip to content
10 changes: 10 additions & 0 deletions src/jsc/VM.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use crate::{JSGlobalObject, JSValue, JsError};
unsafe extern "C" {
safe fn JSC__VM__enableControlFlowProfiler(vm: &VM);
safe fn JSC__VM__hasExecutionTimeLimit(vm: &VM) -> bool;
safe fn JSC__VM__setExecutionTimeLimit(vm: &VM, timeout_seconds: f64);
safe fn JSC__VM__clearExecutionTimeLimit(vm: &VM);
// safe: `VM` is an opaque `UnsafeCell`-backed ZST handle (`&` is ABI-identical
// to non-null `*const`); `ctx` is an opaque round-trip pointer C++ only forwards
// to `callback` (never dereferenced as Rust data) — same contract as
Expand Down Expand Up @@ -58,6 +60,14 @@ impl VM {
JSC__VM__hasExecutionTimeLimit(self)
}

pub fn set_execution_time_limit(&self, timeout_seconds: f64) {
JSC__VM__setExecutionTimeLimit(self, timeout_seconds)
}

pub fn clear_execution_time_limit(&self) {
JSC__VM__clearExecutionTimeLimit(self)
}

/// deprecated in favor of `get_api_lock` to avoid an annoying callback wrapper
#[deprecated = "use get_api_lock"]
pub fn hold_api_lock(&self, ctx: *mut c_void, callback: extern "C" fn(ctx: *mut c_void)) {
Expand Down
35 changes: 18 additions & 17 deletions src/jsc/bindings/NodeVMModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,17 +100,18 @@ JSValue NodeVMModule::evaluate(JSGlobalObject* globalObject, uint32_t timeout, b
// exception-check validator is satisfied before the TOP scope
// below, then convert it to ERR_SCRIPT_EXECUTION_*.
std::ignore = scope.exception();
if ((vm.hasTerminationRequest() || vm.hasPendingTerminationException()) && !Bun__VmHandle__scriptAllowed(WebCore::clientData(vm)->vmHandle)) {
// The VM itself is being stopped; not ours to consume. Propagate the termination.
if (!vm.hasPendingTerminationException())
vm.throwTerminationException();
return {};
}
if (vm.hasTerminationRequest() || vm.hasPendingTerminationException()) {
bool sigint = getSigintReceived();
if (!Bun__VmHandle__scriptAllowed(WebCore::clientData(vm)->vmHandle) || (!sigint && timeout == 0)) {
// Not a termination this evaluation requested; propagate it untouched.
if (!vm.hasPendingTerminationException())
vm.throwTerminationException();
return {};
}
vm.drainMicrotasksForGlobalObject(nodeVmGlobalObject);
DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException();
vm.clearHasTerminationRequest();
if (getSigintReceived()) {
if (sigint) {
setSigintReceived(false);
throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED, "Script execution was interrupted by `SIGINT`"_s);
} else {
Expand Down Expand Up @@ -246,23 +247,23 @@ JSValue NodeVMModule::evaluate(JSGlobalObject* globalObject, uint32_t timeout, b
// termination one is converted to ERR_SCRIPT_EXECUTION_* here. Observe it
// so the exception-check validator is satisfied before the TOP scope.
std::ignore = scope.exception();
if ((vm.hasTerminationRequest() || vm.hasPendingTerminationException()) && !Bun__VmHandle__scriptAllowed(WebCore::clientData(vm)->vmHandle)) {
// The VM itself is being stopped; not ours to consume. Propagate the termination.
if (!vm.hasPendingTerminationException())
vm.throwTerminationException();
return {};
}
if (vm.hasTerminationRequest() || vm.hasPendingTerminationException()) {
bool sigint = getSigintReceived();
if (!Bun__VmHandle__scriptAllowed(WebCore::clientData(vm)->vmHandle) || (!sigint && timeout == 0)) {
// Not a termination this evaluation requested; propagate it untouched
// (and don't record it as the module's evaluation error below).
Comment thread
robobun marked this conversation as resolved.
if (!vm.hasPendingTerminationException())
vm.throwTerminationException();
return {};
}
vm.drainMicrotasksForGlobalObject(nodeVmGlobalObject);
DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException();
vm.clearHasTerminationRequest();
if (getSigintReceived()) {
if (sigint) {
setSigintReceived(false);
throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED, "Script execution was interrupted by `SIGINT`"_s);
} else if (timeout != 0) {
throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_TIMEOUT, makeString("Script execution timed out after "_s, timeout, "ms"_s));
} else {
RELEASE_ASSERT_NOT_REACHED_WITH_MESSAGE("vm.SourceTextModule evaluation terminated due neither to SIGINT nor to timeout");
throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_TIMEOUT, makeString("Script execution timed out after "_s, timeout, "ms"_s));
}
} else {
setSigintReceived(false);
Expand Down
47 changes: 23 additions & 24 deletions src/jsc/bindings/NodeVMScript.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -309,31 +309,30 @@ void NodeVMScript::destroy(JSCell* cell)

static bool checkForTermination(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, NodeVMScript* script, std::optional<double> timeout)
{
if (vm.hasTerminationRequest()) {
// The whole VM is being stopped (worker terminate()/exit): that
// termination is not ours to consume. The caller rethrows what
// evaluate() caught like any other exception.
if (!Bun__VmHandle__scriptAllowed(WebCore::clientData(vm)->vmHandle))
return false;
vm.drainMicrotasksForGlobalObject(globalObject);
// The termination may have fired inside an afterEvaluate microtask
// checkpoint, leaving the termination exception pending; clear it so
// the ERR_SCRIPT_EXECUTION_* error below replaces it.
if (vm.hasPendingTerminationException())
DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException();
vm.clearHasTerminationRequest();
if (script->getSigintReceived()) {
script->setSigintReceived(false);
throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED, "Script execution was interrupted by `SIGINT`"_s);
} else if (timeout) {
throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_TIMEOUT, makeString("Script execution timed out after "_s, *timeout, "ms"_s));
} else {
RELEASE_ASSERT_NOT_REACHED_WITH_MESSAGE("vm.Script terminated due neither to SIGINT nor to timeout");
}
return true;
if (!vm.hasTerminationRequest())
return false;

// Only a termination this script requested (its own SIGINT/timeout) is ours
// to convert; anything else (worker stop, an enclosing watchdog) stays
// pending and the caller rethrows it like any other exception.
Comment thread
robobun marked this conversation as resolved.
bool sigint = script->getSigintReceived();
if (!Bun__VmHandle__scriptAllowed(WebCore::clientData(vm)->vmHandle) || (!sigint && !timeout))
return false;

vm.drainMicrotasksForGlobalObject(globalObject);
// The termination may have fired inside an afterEvaluate microtask
// checkpoint, leaving the termination exception pending; clear it so
// the ERR_SCRIPT_EXECUTION_* error below replaces it.
Comment thread
robobun marked this conversation as resolved.
if (vm.hasPendingTerminationException())
DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException();
vm.clearHasTerminationRequest();
if (sigint) {
script->setSigintReceived(false);
throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED, "Script execution was interrupted by `SIGINT`"_s);
} else {
throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_TIMEOUT, makeString("Script execution timed out after "_s, *timeout, "ms"_s));
}

return false;
return true;
}

void setupWatchdog(VM& vm, double timeout, double* oldTimeout, double* newTimeout)
Expand Down
15 changes: 10 additions & 5 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3047,12 +3047,14 @@ void JSC__VM__collectAsync(JSC::VM* vm)
vm->heap.collectAsync();
}

// Finite, not noTimeLimit: an in-flight Watchdog timer asserts hasTimeLimit() when it fires.
static constexpr WTF::Seconds idleExecutionTimeLimit { static_cast<double>(INT32_MAX) };

extern "C" bool JSC__VM__hasExecutionTimeLimit(JSC::VM* vm)
{
JSC::JSLockHolder locker(vm);
if (vm->watchdog()) {
return vm->watchdog()->hasTimeLimit();
}
if (auto* watchdog = vm->watchdog())
return watchdog->getTimeLimit() < idleExecutionTimeLimit;

return false;
}
Expand Down Expand Up @@ -5085,13 +5087,16 @@ size_t JSC__VM__runGC(JSC::VM* vm, bool sync)
void JSC__VM__clearExecutionTimeLimit(JSC::VM* vm)
{
JSC::JSLockHolder locker(vm);
if (vm->watchdog())
vm->watchdog()->setTimeLimit(JSC::Watchdog::noTimeLimit);
if (auto* watchdog = vm->watchdog())
watchdog->setTimeLimit(idleExecutionTimeLimit);
}
void JSC__VM__setExecutionTimeLimit(JSC::VM* vm, double limit)
{
JSC::JSLockHolder locker(vm);
JSC::Watchdog& watchdog = vm->ensureWatchdog();
// VMEntryScope enters the watchdog itself; a scopeless enteredVM() makes VMTraps deref a null entryScope.
if (vm->entryScope)
watchdog.enteredVM();
watchdog.setTimeLimit(WTF::Seconds { limit });
}

Expand Down
37 changes: 23 additions & 14 deletions src/runtime/test_runner/Execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,6 @@ impl Execution {

/// The kill-only half of [`handle_timeout`]: reaps a timed-out test's spawned processes without touching the runner's queue, so it may run from inside `spawnSync`'s isolated loop.
pub(crate) fn kill_dangling_processes_on_timeout(&mut self, global_this: &JSGlobalObject) {
// if the concurrent group has one sequence and the sequence has an active entry that has timed out,
// kill any dangling processes
// when using test.concurrent(), we can't do this because it could kill multiple tests at once.
if let Some(current_group) = self.active_group() {
// reshaped for borrowck — capture range, drop &mut group, re-borrow sequences
let (start, end) = (current_group.sequence_start, current_group.sequence_end);
Expand All @@ -320,16 +317,7 @@ impl Execution {
let entry = unsafe { entry.as_ref() };
let now = Timespec::now_force_real_time();
if entry.timespec.order(&now) == core::cmp::Ordering::Less {
// SAFETY: bun_vm() returns the live per-thread VM.
let kill_count = global_this.bun_vm().as_mut().auto_killer.kill();
if kill_count.processes > 0 {
bun_core::pretty_errorln!(
"<d>killed {} dangling process{}<r>",
kill_count.processes,
if kill_count.processes != 1 { "es" } else { "" },
);
bun_core::Output::flush();
}
kill_dangling_processes(end - start, global_this);
}
}
}
Expand Down Expand Up @@ -1044,7 +1032,11 @@ fn step_sequence_one(
// SAFETY: re-deref after run_test_callback; sequence_ptr still valid (sequences is a
// Box<[ExecutionSequence]>, never reallocated during execution).
let sequence = unsafe { &mut *sequence_ptr.as_ptr() };
let _ = next_item.evaluate_timeout(sequence, now);
if next_item.evaluate_timeout(sequence, now) {
// SAFETY: group points into this.groups; read-only.
let g = unsafe { group.as_ref() };
kill_dangling_processes(g.sequence_end - g.sequence_start, global_this);
}

// the result is available immediately; advance the sequence and run again.
Execution::advance_sequence(buntest_ptr, sequence_ptr, group);
Expand Down Expand Up @@ -1085,3 +1077,20 @@ fn step_sequence_one(
return Ok(None); // run again
}
}

fn kill_dangling_processes(group_sequence_count: usize, global_this: &JSGlobalObject) {
// The auto-killer is process-global; under test.concurrent() it would hit other tests' children.
if group_sequence_count != 1 {
return;
}
// SAFETY: bun_vm() returns the live per-thread VM.
let kill_count = global_this.bun_vm().as_mut().auto_killer.kill();
if kill_count.processes > 0 {
bun_core::pretty_errorln!(
"<d>killed {} dangling process{}<r>",
kill_count.processes,
if kill_count.processes != 1 { "es" } else { "" },
);
bun_core::Output::flush();
}
}
21 changes: 19 additions & 2 deletions src/runtime/test_runner/bun_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1154,13 +1154,30 @@ impl BunTest {

// SAFETY: `UnsafeCell`-derived; sole `&mut` at this point (before JS re-entry).
unsafe { (*this).update_min_timeout(global_this, timeout) };

// JSC's watchdog catches callbacks that never yield back to the event-loop timer above.
// The grace lets callbacks that do yield be timed by that timer instead of racing it.
Comment thread
robobun marked this conversation as resolved.
const WATCHDOG_GRACE_SECONDS: f64 = 1.0;
let watchdog_armed = !timeout.eql(&Timespec::EPOCH);
if watchdog_armed {
let now = Timespec::now_force_real_time();
let remaining_ns: u64 = if timeout.order(&now).is_gt() { timeout.duration(&now).ns() } else { 0 };
let remaining_seconds = remaining_ns as f64 / bun_core::time::NS_PER_S as f64;
vm.jsc_vm().set_execution_time_limit(remaining_seconds + WATCHDOG_GRACE_SECONDS);
}

let args_slice: &[JSValue] = if !done_arg.is_empty() { core::slice::from_ref(&done_arg) } else { &[] };
let result: JSValue = match vm.event_loop_mut().run_callback_with_result_and_forcefully_drain_microtasks(
let call_result = vm.event_loop_mut().run_callback_with_result_and_forcefully_drain_microtasks(
cfg_callback,
global_this,
JSValue::UNDEFINED,
args_slice,
) {
);
// Relax before reporting: printing the error runs user getters, after the clear below.
if watchdog_armed {
vm.jsc_vm().clear_execution_time_limit();
}
let result: JSValue = match call_result {
Ok(v) => v,
Err(_) => {
global_this.clear_termination_exception();
Expand Down
119 changes: 118 additions & 1 deletion test/cli/test/test-timeout-behavior.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,124 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isFlaky, isLinux } from "harness";
import { bunEnv, bunExe, isFlaky, isLinux, tempDir } from "harness";
import path from "path";

// Runs `source` as a test file under `bun test --timeout=500` and returns its merged output.
async function runWithTimeout(prefix: string, source: string) {
using dir = tempDir(prefix, { "loop.test.ts": source });
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--timeout=500", "loop.test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { combined: stdout + stderr, exitCode };
}

// https://github.com/oven-sh/bun/issues/21277
// A synchronous infinite loop in a test body must be interrupted by the
// per-test timeout. The event-loop timer alone cannot fire while JS is
// running, so the JSC watchdog is armed around the callback to raise a
// TerminationException at the next safepoint.
test.concurrent("synchronous infinite loop is interrupted by --timeout", async () => {
const { combined, exitCode } = await runWithTimeout(
"timeout-sync-loop",
`
import { test } from "bun:test";
test("spins forever", () => {
while (true);
});
test("runs after the timed-out test", () => {});
`,
);

// The spinning test is reported as a timeout (not a generic failure),
// and the next test in the file still runs.
expect(combined).toContain("(fail) spins forever");
expect(combined).toContain("timed out after 500ms");
expect(combined).toContain("(pass) runs after the timed-out test");
expect(exitCode).toBe(1);
});

test.concurrent("synchronous infinite loop after awaited microtask is interrupted by --timeout", async () => {
const { combined, exitCode } = await runWithTimeout(
"timeout-sync-loop-microtask",
`
import { test } from "bun:test";
test("spins after await", async () => {
await Promise.resolve();
while (true);
});
`,
);

expect(combined).toContain("(fail) spins after await");
expect(combined).toContain("timed out after 500ms");
expect(exitCode).toBe(1);
});

// The outer watchdog's TerminationException must propagate through
// node:vm's Script/Module evaluation when the user didn't pass a
// {timeout} option — NodeVMScript::checkForTermination previously
// RELEASE_ASSERT'd that the termination came from its own watchdog.
test.concurrent("synchronous infinite loop inside node:vm without {timeout} is interrupted", async () => {
const { combined, exitCode } = await runWithTimeout(
"timeout-sync-loop-nodevm",
`
import { test } from "bun:test";
import vm from "node:vm";
test("spins inside runInThisContext", () => {
vm.runInThisContext("while (true);");
});
test("spins inside SourceTextModule.evaluate", async () => {
const mod = new vm.SourceTextModule("while (true);");
await mod.link(() => {});
await mod.evaluate();
});
test("runs after the timed-out tests", () => {});
`,
);

expect(combined).toContain("(fail) spins inside runInThisContext");
expect(combined).toContain("(fail) spins inside SourceTextModule.evaluate");
expect(combined.match(/timed out after 500ms/g)).toHaveLength(2);
expect(combined).toContain("(pass) runs after the timed-out tests");
expect(exitCode).toBe(1);
});

// The watchdog must be disarmed before a failure is reported: printing the
// error re-enters user JS (here, a `message` getter). If the watchdog fired in
// there, the termination outlived the runner's clear and the next callback was
// skipped and reported as passing.
test.concurrent("watchdog does not fire while a failure is being reported", async () => {
const { combined, exitCode } = await runWithTimeout(
"timeout-slow-error-message",
`
import { test, expect } from "bun:test";
test("throws", () => {
const error = new Error("boom");
Object.defineProperty(error, "message", {
get() {
// Burn more CPU than the 500ms timeout plus the watchdog's grace.
const start = process.cpuUsage();
while (process.cpuUsage(start).user < 2_000_000) {}
return "boom";
},
});
throw error;
});
test("still runs and fails on its own assertion", () => {
expect(1).toBe(2);
});
`,
);

expect(combined).toContain("(fail) throws");
expect(combined).toContain("(fail) still runs and fails on its own assertion");
expect(exitCode).toBe(1);
});

if (isFlaky && isLinux) {
test.todo("processes get killed");
} else {
Expand Down
Loading