repl: handle Ctrl+C at the prompt on Windows instead of exiting - #38073
repl: handle Ctrl+C at the prompt on Windows instead of exiting#38073robobun wants to merge 4 commits into
Conversation
setup_terminal() set ENABLE_PROCESSED_INPUT on the console, so conhost turned Ctrl+C into a CTRL_C_EVENT (default handler: exit the process with STATUS_CONTROL_C_EXIT) instead of delivering the 0x03 byte that read_key maps to Key::CtrlC. Clear the flag while line editing, as libuv's raw mode does, and set it only for the duration of an evaluation, where nothing reads stdin and Ctrl+C ending the process is the only way out of a hung evaluation. The terminal REPL tests match on ANSI-stripped output and fail as soon as the REPL exits, so the Ctrl+C tests can run under ConPTY as well.
|
Warning Review limit reached
Next review available in: 38 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 1:48 PM PT - Aug 13th, 2026
❌ @robobun, your commit 5d48e86 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 38073That installs a local version of the PR into your bun-38073 --bun |
|
Status: reproduced and fixed, waiting on CI for 5d48e86. Reproduced on Windows Server 2019 x64 and Windows 11 24H2 arm64 by running Review so far: two automated LGTMs, comment-linter threads resolved (comments shortened in 70a7464), and a self-review pass whose points were addressed in the tests (5d48e86). #27558 is referenced in the description rather than closed (see the comments below). |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Referencing #27558 rather than closing it: this PR covers the "Ctrl+C quits the REPL" part of that issue (Ctrl+C at the prompt now clears the line, and two presses in a row exit), but the interruption of a running |
There was a problem hiding this comment.
LGTM — moves ENABLE_PROCESSED_INPUT from set to unset in the REPL's Windows console mode so Ctrl+C arrives as 0x03 at the prompt, and holds a StdinModeGuard during evaluation so Ctrl+C still ends a hung eval.
What was reviewed:
- Verified
StdinModeGuard::setrestores the captured mode on drop and is inert when stdin isn't a console; the guard is gated onoriginal_windows_mode.is_some()so pipedbun replis unchanged. - Checked
evaluate_rawis only reached from insideevaluate_and_print/evaluate_and_copy, so the guard covers every interactive evaluation path (including.loadand editor-mode Ctrl+D);eval_script(-e/-p) never runssetup_terminalso it needs no guard. waitFornow matches on ANSI-stripped text and fails fast when the child exits — checked existing regex waits (/\u276f|> /,/\n\s*N\b/) still hold under stripping; the initial-prompt regex dropping the trailing space can't false-match the welcome banner.
Extended reasoning...
Overview
Two files: src/runtime/cli/repl.rs moves ENABLE_PROCESSED_INPUT from the set word to the unset word in setup_terminal()'s Windows branch, adds a #[cfg(windows)] processed_input_while_evaluating() helper that returns an Option<StdinModeGuard> re-enabling the flag, and binds that guard at the top of evaluate_and_print / evaluate_and_copy. A stale comment in enable_signals_during_wait is updated. test/js/bun/repl/repl.test.ts reworks waitFor to match on stripAnsi output, fail immediately when the child exits, and race the data-waiter against proc.exited; the old POSIX-only Ctrl+C test is replaced by a new cross-platform Bun REPL (Terminal) Ctrl+C describe block with four cases, one Windows-only. POSIX code paths are untouched.
Security risks
None. This is developer-tool terminal-mode plumbing. No parsing of untrusted input, no auth/crypto/permissions, no network. Clearing ENABLE_PROCESSED_INPUT while line-editing is exactly what libuv's raw mode does; Ctrl+Break and GenerateConsoleCtrlEvent still terminate the process.
Level of scrutiny
Low-to-moderate. The Rust change is a one-flag move plus an RAII guard using an existing in-tree helper (bun_sys::windows::StdinModeGuard, already used by prompt() / bun init). I confirmed UpdateStdioModeFlagsOpts derives Default (so unset is 0 in the guard), the guard's Drop restores the captured mode, and the original_windows_mode.is_some() gate makes it a no-op when setup_terminal() didn't touch the console. The test-helper change is the larger surface: I checked that switching waitFor to ANSI-stripped matching can only make existing text/regex patterns easier to satisfy (escape bytes between \n and a digit are removed) and can't false-match on the welcome banner.
Other factors
Callers audited: evaluate_raw is only reached from inside the two guarded functions, so every interactive evaluation (Enter, .load, .copy <expr>, editor Ctrl+D) is covered; eval_script runs in the non-interactive branch that never calls setup_terminal, so no guard needed there. The PR description documents fail-before / pass-after on both Windows CI configurations with repeated runs, and full-file passes on Windows x64 and Linux. The removed Ctrl+C cancels current input test is replaced by a stronger version in the new block. No prior human reviews or outstanding comments.
…ing the REPL A process whose ancestors ignore Ctrl+C (CREATE_NEW_PROCESS_GROUP, as in a CI job tree) never receives CTRL_C_EVENT, so a test expecting the REPL to die during an evaluation hangs there. Read ENABLE_PROCESSED_INPUT from inside a .load'ed evaluation instead, then check Ctrl+C at the following prompt is handled again.
|
Pushed 70a7464, which only shortens the three comments the comment linter flagged (replied on each thread; the flag choice itself is the standard raw console mode, not a workaround, so the two remaining short comments stay). 497ff09 before it replaced the Windows-only evaluation test, see the description. CodeRabbit has been rate limited on every push so far, so there is no review from it to address. |
There was a problem hiding this comment.
LGTM — targeted Windows console-mode fix with RAII guard and thorough tests.
What was reviewed:
setup_terminalnow clearsENABLE_PROCESSED_INPUTso Ctrl+C reachesKey::CtrlC; verifiedStdinModeGuard::set/Drop semantics restore the line-editor mode, and theoriginal_windows_mode.is_some()gate keeps piped stdin untouched.- Guard placement covers both interactive evaluation entry points (
evaluate_and_print,evaluate_and_copy);.load/.editorroute through the former,evaluate_rawis only reached under a live guard, andeval_script(non-tty-e/-p) is correctly untouched. waitForrefactor: ANSI-stripped matching andproc.exitedrace are strict improvements; checked existing terminal tests' patterns still match post-strip and that the loosened prompt regex has no false positive in the welcome banner.
Extended reasoning...
Overview
Two files: src/runtime/cli/repl.rs moves ENABLE_PROCESSED_INPUT from the set to the unset word in the Windows setup_terminal path, adds processed_input_while_evaluating() returning an Option<StdinModeGuard> that re-enables the flag while JS runs, and holds that guard in evaluate_and_print / evaluate_and_copy. test/js/bun/repl/repl.test.ts hardens the withTerminalRepl helper (ANSI-strip before match, fail immediately if the child exits mid-wait, race the data promise against proc.exited), moves the old Ctrl+C test out of the todoIf(isWindows) block into a new cross-platform Ctrl+C describe with four cases, and adds a Windows-only FFI test that reads stdin's console mode during .load evaluation to pin the guard.
Security risks
None. No untrusted input parsing, no auth/crypto/permissions. The console-mode calls go through the existing bun_sys::windows helpers already used by bun init / prompt(). The FFI in the test is read-only (GetConsoleMode).
Level of scrutiny
Medium. This is an interactive-CLI UX fix, Windows-only for the native change and POSIX code is byte-identical. It changes process-global console state, which is the sort of thing that can leak — I checked that the StdinModeGuard restores the pre-guard mode on drop (so the line editor's mode word comes back on every early return), and that the guard is skipped when setup_terminal never ran (original_windows_mode.is_none()), so piped bun repl and bun repl -e/-p are unaffected. UpdateStdioModeFlagsOpts derives Default, so unset is 0 in the guard call.
Other factors
- The PR description documents
USE_SYSTEM_BUN=1failures and debug-build passes on both Windows CI targets, with repeated runs, and explains why the evaluation test observes the mode flag instead of sending Ctrl+C (CI inherits Ctrl+C-ignored). Full file passes on Linux and Windows. - The comment-cop threads were addressed in 70a7464 (comments trimmed) and resolved; no outstanding human review.
- The test-helper change to strip ANSI before matching is applied to all callers, but every existing
waitForpattern in the file matches on plain text or\u276f, which survive stripping; the loosened initial-prompt regex/\u276f|>/cannot false-match the welcome banner. - I confirmed
evaluate_rawhas no other callers,.loadgoes throughevaluate_and_print, andeval_script(the non-interactive path) does not need the guard becausesetup_terminalnever runs for it.
The mode test waited for the evaluation's result, which is printed while the guard is still alive, and then sent Ctrl+C; wait for the prompt that follows instead, since it is printed after the guard has been dropped. Read the mode through .copy as well, so both guard sites are covered, and check that a line typed while an evaluation is running is evaluated afterwards.
|
Pushed 5d48e86 (tests only, after a self-review pass on the previous revision):
Verified on both Windows configurations with and without Ctrl+C ignored in the parent process; the description's test section and runs are updated. The CodeRabbit comments on this PR are rate-limit notices, there is no review from it to address. |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The console-mode flip and the RAII guard around evaluation look correct and match libuv's raw-mode word, and the new tests are designed to fail on the unfixed build. Because it changes Windows console-mode handling and reworks the shared waitFor helper that every terminal REPL test goes through, a human look would still be worthwhile.
Checked: StdinModeGuard captures and restores the pre-evaluation mode on every return path of evaluate_and_print/evaluate_and_copy (including the nested evaluate_raw fallback), and is skipped when setup_terminal didn't touch the console (piped stdin). The waitFor change to match on ANSI-stripped output is safe for the existing POSIX-only terminal tests — none of their patterns depend on escape sequences, and the cursor now consistently tracks the stripped length.
Extended reasoning...
Overview
Two files. In src/runtime/cli/repl.rs (Windows-only paths): setup_terminal() moves ENABLE_PROCESSED_INPUT from the set word to unset, so Ctrl+C at the prompt arrives as 0x03 and reaches the existing Key::CtrlC handler instead of triggering the default CTRL_C_EVENT exit; a new processed_input_while_evaluating() returns a StdinModeGuard (existing in-tree RAII helper) that re-enables the flag for the duration of evaluate_and_print() / evaluate_and_copy(), preserving today's exit-on-Ctrl+C during a hung evaluation. POSIX code is untouched. In test/js/bun/repl/repl.test.ts: the withTerminalRepl helper now strips ANSI before matching (ConPTY re-renders output), fails fast when the child exits mid-wait, and races the data-waiter against proc.exited; a new Bun REPL (Terminal) Ctrl+C block runs on Windows too and covers pending-input, empty-line double-press, multiline, type-ahead during eval, and a Windows-only test that reads the console mode via bun:ffi from inside .load and .copy to pin both guard sites.
Security risks
None. This is interactive REPL terminal-mode handling; no untrusted input parsing, auth, or network surface is touched.
Level of scrutiny
Medium. The Rust diff is small (~20 lines) and uses an existing helper, but Windows console-mode semantics and ConPTY behavior are subtle and easy to regress. I verified StdinModeGuard::set reads the current mode, applies the delta, and restores the captured mode on drop, so every return path from the two evaluate functions (early transform failure, exception, promise reject/pending, success) puts the line editor's mode back. The guard is gated on original_windows_mode.is_some(), which is only set when setup_terminal ran to completion (both stdin and stdout are TTYs), so piped bun repl and repl -e/-p are unaffected. evaluate_raw is only reached from inside the two guarded functions, so it inherits the guard. The one behavior nuance — user JS that changes the console mode during an evaluation is now overwritten by the guard's Drop — is the same trade-off the POSIX side already makes around promise waits, and the REPL needs its raw mode back to read the next keystroke.
Other factors
The test-helper change affects every withTerminalRepl caller (including the ~25 existing POSIX-only tests in the todoIf(isWindows) block): matching now happens on ANSI-stripped text and cursor tracks the stripped length. I checked the existing patterns — none rely on escape sequences, and the ones that call stripAnsi on the returned slice are idempotent. The initial-prompt regex was loosened from /❯|> / to /❯|>/ to accommodate ConPTY rendering the trailing space as an erase; the welcome banner contains neither character so it can't false-match. The new type-ahead test's 200 ms busy loop is wall-clock in the child and the test synchronizes on printed output, so if the send lands late the test still passes (weaker, not flaky). The Windows-only mode test observes the flag rather than sending Ctrl+C during eval, which the description explains is required because CI job trees inherit an ignored-Ctrl+C flag; that reasoning checks out. All comment-cop threads are resolved. CI on the head commit was still building when I reviewed, and I can't run the Windows lane myself — that plus the shared-helper edit is why I'm leaving this for a human to confirm rather than approving.
Problem
bun replwhile sitting at the prompt ends the process withSTATUS_CONTROL_C_EXIT(0xC000013A). On POSIX the same keystroke clears the line, or prints(press Ctrl+C again to exit, or Ctrl+D)on an empty line.setup_terminal()insrc/runtime/cli/repl.rssetsENABLE_PROCESSED_INPUTon the console input mode. With that flag conhost consumes Ctrl+C and raises aCTRL_C_EVENTinstead of queueing the0x03byte, soread_key()never producesKey::CtrlCandhandle_ctrl_c()is dead code on Windows. The REPL installs no console control handler, so the default one exits the process.while (1) {}, is repl: interrupt a running evaluation with Ctrl+C #33411 and is not changed here, so this PR does not close that issue.bun init/bun update -iprompts, where exiting on Ctrl+C is the intended behaviour (init_command.rssays so in a comment). The REPL wants the keystroke.Fix
setup_terminal()clearsENABLE_PROCESSED_INPUTtogether with line input and echo, so Ctrl+C arrives as0x03and goes through the existingKey::CtrlCpath, the same path POSIX raw mode (noISIG) uses. This is also what libuv's raw mode does on Windows (uv_tty_set_modeonly putsENABLE_PROCESSED_INPUTin the normal mode word), so node's REPL andprocess.stdin.setRawMode(true)already run with it off.evaluate_and_print()andevaluate_and_copy()hold aStdinModeGuardthat sets the flag back for the duration of the evaluation (evaluate_rawis only reached from inside these two). Nothing reads stdin while JavaScript runs, so a Ctrl+C typed then would otherwise sit in the input buffer until the next prompt; with the flag set it still raisesCTRL_C_EVENTand ends the process, which is what happens today and is the only way out of a hung evaluation on Windows. The guard restores the line editor's mode on every return path, and is skipped whensetup_terminal()did not touch the console (stdin or stdout not a tty), so pipedbun replis unchanged.CTRL_C_EVENTbeing raised during evaluation, which this change keeps.test/js/bun/repl/repl.test.ts, newBun REPL (Terminal) Ctrl+Cblock that runs on Windows too (the existing terminal block is stilltodoIf(isWindows)). Ctrl+C with pending input, on an empty line (hint, then exit 0 on the second press), and with a multiline input pending, which fail on an unfixed bun on both Windows configurations. A Windows-only test reads stdin's console mode throughbun:ffifrom inside an evaluation, once via.load(evaluate_and_print) and once via.copy <code>(evaluate_and_copy), assertingENABLE_PROCESSED_INPUTis set each time; removing only theevaluate_and_copyguard makes its second assertion fail. Each of those waits also requires the prompt printed after the evaluation, which the REPL writes only after the guard has been dropped, and the test then sends Ctrl+C and expects the hint (the mode was restored; this step fails on an unfixed bun). A cross-platform test types a second line while the first is still evaluating and expects both results: input queued while the evaluation-time mode is in effect is delivered (SetConsoleModedoes not discard queued input). ThewithTerminalReplhelper now matches on ANSI-stripped output, because ConPTY re-renders the REPL's output (the trailing space of>comes back as an erase sequence), and fails immediately with the exit code when the REPL exits while a test is waiting for output.CREATE_NEW_PROCESS_GROUP/SetConsoleCtrlHandler(NULL, TRUE)is inherited by children), and a process that ignores Ctrl+C never receivesCTRL_C_EVENT, so a test expecting the REPL to die during an evaluation hangs there. Reproduced locally by setting that flag in the process that runsbun test: the keystroke tests are unaffected, a kill-based test hangs, the mode-based test passes.USE_SYSTEM_BUN=1(bun 1.4.0) the four Ctrl+C tests fail withREPL exited (code 58, signal null) before printing ...(58 is the low byte of 0xC000013A) and the type-ahead test passes; with the debug build all five pass, both normally and with Ctrl+C ignored in the test runner as in CI, across repeated runs. The full file passes on Windows x64 and Linux; CI on the previous revision was green on both Windows lanes.Background
SetConsoleMode.ENABLE_LINE_INPUT/ENABLE_ECHO_INPUTare the equivalent of termiosICANON/ECHO;ENABLE_PROCESSED_INPUTis the equivalent ofISIG: while set, Ctrl+C is not delivered as input but turned into aCTRL_C_EVENT.ENABLE_VIRTUAL_TERMINAL_INPUTmakes arrow keys and friends arrive as escape sequences, which is how the REPL decodes them.CTRL_C_EVENT: conhost runs the process's console control handlers (SetConsoleCtrlHandler) on a new thread; if none claims the event, the default handler callsExitProcess(STATUS_CONTROL_C_EXIT). The mode flag only affects keyboard Ctrl+C;GenerateConsoleCtrlEventfrom another process still ends the REPL, just askill -INTends it on POSIX.bun_sys::windows::StdinModeGuard: existing RAII helper that applies a set/unset pair to stdin's console mode and restores the previous mode on drop; inert when stdin is not a console. Already used byprompt(),bun initandbun update -i.\x03to it goes through conhost's input path, so it is converted toCTRL_C_EVENTor delivered as a byte depending on the client's mode, exactly like a keypress; the unfixed REPL dies under it on both Windows versions above, which is what makes the tests fail before the fix.Test runs
Windows Server 2019 x64,
USE_SYSTEM_BUN=1 bun test test/js/bun/repl/repl.test.ts -t "Terminal\) Ctrl"(bun 1.4.0):Same machine, debug build with this change, run from a parent that called
SetConsoleCtrlHandler(NULL, TRUE)first (what a CI job tree looks like; the plain run is identical):Same machine, debug build with the
evaluate_and_copyguard deleted (everything else unchanged): the mode test fails at its.copyassertion (Expected: 1, Received: 0), the.loadassertion before it passes.Windows 11 24H2 arm64: identical results in all configurations. Repeated runs of the whole block with the debug build under the Ctrl+C-ignored parent: 5/5 on Server 2019, 3/3 on Windows 11.
Full file: Windows x64 debug build 129 pass / 2 skip / 20 todo / 0 fail (previous revision); Linux debug build 150 pass / 1 skip / 0 fail (previous revision), the block itself re-run on Linux after the last change.
cargo fmt --checkand prettier clean. Thecfg(windows)code was compiled by the x64 and arm64 Windows builds, thecfg(unix)side by the Linux build.The exit code 58 in the messages above is Bun truncating the child's 32-bit status to one byte on Windows (node reports 3221225786); reported separately.