Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/runtime/cli/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -944,19 +944,34 @@ impl<'a> Repl<'a> {
}
#[cfg(windows)]
{
// ENABLE_PROCESSED_INPUT would turn Ctrl+C into a CTRL_C_EVENT (which
// exits the process) instead of the 0x03 byte behind Key::CtrlC.
Comment thread
robobun marked this conversation as resolved.
self.original_windows_mode = bun_sys::windows::update_stdio_mode_flags(
bun_sys::Stdio::StdIn,
bun_sys::windows::UpdateStdioModeFlagsOpts {
set: bun_sys::windows::ENABLE_VIRTUAL_TERMINAL_INPUT
| bun_sys::windows::ENABLE_PROCESSED_INPUT,
set: bun_sys::windows::ENABLE_VIRTUAL_TERMINAL_INPUT,
unset: bun_sys::windows::ENABLE_LINE_INPUT
| bun_sys::windows::ENABLE_ECHO_INPUT,
| bun_sys::windows::ENABLE_ECHO_INPUT
| bun_sys::windows::ENABLE_PROCESSED_INPUT,
},
)
.ok();
}
}

/// Nothing reads stdin while JavaScript runs, so Ctrl+C has to raise
/// CTRL_C_EVENT (ending the process) to get out of a hung evaluation.
/// Dropping the guard restores the line editor's mode.
Comment thread
robobun marked this conversation as resolved.
#[cfg(windows)]
fn processed_input_while_evaluating(&self) -> Option<bun_sys::windows::StdinModeGuard> {
self.original_windows_mode.is_some().then(|| {
bun_sys::windows::StdinModeGuard::set(bun_sys::windows::UpdateStdioModeFlagsOpts {
set: bun_sys::windows::ENABLE_PROCESSED_INPUT,
..Default::default()
})
})
}

fn restore_terminal(&mut self) {
#[cfg(unix)]
{
Expand Down Expand Up @@ -1001,7 +1016,7 @@ impl<'a> Repl<'a> {
bun_sys::posix::sigaction(libc::SIGINT, &raw const act, core::ptr::null_mut());
}
}
// On Windows, ENABLE_PROCESSED_INPUT is already set so Ctrl+C works
// Windows: covered by processed_input_while_evaluating().
}

/// Restore raw terminal mode after promise wait
Expand Down Expand Up @@ -1293,6 +1308,8 @@ impl<'a> Repl<'a> {
let Some(vm) = self.vm else {
return;
};
#[cfg(windows)]
let _processed_input = self.processed_input_while_evaluating();

// Transform the code using REPL mode (hoists declarations, wraps result in { value: expr })
let Some(transformed_code) = self.transform_for_repl(code) else {
Expand Down Expand Up @@ -1607,6 +1624,8 @@ impl<'a> Repl<'a> {
let Some(vm) = self.vm else {
return;
};
#[cfg(windows)]
let _processed_input = self.processed_input_while_evaluating();

let Some(transformed_code) = self.transform_for_repl(code) else {
self.evaluate_raw(code);
Expand Down
136 changes: 114 additions & 22 deletions test/js/bun/repl/repl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,31 +78,35 @@ async function withTerminalRepl(
const waitFor = async (pattern: string | RegExp, timeoutMs = 5000): Promise<string> => {
const deadline = Date.now() + timeoutMs;
while (true) {
const all = received.join("");
// Match on text only: ConPTY re-renders the REPL's output from its screen
// buffer (the trailing space of the "> " prompt comes back as an erase
// sequence, for instance), so the escape sequences are not the REPL's own.
const all = stripAnsi(received.join(""));
const recent = all.slice(cursor);
const matched = typeof pattern === "string" ? recent.includes(pattern) : pattern.test(recent);
if (matched) {
cursor = all.length;
return recent;
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
if (proc.exitCode !== null || proc.signalCode !== null) {
throw new Error(
`Timed out waiting for pattern: ${pattern}\nReceived so far:\n${stripAnsi(received.join("").slice(cursor))}`,
`REPL exited (code ${proc.exitCode}, signal ${proc.signalCode}) before printing ${pattern}\nReceived so far:\n${recent}`,
);
}
// Wait for the next chunk of terminal data (or time out).

await new Promise<void>(resolve => {
resolveWaiter = resolve;
});
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for pattern: ${pattern}\nReceived so far:\n${recent}`);
}
// Wait for the next chunk of terminal data, or for the REPL to exit.
const { promise, resolve } = Promise.withResolvers<void>();
resolveWaiter = resolve;
await Promise.race([promise, proc.exited]);
resolveWaiter = null;
}
};

const allOutput = () => stripAnsi(received.join(""));

await waitFor(/\u276f|> /); // Wait for prompt
await waitFor(/\u276f|>/); // Wait for prompt

await fn({ terminal, proc, send, waitFor, allOutput });

Expand Down Expand Up @@ -973,18 +977,6 @@ describe.todoIf(isWindows)("Bun REPL (Terminal)", () => {
});
});

test("Ctrl+C cancels current input", async () => {
await withTerminalRepl(async ({ send, waitFor, allOutput }) => {
send("some partial input");
await waitFor("some partial input");
send("\x03"); // Ctrl+C
await waitFor(/\u276f|> /);
// Should be back at a clean prompt
send("1 + 1\n");
await waitFor("2");
});
});

test("Ctrl+D exits on empty line", async () => {
await withTerminalRepl(async ({ terminal, proc }) => {
terminal.write("\x04"); // Ctrl+D
Expand Down Expand Up @@ -1193,6 +1185,106 @@ describe.todoIf(isWindows)("Bun REPL (Terminal)", () => {
});
});

// Ctrl+C is a keystroke (0x03) the line editor handles itself. These run on
// Windows too: the REPL used to leave ENABLE_PROCESSED_INPUT set on the console
// while line editing, so conhost turned Ctrl+C into a CTRL_C_EVENT and the
// process exited with STATUS_CONTROL_C_EXIT instead of reaching handle_ctrl_c.
describe("Bun REPL (Terminal) Ctrl+C", () => {
test("Ctrl+C with pending input clears the line", async () => {
await withTerminalRepl(async ({ send, waitFor, proc }) => {
send("some partial input");
await waitFor("some partial input");
send("\x03");
await waitFor("^C");
// The next evaluation must not see the cancelled text. The operands are
// split so the echoed input does not contain the expected result.
send('"still" + "-alive"\n');
await waitFor('"still-alive"');
expect(proc.exitCode).toBeNull();
});
});

test("Ctrl+C on an empty line shows the exit hint; a second one exits", async () => {
await withTerminalRepl(async ({ send, waitFor, proc }) => {
send("\x03");
await waitFor("press Ctrl+C again to exit");
expect(proc.exitCode).toBeNull();
send("\x03");
expect(await proc.exited).toBe(0);
});
});

test("Ctrl+C while a multiline input is pending discards it", async () => {
await withTerminalRepl(async ({ send, waitFor }) => {
send("function __abandoned() {\n");
await waitFor("...");
send("\x03");
// Evaluates as a fresh line only if Ctrl+C dropped the open function body.
send('typeof __abandoned + "-" + "checked"\n');
await waitFor('"undefined-checked"');
});
});

test("input typed while an evaluation is running is evaluated afterwards", async () => {
await withTerminalRepl(async ({ send, waitFor, allOutput }) => {
// The first line reports that it is running, then keeps the REPL away
// from stdin long enough for the second line to be queued by the
// terminal (on Windows: while the evaluation-time console mode is set).
send('console.log("BU" + "SY"); for (const t = Date.now(); Date.now() - t < 200; ); "first" + "-done"\n');
await waitFor("BUSY");
send('"second" + "-done"\n');
await waitFor('"second-done"');
expect(allOutput()).toMatch(/"first-done"[\s\S]*"second-done"/);
});
});

// The REPL does not read stdin while JavaScript runs, so a Ctrl+C typed then
// would sit in the console input buffer until the next prompt. On Windows the
// REPL sets ENABLE_PROCESSED_INPUT for the duration of an evaluation, so
// conhost raises CTRL_C_EVENT instead, and clears it again for the prompt.
// The flag is observed from inside the evaluation rather than by sending
// Ctrl+C during one: CI job trees run with Ctrl+C ignored (an inherited
// per-process flag), and a process that ignores Ctrl+C never sees the event.
test.skipIf(!isWindows)("evaluations run with ENABLE_PROCESSED_INPUT set, the prompt without", async () => {
const ENABLE_PROCESSED_INPUT = 0x1;
using dir = tempDir("repl-console-mode", {
"mode.js": `
const ffi = require("bun:ffi");
const kernel32 = ffi.dlopen("kernel32.dll", {
GetStdHandle: { args: ["i32"], returns: "ptr" },
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
});
function stdinMode() {
const mode = new Uint32Array(1);
const STD_INPUT_HANDLE = -10;
kernel32.symbols.GetConsoleMode(kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE), ffi.ptr(mode));
return mode[0].toString(16);
}
"loadmode=" + stdinMode();
`,
});
const modeIn = (output: string, label: string) =>
parseInt(output.match(new RegExp(`${label}=([0-9a-f]+)`))![1], 16);
await withTerminalRepl(async ({ send, waitFor }) => {
// Each wait also requires the prompt that follows the output: the REPL
// prints it only after the evaluation (and the mode guard) is gone, so
// the Ctrl+C below is sent once the line editor's mode is back.
// `.load` evaluates through evaluate_and_print, `.copy <code>` through
// evaluate_and_copy; the typed `.copy` line echoes `copymode="`, which
// the patterns' hex digits exclude.
send(`.load ${path.join(String(dir), "mode.js")}\n`);
const loaded = await waitFor(/loadmode=[0-9a-f]+[\s\S]*>/);
expect(modeIn(loaded, "loadmode") & ENABLE_PROCESSED_INPUT).toBe(ENABLE_PROCESSED_INPUT);
send('.copy console.log("copymode=" + stdinMode())\n');
const copied = await waitFor(/copymode=[0-9a-f]+[\s\S]*>/);
expect(modeIn(copied, "copymode") & ENABLE_PROCESSED_INPUT).toBe(ENABLE_PROCESSED_INPUT);
// Back at the prompt the flag is off again, so Ctrl+C arrives as a key.
send("\x03");
await waitFor("press Ctrl+C again to exit");
});
});
});

// History file written on REPL exit must be owner-only (0600), since it can
// contain pasted credentials. See src/runtime/cli/repl.rs History::save.
describe.skipIf(isWindows)("REPL history file permissions", () => {
Expand Down