diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index 3081278db388..0e08e55fe538 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -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 @@ -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)) { diff --git a/src/jsc/bindings/NodeVMModule.cpp b/src/jsc/bindings/NodeVMModule.cpp index 5be76aa6e04e..3ad8df79505f 100644 --- a/src/jsc/bindings/NodeVMModule.cpp +++ b/src/jsc/bindings/NodeVMModule.cpp @@ -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 { @@ -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). + 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); diff --git a/src/jsc/bindings/NodeVMScript.cpp b/src/jsc/bindings/NodeVMScript.cpp index 1a8653a1184a..80d336d4260f 100644 --- a/src/jsc/bindings/NodeVMScript.cpp +++ b/src/jsc/bindings/NodeVMScript.cpp @@ -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 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. + 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. + 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) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index b024bc42ca6c..f2ae22788b2d 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -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(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; } @@ -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 }); } diff --git a/src/runtime/test_runner/Execution.rs b/src/runtime/test_runner/Execution.rs index a67b00fcc37a..b2dd466d26bc 100644 --- a/src/runtime/test_runner/Execution.rs +++ b/src/runtime/test_runner/Execution.rs @@ -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); @@ -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!( - "killed {} dangling process{}", - kill_count.processes, - if kill_count.processes != 1 { "es" } else { "" }, - ); - bun_core::Output::flush(); - } + kill_dangling_processes(end - start, global_this); } } } @@ -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); @@ -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!( + "killed {} dangling process{}", + kill_count.processes, + if kill_count.processes != 1 { "es" } else { "" }, + ); + bun_core::Output::flush(); + } +} diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index 8f2ee1f522c2..6721c1b9d1a0 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -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. + 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(); diff --git a/test/cli/test/test-timeout-behavior.test.ts b/test/cli/test/test-timeout-behavior.test.ts index 07e02e3f039c..b6abc64cf8c1 100644 --- a/test/cli/test/test-timeout-behavior.test.ts +++ b/test/cli/test/test-timeout-behavior.test.ts @@ -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 {