Skip to content

node:vm: keep already-queued promise reactions after a timed-out evaluation - #33763

Open
robobun wants to merge 2 commits into
mainfrom
farm/854066bb/vm-timeout-preserve-microtasks
Open

node:vm: keep already-queued promise reactions after a timed-out evaluation#33763
robobun wants to merge 2 commits into
mainfrom
farm/854066bb/vm-timeout-preserve-microtasks

Conversation

@robobun

@robobun robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Repro

import * as vm from "node:vm";

const c = vm.createContext({ o: [] });        // default microtaskMode: shares the host's queue
try {
  vm.runInContext(
    "hostPromise = Promise.resolve().then(()=>{ o.push(1); return 'v' })" +
    "                               .then((x)=>{ o.push(2); return x }); for(;;);",
    c, { timeout: 80 });
} catch (e) { /* ERR_SCRIPT_EXECUTION_TIMEOUT in both runtimes */ }

let hostSaw = "no";
c.hostPromise?.then(() => { hostSaw = "yes"; });
await new Promise(r => setTimeout(r, 20));
console.log(`o=${JSON.stringify(c.o)} | host .then ran=${hostSaw}`);

// node v26.3.0:  o=[1,2] | host .then ran=yes
// bun (before):  o=[]    | host .then ran=no    <- and it never will

The standard sandbox pattern is "give the guest a CPU budget, then await whatever promise it handed back". On bun, jobs that were already enqueued for already-settled promises were discarded with the terminated evaluation, so the host's .then on the guest's promise never settled and the host hung with no error. The .catch shape was worse: a rejection handler the guest attached before expiry never ran, and no unhandledRejection was reported either.

runInThisContext was affected even more directly: it passed the caller's global, so a timed-out evaluation also dropped the caller's own pending reactions queued before the vm call.

Cause

checkForTermination (NodeVMScript.cpp) and the two termination branches in NodeVMModule::evaluate called vm.drainMicrotasksForGlobalObject(globalObject) before clearing the termination request and throwing ERR_SCRIPT_EXECUTION_TIMEOUT. Despite the name, that helper calls MicrotaskQueue::clearForGlobalObject, which removes every pending entry on the VM's default queue whose globalObject() matches. In default microtask mode the guest's reactions live on that shared queue, so they were dropped.

Fix

Remove the three drainMicrotasksForGlobalObject calls. Terminating a timed evaluation only stops execution; jobs that were already enqueued stay on the queue and run at the next host checkpoint once the termination request is cleared, matching Node. microtaskMode: "afterEvaluate" is unaffected: those jobs live on the context's own queue, which the removed call never touched anyway.

Verification

New tests in test/js/node/vm/vm.test.ts spawn a subprocess for each of runInContext, runInThisContext, and SourceTextModule.evaluate, queue a reaction on an already-resolved promise, spin into { timeout: 80 }, and assert the reaction ran and a host .then on the guest's promise settled.

  • USE_SYSTEM_BUN=1 bun test test/js/node/vm/vm.test.ts -t "timeout termination preserves": 0 pass / 3 fail
  • bun bd test test/js/node/vm/vm.test.ts -t "timeout termination preserves": 3 pass / 0 fail
  • Full vm.test.ts: 209 pass / 0 fail
  • Node parallel test-vm-timeout*.js / test-vm-module-* suites: pass

Overlaps with #33411, which narrows the same call to the vm context's global for runInThisContext; this PR removes it outright so the guest's own reactions on the shared queue survive too.

…uation

When a vm.runInContext / runInThisContext / SourceTextModule.evaluate
with { timeout } expires, checkForTermination called
VM::drainMicrotasksForGlobalObject before throwing
ERR_SCRIPT_EXECUTION_TIMEOUT. Despite the name, that helper clears every
pending microtask whose globalObject matches, so promise reactions the
guest had already enqueued on the shared host queue were silently
discarded, and a host .then() on a promise the guest handed back never
settled. runInThisContext passed the caller's global, so it also dropped
the caller's own pending reactions.

Node only stops execution on timeout; jobs that were already queued run
at the next host checkpoint. Stop clearing them.
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4aaafd3c-e34d-44ae-902f-e709c14bee2e

📥 Commits

Reviewing files that changed from the base of the PR and between 332f744 and 37b8d9d.

📒 Files selected for processing (3)
  • src/jsc/bindings/NodeVMModule.cpp
  • src/jsc/bindings/NodeVMScript.cpp
  • test/js/node/vm/vm.test.ts
💤 Files with no reviewable changes (2)
  • src/jsc/bindings/NodeVMScript.cpp
  • src/jsc/bindings/NodeVMModule.cpp

Walkthrough

This PR removes explicit microtask-draining calls (vm.drainMicrotasksForGlobalObject) from termination-handling code paths in NodeVMModule::evaluate and NodeVMScript::checkForTermination before converting pending termination requests into ERR_SCRIPT_EXECUTION_* errors, and adds tests verifying already-queued promise reactions still fire after timeout termination.

Changes

VM Termination Microtask Drain Removal

Layer / File(s) Summary
Remove microtask drain before termination error mapping
src/jsc/bindings/NodeVMModule.cpp, src/jsc/bindings/NodeVMScript.cpp
Removes vm.drainMicrotasksForGlobalObject calls from two branches in NodeVMModule::evaluate and one in NodeVMScript::checkForTermination prior to clearing the termination exception scope and mapping SIGINT/timeout into ERR_SCRIPT_EXECUTION_* errors.
Timeout termination test coverage
test/js/node/vm/vm.test.ts
Adds a describe block with three concurrent tests spawning child Bun processes using vm.runInContext, vm.runInThisContext, and SourceTextModule.evaluate with timeout: 80, asserting already-queued host promise reactions still fire and errors report ERR_SCRIPT_EXECUTION_TIMEOUT.

Possibly related PRs

  • oven-sh/bun#32018: Modifies the same checkForTermination/timeout-to-ERR_SCRIPT_EXECUTION_* mapping logic in NodeVMScript.cpp and NodeVMModule.cpp, changing when pending microtasks/exceptions are drained relative to termination handling.
  • oven-sh/bun#32587: Modifies event-loop termination behavior for queued microtask work when JsTerminated/timeout occurs, related to how termination interacts with pending microtask processing.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: preserving queued promise reactions after VM timeouts.
Description check ✅ Passed It explains the fix and verification, though it uses custom headings instead of the template's.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 8, 2026
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:14 AM PT - Jul 8th, 2026

@robobun, your commit 37b8d9d has 3 failures in Build #70524 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33763

That installs a local version of the PR into your bun-33763 executable, so you can run:

bun-33763 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find issues, but this changes vm termination semantics by removing microtask-clearing on timeout and overlaps with #33411's narrower approach — worth a human confirming the removal (vs. narrowing) is the right call.

Extended reasoning...

Overview

Removes three vm.drainMicrotasksForGlobalObject(...) calls from the termination-handling paths in NodeVMScript.cpp (checkForTermination) and NodeVMModule.cpp (two branches of NodeVMModule::evaluate). Adds three subprocess-based regression tests in test/js/node/vm/vm.test.ts covering runInContext, runInThisContext, and SourceTextModule.evaluate.

The PR's claim is that drainMicrotasksForGlobalObject — despite its name — clears rather than executes pending microtasks (via MicrotaskQueue::clearForGlobalObject), so already-queued promise reactions were silently discarded when a timed evaluation was terminated. The fix lets those reactions survive to run at the next host checkpoint, matching Node.

Security risks

node:vm is sandboxing-adjacent. The change means guest-queued microtasks now survive a timeout and run afterward on the host's queue. The PR argues this already matches Node's behavior in default microtask mode (guest and host share the queue), and microtaskMode: 'afterEvaluate' is unaffected since it uses a separate per-context queue. I don't see a new escape vector here — the guest could already schedule work observable to the host via the shared context object — but it does change what runs after a forced termination, which is the kind of semantic shift a human should confirm.

Level of scrutiny

Moderate-to-high. The native diff is only three deleted lines, but it removes code that was presumably added intentionally in a subtle area (JSC watchdog termination × microtask queue × cross-realm promises). Verifying correctness requires knowing JSC's drainMicrotasksForGlobalObject internals, which I couldn't inspect locally. The PR description is thorough and the tests are well-constructed (verified fail-before/pass-after, plus Node parallel test suites), but this is a behavioral change rather than a mechanical fix.

Other factors

  • The PR explicitly notes overlap with #33411, which narrows the same call to the vm-context global instead of removing it. A human should decide between the two approaches (or confirm this supersedes #33411).
  • The new tests assert stderr: "" exactly, which can be brittle under ASAN/debug builds — though several existing tests in the same file do the same, so this follows local convention.
  • No CODEOWNERS entry covers these files. No prior human review comments on the PR.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

On removal vs. narrowing (#33411):

Narrowing to the vm-context global fixes the runInThisContext case (caller's own reactions were being dropped) but does not fix the case this PR is about. In default microtask mode the guest's reactions live on the shared VM default queue with globalObject == <vm-context global>, and the host can hold the promise they are attached to. Clearing them, even scoped to the vm-context global, leaves that promise permanently pending for the host. Node does not clear them:

$ node -e 'const vm=require("vm");const c=vm.createContext({r:false});
  try{vm.runInContext("Promise.resolve().then(()=>{r=true});for(;;);",c,{timeout:50})}catch{}
  setTimeout(()=>console.log(c.r),0)'
true

For microtaskMode: "afterEvaluate" the removed call was already a no-op: those jobs live on the context's own microtaskQueue(), not the VM default queue that drainMicrotasksForGlobalObject walks, so the test-vm-timeout-escape-promise* suites are unaffected (verified).

So narrowing is a subset: it would still leave the runInContext test in this PR failing.

The stderr: "" assertion matches the existing pattern in this file (e.g. the cyclic graph linking block just above) and passed under the ASAN debug build; happy to loosen it if preferred.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reasoning and tests look solid, but since this changes what guest microtasks survive a node:vm timeout (sandbox-adjacent semantics) and takes a different approach than #33411, it's worth a maintainer confirming removal-over-narrowing is the right call.

Extended reasoning...

Overview

Removes three vm.drainMicrotasksForGlobalObject(...) calls from the timeout/SIGINT termination paths in NodeVMScript.cpp (checkForTermination) and NodeVMModule.cpp (NodeVMModule::evaluate, both branches). Adds three subprocess tests in test/js/node/vm/vm.test.ts covering runInContext, runInThisContext, and SourceTextModule.evaluate. The claim is that this JSC helper clears (rather than executes) matching entries on the VM's default microtask queue, so already-queued promise reactions were being silently dropped when a timed evaluation expired — leaving host-held promises permanently pending, which diverges from Node.

Security risks

node:vm is a sandbox boundary, and this change affects which guest-scheduled microtasks run after the guest has been forcibly terminated. The PR argues (convincingly) that the actual isolation mode, microtaskMode: "afterEvaluate", uses a per-context queue that these calls never touched, and that the Node parallel test-vm-timeout-escape-promise* suites still pass. In default microtask mode the guest already shares the host queue by design, so preserving those reactions matches Node rather than weakening isolation. Still, "guest code runs after termination" is exactly the class of behavior a human reviewer should sign off on.

Level of scrutiny

Medium-high. The native diff is trivially small (pure deletions), but it is a deliberate semantic change to termination behavior in a security-adjacent module, and it competes with an open PR (#33411) that instead narrows the same call to the vm-context global. The author has justified removal over narrowing in the thread, but choosing between the two is a maintainer-level design decision. I could not trace the original rationale for these calls from local git history (the file was introduced whole in a squashed commit), so the "is this load-bearing?" question is answered only by the author's analysis and the passing Node compat suites.

Other factors

Test quality is good: each case runs in a fresh subprocess (isolating termination state), pipes are drained concurrently, exact stdout is asserted alongside the ERR_SCRIPT_EXECUTION_TIMEOUT code, and the author reports USE_SYSTEM_BUN=1 fails 0/3 while the debug build passes 3/3 plus the full vm.test.ts and Node's test-vm-timeout* / test-vm-module-* suites. The stderr: "" assertion mirrors adjacent tests in the same file. No bugs were flagged by the bug-hunting pass. Given the sandbox-adjacent semantics and the overlapping PR, deferring rather than auto-approving.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff itself is green. test/js/node/vm/vm.test.ts (including the three new tests) and all test-vm-timeout* / test-vm-module-* Node compat files pass on every lane in both runs.

The red is unrelated flake, different on each run:

  • #70522: test-worker-message-port-transfer-terminate.js SIGABRT on x64-asan (worker_threads termination race; 20 local ASAN-debug runs on this branch all pass), plus CI-tagged flaky bake/dev-and-prod and node-http on Windows.
  • #70524: proxy-stress-concurrent.test.ts and terminal.test.ts on macOS 14 x64, plus Windows postgres ERR_POSTGRES_CONNECTION_REFUSED.

No overlap between the two runs; none of the failing files touch node:vm. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant