diff --git a/Cargo.lock b/Cargo.lock index e498ad7d..99b5316b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2487,6 +2487,17 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" +[[package]] +name = "send_ctrlc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07beb664b54f51140baf2769d12d5eb07d0e3eccee78fb95c3e76c2644a4cad" +dependencies = [ + "libc", + "tokio", + "windows-sys 0.61.1", +] + [[package]] name = "serde" version = "1.0.228" @@ -3278,6 +3289,7 @@ dependencies = [ "insta", "monostate", "regex", + "send_ctrlc", "serde", "tempfile", "tokio", diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index 59d984dd..34a290ea 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -26,6 +26,7 @@ copy_dir = { workspace = true } cow-utils = { workspace = true } insta = { workspace = true, features = ["glob", "json", "redactions", "filters", "ron"] } regex = { workspace = true } +send_ctrlc = { version = "0.6.0", features = ["tokio"] } serde = { workspace = true, features = ["derive", "rc"] } tempfile = { workspace = true } toml = { workspace = true } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/interrupt-test.js b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/interrupt-test.js new file mode 100644 index 00000000..83f76bf7 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/interrupt-test.js @@ -0,0 +1,12 @@ +// Handle SIGINT gracefully +process.on('SIGINT', () => { + console.log('Received SIGINT, exiting gracefully'); + process.exit(0); +}); + +// Print magic string to trigger Ctrl+C +console.log('[send-me-ctrl-c]'); + +// Keep the process alive to receive SIGINT +// (process will be interrupted by the test infrastructure) +setInterval(() => {}, 1000); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/package.json new file mode 100644 index 00000000..51d25960 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/package.json @@ -0,0 +1,6 @@ +{ + "name": "ctrl-c-interruption", + "scripts": { + "interrupt-test": "node interrupt-test.js" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/snapshots.toml new file mode 100644 index 00000000..17652c8a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/snapshots.toml @@ -0,0 +1,6 @@ +[[e2e]] +name = "interrupted task does not update cache" +steps = [ + "vite run interrupt-test", + "vite run interrupt-test", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/snapshots/interrupted task does not update cache.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/snapshots/interrupted task does not update cache.snap new file mode 100644 index 00000000..dc0aa954 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption/snapshots/interrupted task does not update cache.snap @@ -0,0 +1,45 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +assertion_line: 309 +expression: e2e_outputs +input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c-interruption +--- +> vite run interrupt-test +$ node interrupt-test.js +[send-me-ctrl-c] +Received SIGINT, exiting gracefully + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] ctrl-c-interruption#interrupt-test: $ node interrupt-test.js ✓ + → Cache miss: no previous cache entry found + → Cache not updated: execution interrupted +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +> vite run interrupt-test +$ node interrupt-test.js +[send-me-ctrl-c] +Received SIGINT, exiting gracefully + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] ctrl-c-interruption#interrupt-test: $ node interrupt-test.js ✓ + → Cache miss: no previous cache entry found + → Cache not updated: execution interrupted +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 8913304c..52c5e84a 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -11,6 +11,7 @@ use std::{ use copy_dir::copy_dir; use redact::redact_e2e_output; +use send_ctrlc::{Interruptible as _, InterruptibleCommand as _}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, process::Command, @@ -193,7 +194,7 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); - let mut child = cmd.spawn().unwrap(); + let mut child = cmd.spawn_interruptible().unwrap(); // Write stdin if provided, then close it if let Some(stdin_content) = step.stdin() { @@ -210,6 +211,10 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name let mut stdout_buf = Vec::new(); let mut stderr_buf = Vec::new(); + // Magic string detection + const MAGIC_STRING: &[u8] = b"[send-me-ctrl-c]"; + let mut ctrl_c_sent = false; + // Read chunks concurrently with process wait, using select! with timeout let mut stdout_done = false; let mut stderr_done = false; @@ -232,7 +237,16 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name result = stdout_handle.read(&mut stdout_chunk), if !stdout_done => { match result { Ok(0) => stdout_done = true, - Ok(n) => stdout_buf.extend_from_slice(&stdout_chunk[..n]), + Ok(n) => { + let chunk = &stdout_chunk[..n]; + stdout_buf.extend_from_slice(chunk); + + // Check if accumulated stdout buffer contains magic string + if !ctrl_c_sent && stdout_buf.windows(MAGIC_STRING.len()).any(|w| w == MAGIC_STRING) { + ctrl_c_sent = true; + let _ = child.interrupt(); + } + } Err(_) => stdout_done = true, } }