repl: interrupt a running evaluation with Ctrl+C - #33411
Conversation
WalkthroughThis PR replaces the REPL’s Unix SIGINT path with per-evaluation SigintWatcher-based interruption, updates VM and module termination handling, removes the unused execution-forbidden binding, and adds atomic SIGINT state plus expanded REPL and VM coverage. ChangesREPL SIGINT interruption and termination classification
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 11:17 PM PT - Jul 15th, 2026
❌ @robobun, your commit 92c7b6f has 4 failures in
🧪 To try this PR locally: bunx bun-pr 33411That installs a local version of the PR into your bun-33411 --bun |
|
Review didn't start (rate limit), so flagging that this diff hasn't had a bot pass yet. @coderabbitai review |
|
✅ Action performedReview finished.
|
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Took the first suggestion, not the second. #27558 — yes, added #33237 — this PR does not fix it, so I left it out of the body. That issue is about Corroboration for whoever picks #33237 up, since I hit it head-on: with a pre-created |
|
Four review findings, all landed in 🔴 Nested 🟡 Nested 🟡 Dead 🟡 The
So the parked cases only recover when something unrelated wakes the loop, exactly as you described. Two consequences: the comment now says what actually happens, and the never-settling-await test drops the 600 s timer it used to carry — that timer was what made it park and race. Against an idle loop I did not close the underlying gap here. Doing it right means the Unrelated find while writing the first version of the nested test: the REPL mangles a Also checked: |
|
Good catch on the error-code classification — fixed in const vm = require("vm");
globalThis.inner = () => vm.runInThisContext("while (true) {}");
vm.runInThisContext("inner()", { timeout: 100 });
Took the first option you suggested. A nested script only knows its own SIGINT flag and its own timeout, so when neither is set it re-raises the termination with The REPL path is unaffected, exactly as you predicted: Regression test added in
|
|
Both findings investigated. One landed, one I'm declining with evidence — and it's the interesting one.
|
There was a problem hiding this comment.
Actionable comments posted: 9
🤖 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/bindings.cpp`:
- Around line 6273-6276: Condense the new block comments in bindings.cpp so each
stays within the repository’s 3-line maximum. Update the two REPL/termination
comments near the watcher-thread and termination-trap logic in the affected
bindings code to preserve the same meaning but fit into three lines or fewer,
including the matching comment block in the later section referenced by the
review.
In `@src/jsc/bindings/NodeVMModule.cpp`:
- Around line 248-259: Trim the explanatory comment in
NodeVMModule::reconcileEvaluationState to fit the repo’s 3-line limit by keeping
only the essential invariant and removing the extra rationale/history; preserve
the key reason for returning early instead of falling through to
VM_RETURN_IF_EXCEPTION, but move the longer explanation into the PR discussion
or surrounding docs.
- Line 264: The microtask drain in NodeVMModule should use the normalized global
object instead of nodeVmGlobalObject, since nodeVmGlobalObject may be null on
this termination path. Update the drain call in the NodeVMModule flow to use
globalObject, which already resolves to the NodeVM global when available and is
otherwise guaranteed non-null.
In `@src/jsc/bindings/NodeVMScript.cpp`:
- Around line 289-292: The comment in NodeVMScript::checkForTermination exceeds
the 3-line limit; shorten it by removing one line while preserving the invariant
and the re-raise explanation. Keep the remaining text focused on the distinction
between the script’s own SIGINT/timeout and an enclosing scope, and retain
references to checkForTermination and Bun__REPL__evaluate only if still
necessary.
In `@src/jsc/bindings/vm/SigintWatcher.cpp`:
- Around line 122-126: Shorten the explanatory comment in SigintWatcher’s append
logic to fit the 3-line limit while keeping the core rationale. Keep the key
points near the existing `unregisterGlobalObject`, `signalAll`, and
`notifyNeedTermination` behavior, but compress the nested-holder/duplicate-entry
explanation into a single concise sentence or two. Remove any extra wording so
the comment stays within the repo’s maximum comment length without changing its
meaning.
In `@src/runtime/cli/repl.rs`:
- Around line 1331-1344: The promise wait logic in `repl.rs` is holding a raw
`*mut JSPromise` across `wait_for_promise_interruptible`, which can tick the VM
and run GC, so the promise must be rooted or copied before the wait. Update the
`result.as_promise()` handling path and the related
`wait_for_promise_interruptible` / `auto_tick` flow so the `JSPromise` stays
protected for the full wait, then use the rooted handle again when checking
`status()` and `result()`. Apply the same fix to the other promise-wait block
noted in the diff so no JSValue survives beyond the current call without
rooting.
- Around line 1631-1645: The `.copy` error path in `evaluate_and_print` updates
`last_error` but leaves the REPL `_error` special variable stale, unlike the
`EvalOutcome::Error` branch. Update the `EvalOutcome::Value` branch in
`evaluate_and_print` (around `copy_value_to_clipboard`/`print_js_error`) so the
caught exception is also written to `_error` using the same mechanism as the
normal error path, keeping `_error` in sync after copy failures or rejected
awaits.
- Around line 1384-1393: The `Repl::eval` promise-wait loop is busy-spinning
when `auto_tick()` has no live event-loop work and the promise remains pending.
Update the `while pending(promise) && !Self::sigint_requested(sigint)` block so
that after `vm.as_mut().tick()`/`auto_tick()` it exits once the promise is still
pending and the loop is no longer alive, instead of continuing forever. Use the
existing `pending(promise)`, `Self::sigint_requested`, and
`vm.as_mut().auto_tick()` flow to locate and adjust the return condition.
In `@test/js/node/vm/vm.test.ts`:
- Around line 1000-1004: The crash-regression test is recording stderr too late,
so a native abort can throw before diagnostics are captured. Move the
capture(stderr) call to occur before the combined expect assertion, so the
vm.test.ts case preserves stderr alongside stdout and exitCode when the
subprocess fails. Use the existing capture(stderr) helper and the
expect({...}).toEqual(...) check in this test block as the anchors for the
change.
🪄 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: c432f96c-dd87-495f-9769-06724abe738e
📒 Files selected for processing (10)
src/jsc/VM.rssrc/jsc/bindings/NodeVMModule.cppsrc/jsc/bindings/NodeVMScript.cppsrc/jsc/bindings/bindings.cppsrc/jsc/bindings/headers.hsrc/jsc/bindings/vm/SigintReceiver.hsrc/jsc/bindings/vm/SigintWatcher.cppsrc/runtime/cli/repl.rstest/js/bun/repl/repl.test.tstest/js/node/vm/vm.test.ts
💤 Files with no reviewable changes (2)
- src/jsc/bindings/headers.h
- src/jsc/VM.rs
|
All eleven findings handled in 🔴 The one that mattered: an external SIGINT was being swallowed
Worth stressing that the displaced handler isn't 🟡
|
|
Both landed in The
|
process.on('SIGINT', h) then kill -INT |
|
|---|---|
| release 1.4.0 | REPL alive, listener honored |
435b9fb |
REPL dies, listener discarded |
| now | REPL alive, listener honored |
So fixing the swallowed SIGINT by restoring a snapshot traded one bug for another: anything installed during the armed window got thrown away, and since signalToContextIdsMap keeps its entry, BunProcess never reinstalls. Stranded for the session.
Took your suggested shape — uninstall() reads the current disposition and restores m_previousAction only when the handler still installed is the watcher's own, otherwise leaves it alone. The inline lambda became a named sigintWatcherHandler so it can be compared, with an SA_SIGINFO guard since that would mean it isn't ours anyway.
What I like about this is that the two tests form a vise, so neither fix can regress the other:
| no listener → must terminate | listener → must survive | |
|---|---|---|
unconditional restore (435b9fb) |
pass | fail ("exited" vs "alive") |
hardcoded Bun__onPosixSignal (pre-435b9fb) |
fail ("still running") |
pass |
| conditional restore (now) | pass | pass |
Left alone, as you noted: a SIGINT listener registered before a spin still displaces the watcher's handler for the rest of that evaluation, so process.on('SIGINT', () => {}); while (true) {} stays uninterruptible. Not a regression, and fixing it needs BunProcess and the watcher to agree on who owns the disposition.
Spawned fixture for the nested-timeout test
Taken — Bun.spawn + {stdout, exitCode}, matching the sibling test. You're right that a regression back to RELEASE_ASSERT_NOT_REACHED would otherwise take the whole vm.test.ts run down with a bare SIGABRT instead of naming the test.
On the CI red
Triaged, none of it mine:
test-vm-module-basic.jstimes out onmaintoo (hangs locally withsrc/stashed, exit 124). Pre-existing; CI only surfaced it on the Windows lanes.test-worker-message-port-transfer-terminate.jsSIGABRT on x64-asan: passes 3/3 locally under the same ASAN build.hot.test.ts(Windows) andcomplex-workspace.test.tsare both annotatedctx=flakyand passed on retry.
331/331 on repl + vm, test-vm-sigint{,-existing-handler}, the test-vm-timeout* set, and test-signal-handler.js all pass.
|
Status on the red X, since it has now recurred across several builds and none of it comes from this diff. Both failed jobs in #68816 are the same infra hiccup, on two shards of Zero tests ran on either — the runner never got the binary. The same timeout took a darwin shard in #68720 and #68749; in #68720 the sibling darwin shards passed and the build reached 283 passed / 1 failed before one of my own pushes cancelled it. The two annotations are likewise not mine:
Earlier rounds were the same story: Not pushing a retrigger: the build is still running with ~185 jobs queued, so a push would cancel a build that is passing everywhere it has reported, to re-roll the same artifact-download dice. Happy to re-roll if a maintainer would rather see a clean board. Locally on |
|
Correction to my last comment: I called it pre-existing off a local repro that hung on The bugI took the void VM::drainMicrotasksForGlobalObject(JSGlobalObject* globalObject)
{
m_defaultMicrotaskQueue->clearForGlobalObject(globalObject);
}It doesn't drain, it clears. So the argument isn't "which global do we pump", it's "whose pending microtasks do we throw away".
Controlled A/B, same tree, one word, rebuilt each side:
Reverted, with a comment naming the trap so nobody re-applies it, and a regression test that parks a continuation in the main queue across a terminated context-less evaluation — it fails against the On the rest of the boardThe remaining reds are what I described before, and those I did verify:
Now green locally: Lesson recorded for the next reviewer of this PR: a "Quick win" one-word suggestion on a function whose name lies is exactly the kind I should have traced before taking. |
|
Three findings, all three landed. Two were real bugs, and one of them is a
const { promise, resolve } = Promise.withResolvers();
const chain = promise.then(() => "survived");
resolve();
try { vm.runInThisContext("while (true) {}", { timeout: 100 }); } catch {}
chain.then(v => console.log(v));$ node r.js
survived
$ bun r.js # prints nothingThe contextified global is now its own parameter: The SIGINT disposition has two owners (
Fixed where the conflict lives: while armed the watcher owns the disposition, so The SIGINT vise is now four legs, one per reachable state, and the two new ones fail with the fix stubbed out on the same build. The signal and Comment length ( |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/jsc/bindings/vm/SigintWatcher.cpp (1)
89-110: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winRestore SIGINT before marking the watcher uninstalled.
Line 91 clears
m_installedwhilesigintWatcherHandleris still installed. A SIGINT in that window wakes the watcher, then Line 69 returns before forwarding, swallowing the interrupt.Proposed ordering fix
void SigintWatcher::uninstall() { - if (m_installed.exchange(false)) { + if (m_installed.load()) { WTF::Thread* currentThread = WTF::Thread::currentMayBeNull(); ASSERT(!currentThread || m_thread->uid() != currentThread->uid()); `#if` OS(WINDOWS) SetConsoleCtrlHandler(WindowsCtrlHandler, false); @@ sigaction(SIGINT, &m_previousAction, nullptr); } `#endif` + m_installed.store(false); m_semaphore.signal(); m_thread->waitForCompletion(); } }🤖 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/vm/SigintWatcher.cpp` around lines 89 - 110, The uninstall flow in SigintWatcher::uninstall clears m_installed before restoring the SIGINT handler, leaving a window where sigintWatcherHandler can still intercept and drop an interrupt. Reorder the logic so the existing handler is restored first (using the current sigaction/SetConsoleCtrlHandler path), then mark the watcher uninstalled and proceed with the semaphore signal and waitForCompletion. Keep the fix localized to SigintWatcher::uninstall and preserve the current platform-specific behavior.src/runtime/cli/repl.rs (1)
1017-1053: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winClose the final SIGINT race before disarming.
take_interrupt_error()runs while the terminal is still cooked. A Ctrl+C arriving after Line 1354 but before Line 1357 can be caught by the armed watcher after the final take, thenend_interruptible_eval()disarms without converting/clearing that interrupt for this evaluation. Restore raw mode first, take the interrupt while the scope is still armed, then disarm.Proposed fix
- fn end_interruptible_eval(&mut self, scope: Option<SigintScope>) { + fn end_interruptible_eval(&mut self, scope: Option<SigintScope>) -> Option<JSValue> { let Some(scope) = scope else { - return; + return None; }; // Before anything fallible: leaving the prompt in cooked mode is worse // than any error we could hit below. #[cfg(unix)] let _ = tty::set_mode(0, tty::Mode::Raw); @@ - // SAFETY: `scope.scope` came from `Bun__REPL__armSigint` and is consumed - // once; `global` is a live opaque `JSGlobalObject` handle. + // SAFETY: `global` is live and `scope.scope` remains armed until disarm. + let error = unsafe { Bun__REPL__takeSigintError(global, scope.scope) }; + let error = (!error.is_empty()).then_some(error); + + // SAFETY: `scope.scope` came from `Bun__REPL__armSigint` and is consumed + // once; `global` is a live opaque `JSGlobalObject` handle. unsafe { Bun__REPL__disarmSigint(global, scope.scope) }; + error } @@ - if let Some(error) = self.take_interrupt_error(sigint.as_ref()) { + if let Some(error) = self.end_interruptible_eval(sigint) { outcome = EvalOutcome::Error(error); } - self.end_interruptible_eval(sigint);Also applies to: 1352-1357
🤖 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/runtime/cli/repl.rs` around lines 1017 - 1053, The SIGINT cleanup in end_interruptible_eval is ordered too late: take_interrupt_error can miss a Ctrl+C that arrives after the final check but before disarm, leaving the interrupt uncleared for this evaluation. Update end_interruptible_eval to restore raw mode first, then call take_interrupt_error while the SigintScope is still armed, and only after that call Bun__REPL__disarmSigint; keep the existing scope/global handling and use sigint_requested/take_interrupt_error as the key helpers to preserve the final race-free cleanup.
🤖 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/NodeVMScript.cpp`:
- Around line 284-300: `checkForTermination()` in `NodeVMScript.cpp` is only
checking `vm.hasTerminationRequest()` before the early rethrow path, so a
pending termination exception can bypass the SIGINT/timeout classification.
Update the initial guard in `checkForTermination(JSC::VM&, JSC::JSGlobalObject*,
NodeVMGlobalObject*, JSC::ThrowScope&, NodeVMScript*, std::optional<double>)` to
also account for `hasPendingTerminationException()`, matching the logic used in
`NodeVMModule.cpp`, so pending termination is handled in the same branch as
termination requests.
In `@test/js/bun/repl/repl.test.ts`:
- Around line 1024-1029: The REPL SIGINT test in repl.test.ts only proves the
process stays alive, so it can pass even if the JS listener never runs; update
the test around the existing process.on('SIGINT', ...) setup to emit a clear
marker from the handler and wait for that marker before asserting the outcome.
Use the same REPL send/waitFor flow and the proc.kill("SIGINT") step, but
strengthen the assertion by verifying the listener callback actually executed
rather than relying on the "alive" race alone.
In `@test/js/node/vm/vm.test.ts`:
- Around line 981-984: Remove the fixture watchdog timer from the vm test and
rely on the existing stdout/exitCode assertions instead. In the vm.test.ts
scenario around m.evaluate and chain, eliminate the setTimeout/process.exit
guard and keep the continuation log plus the current promise/exit assertions so
the test still fails if the expected output is missing. Use the existing
m.evaluate and chain flow to preserve the invariant without any embedded timer
watchdog.
---
Outside diff comments:
In `@src/jsc/bindings/vm/SigintWatcher.cpp`:
- Around line 89-110: The uninstall flow in SigintWatcher::uninstall clears
m_installed before restoring the SIGINT handler, leaving a window where
sigintWatcherHandler can still intercept and drop an interrupt. Reorder the
logic so the existing handler is restored first (using the current
sigaction/SetConsoleCtrlHandler path), then mark the watcher uninstalled and
proceed with the semaphore signal and waitForCompletion. Keep the fix localized
to SigintWatcher::uninstall and preserve the current platform-specific behavior.
In `@src/runtime/cli/repl.rs`:
- Around line 1017-1053: The SIGINT cleanup in end_interruptible_eval is ordered
too late: take_interrupt_error can miss a Ctrl+C that arrives after the final
check but before disarm, leaving the interrupt uncleared for this evaluation.
Update end_interruptible_eval to restore raw mode first, then call
take_interrupt_error while the SigintScope is still armed, and only after that
call Bun__REPL__disarmSigint; keep the existing scope/global handling and use
sigint_requested/take_interrupt_error as the key helpers to preserve the final
race-free cleanup.
🪄 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: 611d0156-ba72-4f66-9f0f-b41aae45e302
📒 Files selected for processing (9)
src/jsc/bindings/BunProcess.cppsrc/jsc/bindings/NodeVMModule.cppsrc/jsc/bindings/NodeVMScript.cppsrc/jsc/bindings/bindings.cppsrc/jsc/bindings/vm/SigintWatcher.cppsrc/jsc/bindings/vm/SigintWatcher.hsrc/runtime/cli/repl.rstest/js/bun/repl/repl.test.tstest/js/node/vm/vm.test.ts
There was a problem hiding this comment.
The last two commits address all three of my remaining points — the deferSigintDisposition handshake closes the add/remove-listener vise cleanly, and threading contextGlobalObject through checkForTermination fixes the runInThisContext sibling with a matching test. Nothing further from me; deferring to a human for the final call given the new BunProcess ↔ SigintWatcher disposition-ownership protocol is an architectural coupling worth a maintainer's eyes.
Extended reasoning...
Overview
This PR makes Ctrl+C interrupt a running REPL evaluation instead of hanging the process. It touches 12 files across the REPL (repl.rs), JSC VM termination handling (bindings.cpp, NodeVMScript.cpp, NodeVMModule.cpp), the process-wide SIGINT watcher (SigintWatcher.{h,cpp}, SigintReceiver.h), and — as of the latest commit — BunProcess.cpp's process.on('SIGINT') path. Since my last review at 607092f, two new commits landed: 0298906 threads a separate contextGlobalObject through checkForTermination so runInThisContext no longer clears the caller's microtask queue on timeout, and 485045e introduces SigintWatcher::deferSigintDisposition, a handshake that lets onDidChangeListeners hand its intended SIGINT action to the armed watcher rather than installing over it. Both come with regression tests, and the 4-line comments I flagged are now 3.
Security risks
None identified. Signal-handler installation is confined to SIGINT and uses the existing async-signal-safe pattern (semaphore post → watcher thread). The new deferSigintDisposition path is guarded by m_refCountMutex and only mutates m_previousAction; no new user-controlled data reaches native code.
Level of scrutiny
High. This is not a mechanical change: it rewires SIGINT disposition ownership across three subsystems (the REPL, node:vm's breakOnSigint, and process.on('SIGINT')), restructures node:vm termination classification to re-raise across nested scopes, and introduces a new coordination protocol (deferSigintDisposition) between BunProcess.cpp and SigintWatcher. The PR has gone through ~10 review rounds, each surfacing a real interaction bug (RELEASE_ASSERT abort, swallowed external SIGINT, clobbered listener, cleared main-thread microtask queue), which is itself evidence that the state space here is subtle. The Windows path is explicitly untested (describe.todoIf(isWindows)), and two known gaps are documented in the PR body (parked event loop, nested-timeout classification).
Other factors
Test coverage is thorough — 7 new pty-driven REPL tests forming a three-leg vise on SIGINT disposition, plus 4 spawned vm.test.ts regressions for the abort/microtask-clear cases. The author has been rigorous about A/B verification and honest about what's deferred. That said, the deferSigintDisposition mechanism in 485045e is a new architectural coupling introduced in the final commit specifically to close the third leg of the vise; a maintainer should confirm that having BunProcess reach into the SIGINT watcher's saved-action slot is the right layering, versus e.g. having the watcher chain to forwardSignal while armed. I'm not approving on that basis, not because I've found a defect.
|
Five findings this round, three landed in Taken
The window is nanoseconds, so I widened it with a temporary 50ms sleep and counted where 30 signals went. Every signal should either interrupt the armed
Two signals vanished outright in the run that finished; the other two runs never reached the fixture's own exit path. The restore is already guarded on the handler still being ours, so moving it above the exchange is idempotent and the exchange still gates the thread teardown to one caller. Assert the SIGINT listener actually runs. The Drop the fixture watchdog timer. Switching Declined
// A signal that raced the disarm leaves a trap bit nobody will service;
// the next evaluation would terminate the instant it entered the VM.
if (vm.traps().hasTrapBit(JSC::VMTraps::NeedTermination)) [[unlikely]]
replClearTermination(vm);Reordering narrows the window but doesn't close it (a signal can still land between the take and the disarm), and the evaluation is already over by then, so there is nothing left to report as interrupted. The proposal also converts a dropped keystroke into a false
@claude, noted on the layering. The alternative you name, having the watcher chain to |
c7c5071 to
2838365
Compare
|
Rebased onto The red X on the previous build was two failures, neither from this diff:
Re-verified on the new base: Zero file overlap between this diff and the 62 files |
Status: diff is green, CI is red on four unrelated failuresBuild 73614 (the rebased
None of the crash stacks (
Local, on the current head:
The one CI retrigger allowed per iteration is spent (build 69189 → 69274), so I'm leaving this for a maintainer rather than pushing another. The change is ready. |
The REPL stays in raw mode while it evaluates, so Ctrl+C is delivered as a byte
nobody reads. After `while (true) {}` the session is unrecoverable: two Ctrl+Cs
do nothing, the process has to be killed from another terminal, and termios is
left in raw mode.
Hand the terminal back to the line discipline for the duration of an evaluation
(what node's REPL does, so Ctrl+C arrives as SIGINT) and arm the existing
SigintWatcher, which raises a JSC termination trap. Synchronous code unwinds and
the REPL reports ERR_SCRIPT_EXECUTION_INTERRUPTED, matching node.
This also removes the old interrupt path, which broke out of a promise wait by
calling `setExecutionForbidden()`. That is one-way in JSC, so a single Ctrl+C
during an `await` left the VM silently dropping every microtask for the rest of
the session.
SigintReceiver's flag is written by the watcher thread and read by the VM
thread, so it becomes atomic.
The REPL's terminal tests now spawn through `Bun.spawn`'s inline `terminal`
option: handing it an already-created `Bun.Terminal` skips the setsid +
TIOCSCTTY setup, so the child has no controlling terminal and never sees SIGINT.
…idden setter
Arming the SIGINT watcher around every REPL evaluation made the REPL an outer
`SigintWatcher` holder, which exposed two bugs in code the REPL now reaches:
A nested `vm.runInThisContext(src)` with no `breakOnSigint` aborted the process
on Ctrl+C. The inner script's termination reached `checkForTermination` with
neither a SIGINT flag of its own nor a timeout, and fell into
`RELEASE_ASSERT_NOT_REACHED`. An enclosing scope's SIGINT is still a SIGINT, so
report it as one. `NodeVMModule` had the same shape.
A nested `vm.runInThisContext(src, { breakOnSigint: true })` silently disarmed
the REPL: `registerGlobalObject` skipped the duplicate while
`unregisterGlobalObject` removed one entry unconditionally, so the inner holder
took the outer holder's registration with it. Append unconditionally to balance
the two.
`JSC::VM::setExecutionForbidden` lost its last caller when the old interrupt
path went away. The C++ shim ignored its `bool`, so `set_execution_forbidden(false)`
never cleared anything and was a trap for the next caller. The getter stays.
Also corrects the promise-wait comment: every poll path in
`us_loop_run_bun_tick` re-enters on EINTR, so a signal does not wake a parked
loop. The wait is only prompt because an idle loop polls without blocking, which
is what the test now exercises.
The branch added for a nested script terminated from the outside reported every
such termination as a SIGINT. It is also reached when an *outer*
`runInThisContext({ timeout: N })` watchdog fires while a nested no-options
script is on the stack, where no signal is involved at all:
const vm = require("vm");
globalThis.inner = () => vm.runInThisContext("while (true) {}");
vm.runInThisContext("inner()", { timeout: 100 });
// was: ERR_SCRIPT_EXECUTION_INTERRUPTED, "interrupted by `SIGINT`"
// node: ERR_SCRIPT_EXECUTION_TIMEOUT, "timed out after 100ms"
A nested script cannot classify a termination it did not request: it only knows
its own SIGINT flag and its own timeout. So when neither is set, re-raise the
termination instead of converting it, leaving `hasTerminationRequest()` intact.
The enclosing scope's `checkForTermination` then picks the right branch with its
own limit and receiver flag, and the REPL's `Bun__REPL__evaluate` still sees a
termination exception and reports the interrupt.
`NodeVMModule` had the same shape. Now matches node on all three paths.
Falling through to VM_RETURN_IF_EXCEPTION in the re-raise branch looks like the
tidier shape — it is what the sibling SIGINT/timeout branches do — but it stores
the VM's singleton TerminationException in `m_evaluationException`. Re-throwing
that once the request has been cleared trips
ASSERTION FAILED: !isTerminationException(exception) || hasTerminationRequest()
JSC::VM::setException(Exception *)
and aborts. `reconcileEvaluationState` settles the status lazily instead,
wrapping the error *value* in a fresh Exception that is safe to re-throw, so the
module still ends up `errored`.
Record why the early return is load-bearing, and add a regression test that
re-evaluates the errored module — the step where the stored exception is
re-thrown. The test exits 134 (SIGABRT) against the fall-through shape.
`SigintWatcher::uninstall()` hardcoded `Bun__onPosixSignal` + `SA_RESTART`
instead of restoring what `install()` displaced. Arming the watcher around every
REPL evaluation made that hot: after the first command, `kill -INT` on a
`bun repl` was enqueued, dropped for want of a listener, and the startup handler
that restores the terminal on the way out never ran again.
release: kill -INT at the prompt -> dies, terminal restored
before: kill -INT at the prompt -> swallowed, terminal left raw
`install()` now saves the displaced disposition and `uninstall()` puts it back,
which also stops a plain `vm.runInThisContext(x, { breakOnSigint: true })` from
leaking the same change into the rest of the process.
`registerReceiver` had the same `appendIfNotContains`-vs-unconditional-remove
asymmetry as `registerGlobalObject`: nested holders on one `NodeVMScript` let the
inner unregister the outer's receiver. `setSigintReceived` is an idempotent
atomic store, so appending is safe.
Also from review:
- Root the promise across `wait_for_promise_interruptible`; ticking can collect.
- Leave the promise wait once the loop has no work, rather than burning a core
on an `await` nothing can ever settle (201 CPU ticks/2s -> 0).
- `drainMicrotasksForGlobalObject(globalObject)`: `nodeVmGlobalObject` is
nullable and `globalObject` already resolves to it when present.
- Keep `_error` in sync on `.copy`'s failure paths.
- Trim four comments to the 3-line limit; capture stderr before asserting.
Saving the displaced disposition and restoring it unconditionally fixed the
swallowed external SIGINT, but it also discarded any handler installed *while*
the watcher was armed. `process.on("SIGINT", h)` inside a REPL evaluation
installs BunProcess's handler and records the signal in `signalToContextIdsMap`;
the disarm then threw that handler away, and because the map entry survives,
BunProcess never reinstalls it. The listener was stranded for the session.
release: process.on("SIGINT") then kill -INT -> repl alive, listener honored
before: process.on("SIGINT") then kill -INT -> repl dies, listener discarded
`uninstall()` now restores only when the handler still installed is the
watcher's own, so it undoes itself and leaves anything else alone. The lambda
becomes a named function so it can be compared. Two tests hold both halves: with
no listener an external SIGINT must still terminate, with a listener the session
must survive. The unconditional restore passes the first and fails the second.
The nested-timeout vm test now runs its input as a spawned fixture, matching its
sibling, so a regression back to the abort is an attributable failure rather
than a dead test runner.
… context
`drainMicrotasksForGlobalObject` does not drain, it *clears*:
void VM::drainMicrotasksForGlobalObject(JSGlobalObject* g)
{ m_defaultMicrotaskQueue->clearForGlobalObject(g); }
So it has to stay scoped to the terminated context's global. `nodeVmGlobalObject`
is deliberately null when the module has no context, meaning there is nothing to
clear. Passing the caller's `globalObject` instead discarded the *main* thread's
pending microtasks, so every parked `await` continuation vanished and the process
wedged.
`new SourceTextModule("while (true) {}")` with `evaluate({ timeout })` and no
context is exactly that shape, which is why
`test/js/node/test/parallel/test-vm-module-basic.js` timed out on every platform.
drain target = nodeVmGlobalObject -> exit 0 (x3)
drain target = globalObject -> exit 124 (x3)
Reverted, with a comment naming the trap, plus a regression test that parks a
continuation in the main queue across a terminated context-less evaluation. It
fails against the `globalObject` target.
…s queue
`drainMicrotasksForGlobalObject` clears rather than drains, so a terminated
script may only clear the queue of the context it ran in. `runInThisContext`
has no context of its own, and `checkForTermination` was handing it the
caller's global:
const { promise, resolve } = Promise.withResolvers();
const chain = promise.then(() => "survived");
resolve();
try { vm.runInThisContext("while (true) {}", { timeout: 100 }); } catch {}
chain.then(v => console.log(v)); // never prints; node prints "survived"
Pass the contextified global separately: `runInContext` keeps its own
`NodeVMGlobalObject`, `runInThisContext` passes null (nothing to clear),
matching what `NodeVMModule::evaluate` already does.
BunProcess and the watcher each install their own SIGINT handler and each refuse to uninstall one they did not install, which leaves the disposition wrong in both directions once the REPL arms the watcher around every evaluation. Removing the last listener: BunProcess's `signal(SIGINT, SIG_DFL)` finds the watcher's handler, so it reinstates it; the disarm then sees its own handler intact and restores the snapshot it took at arm time, which is the forwarder BunProcess just asked to drop. An external `kill -INT` is swallowed from then on. Adding one: BunProcess installs the forwarder over the watcher's handler, so Ctrl+C during that same evaluation only queues a JS event, which the loop it is supposed to interrupt never drains. While armed the watcher owns the disposition, so BunProcess hands it the action to apply on disarm instead of installing over it. When nothing is armed `deferSigintDisposition` returns false immediately and BunProcess is unchanged.
`uninstall()` cleared `m_installed` while its handler was still the SIGINT disposition. A signal landing in that window runs the handler, which posts the semaphore, and the watcher thread then bails on `if (!m_installed) return;` before forwarding it, so the signal reaches nobody at all. It also strands `m_waiting` set, which deafens the watcher for the rest of the process. Hand the disposition back first, then go unarmed. The restore is already guarded on the handler still being ours, so the second caller of a racing `uninstall()` (only the destructor; `deref` holds the mutex) finds nothing to undo and the exchange still gates the thread teardown to one caller. Also: prove the REPL's SIGINT listener actually runs rather than only that the process stayed alive, and drop the watchdog timer from the module microtask fixture now that the continuation is a `.then` rather than an `await`, so the cleared-queue case fails on the missing line instead of on a timer.
80513b5 to
92c7b6f
Compare
|
Rebased onto main ( Re-verified on the new base: |
|
Independent confirmation of the The "one Ctrl+C during an Note that the branch is conflicting with main again (#37075 touched the |
Repro
Node interrupts with
ERR_SCRIPT_EXECUTION_INTERRUPTEDand keeps going.A second, quieter one: a single Ctrl+C during an
awaitleaves the sessionsilently broken.
Cause
The REPL keeps the terminal in raw mode while it evaluates. Raw mode clears
ISIG, so Ctrl+C is not a signal, it is byte0x03sitting in the tty inputqueue with nobody reading it. Nothing can unwind the running script.
Node's REPL hands the terminal back to the line discipline for the duration of a
command (
ISIG/ICANON/ECHOall back on), so Ctrl+C arrives as a real SIGINT,and
runInThisContext({ breakOnSigint: true })turns that into a V8 termination.The
awaitcase went throughRepl.enable_signals_during_wait, whose handlercalled
JSC::VM::setExecutionForbidden(). That flag is one-way (JSC has no APIto clear it), and
VM::drainMicrotasksthen clears the microtask queue on everytick, so the session never runs another microtask.
Fix
begin_interruptible_evalputs the terminal back in cooked mode and arms theSigintWatcheralready used bynode:vm'sbreakOnSigint. The watcher threadcalls
VM::notifyNeedTermination(), which raises aVMTraps::NeedTerminationtrap at the next safepoint, so
while (true) {}unwinds. The REPL then reportsERR_SCRIPT_EXECUTION_INTERRUPTEDand returns to the prompt, exactly as nodedoes. Windows already sets
ENABLE_PROCESSED_INPUT, so only the watcher isneeded there.
One wrinkle worth recording:
VM::executeEntryScopeServicesOnExit()clearshasTerminationRequest()once the outermost entry scope unwinds, and the trapbit is cleared by
handleTraps()before that. Neither survives the return fromJSC::evaluate, so the durable record of the signal is theSigintReceiverflag, which the watcher sets before raising the trap. That flag is written by
the watcher thread and read by the VM thread, so it is now
std::atomic<bool>(
node:vmwas reading it across threads too).The
setExecutionForbidden()path is gone. The promise wait now polls thereceiver flag and breaks out on it, leaving the VM able to run JavaScript.
evaluate_and_printandevaluate_and_copyhad a duplicated copy of theevaluate/await/unwrap sequence; they now share
evaluate_transformed, which iswhere the interrupt handling lives.
Who owns the SIGINT disposition
Arming the watcher around every evaluation also puts it in conflict with
BunProcess, which installs its ownforwardSignalhandler forprocess.on("SIGINT"). Both sides refuse to uninstall a handler they did notinstall, and that guard leaves the disposition wrong in both directions.
Adding a listener,
sigaction(SIGINT, forwardSignal)displaces the watcher'shandler while it is armed, so Ctrl+C is downgraded to a JS event that the
loop it is meant to interrupt never drains:
Removing the last one,
signal(SIGINT, SIG_DFL)finds the watcher's handler,does not recognise it, and reinstates it. The disarm then sees its own handler
still installed and restores the snapshot it took at arm time, which is the
forwarder
BunProcessjust asked to drop, so an externalkill -INTissilently swallowed from then on.
While the watcher is armed it owns the disposition, so
BunProcesshands it theaction to apply on disarm (
SigintWatcher::deferSigintDisposition) instead ofinstalling over its handler. With nothing armed the method returns false on its
first line and
BunProcessis unchanged, which is every path outsidenode:vmand the REPL.
One more ordering bug fell out of the same audit.
uninstall()clearedm_installedwhile its own handler was still the disposition, so a signal inthat window ran the handler, posted the semaphore, and the watcher thread bailed
on
if (!m_installed) return;before forwarding it. Nobody saw the signal, andm_waitingstayed set, which deafens the watcher from then on. The dispositionnow goes back first. With the window artificially widened to 50ms, 30 signals
land somewhere accounted for (
fired + interrupted == 30) on every run; with theold ordering two vanished outright in the run that finished and the other two
runs never reached the fixture's exit path.
Tests
The new REPL tests drive a real pty. They needed one harness change: handing
Bun.spawnan already-createdBun.Terminalskips thesetsid()+ioctl(TIOCSCTTY)the inlineterminal: {...}option performs, so the child hadno controlling terminal (
tty_nr: 0,tpgid: -1) and the line discipline had noforeground process group to signal.
withTerminalReplnow uses the inline form.Its
waitForalso gained the timeout itsdeadlinealways implied: it used tosleep forever when the child went quiet, reporting a bare test timeout instead of
the captured output.
Four of the REPL tests pin the SIGINT disposition, one per reachable state: no
listener (an external SIGINT terminates), a listener added (it does not), the
listener then removed (it terminates again), and a listener added in front of the
loop Ctrl+C has to break out of. The last two fail with
deferSigintDispositionstubbed to
return falseon the same build.node:vm's sigint/timeout suites (test-vm-sigint.js,test-vm-sigint-existing-handler.js,test-vm-timeout.js,test-vm-timeout-escape-promise.js,test/js/node/vm/vm.test.ts) still pass,as does
test/js/bun/terminal/.bun run rust:check-allis clean on all 10targets.
On Windows the watcher goes through
SetConsoleCtrlHandler, and the REPL's rawmode already sets
ENABLE_PROCESSED_INPUT, so Ctrl+C was already arriving as aconsole control event and only the watcher was missing. That path is untested
here: the terminal REPL suite is
describe.todoIf(isWindows).Bugs this surfaces in
node:vmArming the watcher around every REPL evaluation makes the REPL an outer
SigintWatcherholder, which reaches code nothing reached before.require('vm').runInThisContext('while(true){}')+ Ctrl+C aborted theprocess: the inner script's termination hit
checkForTerminationwith neithera SIGINT flag of its own nor a timeout and fell into
RELEASE_ASSERT_NOT_REACHED("vm.Script terminated due neither to SIGINT nor to timeout").A nested script can't classify a termination it didn't request; it only knows
its own SIGINT flag and its own timeout. So when neither is set it now re-raises
the termination instead of converting it, and the enclosing scope picks the right
branch with its own limit and receiver flag. That matters beyond the REPL: an
outer
runInThisContext({ timeout: N })firing while a nested no-options scriptis on the stack involves no signal at all, and node reports
ERR_SCRIPT_EXECUTION_TIMEOUTthere.NodeVMModulehad the identical shape.require('vm').runInThisContext('1', {breakOnSigint:true}); while(true){}+Ctrl+C hung:
registerGlobalObjectusedappendIfNotContainswhileunregisterGlobalObjectremoves one entry unconditionally, so the inner holdercarried the outer holder's registration out with it. Appending unconditionally
balances the two;
signalAlltolerates duplicates.A third one, pre-existing rather than surfaced, shares a line with the above.
drainMicrotasksForGlobalObjectclears rather than drains, so a terminatedscript may only clear the queue of the context it ran in.
runInThisContexthasno context of its own and
checkForTerminationwas handing it the caller'sglobal, discarding everything the caller had parked:
The contextified global is now a parameter of its own:
runInContextpasses itsNodeVMGlobalObject,runInThisContextpasses null.clearForGlobalObjectearly-returns on null, so that path clears nothing.
Known gap: nested
vmtimeout classificationThe re-raise guard asks whether this scope configured a timeout, not whether its
watchdog actually fired. So an inner
runInThisContext(src, { timeout: N })withno
breakOnSigint, terminated by an enclosing SIGINT before N elapses, stillreports its own
ERR_SCRIPT_EXECUTION_TIMEOUT.Pre-existing: the old
else if (timeout)was byte-identical, and the non-REPLshape (outer
breakOnSigint+ innertimeout) already behaved this way. The REPLprints the right thing regardless, because
take_interrupt_errorsupersedes offthe sticky receiver flag; it only leaks to a caller that wraps the nested call in
its own
try/catch.Not fixed here because the obvious discriminator is unsafe: comparing a monotonic
deadline would, on a false "didn't fire" at the outermost scope, re-raise an
uncatchable
TerminationExceptioninto user JS instead of the catchableERR_SCRIPT_EXECUTION_TIMEOUTthattest-vm-timeout.jsasserts on. Doing it rightmeans tracking fired-state the way node's
Watchdogcarriestimed_out_, andsetupWatchdog'senteredVM()already resets an enclosing deadline, and thatbookkeeping wants its own change.
Known gap: a parked event loop
wait_for_promise_interruptibleis only prompt because an idle loop pollswithout blocking. Once something keeps the loop alive it parks in
epoll_pwait2/kevent64, and every path inus_loop_run_bun_tickre-entersthe poll on
EINTR, so a signal alone never returns from it. A Ctrl+C landingafter the park waits for whatever wakes the loop next.
This is not a regression (the old
sigint_handler→wait_for_promise→auto_tickpath had the same gap), and thewhile (true) {}case in #27558 isunaffected because that thread is running JS, not parked. Closing it properly
means waking the loop from the watcher thread the way
WebWorker__notifyNeedTerminationdoes:notify_need_termination()followed byevent_loop().wakeup(). That can't be done blindly insignalAll(), becauseNodeVMGlobalObjectderives fromBun::GlobalScope, notZig::GlobalObject, soit has no
bunVM()to reach a loop through. Left for a follow-up rather thanbolted on here. The never-settling-await test is written against the idle loop so
it is deterministic rather than racing the park.
Not in scope
The related complaint that the JS event loop does not run while the REPL waits
at the prompt (timers, servers and
fetches frozen between commands) is alreadycovered by #30560, so this PR leaves
read_bytealone.#33237 (
Bun.spawn({ terminal })gives the child no controlling terminal) is areal bug that this PR ran into, but does not fix: the spawn path is untouched,
only the REPL's own test harness moves to the inline
terminal: {...}option,which already does the
setsid()+ioctl(TIOCSCTTY). Confirmed whiledebugging, for whoever picks it up: with a pre-created
Bun.Terminalthe childcomes up
tty_nr: 0/tpgid: -1, with the inline option it gets the pty.Fixes #27558
no test proof · iteration 8 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/repl/repl.test.ts