node:vm: enforce timeout as a wall-clock deadline that interrupts Atomics.wait - #33764
node:vm: enforce timeout as a wall-clock deadline that interrupts Atomics.wait#33764robobun wants to merge 4 commits into
Conversation
…mics.wait The previous implementation drove timeout through JSC::Watchdog, which measures CPU time and only fires at JS back-edges. A guest blocked in Atomics.wait consumed no CPU and had no back-edge, so a single long wait ran to completion past the deadline, and chunked short waits overshot by ~150x. The missed deadline also left the Watchdog with a stale m_deadline and a pending NeedWatchdogCheck trap; the next timed evaluation then called startTimer() with no time limit and tripped ASSERT(hasTimeLimit()). Replace the Watchdog with a per-evaluation TimeoutWatchdog thread that parks on its own condvar until the wall-clock deadline, then requests VM termination and wakes vm.syncWaiter() so a blocked Atomics.wait returns Terminated. Each scope only translates a termination it initiated, so a termination from an enclosing scope propagates unchanged and the nested timeout tests keep reporting the outer scope's limit.
|
Updated 11:54 AM PT - Jul 8th, 2026
❌ @robobun, your commit 7051bc3 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 33764That installs a local version of the PR into your bun-33764 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
#32773 overlaps on the Watchdog-state half of this (the This PR's Happy to fold the syncWaiter wake into #32773 instead if that one is preferred; either way one of the two should land. |
|
Caution Review failedAn error occurred during the review process. Please try again later. WalkthroughThis PR replaces the VM timeout path in ChangesVM timeout watchdog refactor
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/jsc/bindings/NodeVMModule.cpp (1)
238-256: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winPropagate enclosing terminations before marking this module errored.
If an outer watchdog fires while evaluating a dependency, the inner evaluation has
didTimeOut == falseand falls through toVM_RETURN_IF_EXCEPTION, which stores the raw termination sentinel inm_evaluationException. Return with the pending termination instead, so the initiating scope translates it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/jsc/bindings/NodeVMModule.cpp` around lines 238 - 256, The evaluation path in NodeVMModule::evaluate is swallowing an enclosing termination when didTimeOut is false, causing a raw termination sentinel to be stored instead of propagating to the initiating scope. Update the post-evaluation handling around scope.exception(), getSigintReceived(), and didTimeOut so any pending termination from an outer watchdog is returned immediately before VM_RETURN_IF_EXCEPTION, letting the outer scope translate it consistently instead of marking the module errored.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/bindings/NodeVMModule.cpp`:
- Around line 101-104: The new exception-scope comments in NodeVMModule::drain
and the similar block at the later exception-handling site are too long and
exceed the 3-line comment limit. Condense each comment to three lines or fewer
by keeping only the essential summary, and move any extra explanation into
surrounding code or a nearby descriptive identifier if needed. Use the existing
exception-scope handling code in NodeVMModule.cpp to locate both comment blocks.
- Around line 244-245: The timeout cleanup in NodeVMModule should only drain
microtasks when there is an actual VM global object to target. Update the code
around nodeVmGlobalObject in the cleanup path to guard the call to
vm.drainMicrotasksForGlobalObject so it runs only when that object exists, while
still always clearing the termination state with
TimeoutWatchdog::clearTerminationState.
In `@src/jsc/bindings/NodeVMScript.cpp`:
- Around line 295-296: The microtask drain in checkForTermination is being
applied too broadly and can clear the caller’s main queue when runInThisContext
is used. Update the logic around vm.drainMicrotasksForGlobalObject so it only
runs for contextified NodeVMGlobalObject instances that own their own microtask
queue, and skip it for the main/caller globalObject while keeping
TimeoutWatchdog::clearTerminationState unchanged.
In `@src/jsc/bindings/vm/TimeoutWatchdog.cpp`:
- Around line 16-20: The new explanatory comments in TimeoutWatchdog should be
shortened to comply with the 3-line comment limit while keeping the same
invariants. Update the comment blocks around vm.ensureTerminationException() and
the other cited sections in TimeoutWatchdog so each explanatory note fits within
three lines without losing the key rationale tied to
throwTerminationException(), VMTraps::handleTraps, and Atomics.wait termination
handling.
In `@src/jsc/bindings/vm/TimeoutWatchdog.h`:
- Around line 11-14: The block comment in TimeoutWatchdog should be trimmed to
fit the 3-line limit while keeping the essential summary of its purpose. Update
the comment above the watchdog definition/constructor to briefly state that it
enforces node:vm timeout by requesting VM termination, waking Atomics.wait, and
joining the worker thread in the destructor. Keep the relevant context tied to
TimeoutWatchdog and JSC::evaluate, but remove extra wording so the comment is no
longer than three lines.
In `@test/js/node/vm/vm.test.ts`:
- Around line 1239-1253: This test does not verify that the first
vm.runInNewContext call actually hit the timeout, because the empty catch
swallows any outcome and lets the test pass even if the watchdog never fired. In
the vm.test.ts case for the “blocked deadline” scenario, assert the first
evaluation fails with the expected timeout/error before running the second
vm.runInNewContext call, so the precondition is confirmed and the test only
exercises the post-timeout path when the watchdog was actually triggered.
- Around line 1181-1195: The subprocess-based VM timeout tests are still running
serially even though they are independent. Update the suite around the existing
describe block and each affected test case to use describe.concurrent and
test.concurrent so the subprocess runs happen in parallel, while keeping the
shared helper run unchanged since it has no mutable shared state.
---
Outside diff comments:
In `@src/jsc/bindings/NodeVMModule.cpp`:
- Around line 238-256: The evaluation path in NodeVMModule::evaluate is
swallowing an enclosing termination when didTimeOut is false, causing a raw
termination sentinel to be stored instead of propagating to the initiating
scope. Update the post-evaluation handling around scope.exception(),
getSigintReceived(), and didTimeOut so any pending termination from an outer
watchdog is returned immediately before VM_RETURN_IF_EXCEPTION, letting the
outer scope translate it consistently instead of marking the module errored.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1a1fc848-ef59-40c1-a3fb-8e73458239b3
📒 Files selected for processing (5)
src/jsc/bindings/NodeVMModule.cppsrc/jsc/bindings/NodeVMScript.cppsrc/jsc/bindings/vm/TimeoutWatchdog.cppsrc/jsc/bindings/vm/TimeoutWatchdog.htest/js/node/vm/vm.test.ts
…t, concurrent tests - TimeoutWatchdog comments trimmed to the 3-line limit. - checkForTermination only clears the default microtask queue for NodeVMGlobalObject instances, so a timed-out runInThisContext no longer discards the caller's own queued microtasks (covered by the expanded runInThisContext test). - NodeVMModule::evaluate null-checks nodeVmGlobalObject before the same call. - The four Atomics.wait timeout tests run under describe.concurrent, and the follow-on evaluation test asserts the first call actually timed out before exercising the second.
The post-fire loop previously only re-notified the sync waiter. A nested scope's clearTerminationState can wipe the request this watchdog installed after it has already fired; re-calling fire() every tick re-asserts hasTerminationRequest and the NeedTermination trap so a CPU-bound outer script cannot outrun its own deadline after catching an inner timeout.
|
CI status after two runs (build 70539, retrigger 70543): the
None of these touch The new |
Problem
node:vm'stimeoutoption is not enforced against a guest blocked inAtomics.wait, and the missed deadline leaves JSC's Watchdog in an inconsistent state:timeoutis the whole point of running untrusted code throughnode:vm, so a guest can currently evade the budget entirely with oneAtomics.wait.Cause
setupWatchdogdrovetimeoutthroughJSC::Watchdog, which is the wrong tool on two axes:NeedWatchdogChecktrap bit, which is polled at JS back-edges. A thread parked inWaiterListManager::waitForSynchas no back-edge, so the fire is never observed while the wait runs; the script returns"timed-out"after the full wait.Watchdog::shouldTerminatedecides on CPU time (m_cpuDeadline), not wall-clock. A sleeping thread consumes ~zero CPU, so chunked short waits under an 80 ms budget only terminate once 80 ms of CPU has accumulated, ~150x later.After the miss,
setTimeLimit(oldLimit)resetsm_timeLimitto infinity butm_deadline(wall-clock, now in the past) and the pendingNeedWatchdogChecktrap bit remain. The next trap check callsshouldTerminate, findscpuTime < m_cpuDeadline, and callsstartTimer(remaining)withhasTimeLimit()false, tripping the assert.Fix
Replace the JSC Watchdog with a per-evaluation
TimeoutWatchdogthat spawns a worker thread parked on its own condvar until the wall-clock deadline. On fire it:vm.setHasTerminationRequest()sowaitForSync's loop predicate (!vm.hasTerminationRequest()) falls through andAtomics.waitreturnsTerminated,vm.notifyNeedTermination()so running JS terminates at the next back-edge,vm.syncWaiter()->condition()(and keeps re-notifying at 1 ms until disarmed, covering the lost-wakeup window where the first notify lands between the waiter's predicate check and park).Each scope only translates a termination it initiated (its own
didFire()or a received SIGINT); a termination from an enclosing scope propagates unchanged. This matches Node'sWatchdogsemantics and keeps the nested-timeout tests reporting the correct (outer) limit.clearTerminationStatewipes the request flag, the pending termination exception, and theNeedTermination/NeedWatchdogChecktrap bits, so the next timed evaluation starts clean.This is the same off-thread termination contract as #32802 (which does it for
worker.terminate()), applied tonode:vm timeout.Verification
test/js/node/vm/vm.test.tscover: single long wait, chunked short waits,runInThisContext, and the follow-on timed evaluation. All four fail on the system bun (three by 5 s test timeout, one by subprocess abort on the assert build) and pass on this branch.test/js/node/test/{parallel,sequential}/test-vm-*.jspass, includingtest-vm-timeout.js(nested timeouts) and the threetest-vm-timeout-escape-promise-module*variants.BUN_JSC_validateExceptionChecks=1clean on the repro and ontest-vm-timeout.js.