From 797d5e410e3d6cec8b0ca871ce907bab88bfecad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:04:02 +0000 Subject: [PATCH 1/2] node:vm: keep already-queued promise reactions after a timed-out evaluation 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. --- src/jsc/bindings/NodeVMModule.cpp | 2 - src/jsc/bindings/NodeVMScript.cpp | 1 - test/js/node/vm/vm.test.ts | 105 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/NodeVMModule.cpp b/src/jsc/bindings/NodeVMModule.cpp index 23292142dc32..8811d70346f7 100644 --- a/src/jsc/bindings/NodeVMModule.cpp +++ b/src/jsc/bindings/NodeVMModule.cpp @@ -105,7 +105,6 @@ JSValue NodeVMModule::evaluate(JSGlobalObject* globalObject, uint32_t timeout, b // below, then convert it to ERR_SCRIPT_EXECUTION_*. std::ignore = scope.exception(); if (vm.hasTerminationRequest() || vm.hasPendingTerminationException()) { - vm.drainMicrotasksForGlobalObject(nodeVmGlobalObject); DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException(); vm.clearHasTerminationRequest(); if (getSigintReceived()) { @@ -245,7 +244,6 @@ JSValue NodeVMModule::evaluate(JSGlobalObject* globalObject, uint32_t timeout, b // so the exception-check validator is satisfied before the TOP scope. std::ignore = scope.exception(); if (vm.hasTerminationRequest() || vm.hasPendingTerminationException()) { - vm.drainMicrotasksForGlobalObject(nodeVmGlobalObject); DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException(); vm.clearHasTerminationRequest(); if (getSigintReceived()) { diff --git a/src/jsc/bindings/NodeVMScript.cpp b/src/jsc/bindings/NodeVMScript.cpp index 90d1df51b524..7f7fe022099a 100644 --- a/src/jsc/bindings/NodeVMScript.cpp +++ b/src/jsc/bindings/NodeVMScript.cpp @@ -284,7 +284,6 @@ 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()) { - 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. diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index 296971c440be..a9eb09bfe66a 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -1177,3 +1177,108 @@ describe("node:vm SourceTextModule cyclic graph linking", () => { expect(exitCode).toBe(0); }); }); + +describe("timeout termination preserves already-queued promise reactions", () => { + // default microtask mode: jobs the guest queued before expiry share the host's queue + // and must run at the next host checkpoint, so a host `.then` on the guest's promise settles. + test.concurrent("runInContext", async () => { + const fixture = ` + const vm = require("node:vm"); + const c = vm.createContext({ o: [] }); + let code; + 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) { code = e.code; } + let hostSaw = "no"; + c.hostPromise?.then(() => { hostSaw = "yes"; }); + setTimeout(() => { + console.log("o=" + JSON.stringify(c.o) + " host=" + hostSaw + " code=" + code); + }, 0); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: "o=[1,2] host=yes code=ERR_SCRIPT_EXECUTION_TIMEOUT", + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("runInThisContext", async () => { + const fixture = ` + const vm = require("node:vm"); + globalThis.o = []; + Promise.resolve().then(() => { o.push("host-before"); }); + let code; + try { + vm.runInThisContext( + "globalThis.guestPromise = Promise.resolve().then(()=>{ o.push('guest'); }); for(;;);", + { timeout: 80 }); + } catch (e) { code = e.code; } + let hostSaw = "no"; + globalThis.guestPromise?.then(() => { hostSaw = "yes"; }); + setTimeout(() => { + console.log("o=" + JSON.stringify(o) + " host=" + hostSaw + " code=" + code); + }, 0); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: 'o=["host-before","guest"] host=yes code=ERR_SCRIPT_EXECUTION_TIMEOUT', + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("SourceTextModule.evaluate", async () => { + const fixture = ` + const vm = require("node:vm"); + const c = vm.createContext({ o: [] }); + const mod = new vm.SourceTextModule( + "globalThis.hostPromise = Promise.resolve().then(()=>{ o.push(1); }); for(;;);", + { context: c }); + await mod.link(() => { throw 0; }); + let code; + try { await mod.evaluate({ timeout: 80 }); } catch (e) { code = e.code; } + let hostSaw = "no"; + c.hostPromise?.then(() => { hostSaw = "yes"; }); + setTimeout(() => { + console.log("o=" + JSON.stringify(c.o) + " host=" + hostSaw + " code=" + code); + }, 0); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--input-type=module", "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: "o=[1] host=yes code=ERR_SCRIPT_EXECUTION_TIMEOUT", + stderr: "", + exitCode: 0, + }); + }); +}); From 37b8d9d263421ac2e9ac79b01e90edd7d3812757 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:26:12 +0000 Subject: [PATCH 2/2] ci: retrigger