From f1f1725f3ba4af562d52e600793a28419178ce3a Mon Sep 17 00:00:00 2001 From: yii Date: Mon, 6 Jul 2026 17:04:28 +0800 Subject: [PATCH 1/4] fix(cli): odd osc sequences after killing vp run with ctrl-c --- crates/vite_command/Cargo.toml | 2 +- crates/vite_command/src/lib.rs | 24 ++++++++++++++++++++--- crates/vite_global_cli/src/js_executor.rs | 8 +++----- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/vite_command/Cargo.toml b/crates/vite_command/Cargo.toml index b2bc76aead..0254bcda39 100644 --- a/crates/vite_command/Cargo.toml +++ b/crates/vite_command/Cargo.toml @@ -19,7 +19,7 @@ vite_shared = { workspace = true } which = { workspace = true, features = ["tracing"] } [target.'cfg(not(target_os = "windows"))'.dependencies] -nix = { workspace = true, features = ["term"] } +nix = { workspace = true, features = ["signal", "term"] } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/vite_command/src/lib.rs b/crates/vite_command/src/lib.rs index 61f6f041a4..7102ad25a9 100644 --- a/crates/vite_command/src/lib.rs +++ b/crates/vite_command/src/lib.rs @@ -98,7 +98,7 @@ pub async fn execute_with_terminal_guard(mut cmd: Command) -> Result, } #[cfg(unix)] @@ -326,22 +327,39 @@ impl TerminalStateGuard { } match tcgetattr(borrowed_fd) { - Ok(original) => Some(Self { fd, original }), + Ok(original) => Some(Self { fd, original, original_sigint: Self::ignore_sigint() }), Err(_) => None, } } + + fn ignore_sigint() -> Option { + use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction}; + + let ignore = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty()); + // SAFETY: installs a process signal disposition for SIGINT and returns + // the previous disposition, which this guard restores on drop. + unsafe { sigaction(Signal::SIGINT, &ignore).ok() } + } } #[cfg(unix)] impl Drop for TerminalStateGuard { fn drop(&mut self) { - use nix::sys::termios::{SetArg, tcsetattr}; + use nix::sys::{ + signal::{Signal, sigaction}, + termios::{SetArg, tcsetattr}, + }; // SAFETY: fd comes from stdin/stdout/stderr and the guard does not outlive the process let borrowed_fd = unsafe { BorrowedFd::borrow_raw(self.fd) }; // Best effort: ignore errors during cleanup let _ = tcsetattr(borrowed_fd, SetArg::TCSANOW, &self.original); + + // SAFETY: restores the signal disposition captured by save(). + if let Some(original_sigint) = self.original_sigint { + let _ = unsafe { sigaction(Signal::SIGINT, &original_sigint) }; + } } } diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index fa5404d883..a4e6eee9d0 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -295,8 +295,7 @@ impl JsExecutor { let mut cmd = Self::create_js_command(&node_binary, &bin_prefix); cmd.arg(entry_point.as_path()).args(args).current_dir(project_path.as_path()); - let status = cmd.status().await?; - Ok(status) + Ok(vite_command::execute_with_terminal_guard(cmd).await?) } /// Delegate to local or global vite-plus CLI using the CLI's own runtime. @@ -352,9 +351,8 @@ impl JsExecutor { bin_prefix: &AbsolutePath, args: &[String], ) -> Result { - let mut cmd = self.prepare_js_entry(project_path, node_binary, bin_prefix, args)?; - let status = cmd.status().await?; - Ok(status) + let cmd = self.prepare_js_entry(project_path, node_binary, bin_prefix, args)?; + Ok(vite_command::execute_with_terminal_guard(cmd).await?) } /// Like [`run_js_entry`], but returns `Output`. From edb8228eda4523ad8caf19fd748c1db998596567 Mon Sep 17 00:00:00 2001 From: yii Date: Mon, 6 Jul 2026 22:31:49 +0800 Subject: [PATCH 2/4] fix(cli): take and restore old sigint action --- crates/vite_command/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_command/src/lib.rs b/crates/vite_command/src/lib.rs index 7102ad25a9..c59cb3b114 100644 --- a/crates/vite_command/src/lib.rs +++ b/crates/vite_command/src/lib.rs @@ -357,7 +357,7 @@ impl Drop for TerminalStateGuard { let _ = tcsetattr(borrowed_fd, SetArg::TCSANOW, &self.original); // SAFETY: restores the signal disposition captured by save(). - if let Some(original_sigint) = self.original_sigint { + if let Some(original_sigint) = self.original_sigint.take() { let _ = unsafe { sigaction(Signal::SIGINT, &original_sigint) }; } } From f37bc414d24f02071b83c75c6eec7f831a49df0b Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 7 Jul 2026 01:08:50 +0800 Subject: [PATCH 3/4] fix(cli): swallow interrupts with a handler and escalate on second ctrl-c Builds on the terminal-guard approach from #2065 with three adjustments: Swallowing moves out of the guard's sigaction and into the wait loop as a signal handler. SIG_IGN survives exec into the entire child tree, so any task without its own SIGINT handler (a plain script, a shell) would become uninterruptible; handler dispositions reset to default on exec and leave children untouched. It also applies when stdin is not a terminal, where the guard's SIG_IGN never installed at all. A second Ctrl+C force-kills the child, so a task that ignores the first interrupt cannot make vp unstoppable. Without it, killing a stuck task requires an external signal. Children killed by a signal now exit with the conventional 128 + signal code (130 for SIGINT) instead of a generic failure. Refs #2036 --- crates/vite_command/Cargo.toml | 4 +- crates/vite_command/src/lib.rs | 90 +++++++++++++++++++----------- crates/vite_global_cli/src/main.rs | 11 ++++ 3 files changed, 69 insertions(+), 36 deletions(-) diff --git a/crates/vite_command/Cargo.toml b/crates/vite_command/Cargo.toml index 0254bcda39..e7f1f45b7b 100644 --- a/crates/vite_command/Cargo.toml +++ b/crates/vite_command/Cargo.toml @@ -9,7 +9,7 @@ rust-version.workspace = true [dependencies] fspy = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["macros", "process", "signal"] } tokio-util = { workspace = true } tracing = { workspace = true } vite_error = { workspace = true } @@ -19,7 +19,7 @@ vite_shared = { workspace = true } which = { workspace = true, features = ["tracing"] } [target.'cfg(not(target_os = "windows"))'.dependencies] -nix = { workspace = true, features = ["signal", "term"] } +nix = { workspace = true, features = ["term"] } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/vite_command/src/lib.rs b/crates/vite_command/src/lib.rs index c59cb3b114..88f35a66b0 100644 --- a/crates/vite_command/src/lib.rs +++ b/crates/vite_command/src/lib.rs @@ -86,31 +86,71 @@ pub fn build_command(bin_path: &AbsolutePath, cwd: &AbsolutePath) -> Command { cmd } -/// Execute a command while preserving terminal state. +/// Execute a command while preserving terminal state and waiting through +/// Ctrl+C. /// -/// This prevents escape sequences from appearing in the prompt when the child process -/// is interrupted (e.g., via Ctrl+C) while the terminal is in a non-standard state. +/// The terminal delivers an interrupt to the whole foreground process +/// group; the child's tree owns the graceful shutdown, and this process +/// must outlive it, or the terminal is handed back to the shell while the +/// dying tree still writes to it and restores terminal modes, which is how +/// the stray escape sequences of issue #2036 ended up on the prompt. +/// Interrupts are therefore swallowed while the child runs, and the +/// child's own exit decides when this returns. Swallowing uses a signal +/// handler rather than `SIG_IGN`: an ignored disposition survives exec +/// into the entire child tree, which would make tasks without their own +/// SIGINT handler uninterruptible. A second interrupt force-kills the +/// child so a task that ignores the first one cannot make the CLI +/// unstoppable. /// -/// On Unix, saves the terminal state before spawning the child process and restores -/// it after the child exits. On Windows, this is a simple pass-through. +/// On Unix, the terminal state saved before spawning is restored after +/// the child exits, so a child killed mid-raw-mode cannot leave the +/// terminal broken for the next prompt. pub async fn execute_with_terminal_guard(mut cmd: Command) -> Result { #[cfg(unix)] - { + let _guard = { use nix::libc::STDIN_FILENO; + TerminalStateGuard::save(STDIN_FILENO) + }; - // Save terminal state and ignore sigint signal for current process before spawning child - let _guard = TerminalStateGuard::save(STDIN_FILENO); + let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; - // Spawn and wait for child - guard will restore terminal state on drop - let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; - child.wait().await.map_err(|e| Error::Anyhow(e.into())) + #[cfg(unix)] + { + let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .map_err(|e| Error::Anyhow(e.into()))?; + let mut interrupts = 0u32; + loop { + tokio::select! { + status = child.wait() => return status.map_err(|e| Error::Anyhow(e.into())), + _ = sigint.recv() => { + interrupts += 1; + if interrupts >= 2 { + let _ = child.start_kill(); + } + } + } + } } - #[cfg(not(unix))] + #[cfg(windows)] { - let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; - child.wait().await.map_err(|e| Error::Anyhow(e.into())) + let mut ctrl_c = tokio::signal::windows::ctrl_c().map_err(|e| Error::Anyhow(e.into()))?; + let mut interrupts = 0u32; + loop { + tokio::select! { + status = child.wait() => return status.map_err(|e| Error::Anyhow(e.into())), + _ = ctrl_c.recv() => { + interrupts += 1; + if interrupts >= 2 { + let _ = child.start_kill(); + } + } + } + } } + + #[cfg(not(any(unix, windows)))] + child.wait().await.map_err(|e| Error::Anyhow(e.into())) } /// Build a `tokio::process::Command` for shell execution. @@ -308,7 +348,6 @@ pub fn fix_stdio_streams() { struct TerminalStateGuard { fd: RawFd, original: nix::sys::termios::Termios, - original_sigint: Option, } #[cfg(unix)] @@ -327,39 +366,22 @@ impl TerminalStateGuard { } match tcgetattr(borrowed_fd) { - Ok(original) => Some(Self { fd, original, original_sigint: Self::ignore_sigint() }), + Ok(original) => Some(Self { fd, original }), Err(_) => None, } } - - fn ignore_sigint() -> Option { - use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction}; - - let ignore = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty()); - // SAFETY: installs a process signal disposition for SIGINT and returns - // the previous disposition, which this guard restores on drop. - unsafe { sigaction(Signal::SIGINT, &ignore).ok() } - } } #[cfg(unix)] impl Drop for TerminalStateGuard { fn drop(&mut self) { - use nix::sys::{ - signal::{Signal, sigaction}, - termios::{SetArg, tcsetattr}, - }; + use nix::sys::termios::{SetArg, tcsetattr}; // SAFETY: fd comes from stdin/stdout/stderr and the guard does not outlive the process let borrowed_fd = unsafe { BorrowedFd::borrow_raw(self.fd) }; // Best effort: ignore errors during cleanup let _ = tcsetattr(borrowed_fd, SetArg::TCSANOW, &self.original); - - // SAFETY: restores the signal disposition captured by save(). - if let Some(original_sigint) = self.original_sigint.take() { - let _ = unsafe { sigaction(Signal::SIGINT, &original_sigint) }; - } } } diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 1fdd658238..f721805da5 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -212,6 +212,17 @@ fn exit_status_to_exit_code(exit_status: ExitStatus) -> ExitCode { if exit_status.success() { ExitCode::SUCCESS } else { + // A child killed by a signal has no exit code; report the shell + // convention 128 + signal (e.g. 130 for SIGINT) instead of a + // generic failure. + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt as _; + if let Some(signal) = exit_status.signal() { + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + return ExitCode::from(128u8.wrapping_add(signal as u8)); + } + } #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] exit_status.code().map_or(ExitCode::FAILURE, |c| ExitCode::from(c as u8)) } From 20d66067f349896841df74ab193a7e64adfe554a Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 7 Jul 2026 01:08:50 +0800 Subject: [PATCH 4/4] test(snapshot): pin ctrl-c graceful-shutdown wait and double ctrl-c escalation The run_ctrlc_teardown snapshot flips from "task was torn down before its graceful shutdown finished" to "task completed its graceful shutdown" and now guards the waiting behavior. A second case covers the escalation: a task that ignores the interrupt (vpt report-orphan-on-ctrlc --ignore-interrupt) keeps vp waiting after the first Ctrl+C and is force-killed by the second, with vp exiting 137 and the surviving watcher recording the unfinished shutdown. The "interrupted" milestone between the two Ctrl+C presses keeps them from coalescing into a single signal. --- .../src/bin/vpt/report_orphan_on_ctrlc.rs | 36 +++++++++-- .../tests/cli_snapshots/README.md | 3 +- .../fixtures/run_ctrlc_teardown/package.json | 3 +- .../run_ctrlc_teardown/snapshots.toml | 63 ++++++++++++------- .../run_ctrlc_exits_before_task_shutdown.md | 36 ----------- .../run_ctrlc_waits_for_task_shutdown.md | 33 ++++++++++ ...run_double_ctrlc_force_kills_stuck_task.md | 48 ++++++++++++++ 7 files changed, 157 insertions(+), 65 deletions(-) delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_ctrlc_exits_before_task_shutdown.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_ctrlc_waits_for_task_shutdown.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_double_ctrlc_force_kills_stuck_task.md diff --git a/crates/vite_cli_snapshots/src/bin/vpt/report_orphan_on_ctrlc.rs b/crates/vite_cli_snapshots/src/bin/vpt/report_orphan_on_ctrlc.rs index b46425fe50..36c030a5ce 100644 --- a/crates/vite_cli_snapshots/src/bin/vpt/report_orphan_on_ctrlc.rs +++ b/crates/vite_cli_snapshots/src/bin/vpt/report_orphan_on_ctrlc.rs @@ -1,10 +1,16 @@ -/// report-orphan-on-ctrlc `` +/// report-orphan-on-ctrlc `` \[--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`). @@ -12,8 +18,8 @@ /// its shutdown completes. Pipe EOF without "done" means the task was torn /// down mid-shutdown, and the watcher records that in `` /// (atomically, via a temp-file rename) for a follow-up `vpt wait-file` -/// step. Nothing is printed after the milestone, so the terminal snapshot -/// stays deterministic no matter how quickly the task tree is killed. +/// 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> { if args.first().is_some_and(|arg| arg == "--watch") { @@ -22,8 +28,17 @@ pub fn run(args: &[String]) -> Result<(), Box> { use std::{io::Write as _, os::fd::OwnedFd, sync::Arc, time::Duration}; - let Some(verdict_path) = args.first() else { - return Err("Usage: vpt report-orphan-on-ctrlc ".into()); + 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 [--ignore-interrupt]".into()); }; let (pipe_read, pipe_write): (OwnedFd, OwnedFd) = nix::unistd::pipe()?; @@ -61,6 +76,17 @@ pub fn run(args: &[String]) -> Result<(), Box> { pty_terminal_test_client::mark_milestone("ready"); ctrlc_once_lock.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. diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md index 5e40d557fc..ea51cf67a4 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md @@ -124,7 +124,8 @@ is identical on every platform: self-tests), `vpt check-tty`, `vpt read-stdin`, `vpt exit `, `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), `vpt barrier`. `vpt wait-file [timeout-ms]` polls until a +shutdown; `--ignore-interrupt` simulates a task whose shutdown hangs), +`vpt barrier`. `vpt wait-file [timeout-ms]` polls until a file exists and prints it, for asserting on files written by a previous step's surviving process tree. diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/package.json index 246f461d90..867bb68610 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/package.json +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/package.json @@ -2,6 +2,7 @@ "name": "run-ctrlc-teardown", "private": true, "scripts": { - "dev": "vpt report-orphan-on-ctrlc verdict.txt" + "dev": "vpt report-orphan-on-ctrlc verdict.txt", + "stuck-dev": "vpt report-orphan-on-ctrlc verdict.txt --ignore-interrupt" } } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots.toml index 67769bde84..b080f048d4 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots.toml @@ -1,35 +1,54 @@ -# Reproduces https://github.com/voidzero-dev/vite-plus/issues/2036: killing a -# `vp run` task with Ctrl+C leaves stray escape sequences on the next fish -# prompt. fish probes the terminal (OSC 11, CSI 6n, DA1) every time it draws -# a prompt; after `vp run` + Ctrl+C, the task's process tree (vite dev server, -# managed node) is still shutting down when the shell takes the terminal back, -# and its late terminal writes and mode restores race the shell's probes, so -# the terminal's replies land as garbage input on the prompt -# (`^[]11;rgb:...^[\^[[9;1R^[[?62;22;52c`). Plain `vite dev` is clean because -# the shell waits for vite itself, which finishes its terminal cleanup before -# exiting. +# Guards the fix for https://github.com/voidzero-dev/vite-plus/issues/2036: +# killing a `vp run` task with Ctrl+C used to leave stray escape sequences on +# the next fish prompt. fish probes the terminal (OSC 11, CSI 6n, DA1) every +# time it draws a prompt; vp used to die of SIGINT within milliseconds (it is +# the PTY session leader, so the whole session got SIGHUP'd) while the task's +# process tree (vite dev server, managed node) was still shutting down, and +# the dying tree's late terminal writes and mode restores raced the shell's +# probes, turning their replies into garbage input +# (`^[]11;rgb:...^[\^[[9;1R^[[?62;22;52c`). # -# The vp-side defect this case pins: on Ctrl+C, `vp run` tears the terminal -# and the task down within milliseconds instead of letting the task shut -# down gracefully. The payload simulates a dev-server-like task whose -# shutdown takes 300 ms; a watcher process outside vp's reach records to -# verdict.txt whether that shutdown was allowed to finish. +# `vp run` must instead swallow the interrupt and wait for the task tree's +# graceful shutdown before handing the terminal back. The payload simulates a +# dev-server-like task whose shutdown takes 300 ms; a watcher process in its +# own process group (it survives any teardown signals, like real dev-server +# grandchildren do) records to verdict.txt whether that shutdown finished. [[case]] -name = "run_ctrlc_exits_before_task_shutdown" +name = "run_ctrlc_waits_for_task_shutdown" vp = "global" skip-platforms = ["windows"] comment = """ -Issue #2036: on Ctrl+C, `vp run` tears the task down immediately instead of -waiting for its graceful shutdown, and whatever the dying task tree still -does to the terminal races the next shell prompt. verdict.txt records the -current buggy behavior; a fix should flip it to -"task completed its graceful shutdown". +Issue #2036: Ctrl+C on `vp run dev` must let the task finish its graceful +shutdown before vp exits and the shell takes the terminal back. The verdict +below flips to "task was torn down before its graceful shutdown finished" +when vp abandons the task mid-shutdown again. """ steps = [ { argv = ["vp", "run", "dev"], continue-on-failure = true, interactions = [ { "expect-milestone" = "ready" }, { "write-key" = "ctrl-c" }, ] }, - { argv = ["vpt", "wait-file", "verdict.txt"], comment = "The verdict the task wrote after `vp run` already exited." }, + { argv = ["vpt", "wait-file", "verdict.txt"], comment = "The verdict recorded by the task's watcher process." }, +] + +[[case]] +name = "run_double_ctrlc_force_kills_stuck_task" +vp = "global" +skip-platforms = ["windows"] +comment = """ +The escape hatch for the waiting behavior above: a task that ignores the +interrupt must not make vp unstoppable. The second Ctrl+C force-kills the +task, so vp exits with the kill signal's code and the watcher records an +unfinished shutdown. The "interrupted" milestone between the two Ctrl+C +presses keeps them from coalescing into a single signal. +""" +steps = [ + { argv = ["vp", "run", "stuck-dev"], continue-on-failure = true, interactions = [ + { "expect-milestone" = "ready" }, + { "write-key" = "ctrl-c" }, + { "expect-milestone" = "interrupted" }, + { "write-key" = "ctrl-c" }, + ] }, + { argv = ["vpt", "wait-file", "verdict.txt"], comment = "The verdict recorded by the task's watcher process." }, ] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_ctrlc_exits_before_task_shutdown.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_ctrlc_exits_before_task_shutdown.md deleted file mode 100644 index 158d4bad0e..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_ctrlc_exits_before_task_shutdown.md +++ /dev/null @@ -1,36 +0,0 @@ -# run_ctrlc_exits_before_task_shutdown - -Issue #2036: on Ctrl+C, `vp run` tears the task down immediately instead of -waiting for its graceful shutdown, and whatever the dying task tree still -does to the terminal races the next shell prompt. verdict.txt records the -current buggy behavior; a fix should flip it to -"task completed its graceful shutdown". - -## `vp run dev` - -**Exit code:** 1 - -**→ expect-milestone:** `ready` - -``` -VITE+ - The Unified Toolchain for the Web - -$ vpt report-orphan-on-ctrlc verdict.txt ⊘ cache disabled -``` - -**← write-key:** `ctrl-c` - -``` -VITE+ - The Unified Toolchain for the Web - -$ vpt report-orphan-on-ctrlc verdict.txt ⊘ cache disabled - -``` - -## `vpt wait-file verdict.txt` - -The verdict the task wrote after `vp run` already exited. - -``` -task was torn down before its graceful shutdown finished -``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_ctrlc_waits_for_task_shutdown.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_ctrlc_waits_for_task_shutdown.md new file mode 100644 index 0000000000..8c6c485599 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_ctrlc_waits_for_task_shutdown.md @@ -0,0 +1,33 @@ +# run_ctrlc_waits_for_task_shutdown + +Issue #2036: Ctrl+C on `vp run dev` must let the task finish its graceful +shutdown before vp exits and the shell takes the terminal back. The verdict +below flips to "task was torn down before its graceful shutdown finished" +when vp abandons the task mid-shutdown again. + +## `vp run dev` + +**→ expect-milestone:** `ready` + +``` +VITE+ - The Unified Toolchain for the Web + +$ vpt report-orphan-on-ctrlc verdict.txt ⊘ cache disabled +``` + +**← write-key:** `ctrl-c` + +``` +VITE+ - The Unified Toolchain for the Web + +$ vpt report-orphan-on-ctrlc verdict.txt ⊘ cache disabled + +``` + +## `vpt wait-file verdict.txt` + +The verdict recorded by the task's watcher process. + +``` +task completed its graceful shutdown +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_double_ctrlc_force_kills_stuck_task.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_double_ctrlc_force_kills_stuck_task.md new file mode 100644 index 0000000000..98b6176fd2 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/run_ctrlc_teardown/snapshots/run_double_ctrlc_force_kills_stuck_task.md @@ -0,0 +1,48 @@ +# run_double_ctrlc_force_kills_stuck_task + +The escape hatch for the waiting behavior above: a task that ignores the +interrupt must not make vp unstoppable. The second Ctrl+C force-kills the +task, so vp exits with the kill signal's code and the watcher records an +unfinished shutdown. The "interrupted" milestone between the two Ctrl+C +presses keeps them from coalescing into a single signal. + +## `vp run stuck-dev` + +**Exit code:** 137 + +**→ expect-milestone:** `ready` + +``` +VITE+ - The Unified Toolchain for the Web + +$ vpt report-orphan-on-ctrlc verdict.txt --ignore-interrupt ⊘ cache disabled +``` + +**← write-key:** `ctrl-c` + +**→ expect-milestone:** `interrupted` + +``` +VITE+ - The Unified Toolchain for the Web + +$ vpt report-orphan-on-ctrlc verdict.txt --ignore-interrupt ⊘ cache disabled +ignoring interrupt; still running +``` + +**← write-key:** `ctrl-c` + +``` +VITE+ - The Unified Toolchain for the Web + +$ vpt report-orphan-on-ctrlc verdict.txt --ignore-interrupt ⊘ cache disabled +ignoring interrupt; still running + +``` + +## `vpt wait-file verdict.txt` + +The verdict recorded by the task's watcher process. + +``` +task was torn down before its graceful shutdown finished +```