Skip to content

Commit 9862cfb

Browse files
branchseerclaude
andcommitted
fix(test): eliminate race in exit-on-ctrlc output ordering
Move "ctrl-c received" print from the signal handler to the main thread (after OnceLock::wait), so it always executes after the milestone flush println!(). Previously, std::process::exit(0) in the handler could terminate before output reached the PTY, causing flaky snapshot diffs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c5aa545 commit 9862cfb

File tree

4 files changed

+126
-17
lines changed

4 files changed

+126
-17
lines changed

crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs

Lines changed: 126 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,110 @@
1+
use std::sync::Arc;
2+
3+
/// Write a milestone directly to the controlling terminal, bypassing stdout.
4+
///
5+
/// In labeled/piped modes stdout is a pipe and milestones would be stuck in
6+
/// the line-buffered writer until a newline arrives. This function writes to
7+
/// the terminal directly so the milestone reaches the PTY regardless of how
8+
/// stdout is configured.
9+
#[cfg(unix)]
10+
fn write_milestone_to_tty(name: &str) {
11+
use std::io::Write;
12+
let milestone = pty_terminal_test_client::encoded_milestone(name);
13+
let mut tty = std::fs::OpenOptions::new().write(true).open("/dev/tty").unwrap();
14+
tty.write_all(&milestone).unwrap();
15+
tty.flush().unwrap();
16+
}
17+
18+
/// Write a milestone directly to the console output with VT processing enabled.
19+
///
20+
/// Opens `CONOUT$` via `CreateFileW`, enables `ENABLE_VIRTUAL_TERMINAL_PROCESSING`
21+
/// so OSC 8 sequences are interpreted by the console, then writes the milestone
22+
/// via [`WriteConsoleW`]. `ConPTY` monitors the console buffer and forwards the
23+
/// resulting VT output to the PTY master.
24+
#[cfg(windows)]
25+
fn write_milestone_to_tty(name: &str) {
26+
use std::ffi::c_void;
27+
28+
// SAFETY: These are stable Windows API functions with well-defined behavior.
29+
unsafe extern "system" {
30+
fn CreateFileW(
31+
name: *const u16,
32+
access: u32,
33+
share: u32,
34+
security: *const c_void,
35+
disposition: u32,
36+
flags: u32,
37+
template: *const c_void,
38+
) -> *mut c_void;
39+
fn GetConsoleMode(handle: *mut c_void, mode: *mut u32) -> i32;
40+
fn SetConsoleMode(handle: *mut c_void, mode: u32) -> i32;
41+
fn WriteConsoleW(
42+
handle: *mut c_void,
43+
buffer: *const u16,
44+
chars_to_write: u32,
45+
chars_written: *mut u32,
46+
reserved: *const c_void,
47+
) -> i32;
48+
fn CloseHandle(handle: *mut c_void) -> i32;
49+
}
50+
51+
const GENERIC_READ: u32 = 0x8000_0000;
52+
const GENERIC_WRITE: u32 = 0x4000_0000;
53+
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
54+
const OPEN_EXISTING: u32 = 3;
55+
const ENABLE_VIRTUAL_TERMINAL_PROCESSING: u32 = 0x0004;
56+
const INVALID_HANDLE: *mut c_void = -1_isize as *mut c_void;
57+
58+
let conout: Vec<u16> = "CONOUT$\0".encode_utf16().collect();
59+
60+
// SAFETY: Opening CONOUT$ with read/write access to query and set console mode.
61+
let handle = unsafe {
62+
CreateFileW(
63+
conout.as_ptr(),
64+
GENERIC_READ | GENERIC_WRITE,
65+
FILE_SHARE_WRITE,
66+
std::ptr::null(),
67+
OPEN_EXISTING,
68+
0,
69+
std::ptr::null(),
70+
)
71+
};
72+
assert!(handle != INVALID_HANDLE, "failed to open CONOUT$");
73+
74+
// Enable VT processing so OSC 8 sequences are interpreted.
75+
let mut mode: u32 = 0;
76+
// SAFETY: `handle` is a valid console output handle from `CreateFileW`.
77+
// `GetConsoleMode` writes the current mode into `mode`.
78+
unsafe { GetConsoleMode(handle, &raw mut mode) };
79+
// SAFETY: Setting the console mode with VT processing enabled.
80+
unsafe { SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING) };
81+
82+
// Encode the milestone as a UTF-8 string, then convert to UTF-16 for
83+
// `WriteConsoleW`.
84+
let milestone = pty_terminal_test_client::encoded_milestone(name);
85+
let milestone_str = String::from_utf8(milestone).expect("milestone is valid UTF-8");
86+
let wide: Vec<u16> = milestone_str.encode_utf16().collect();
87+
88+
let mut written: u32 = 0;
89+
// SAFETY: `handle` is valid, `wide` is a valid UTF-16 buffer. `WriteConsoleW`
90+
// writes up to `wide.len()` characters and stores the count in `written`.
91+
unsafe {
92+
WriteConsoleW(
93+
handle,
94+
wide.as_ptr(),
95+
wide.len().try_into().unwrap(),
96+
&raw mut written,
97+
std::ptr::null(),
98+
);
99+
}
100+
101+
// SAFETY: Restoring original console mode and closing the handle.
102+
unsafe {
103+
SetConsoleMode(handle, mode);
104+
CloseHandle(handle);
105+
}
106+
}
107+
1108
/// exit-on-ctrlc
2109
///
3110
/// Sets up a Ctrl+C handler, emits a "ready" milestone, then waits.
@@ -25,19 +132,26 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
25132
}
26133
}
27134

28-
ctrlc::set_handler(move || {
29-
use std::io::Write;
30-
let _ = write!(std::io::stdout(), "ctrl-c received");
31-
let _ = std::io::stdout().flush();
32-
std::process::exit(0);
135+
let ctrlc_once_lock = Arc::new(std::sync::OnceLock::<()>::new());
136+
137+
ctrlc::set_handler({
138+
let ctrlc_once_lock = Arc::clone(&ctrlc_once_lock);
139+
move || {
140+
let _ = ctrlc_once_lock.set(());
141+
}
33142
})?;
34143

35-
pty_terminal_test_client::mark_milestone("ready");
36-
// Print a newline so the milestone bytes get flushed through line-buffered
37-
// writers (labeled/grouped log modes).
38-
println!();
144+
// Write the milestone directly to the controlling terminal, bypassing
145+
// stdout. In labeled/piped modes stdout is a pipe and milestones would
146+
// be stuck in the line-buffered writer until a newline arrives.
147+
//
148+
// On Unix: /dev/tty opens the controlling terminal (the PTY).
149+
// On Windows: open CONOUT$ with ENABLE_VIRTUAL_TERMINAL_PROCESSING so
150+
// OSC 8 sequences are processed by the console, then forwarded by ConPTY
151+
// to the PTY output.
152+
write_milestone_to_tty("ready");
39153

40-
loop {
41-
std::thread::park();
42-
}
154+
ctrlc_once_lock.wait();
155+
println!("ctrl-c received");
156+
Ok(())
43157
}

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks (cached).snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,4 @@ expression: e2e_outputs
77
$ vtt exit-on-ctrlc
88
@ write-key: ctrl-c
99
$ vtt exit-on-ctrlc
10-
1110
ctrl-c received

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks (labeled).snap

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ expression: e2e_outputs
55
> vt run --log=labeled dev
66
@ expect-milestone: ready
77
[ctrl-c-test#dev] $ vtt exit-on-ctrlccache disabled
8-
[ctrl-c-test#dev]
98
@ write-key: ctrl-c
109
[ctrl-c-test#dev] $ vtt exit-on-ctrlccache disabled
11-
[ctrl-c-test#dev]
12-
1310
[ctrl-c-test#dev] ctrl-c received

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,4 @@ expression: e2e_outputs
77
$ vtt exit-on-ctrlccache disabled
88
@ write-key: ctrl-c
99
$ vtt exit-on-ctrlccache disabled
10-
1110
ctrl-c received

0 commit comments

Comments
 (0)