Skip to content
Closed
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/vite_cli_snapshots/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ tempfile = { workspace = true }
toml = { workspace = true }
which = { workspace = true }

[target.'cfg(unix)'.dependencies]
nix = { workspace = true, features = ["fs", "poll", "process"] }

[target.'cfg(windows)'.dev-dependencies]
junction = { workspace = true }

Expand Down
37 changes: 37 additions & 0 deletions crates/vite_cli_snapshots/src/bin/vpt/ctrlc_util.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use std::sync::{Arc, OnceLock};

/// Installs a Ctrl+C handler and returns the flag it sets, shared by the
/// ctrl-c task payloads (`exit-on-ctrlc`, `report-orphan-on-ctrlc`).
///
/// On Windows, an ancestor process (e.g. cargo, the test runner) may have
/// been created with `CREATE_NEW_PROCESS_GROUP`, which implicitly calls
/// `SetConsoleCtrlHandler(NULL, TRUE)` and sets `CONSOLE_IGNORE_CTRL_C` in
/// the PEB's ConsoleFlags. This flag is inherited by all descendants and
/// takes precedence over registered handlers — `CTRL_C_EVENT` is silently
/// dropped. Clear it so the handler can fire.
/// Ref: <https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags>
pub fn install_ctrlc_flag() -> Result<Arc<OnceLock<()>>, Box<dyn std::error::Error>> {
#[cfg(windows)]
{
// SAFETY: Passing (None, FALSE) clears the per-process CTRL_C ignore flag.
unsafe extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
// SAFETY: Clearing the inherited ignore flag.
unsafe {
SetConsoleCtrlHandler(None, 0);
}
}

let ctrlc_flag = Arc::new(OnceLock::new());
ctrlc::set_handler({
let ctrlc_flag = Arc::clone(&ctrlc_flag);
move || {
let _ = ctrlc_flag.set(());
}
})?;
Ok(ctrlc_flag)
}
35 changes: 2 additions & 33 deletions crates/vite_cli_snapshots/src/bin/vpt/exit_on_ctrlc.rs
Original file line number Diff line number Diff line change
@@ -1,44 +1,13 @@
use std::sync::Arc;

/// exit-on-ctrlc
///
/// Sets up a Ctrl+C handler, emits a "ready" milestone, then waits.
/// When Ctrl+C is received, prints "ctrl-c received" and exits.
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
// On Windows, an ancestor process (e.g. cargo, the test runner) may have
// been created with CREATE_NEW_PROCESS_GROUP, which implicitly calls
// SetConsoleCtrlHandler(NULL, TRUE) and sets CONSOLE_IGNORE_CTRL_C in the
// PEB's ConsoleFlags. This flag is inherited by all descendants and takes
// precedence over registered handlers — CTRL_C_EVENT is silently dropped.
// Clear it so our handler can fire.
// Ref: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
#[cfg(windows)]
{
// SAFETY: Passing (None, FALSE) clears the per-process CTRL_C ignore flag.
unsafe extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
// SAFETY: Clearing the inherited ignore flag.
unsafe {
SetConsoleCtrlHandler(None, 0);
}
}

let ctrlc_once_lock = Arc::new(std::sync::OnceLock::<()>::new());

ctrlc::set_handler({
let ctrlc_once_lock = Arc::clone(&ctrlc_once_lock);
move || {
let _ = ctrlc_once_lock.set(());
}
})?;
let ctrlc_flag = crate::ctrlc_util::install_ctrlc_flag()?;

pty_terminal_test_client::mark_milestone("ready");

ctrlc_once_lock.wait();
ctrlc_flag.wait();
println!("ctrl-c received");
Ok(())
}
10 changes: 8 additions & 2 deletions crates/vite_cli_snapshots/src/bin/vpt/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// rather than the project's custom types (vite_str, vite_path, etc.). Its
// subcommand names and semantics follow vite-task's `vtt` multitool verbatim
// wherever they overlap, so fixtures and habits transfer between the repos;
// `chmod`, `json-edit`, and `probe` are vite-plus additions.
// `chmod`, `json-edit`, `probe`, `report-orphan-on-ctrlc`, and `wait-file`
// are vite-plus additions.
#![expect(clippy::disallowed_types, reason = "standalone test utility uses std types")]
#![expect(clippy::disallowed_macros, reason = "standalone test utility uses std macros")]
#![expect(clippy::disallowed_methods, reason = "standalone test utility uses std methods")]
Expand All @@ -13,6 +14,7 @@ mod barrier;
mod check_tty;
mod chmod;
mod cp;
mod ctrlc_util;
mod exit;
mod exit_on_ctrlc;
mod grep_file;
Expand All @@ -29,17 +31,19 @@ mod print_native_path;
mod probe;
mod read_stdin;
mod replace_file_content;
mod report_orphan_on_ctrlc;
mod rm;
mod stat_file;
mod touch_file;
mod wait_file;
mod write_file;

fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("Usage: vpt <subcommand> [args...]");
eprintln!(
"Subcommands: barrier, check-tty, chmod, cp, exit, exit-on-ctrlc, grep-file, json-edit, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, print-native-path, probe, read-stdin, replace-file-content, rm, stat-file, touch-file, write-file"
"Subcommands: barrier, check-tty, chmod, cp, exit, exit-on-ctrlc, grep-file, json-edit, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, print-native-path, probe, read-stdin, replace-file-content, report-orphan-on-ctrlc, rm, stat-file, touch-file, wait-file, write-file"
);
std::process::exit(1);
}
Expand Down Expand Up @@ -74,9 +78,11 @@ fn main() {
"probe" => probe::run(),
"read-stdin" => read_stdin::run(),
"replace-file-content" => replace_file_content::run(&args[2..]),
"report-orphan-on-ctrlc" => report_orphan_on_ctrlc::run(&args[2..]),
"rm" => rm::run(&args[2..]),
"stat-file" => stat_file::run(&args[2..]),
"touch-file" => touch_file::run(&args[2..]),
"wait-file" => wait_file::run(&args[2..]),
"write-file" => write_file::run(&args[2..]),
other => {
eprintln!("Unknown subcommand: {other}");
Expand Down
146 changes: 146 additions & 0 deletions crates/vite_cli_snapshots/src/bin/vpt/report_orphan_on_ctrlc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/// report-orphan-on-ctrlc `<verdict-file>` \[--ignore-interrupt\]
///
/// Simulates a task with a graceful shutdown phase (a vite dev server
/// closing on Ctrl+C) and records whether `vp run` let that shutdown finish
/// (issue #2036): emits a "ready" milestone, waits for Ctrl+C, then spends
/// 300 ms shutting down before exiting cleanly.
///
/// With `--ignore-interrupt` the task instead announces the interrupt (a
/// printed line plus an "interrupted" milestone, so a test can synchronize
/// a second Ctrl+C on it) and keeps running forever, like a task whose
/// shutdown hangs. Its graceful shutdown then never completes; ending it
/// takes vp's force-kill escalation.
///
/// The verdict is written by a watcher process spawned into its own process
/// group, insulated from whatever signals vp sends the task tree during
/// teardown (the same way real dev-server grandchildren outlive `vp run`).
/// The watcher holds the read end of a pipe; the task writes "done" after
/// its shutdown completes. Pipe EOF without "done" means the task was torn
/// down mid-shutdown, and the watcher records that in `<verdict-file>`
/// (atomically, via a temp-file rename) for a follow-up `vpt wait-file`
/// step. Nothing else is printed, so the terminal snapshot stays
/// deterministic no matter how quickly the task tree is killed.
#[cfg(unix)]
pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
if args.first().is_some_and(|arg| arg == "--watch") {
return watch(args.get(1).ok_or("--watch needs a verdict file")?);
}

use std::{io::Write as _, os::fd::OwnedFd, time::Duration};

let mut verdict_path = None;
let mut ignore_interrupt = false;
for arg in args {
if arg == "--ignore-interrupt" {
ignore_interrupt = true;
} else {
verdict_path = Some(arg);
}
}
let Some(verdict_path) = verdict_path else {
return Err("Usage: vpt report-orphan-on-ctrlc <verdict-file> [--ignore-interrupt]".into());
};

let (pipe_read, pipe_write): (OwnedFd, OwnedFd) = nix::unistd::pipe()?;
// The write end must not leak into the watcher, or the pipe never
// reports EOF (macOS has no pipe2, so CLOEXEC is set separately).
nix::fcntl::fcntl(&pipe_write, nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::FD_CLOEXEC))?;

let mut watcher = std::process::Command::new(std::env::current_exe()?);
watcher
.arg("report-orphan-on-ctrlc")
.arg("--watch")
.arg(verdict_path)
.stdin(std::process::Stdio::from(pipe_read))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// SAFETY: setpgid is async-signal-safe; the watcher moves to its own
// process group so group-directed teardown signals cannot reach it.
unsafe {
std::os::unix::process::CommandExt::pre_exec(&mut watcher, || {
let _ =
nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0));
Ok(())
});
}
watcher.spawn()?;

let ctrlc_flag = crate::ctrlc_util::install_ctrlc_flag()?;

pty_terminal_test_client::mark_milestone("ready");

ctrlc_flag.wait();
if ignore_interrupt {
// A task whose shutdown hangs: announce the interrupt so the test
// can synchronize the second Ctrl+C on it, then never finish. The
// "done" marker below is unreachable, so the watcher records the
// eventual force-kill as an unfinished shutdown.
println!("ignoring interrupt; still running");
pty_terminal_test_client::mark_milestone("interrupted");
loop {
std::thread::park();
}
}
// The graceful shutdown a real dev server performs after SIGINT. A
// buggy vp tears the task down milliseconds into this window, so the
// "done" marker below never gets written.
std::thread::sleep(Duration::from_millis(300));

let mut done_pipe = std::fs::File::from(pipe_write);
let _ = done_pipe.write_all(b"done\n");
Ok(())
}

/// Watcher mode: reads the pipe on stdin until EOF (or a 10 s safety cap so
/// an orphaned watcher can never outlive the suite), then records whether
/// the task's shutdown completed.
#[cfg(unix)]
fn watch(verdict_path: &str) -> Result<(), Box<dyn std::error::Error>> {
use std::{
io::Read as _,
os::fd::AsFd as _,
time::{Duration, Instant},
};

use nix::poll::{PollFd, PollFlags, PollTimeout, poll};

let mut received = Vec::new();
let deadline = Instant::now() + Duration::from_secs(10);
let stdin = std::io::stdin();
let mut stdin_lock = stdin.lock();
loop {
let mut fds = [PollFd::new(stdin_lock.as_fd(), PollFlags::POLLIN)];
if poll(&mut fds, PollTimeout::from(100u8)).is_err() {
break;
}
let revents = fds[0].revents().unwrap_or(PollFlags::empty());
if revents.intersects(PollFlags::POLLIN) {
let mut buf = [0u8; 64];
match stdin_lock.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => received.extend_from_slice(&buf[..n]),
}
} else if revents.intersects(PollFlags::POLLHUP | PollFlags::POLLERR | PollFlags::POLLNVAL)
{
break;
}
if Instant::now() >= deadline {
break;
}
}

let verdict = if received.windows(4).any(|w| w == b"done") {
"task completed its graceful shutdown"
} else {
"task was torn down before its graceful shutdown finished"
};
let tmp = format!("{verdict_path}.tmp");
std::fs::write(&tmp, format!("{verdict}\n"))?;
std::fs::rename(&tmp, verdict_path)?;
Ok(())
}

#[cfg(not(unix))]
pub fn run(_args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
Err("report-orphan-on-ctrlc is unix-only (process-group/PTY semantics)".into())
}
31 changes: 31 additions & 0 deletions crates/vite_cli_snapshots/src/bin/vpt/wait_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/// wait-file `<path>` \[timeout-ms\]
///
/// Polls until `<path>` exists, then prints its contents. Fails after the
/// timeout (default 5000 ms). For asserting on files written asynchronously
/// by a previous step's process tree (e.g. a task outliving `vp run`).
pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let Some(path) = args.first() else {
return Err("Usage: vpt wait-file <path> [timeout-ms]".into());
};
let timeout_ms: u64 = match args.get(1) {
Some(raw) => raw.parse()?,
None => 5000,
};

let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
loop {
match std::fs::read_to_string(path) {
Ok(content) => {
print!("{content}");
return Ok(());
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
if std::time::Instant::now() >= deadline {
return Err(format!("timed out waiting for file: {path}").into());
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
Err(err) => return Err(err.into()),
}
}
}
7 changes: 6 additions & 1 deletion crates/vite_cli_snapshots/tests/cli_snapshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ is identical on every platform:
`vpt print`, `vpt print-color`, `vpt print-env`, `vpt print-cwd`,
`vpt print-native-path` (prints OS-native separators, for redaction
self-tests), `vpt check-tty`, `vpt read-stdin`, `vpt exit <code>`,
`vpt exit-on-ctrlc`, `vpt barrier`.
`vpt exit-on-ctrlc`, `vpt report-orphan-on-ctrlc` (records via a watcher
process that survives `vp run` whether the task got to finish its graceful
shutdown; `--ignore-interrupt` simulates a task whose shutdown hangs),
`vpt barrier`. `vpt wait-file <path> [timeout-ms]` polls until a
file exists and prints it, for asserting on files written by a previous
step's surviving process tree.

## Interactive cases

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "run-ctrlc-teardown",
"private": true,
"scripts": {
"dev": "vpt report-orphan-on-ctrlc verdict.txt",
"stuck-dev": "vpt report-orphan-on-ctrlc verdict.txt --ignore-interrupt"
}
}
Loading
Loading