Skip to content

Commit d6668ac

Browse files
kazuponcodex
andcommitted
fix: avoid Windows cached stdio drain hang
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent 45c54bf commit d6668ac

5 files changed

Lines changed: 87 additions & 27 deletions

File tree

crates/vite_task/src/session/execute/mod.rs

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -581,42 +581,71 @@ fn replay_cache_hit(
581581
}
582582

583583
/// Phase 6: drain the child's pipes (if piped) and wait for exit, with a
584-
/// single error sink — a pipe failure cancels (so the wait kills the child
585-
/// instead of orphaning it) and surfaces through the same returned result as
586-
/// a wait failure. After the child exits (on every path), `stop_accepting`
587-
/// is signalled so the IPC server stops accepting and starts draining.
584+
/// single error sink. Pipe drain and child wait must be driven together: on
585+
/// Windows, descendants can inherit stdout/stderr handles and keep the pipe
586+
/// open after the direct child exits. Polling wait promptly lets the Job
587+
/// Object cleanup run, which terminates those descendants and lets pipe drain
588+
/// reach EOF. After the child exits (on every path), `stop_accepting` is
589+
/// signalled so the IPC server stops accepting and starts draining.
588590
async fn run_child(
589591
mut child: ChildHandle,
590592
sinks: Option<PipeSinks<'_>>,
591593
stop_accepting: Option<&StopAccepting>,
592594
fast_fail_token: CancellationToken,
593595
) -> anyhow::Result<ChildOutcome> {
594-
let pipe_result: anyhow::Result<()> = if let Some(sinks) = sinks {
595-
let stdout = child.stdout.take().expect("SpawnStdio::Piped yields a stdout pipe");
596-
let stderr = child.stderr.take().expect("SpawnStdio::Piped yields a stderr pipe");
597-
#[expect(
598-
clippy::large_futures,
599-
reason = "pipe_stdio streams child I/O and creates a large future"
600-
)]
601-
let r = pipe_stdio(stdout, stderr, sinks, fast_fail_token.clone()).await;
602-
r.map_err(anyhow::Error::from)
603-
} else {
604-
Ok(())
596+
let Some(sinks) = sinks else {
597+
let wait_result = child.wait.await.map_err(anyhow::Error::from);
598+
if let Some(stop_accepting) = stop_accepting {
599+
stop_accepting.signal();
600+
}
601+
return wait_result;
605602
};
606603

607-
let wait_result = match pipe_result {
608-
Ok(()) => child.wait.await.map_err(anyhow::Error::from),
609-
Err(err) => {
610-
// Pipe failed — cancel so `child.wait` kills the child instead of
611-
// orphaning it. Still signal the server below so it can drain.
612-
fast_fail_token.cancel();
613-
let _ = child.wait.await;
614-
Err(err)
604+
let stdout = child.stdout.take().expect("SpawnStdio::Piped yields a stdout pipe");
605+
let stderr = child.stderr.take().expect("SpawnStdio::Piped yields a stderr pipe");
606+
607+
let mut pipe = Box::pin(pipe_stdio(stdout, stderr, sinks, fast_fail_token.clone()));
608+
let mut wait = child.wait;
609+
610+
tokio::select! {
611+
pipe_result = &mut pipe => {
612+
match pipe_result.map_err(anyhow::Error::from) {
613+
Ok(()) => {
614+
let wait_result = wait.await.map_err(anyhow::Error::from);
615+
if let Some(stop_accepting) = stop_accepting {
616+
stop_accepting.signal();
617+
}
618+
wait_result
619+
}
620+
Err(err) => {
621+
// Pipe failed — cancel so `child.wait` kills the child instead of
622+
// orphaning it. Still signal the server below so it can drain.
623+
fast_fail_token.cancel();
624+
let _ = wait.await;
625+
if let Some(stop_accepting) = stop_accepting {
626+
stop_accepting.signal();
627+
}
628+
Err(err)
629+
}
630+
}
615631
}
616-
};
632+
wait_result = &mut wait => {
633+
let wait_result = wait_result.map_err(anyhow::Error::from);
634+
if let Some(stop_accepting) = stop_accepting {
635+
stop_accepting.signal();
636+
}
617637

618-
if let Some(stop_accepting) = stop_accepting {
619-
stop_accepting.signal();
638+
match wait_result {
639+
Ok(outcome) => {
640+
pipe.await.map_err(anyhow::Error::from)?;
641+
Ok(outcome)
642+
}
643+
Err(err) => {
644+
fast_fail_token.cancel();
645+
let _ = pipe.await;
646+
Err(err)
647+
}
648+
}
649+
}
620650
}
621-
wait_result
622651
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[[e2e]]
2+
name = "windows_cached_pipe_handle_hang"
3+
platform = "windows"
4+
comment = """
5+
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.
6+
"""
7+
steps = [["vt", "run", "test"]]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# windows_cached_pipe_handle_hang
2+
3+
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.
4+
5+
## `vt run test`
6+
7+
```
8+
$ cmd /c start /b vtt barrier .hold stdio 1 --hang
9+
10+
$ vtt print after
11+
after
12+
13+
---
14+
vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details)
15+
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"tasks": {
3+
"test": {
4+
"command": "cmd /c start /b vtt barrier .hold stdio 1 --hang && vtt print after",
5+
"cache": true
6+
}
7+
}
8+
}

0 commit comments

Comments
 (0)