From 24e89883c43a464ce52ea6680db8db8c626295f8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 20 Aug 2026 12:41:39 -0500 Subject: [PATCH] fix(installer): keep the post-EOF wait cancellable Phase 1 leaves on both pipes reaching EOF without an exit status, so the final wait was the only thing holding the turn -- and it was a bare child.wait(), which stranded the cancel channel against a child that closes its pipes and lingers. Poll cancellation there too. --- .../src-tauri/src/powershell.rs | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/bootstrap-installer/src-tauri/src/powershell.rs b/apps/bootstrap-installer/src-tauri/src/powershell.rs index c4091e79ee..90bf084660 100644 --- a/apps/bootstrap-installer/src-tauri/src/powershell.rs +++ b/apps/bootstrap-installer/src-tauri/src/powershell.rs @@ -256,7 +256,20 @@ where let status = match status { Some(s) => s, - None => child.wait().await.context("waiting for child to exit")?, + // Both pipes reached EOF while the child stayed alive. Reads are done, + // but the process is still the authoritative terminal condition — and + // it may never exit on its own, so this wait stays cancellable. A bare + // `child.wait()` here would strand the caller's cancel channel exactly + // when it is the only way out. + None => tokio::select! { + reaped = child.wait() => reaped.context("waiting for child to exit")?, + _ = recv_cancel(cancel_rx) => { + tracing::warn!("cancellation received after EOF — killing child"); + cancelled = true; + let _ = child.start_kill(); + child.wait().await.context("waiting for killed child to exit")? + } + }, }; Ok(PumpOutcome { exit_code: status.code(), @@ -785,4 +798,45 @@ info line started.elapsed() ); } + + /// The reverse topology of the test above: the pipes die and the *process* + /// outlives them. Phase 1 leaves on EOF with no exit status, so the final + /// wait is the only thing left holding the turn -- and it has to stay + /// cancellable. A bare `child.wait()` there strands the cancel channel + /// against a child that may never exit on its own. + #[cfg(unix)] + #[tokio::test] + async fn pump_child_cancellation_returns_after_both_pipes_reach_eof() { + // Closes fd 1 and 2, then lingers: both reads hit EOF immediately while + // the process stays alive far past any plausible test duration. + let mut child = sh("echo bye; exec 1>&- 2>&-; sleep 30"); + let (tx, rx) = mpsc::channel(1); + let mut cancel = Some(rx); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + let _ = tx.send(()).await; + }); + + let started = std::time::Instant::now(); + let mut lines = Vec::new(); + let outcome = pump_child( + &mut child, + |l| lines.push(l.to_string()), + |_| {}, + &mut cancel, + Duration::from_millis(300), + ) + .await + .expect("pump"); + let elapsed = started.elapsed(); + + assert!(outcome.killed, "cancellation should report killed"); + assert!( + !outcome.abandoned, + "both pipes reached EOF, so nothing was abandoned" + ); + assert_eq!(lines, vec!["bye"], "output written before EOF must survive"); + // Far below the child's 30s: a pass cannot be it exiting on its own. + assert!(elapsed < Duration::from_secs(10), "took {elapsed:?}"); + } }