diff --git a/Cargo.lock b/Cargo.lock index e498ad7d..c227088c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -525,6 +525,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "conpty" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72b06487a0d4683349ad74d62e87ad639b09667082b3c495c5b6bab7d84b3da" +dependencies = [ + "windows 0.44.0", +] + [[package]] name = "console" version = "0.15.11" @@ -968,6 +977,18 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "expectrl" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0e0706d01b4f43adaf7e0fb460e07477c36b74ae60fdeb1d045001bd77b4bd1" +dependencies = [ + "conpty", + "nix 0.26.4", + "ptyprocess", + "regex", +] + [[package]] name = "eyre" version = "0.6.12" @@ -1647,6 +1668,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -1718,6 +1748,19 @@ dependencies = [ "memoffset 0.6.5", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + [[package]] name = "nix" version = "0.28.0" @@ -2163,6 +2206,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "ptyprocess" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "101be273c0b1680d7056afddbaa88f02b6e9f2dc161165c30bee9914b6025a79" +dependencies = [ + "nix 0.26.4", +] + [[package]] name = "quote" version = "1.0.41" @@ -3275,6 +3327,7 @@ dependencies = [ "clap", "copy_dir", "cow-utils", + "expectrl", "insta", "monostate", "regex", @@ -3555,7 +3608,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b7b128a98c1cfa201b09eb49ba285887deb3cbe7466a98850eb1adabb452be5" dependencies = [ - "windows", + "windows 0.34.0", ] [[package]] @@ -3602,6 +3655,15 @@ dependencies = [ "windows_x86_64_msvc 0.34.0", ] +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-link" version = "0.2.0" @@ -3644,6 +3706,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3677,6 +3754,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3695,6 +3778,12 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17cffbe740121affb56fad0fc0e421804adf0ae00891205213b5cecd30db881d" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3713,6 +3802,12 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2564fde759adb79129d9b4f54be42b32c89970c18ebf93124ca8870a498688ed" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3743,6 +3838,12 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cd9d32ba70453522332c14d38814bceeb747d80b3958676007acadd7e166956" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3761,6 +3862,12 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfce6deae227ee8d356d19effc141a509cc503dfd1f850622ec4b0f84428e1f4" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3773,6 +3880,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3791,6 +3904,12 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d19538ccc21819d01deaf88d6a17eae6596a12e9aafdbb97916fb49896d89de9" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index b928c23b..063d566f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ derive_more = "2.0.1" diff-struct = "0.5.3" directories = "6.0.0" elf = { version = "0.8.0", default-features = false } +expectrl = "0.8.0" flate2 = "1.0.35" fspy = { path = "crates/fspy" } fspy_detours_sys = { path = "crates/fspy_detours_sys" } diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index 59d984dd..b5482068 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -24,6 +24,7 @@ which = { workspace = true } [dev-dependencies] copy_dir = { workspace = true } cow-utils = { workspace = true } +expectrl = { workspace = true } insta = { workspace = true, features = ["glob", "json", "redactions", "filters", "ron"] } regex = { workspace = true } serde = { workspace = true, features = ["derive", "rc"] } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/echo-stdin.js b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/echo-stdin.js new file mode 100644 index 00000000..f84be2e5 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/echo-stdin.js @@ -0,0 +1,22 @@ +// Use writeSync to avoid buffering issues +const fs = require('fs'); + +// First, verify we're running in a TTY (should print "true" when using expectrl PTY) +fs.writeSync(1, 'TTY: ' + String(process.stdin.isTTY) + '\n'); + +// Signal the test runner to write "hello from stdin" to our stdin +fs.writeSync(1, '[write-stdin:hello from stdin]'); + +// Signal that we're done with stdin commands (this triggers EOF handling) +fs.writeSync(1, '[write-stdin:]'); + +// Read stdin asynchronously - the test runner will send data then EOF +process.stdin.setEncoding('utf8'); +process.stdin.once('readable', () => { + const chunk = process.stdin.read(); + if (chunk !== null) { + fs.writeSync(1, chunk); + } + fs.writeSync(1, 'Done\n'); + process.exit(0); +}); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/package.json index 5fd51717..e10f0c76 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/package.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/package.json @@ -1,6 +1,3 @@ { - "name": "stdin-passthrough", - "scripts": { - "echo-stdin": "node -e \"process.stdin.pipe(process.stdout)\"" - } + "name": "stdin-passthrough" } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots.toml index a3e84b79..b889bba9 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots.toml @@ -1,7 +1,7 @@ -# Tests that stdin is passed through to tasks +# Tests that stdin is passed through to tasks with interactive TTY [[e2e]] name = "stdin passthrough to single task" steps = [ - { cmd = "vite run echo-stdin", stdin = "hello from stdin" }, + { cmd = "vite run echo-stdin", interactive = true }, ] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots/stdin passthrough to single task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots/stdin passthrough to single task.snap index e7355cdb..f899331d 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots/stdin passthrough to single task.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots/stdin passthrough to single task.snap @@ -1,12 +1,16 @@ --- source: crates/vite_task_bin/tests/e2e_snapshots/main.rs -assertion_line: 203 expression: e2e_outputs input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough --- > vite run echo-stdin -$ node -e "process.stdin.pipe(process.stdout)" -hello from stdin +$ node echo-stdin.js +TTY: true +$ node echo-stdin.js +TTY: true +[write-stdin:hello from stdin][write-stdin:]hello from stdin +Done + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Vite+ Task Runner • Execution Summary @@ -17,6 +21,6 @@ Performance: 0% cache hit rate Task Details: ──────────────────────────────────────────────── - [1] stdin-passthrough#echo-stdin: $ node -e "process.stdin.pipe(process.stdout)" ✓ + [1] stdin-passthrough#echo-stdin: $ node echo-stdin.js ✓ → Cache miss: no previous cache entry found ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/vite.config.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/vite.config.json new file mode 100644 index 00000000..edb93769 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/vite.config.json @@ -0,0 +1,7 @@ +{ + "tasks": { + "echo-stdin": { + "command": "node echo-stdin.js" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 53ce4010..fadd6c02 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -3,6 +3,7 @@ mod redact; use std::{ env::{self, join_paths, split_paths}, ffi::OsStr, + mem::ManuallyDrop, path::{Path, PathBuf}, process::Stdio, sync::Arc, @@ -10,11 +11,9 @@ use std::{ }; use copy_dir::copy_dir; +use expectrl::Expect; use redact::redact_e2e_output; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - process::Command, -}; +use tokio::{io::AsyncReadExt, process::Command}; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; use vite_str::Str; use vite_workspace::find_workspace_root; @@ -51,21 +50,21 @@ fn get_shell_exe() -> PathBuf { #[serde(untagged)] enum Step { Simple(Str), - WithStdin { cmd: Str, stdin: Str }, + Interactive { cmd: Str, interactive: bool }, } impl Step { fn cmd(&self) -> &str { match self { Step::Simple(s) => s.as_str(), - Step::WithStdin { cmd, .. } => cmd.as_str(), + Step::Interactive { cmd, .. } => cmd.as_str(), } } - fn stdin(&self) -> Option<&str> { + fn is_interactive(&self) -> bool { match self { - Step::Simple(_) => None, - Step::WithStdin { stdin, .. } => Some(stdin.as_str()), + Step::Simple(_) => false, + Step::Interactive { interactive, .. } => *interactive, } } } @@ -87,6 +86,150 @@ struct SnapshotsFile { pub e2e_cases: Vec, } +struct InteractiveResult { + output: String, + exit_code: Option, +} + +/// Run a step interactively using expectrl with PTY. +/// +/// Watches for `[write-stdin:...]` patterns in stdout: +/// - `[write-stdin:content]` - writes "content\n" to stdin +/// - `[write-stdin:]` - signals EOF (closes stdin) +async fn run_interactive_step( + #[cfg_attr(windows, allow(unused_variables))] shell_exe: &Path, + cmd: &str, + cwd: &Path, + env_path: &OsStr, +) -> std::io::Result { + // Build the command - use PowerShell on Windows for ConPTY support and UNC path handling + #[cfg(windows)] + let command = { + let mut ps = std::process::Command::new("powershell.exe"); + // -NoProfile: don't load user profile (faster startup) + // -NonInteractive: no interactive prompts + // -Command: run the specified command + ps.args(["-NoProfile", "-NonInteractive", "-Command", cmd]).current_dir(cwd); + ps.env_clear().env("PATH", env_path).env("NO_COLOR", "1"); + if let Ok(pathext) = std::env::var("PATHEXT") { + ps.env("PATHEXT", pathext); + } + ps + }; + + #[cfg(unix)] + let command = { + let mut bash = std::process::Command::new(shell_exe); + bash.arg("-c").arg(cmd).current_dir(cwd); + bash.env_clear().env("PATH", env_path).env("NO_COLOR", "1"); + bash + }; + + // Run the synchronous expectrl code in a blocking task + tokio::task::spawn_blocking(move || { + // Spawn with PTY using expectrl's sync API + let mut session = expectrl::session::Session::spawn(command) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + let mut output = String::new(); + let write_stdin_pattern = expectrl::Regex(r"\[write-stdin:([^\]]*)\]"); + + loop { + // Use expectrl's expect() to wait for the pattern + let expect_result = session.expect(&write_stdin_pattern); + + match expect_result { + Ok(found) => { + // Append any output before the match + let before = String::from_utf8_lossy(found.before()); + output.push_str(&before); + + // Append the matched pattern itself (keep it visible in output) + let matched = String::from_utf8_lossy(found.as_bytes()); + output.push_str(&matched); + + // Extract the content from the capture group + let content = found + .get(1) + .map(|m| String::from_utf8_lossy(m).to_string()) + .unwrap_or_default(); + + if content.is_empty() { + // EOF signal - send Ctrl-D (EOF character) to close stdin + session + .send(&[4]) // ASCII 4 = Ctrl-D = EOF + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + // Read any remaining output until process ends + let remaining = session.expect(expectrl::Eof); + if let Ok(eof_found) = remaining { + output.push_str(&String::from_utf8_lossy(eof_found.before())); + } + break; + } else { + // Write content to stdin + let to_write = format!("{}\n", content); + session + .send(to_write.as_bytes()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + // Small delay to let the PTY process the input + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + Err(expectrl::Error::Eof) => { + // Process ended without matching pattern - collect what we have + use std::io::Read; + let mut remaining = Vec::new(); + let _ = session.read_to_end(&mut remaining); + output.push_str(&String::from_utf8_lossy(&remaining)); + break; + } + Err(expectrl::Error::ExpectTimeout) => { + // Timeout waiting for pattern - collect what we have + use std::io::Read; + let mut remaining = Vec::new(); + let _ = session.read_to_end(&mut remaining); + let remaining_str = String::from_utf8_lossy(&remaining); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "expectrl timeout waiting for [write-stdin:] pattern. Output so far: '{}'. Remaining: '{}'", + output, remaining_str + ), + )); + } + Err(e) => { + return Err(std::io::Error::new(std::io::ErrorKind::Other, e)); + } + } + } + + // Get exit status (platform-specific) + #[cfg(unix)] + let exit_code = { + use expectrl::process::unix::WaitStatus; + session.get_process().wait().ok().and_then(|status| match status { + WaitStatus::Exited(_, code) => Some(code), + WaitStatus::Signaled(_, _, _) => None, + _ => None, + }) + }; + + #[cfg(windows)] + let exit_code = { + // conpty's wait(timeout) returns Result directly + session + .get_process() + .wait(None) + .ok() + .map(|code| code as i32) + }; + + Ok(InteractiveResult { output, exit_code }) + }) + .await + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))? +} + fn run_case( runtime: &tokio::runtime::Runtime, tmpdir: &AbsolutePath, @@ -188,106 +331,135 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name let mut e2e_outputs = String::new(); for step in e2e.steps { - let mut cmd = Command::new(&shell_exe); - cmd.arg("-c") - .arg(step.cmd()) - .env_clear() - .env("PATH", &e2e_env_path) - .env("NO_COLOR", "1") - .current_dir(e2e_stage_path.join(&e2e.cwd)); - - // On Windows, inherit PATHEXT for executable lookup - if cfg!(windows) { - if let Ok(pathext) = std::env::var("PATHEXT") { - cmd.env("PATHEXT", pathext); - } + enum TerminationState { + Exited { exit_code: Option }, + TimedOut, } - // Spawn the child process - cmd.stdin(if step.stdin().is_some() { Stdio::piped() } else { Stdio::null() }); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); + let (termination_state, stdout, stderr) = if step.is_interactive() { + // Interactive mode: use expectrl with PTY + let timeout = tokio::time::sleep(STEP_TIMEOUT); + tokio::pin!(timeout); - let mut child = cmd.spawn().unwrap(); + let step_cwd = e2e_stage_path.join(&e2e.cwd); + let interactive_fut = + run_interactive_step(&shell_exe, step.cmd(), step_cwd.as_path(), &e2e_env_path); - // Write stdin if provided, then close it - if let Some(stdin_content) = step.stdin() { - let mut stdin = child.stdin.take().unwrap(); - stdin.write_all(stdin_content.as_bytes()).await.unwrap(); - drop(stdin); // Close stdin to signal EOF - } + tokio::select! { + result = interactive_fut => { + match result { + Ok(interactive_result) => { + ( + TerminationState::Exited { exit_code: interactive_result.exit_code }, + interactive_result.output, + String::new(), // PTY combines stdout/stderr + ) + } + Err(e) => { + panic!("Interactive step failed: {}", e); + } + } + } + _ = &mut timeout => { + (TerminationState::TimedOut, String::new(), String::new()) + } + } + } else { + // Non-interactive mode: use tokio::process::Command + let mut cmd = Command::new(&shell_exe); + cmd.arg("-c") + .arg(step.cmd()) + .env_clear() + .env("PATH", &e2e_env_path) + .env("NO_COLOR", "1") + .current_dir(e2e_stage_path.join(&e2e.cwd)); + + // On Windows, inherit PATHEXT for executable lookup + if cfg!(windows) { + if let Ok(pathext) = std::env::var("PATHEXT") { + cmd.env("PATHEXT", pathext); + } + } - // Take stdout/stderr handles - let mut stdout_handle = child.stdout.take().unwrap(); - let mut stderr_handle = child.stderr.take().unwrap(); + // Spawn the child process + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); - // Buffers for accumulating output - let mut stdout_buf = Vec::new(); - let mut stderr_buf = Vec::new(); + let mut child = cmd.spawn().unwrap(); - // Read chunks concurrently with process wait, using select! with timeout - let mut stdout_done = false; - let mut stderr_done = false; + // Take stdout/stderr handles + let mut stdout_handle = child.stdout.take().unwrap(); + let mut stderr_handle = child.stderr.take().unwrap(); - enum TerminationState { - Exited(std::process::ExitStatus), - TimedOut, - } - // Initial state is running - let mut termination_state: Option = None; + // Buffers for accumulating output + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); - let timeout = tokio::time::sleep(STEP_TIMEOUT); - tokio::pin!(timeout); + // Read chunks concurrently with process wait, using select! with timeout + let mut stdout_done = false; + let mut stderr_done = false; - let termination_state = loop { - let mut stdout_chunk = [0u8; 8192]; - let mut stderr_chunk = [0u8; 8192]; + // Initial state is running + let mut term_state: Option = None; - tokio::select! { - 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]), - Err(_) => stdout_done = true, + let timeout = tokio::time::sleep(STEP_TIMEOUT); + tokio::pin!(timeout); + + loop { + let mut stdout_chunk = [0u8; 8192]; + let mut stderr_chunk = [0u8; 8192]; + + tokio::select! { + 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]), + Err(_) => stdout_done = true, + } } - } - result = stderr_handle.read(&mut stderr_chunk), if !stderr_done => { - match result { - Ok(0) => stderr_done = true, - Ok(n) => stderr_buf.extend_from_slice(&stderr_chunk[..n]), - Err(_) => stderr_done = true, + result = stderr_handle.read(&mut stderr_chunk), if !stderr_done => { + match result { + Ok(0) => stderr_done = true, + Ok(n) => stderr_buf.extend_from_slice(&stderr_chunk[..n]), + Err(_) => stderr_done = true, + } + } + result = child.wait(), if term_state.is_none() => { + let status = result.unwrap(); + term_state = Some(TerminationState::Exited { exit_code: status.code() }); + } + _ = &mut timeout, if term_state.is_none() => { + // Timeout - kill the process + let _ = child.kill().await; + term_state = Some(TerminationState::TimedOut); } } - result = child.wait(), if termination_state.is_none() => { - termination_state = Some(TerminationState::Exited(result.unwrap())); - } - _ = &mut timeout, if termination_state.is_none() => { - // Timeout - kill the process - let _ = child.kill().await; - termination_state = Some(TerminationState::TimedOut); + + // Exit conditions: + // 1. Process exited and all output drained + // 2. Timed out and all output drained (after kill, pipes close) + if term_state.is_some() && stdout_done && stderr_done { + break; } } - // Exit conditions: - // 1. Process exited and all output drained - // 2. Timed out and all output drained (after kill, pipes close) - if let Some(termination_state) = &termination_state - && stdout_done - && stderr_done - { - break termination_state; - } + let stdout = String::from_utf8_lossy(&stdout_buf).into_owned(); + let stderr = String::from_utf8_lossy(&stderr_buf).into_owned(); + + // term_state is guaranteed to be Some here due to the break condition + (term_state.unwrap(), stdout, stderr) }; // Format output - match termination_state { + match &termination_state { TerminationState::TimedOut => { e2e_outputs.push_str("[timeout]"); } - TerminationState::Exited(status) => { - let exit_code = status.code().unwrap_or(-1); - if exit_code != 0 { - e2e_outputs.push_str(format!("[{}]", exit_code).as_str()); + TerminationState::Exited { exit_code } => { + let code = exit_code.unwrap_or(-1); + if code != 0 { + e2e_outputs.push_str(format!("[{}]", code).as_str()); } } } @@ -296,8 +468,6 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name e2e_outputs.push_str(step.cmd()); e2e_outputs.push('\n'); - let stdout = String::from_utf8_lossy(&stdout_buf).into_owned(); - let stderr = String::from_utf8_lossy(&stderr_buf).into_owned(); e2e_outputs.push_str(&redact_e2e_output(stdout, e2e_stage_path_str)); e2e_outputs.push_str(&redact_e2e_output(stderr, e2e_stage_path_str)); e2e_outputs.push('\n'); @@ -320,7 +490,9 @@ fn main() { let tests_dir = std::env::current_dir().unwrap().join("tests"); // Create tokio runtime for async operations - let runtime = tokio::runtime::Runtime::new().unwrap(); + // tokio Runtime's drop blocks until all spawned steps complete. It could lead to infinite wait if some step hangs. + // So we use `ManuallyDrop` here to avoid the drop (the process will exit anyway). + let runtime = ManuallyDrop::new(tokio::runtime::Runtime::new().unwrap()); insta::glob!(tests_dir, "e2e_snapshots/fixtures/*", |case_path| { run_case(&runtime, &tmp_dir_path, case_path, filter.as_deref())