Skip to content

Commit a00bca5

Browse files
branchseerclaude
andcommitted
fix: use ctrlc crate for cross-platform Ctrl+C detection in pty test
Replace platform-divergent signal-hook (Unix) / default-handler (Windows) approach with the `ctrlc` crate on both platforms. The handler writes "INTERRUPTED" and exits directly, avoiding race conditions between the callback thread and main thread. On Windows, the Rust runtime sets SetConsoleCtrlHandler(NULL, TRUE) during init which ignores CTRL_C_EVENT — clear this flag before registering the handler so ConPTY-generated events reach it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2fc7fdb commit a00bca5

File tree

4 files changed

+83
-54
lines changed

4 files changed

+83
-54
lines changed

Cargo.lock

Lines changed: 62 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ cow-utils = "0.1.3"
6262
crossterm = { version = "0.29.0", features = ["event-stream"] }
6363
csv-async = { version = "1.3.1", features = ["tokio"] }
6464
ctor = "0.6"
65+
ctrlc = "3.5.2"
6566
derive_more = "2.0.1"
6667
diff-struct = "0.5.3"
6768
directories = "6.0.0"

crates/pty_terminal/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ vt100 = { workspace = true }
1414

1515
[dev-dependencies]
1616
ctor = { workspace = true }
17+
ctrlc = { workspace = true }
1718
ntest = "0.9.5"
1819
subprocess_test = { workspace = true, features = ["portable-pty"] }
1920
terminal_size = "0.4"

crates/pty_terminal/tests/terminal.rs

Lines changed: 19 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -366,29 +366,10 @@ fn resize_terminal() {
366366
#[expect(clippy::print_stdout, reason = "subprocess test output")]
367367
fn send_ctrl_c_interrupts_process() {
368368
let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| {
369-
use std::io::{Write, stdin, stdout};
370-
#[cfg(unix)]
371-
use std::sync::Arc;
372-
#[cfg(unix)]
373-
use std::sync::atomic::{AtomicBool, Ordering};
374-
375-
#[cfg(unix)]
376-
let interrupted = Arc::new(AtomicBool::new(false));
377-
#[cfg(unix)]
378-
let interrupted_clone = Arc::clone(&interrupted);
369+
use std::io::{Write, stdout};
379370

380-
// Install SIGINT handler on Unix
381-
#[cfg(unix)]
382-
// SAFETY: The closure only performs an atomic store, which is signal-safe.
383-
unsafe {
384-
signal_hook::low_level::register(signal_hook::consts::SIGINT, move || {
385-
interrupted_clone.store(true, Ordering::SeqCst);
386-
})
387-
.unwrap();
388-
}
389-
390-
// On Windows, explicitly ensure Ctrl+C is NOT ignored, so that
391-
// CTRL_C_EVENT terminates the process via the default handler.
371+
// On Windows, clear the "ignore CTRL_C" flag set by Rust runtime
372+
// so that CTRL_C_EVENT reaches the ctrlc handler.
392373
#[cfg(windows)]
393374
{
394375
// SAFETY: Declaring correct signature for SetConsoleCtrlHandler from kernel32.
@@ -399,29 +380,28 @@ fn send_ctrl_c_interrupts_process() {
399380
) -> i32;
400381
}
401382

402-
// SAFETY: Clearing the "ignore CTRL_C" flag so the default handler runs.
383+
// SAFETY: Clearing the "ignore CTRL_C" flag so handlers are invoked.
403384
unsafe {
404385
SetConsoleCtrlHandler(None, 0); // FALSE = remove ignore
405386
}
406387
}
407388

389+
ctrlc::set_handler(move || {
390+
// Write directly and exit from the handler to avoid races.
391+
use std::io::Write;
392+
let _ = write!(std::io::stdout(), "INTERRUPTED");
393+
let _ = std::io::stdout().flush();
394+
std::process::exit(0);
395+
})
396+
.unwrap();
397+
408398
println!("ready");
409399
stdout().flush().unwrap();
410400

411-
// Block on stdin. On Unix, SIGINT interrupts read_line. On Windows,
412-
// CTRL_C_EVENT terminates the process via the default handler.
413-
let mut input = std::string::String::new();
414-
let _ = stdin().read_line(&mut input);
415-
416-
// On Unix, check if SIGINT was delivered via the signal handler.
417-
// On Windows, this code is unreachable: the process is terminated
418-
// by CTRL_C_EVENT before read_line returns.
419-
#[cfg(unix)]
420-
if interrupted.load(Ordering::SeqCst) {
421-
println!("INTERRUPTED");
401+
// Block until Ctrl+C handler exits the process.
402+
loop {
403+
std::thread::park();
422404
}
423-
424-
stdout().flush().unwrap();
425405
}));
426406

427407
let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
@@ -432,23 +412,10 @@ fn send_ctrl_c_interrupts_process() {
432412
// Send Ctrl+C
433413
terminal.send_ctrl_c().unwrap();
434414

435-
// On Unix, send newline to unblock read_line and verify SIGINT detection.
436-
#[cfg(unix)]
437-
{
438-
terminal.write(b"\n").unwrap();
439-
terminal.read_until("INTERRUPTED").unwrap();
440-
}
415+
// Verify interruption was detected
416+
terminal.read_until("INTERRUPTED").unwrap();
441417

442-
let status = terminal.read_to_end().unwrap();
443-
444-
// On Unix, the process exits normally after detecting SIGINT.
445-
#[cfg(unix)]
446-
assert!(status.success());
447-
448-
// On Windows, the default CTRL_C handler calls ExitProcess with
449-
// STATUS_CONTROL_C_EXIT, resulting in a non-zero exit code.
450-
#[cfg(windows)]
451-
assert!(!status.success());
418+
let _ = terminal.read_to_end().unwrap();
452419
}
453420

454421
#[test]

0 commit comments

Comments
 (0)