From d6668acd0a7bf8dd78721c6758d903d05867d317 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 25 Jun 2026 00:26:15 +0900 Subject: [PATCH] fix: avoid Windows cached stdio drain hang Co-authored-by: GPT-5 Codex --- crates/vite_task/src/session/execute/mod.rs | 83 +++++++++++++------ .../package.json | 1 + .../snapshots.toml | 7 ++ .../windows_cached_pipe_handle_hang.md | 15 ++++ .../vite-task.json | 8 ++ 5 files changed, 87 insertions(+), 27 deletions(-) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/snapshots/windows_cached_pipe_handle_hang.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/vite-task.json diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index a3ed59242..39bbe5253 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -581,42 +581,71 @@ fn replay_cache_hit( } /// Phase 6: drain the child's pipes (if piped) and wait for exit, with a -/// single error sink — a pipe failure cancels (so the wait kills the child -/// instead of orphaning it) and surfaces through the same returned result as -/// a wait failure. After the child exits (on every path), `stop_accepting` -/// is signalled so the IPC server stops accepting and starts draining. +/// single error sink. Pipe drain and child wait must be driven together: on +/// Windows, descendants can inherit stdout/stderr handles and keep the pipe +/// open after the direct child exits. Polling wait promptly lets the Job +/// Object cleanup run, which terminates those descendants and lets pipe drain +/// reach EOF. After the child exits (on every path), `stop_accepting` is +/// signalled so the IPC server stops accepting and starts draining. async fn run_child( mut child: ChildHandle, sinks: Option>, stop_accepting: Option<&StopAccepting>, fast_fail_token: CancellationToken, ) -> anyhow::Result { - let pipe_result: anyhow::Result<()> = if let Some(sinks) = sinks { - let stdout = child.stdout.take().expect("SpawnStdio::Piped yields a stdout pipe"); - let stderr = child.stderr.take().expect("SpawnStdio::Piped yields a stderr pipe"); - #[expect( - clippy::large_futures, - reason = "pipe_stdio streams child I/O and creates a large future" - )] - let r = pipe_stdio(stdout, stderr, sinks, fast_fail_token.clone()).await; - r.map_err(anyhow::Error::from) - } else { - Ok(()) + let Some(sinks) = sinks else { + let wait_result = child.wait.await.map_err(anyhow::Error::from); + if let Some(stop_accepting) = stop_accepting { + stop_accepting.signal(); + } + return wait_result; }; - let wait_result = match pipe_result { - Ok(()) => child.wait.await.map_err(anyhow::Error::from), - Err(err) => { - // Pipe failed — cancel so `child.wait` kills the child instead of - // orphaning it. Still signal the server below so it can drain. - fast_fail_token.cancel(); - let _ = child.wait.await; - Err(err) + let stdout = child.stdout.take().expect("SpawnStdio::Piped yields a stdout pipe"); + let stderr = child.stderr.take().expect("SpawnStdio::Piped yields a stderr pipe"); + + let mut pipe = Box::pin(pipe_stdio(stdout, stderr, sinks, fast_fail_token.clone())); + let mut wait = child.wait; + + tokio::select! { + pipe_result = &mut pipe => { + match pipe_result.map_err(anyhow::Error::from) { + Ok(()) => { + let wait_result = wait.await.map_err(anyhow::Error::from); + if let Some(stop_accepting) = stop_accepting { + stop_accepting.signal(); + } + wait_result + } + Err(err) => { + // Pipe failed — cancel so `child.wait` kills the child instead of + // orphaning it. Still signal the server below so it can drain. + fast_fail_token.cancel(); + let _ = wait.await; + if let Some(stop_accepting) = stop_accepting { + stop_accepting.signal(); + } + Err(err) + } + } } - }; + wait_result = &mut wait => { + let wait_result = wait_result.map_err(anyhow::Error::from); + if let Some(stop_accepting) = stop_accepting { + stop_accepting.signal(); + } - if let Some(stop_accepting) = stop_accepting { - stop_accepting.signal(); + match wait_result { + Ok(outcome) => { + pipe.await.map_err(anyhow::Error::from)?; + Ok(outcome) + } + Err(err) => { + fast_fail_token.cancel(); + let _ = pipe.await; + Err(err) + } + } + } } - wait_result } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/package.json @@ -0,0 +1 @@ +{} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/snapshots.toml new file mode 100644 index 000000000..b9063573d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/snapshots.toml @@ -0,0 +1,7 @@ +[[e2e]] +name = "windows_cached_pipe_handle_hang" +platform = "windows" +comment = """ +A cached Windows leaf whose direct child exits while a descendant keeps stdout/stderr handles open should still observe the direct child exit and continue to the next `&&` item. +""" +steps = [["vt", "run", "test"]] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/snapshots/windows_cached_pipe_handle_hang.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/snapshots/windows_cached_pipe_handle_hang.md new file mode 100644 index 000000000..e130960a0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/snapshots/windows_cached_pipe_handle_hang.md @@ -0,0 +1,15 @@ +# windows_cached_pipe_handle_hang + +A cached Windows leaf whose direct child exits while a descendant keeps stdout/stderr handles open should still observe the direct child exit and continue to the next `&&` item. + +## `vt run test` + +``` +$ cmd /c start /b vtt barrier .hold stdio 1 --hang + +$ vtt print after +after + +--- +vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/vite-task.json new file mode 100644 index 000000000..f2874f9a0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/windows_cached_pipe_handle_hang/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "test": { + "command": "cmd /c start /b vtt barrier .hold stdio 1 --hang && vtt print after", + "cache": true + } + } +}