fix(installer): bound the bootstrap installer's pipe drain
`run_script` and `run_streamed` both left their select loop on stdout EOF and then ran unbounded post-loop drains, so `child.wait()` sat downstream of a read that a surviving descendant can hold open forever. Pipe EOF is not the child's to give: the write end is inherited by every descendant spawned without its own redirection, and `hermes update` deliberately runs its build steps with stdout inherited. One resident gateway stranded the whole update, exit code included. Both now go through one `pump_child`, which takes the exit status from waiting on the process and bounds the drain from the moment it exits — a slow child is not a stuck one, so nothing is metered while it runs. An abandoned drain says so in the log rather than silently truncating. Cancelling was not an escape hatch either: `start_kill` reaches the child, not the grandchild with the handle, so the bounded drain is what lets a cancel return at all. Same bound `Invoke-HermesStep` grew in windows.ps1 (#90455), and the same shape as Go's `exec.Cmd.WaitDelay`.
This commit is contained in:
@@ -8,10 +8,12 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// CP1252 mapping for bytes `0x80..=0x9F` (the range that differs from Latin-1).
|
||||
/// Undefined slots keep the C1 control code points, matching Windows-1252
|
||||
@@ -129,6 +131,140 @@ pub struct ScriptResult {
|
||||
/// Cancellation signal — `cancel_tx.send(()).await` aborts the running script.
|
||||
pub type CancelRx = mpsc::Receiver<()>;
|
||||
|
||||
/// How long a child's pipes get to reach EOF AFTER the child itself has exited.
|
||||
///
|
||||
/// This is not a timeout on the child. The clock starts once the process is
|
||||
/// already gone and everything it wrote is sitting in the pipe buffer, so a
|
||||
/// 40-minute `uv pip install` is untouched — the grace only covers the final
|
||||
/// drain.
|
||||
///
|
||||
/// It exists because pipe EOF is not the child's to give. The write end of a
|
||||
/// redirected pipe is handed to the child as an inheritable handle, so every
|
||||
/// descendant spawned without its own redirection holds a duplicate, and the
|
||||
/// read side does not see EOF until the last of them closes it. `hermes update`
|
||||
/// deliberately runs its build steps with stdout inherited, so the tree under a
|
||||
/// child is arbitrarily deep and not something the caller can enumerate. When
|
||||
/// one of those descendants is a resident gateway, the pipe stays open for the
|
||||
/// life of the gateway — and every obligation downstream of the read is
|
||||
/// stranded with it.
|
||||
///
|
||||
/// Same bound `Invoke-HermesStep` grew in `scripts/desktop-update/windows.ps1`
|
||||
/// (#90455), and the same shape as Go's `exec.Cmd.WaitDelay`.
|
||||
pub(crate) const DRAIN_GRACE: Duration = Duration::from_secs(20);
|
||||
|
||||
/// What [`pump_child`] observed.
|
||||
pub(crate) struct PumpOutcome {
|
||||
pub exit_code: Option<i32>,
|
||||
/// The child was killed because the caller cancelled.
|
||||
pub killed: bool,
|
||||
/// The child exited but its pipes never reached EOF within [`DRAIN_GRACE`],
|
||||
/// so the tail of its output was dropped. Callers must say so out loud: a
|
||||
/// silently truncated log is indistinguishable from one that was empty.
|
||||
pub abandoned: bool,
|
||||
}
|
||||
|
||||
/// Stream a child's stdout/stderr line by line, then reap it.
|
||||
///
|
||||
/// The contract that matters: the exit status comes from waiting on the
|
||||
/// *process*, never from pipe EOF. See [`DRAIN_GRACE`] for why those are not
|
||||
/// the same event; callers pass it, tests pass something they can wait out.
|
||||
pub(crate) async fn pump_child<FO, FE>(
|
||||
child: &mut Child,
|
||||
mut on_stdout: FO,
|
||||
mut on_stderr: FE,
|
||||
cancel_rx: &mut Option<CancelRx>,
|
||||
grace: Duration,
|
||||
) -> Result<PumpOutcome>
|
||||
where
|
||||
FO: FnMut(&str),
|
||||
FE: FnMut(&str),
|
||||
{
|
||||
let stdout = child.stdout.take().context("stdout was piped")?;
|
||||
let stderr = child.stderr.take().context("stderr was piped")?;
|
||||
let mut out = BufReader::new(stdout);
|
||||
let mut err = BufReader::new(stderr);
|
||||
let mut out_buf = Vec::new();
|
||||
let mut err_buf = Vec::new();
|
||||
let mut out_done = false;
|
||||
let mut err_done = false;
|
||||
let mut status: Option<ExitStatus> = None;
|
||||
let mut cancelled = false;
|
||||
|
||||
// Phase 1 — the child is alive, so there is no deadline. A slow child is
|
||||
// not a stuck one, and the point of streaming is that a long build keeps
|
||||
// reporting. We leave on whichever lands first: both pipes at EOF (the
|
||||
// clean case), the process exiting (the case that used to hang here), or
|
||||
// cancellation.
|
||||
while !(out_done && err_done) {
|
||||
tokio::select! {
|
||||
line = read_decoded_line(&mut out, &mut out_buf), if !out_done => match line {
|
||||
Ok(Some(l)) => on_stdout(&l),
|
||||
Ok(None) => out_done = true,
|
||||
Err(e) => {
|
||||
tracing::warn!("stdout read error: {e}");
|
||||
out_done = true;
|
||||
}
|
||||
},
|
||||
line = read_decoded_line(&mut err, &mut err_buf), if !err_done => match line {
|
||||
Ok(Some(l)) => on_stderr(&l),
|
||||
Ok(None) => err_done = true,
|
||||
Err(e) => {
|
||||
tracing::warn!("stderr read error: {e}");
|
||||
err_done = true;
|
||||
}
|
||||
},
|
||||
reaped = child.wait() => {
|
||||
status = Some(reaped.context("waiting for child to exit")?);
|
||||
break;
|
||||
}
|
||||
_ = recv_cancel(cancel_rx) => {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kill outside the loop: `child.wait()` above holds the mutable borrow for
|
||||
// as long as the select is in scope.
|
||||
if cancelled {
|
||||
tracing::warn!("cancellation received — killing child");
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
|
||||
// Phase 2 — bounded. Whatever the child already wrote is still worth
|
||||
// keeping, so we keep reading; we just stop caring once a descendant is the
|
||||
// only thing still holding the pipe open. Cancelling does not rescue us
|
||||
// either: `start_kill` kills the child, not the grandchild with the handle.
|
||||
let mut abandoned = false;
|
||||
if !(out_done && err_done) {
|
||||
let drain = async {
|
||||
while !(out_done && err_done) {
|
||||
tokio::select! {
|
||||
line = read_decoded_line(&mut out, &mut out_buf), if !out_done => match line {
|
||||
Ok(Some(l)) => on_stdout(&l),
|
||||
_ => out_done = true,
|
||||
},
|
||||
line = read_decoded_line(&mut err, &mut err_buf), if !err_done => match line {
|
||||
Ok(Some(l)) => on_stderr(&l),
|
||||
_ => err_done = true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
abandoned = timeout(grace, drain).await.is_err();
|
||||
}
|
||||
|
||||
let status = match status {
|
||||
Some(s) => s,
|
||||
None => child.wait().await.context("waiting for child to exit")?,
|
||||
};
|
||||
Ok(PumpOutcome {
|
||||
exit_code: status.code(),
|
||||
killed: cancelled,
|
||||
abandoned,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawns install.ps1 / install.sh with the given args and streams output.
|
||||
///
|
||||
/// `hermes_home_override` propagates to the child as $HERMES_HOME so the
|
||||
@@ -171,88 +307,49 @@ pub async fn run_script(
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning {} via {}", script_path.display(), interpreter_label()))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout was piped");
|
||||
let stderr = child.stderr.take().expect("stderr was piped");
|
||||
|
||||
// Byte-oriented readers + [`decode_console_bytes`]: do NOT use
|
||||
// `BufReader::lines()`, which requires valid UTF-8 and hides localized
|
||||
// PowerShell errors on non-English Windows (#67193).
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
let mut stdout_buf = Vec::new();
|
||||
let mut stderr_buf = Vec::new();
|
||||
|
||||
// PowerShell errors on non-English Windows (#67193). [`pump_child`] owns
|
||||
// that, plus the rule that the exit status comes from the process and not
|
||||
// from pipe EOF.
|
||||
let mut combined_stdout = String::new();
|
||||
let mut combined_stderr = String::new();
|
||||
let mut killed = false;
|
||||
|
||||
// Loop: poll stdout, stderr, cancel, and child exit concurrently.
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = read_decoded_line(&mut stdout_reader, &mut stdout_buf) => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// EOF on stdout — wait for stderr + exit.
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stdout read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
line = read_decoded_line(&mut stderr_reader, &mut stderr_buf) => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// stderr EOF — keep draining stdout.
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stderr read error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = recv_cancel(cancel_rx) => {
|
||||
tracing::warn!("cancellation received — killing child");
|
||||
killed = true;
|
||||
// best-effort kill; don't propagate errors
|
||||
let _ = child.start_kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let outcome = pump_child(
|
||||
&mut child,
|
||||
|l| {
|
||||
(sink.on_stdout_line)(l);
|
||||
combined_stdout.push_str(l);
|
||||
combined_stdout.push('\n');
|
||||
},
|
||||
|l| {
|
||||
(sink.on_stderr_line)(l);
|
||||
combined_stderr.push_str(l);
|
||||
combined_stderr.push('\n');
|
||||
},
|
||||
cancel_rx,
|
||||
DRAIN_GRACE,
|
||||
)
|
||||
.await
|
||||
.context("streaming install script output")?;
|
||||
|
||||
// Drain remaining lines after the loop exited.
|
||||
while let Ok(Some(l)) = read_decoded_line(&mut stdout_reader, &mut stdout_buf).await {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
while let Ok(Some(l)) = read_decoded_line(&mut stderr_reader, &mut stderr_buf).await {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
if outcome.abandoned {
|
||||
let note = format!(
|
||||
"install script exited but a surviving descendant still holds its \
|
||||
stdout/stderr; gave up on the last {}s of output (#90455)",
|
||||
DRAIN_GRACE.as_secs()
|
||||
);
|
||||
tracing::warn!("{note}");
|
||||
(sink.on_stderr_line)(¬e);
|
||||
combined_stderr.push_str(¬e);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("waiting for install script to exit")?;
|
||||
|
||||
Ok(ScriptResult {
|
||||
stdout: combined_stdout,
|
||||
stderr: combined_stderr,
|
||||
exit_code: status.code(),
|
||||
killed,
|
||||
exit_code: outcome.exit_code,
|
||||
killed: outcome.killed,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -550,4 +647,142 @@ info line
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// Spawn `sh -c <script>` with both pipes redirected, the way run_script and
|
||||
/// run_streamed do.
|
||||
#[cfg(unix)]
|
||||
fn sh(script: &str) -> Child {
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn /bin/sh")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn pump_collect(child: &mut Child, grace: Duration) -> (PumpOutcome, Vec<String>) {
|
||||
let mut lines = Vec::new();
|
||||
let outcome = pump_child(
|
||||
child,
|
||||
|l| lines.push(l.to_string()),
|
||||
|_| {},
|
||||
&mut None,
|
||||
grace,
|
||||
)
|
||||
.await
|
||||
.expect("pump");
|
||||
(outcome, lines)
|
||||
}
|
||||
|
||||
/// The #90455 deadlock: a child exits, but a descendant it spawned still
|
||||
/// holds the inherited write end of the pipe, so EOF never comes. The pump
|
||||
/// must return on the *process* exiting and abandon the drain.
|
||||
///
|
||||
/// The Windows half of this contract lives in `-SelfTestPipeDrain`
|
||||
/// (scripts/desktop-update/windows.ps1) and runs on the Windows CI lane; the
|
||||
/// pump logic under test here is OS-agnostic, only the fixture is not.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pump_child_returns_when_a_grandchild_still_holds_the_pipe() {
|
||||
// `sleep` inherits stdout and outlives the shell by design. Nothing
|
||||
// redirects it -- redirecting is what would close the handle and stop
|
||||
// the bug from reproducing at all.
|
||||
let mut child = sh("sleep 30 & echo hello; exit 7");
|
||||
let started = std::time::Instant::now();
|
||||
let (outcome, lines) = pump_collect(&mut child, Duration::from_millis(300)).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(outcome.abandoned, "drain should have been abandoned");
|
||||
assert_eq!(
|
||||
outcome.exit_code,
|
||||
Some(7),
|
||||
"exit code must survive an abandoned drain"
|
||||
);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec!["hello"],
|
||||
"output written before the pipe was stranded must survive"
|
||||
);
|
||||
assert!(!outcome.killed);
|
||||
// Far below the grandchild's 30s: a pass cannot be it exiting on its own.
|
||||
assert!(elapsed < Duration::from_secs(10), "took {elapsed:?}");
|
||||
}
|
||||
|
||||
/// The other cliff: a child that leaks nothing must not pay the grace. This
|
||||
/// is what fails if the pump ever waits on the deadline unconditionally
|
||||
/// instead of only when a pipe outlives its process.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pump_child_does_not_pay_the_grace_when_pipes_close_cleanly() {
|
||||
let mut child = sh("echo one; echo two >&2; echo three; exit 3");
|
||||
let started = std::time::Instant::now();
|
||||
let (outcome, lines) = pump_collect(&mut child, Duration::from_secs(30)).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(!outcome.abandoned);
|
||||
assert_eq!(outcome.exit_code, Some(3));
|
||||
assert_eq!(lines, vec!["one", "three"]);
|
||||
assert!(elapsed < Duration::from_secs(10), "took {elapsed:?}");
|
||||
}
|
||||
|
||||
/// A chatty child must stream at pipe speed, not at one buffer per tick.
|
||||
/// The PowerShell port of this pump regressed exactly here: idling after
|
||||
/// every chunk it *did* read metered the drain and backpressured the running
|
||||
/// child. `hermes update` is this shape -- the Electron build alone is
|
||||
/// megabytes.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pump_child_streams_a_flood_without_metering_it() {
|
||||
let mut child = sh("i=0; while [ $i -lt 20000 ]; do echo line$i; i=$((i+1)); done; exit 0");
|
||||
let started = std::time::Instant::now();
|
||||
let (outcome, lines) = pump_collect(&mut child, Duration::from_secs(30)).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert_eq!(outcome.exit_code, Some(0));
|
||||
assert!(!outcome.abandoned);
|
||||
assert_eq!(lines.len(), 20000, "every line must survive");
|
||||
assert_eq!(lines.last().unwrap(), "line19999");
|
||||
// Loose on purpose: this catches per-chunk sleeping (which would put
|
||||
// this in the tens of seconds), not small scheduler variance.
|
||||
assert!(elapsed < Duration::from_secs(20), "took {elapsed:?}");
|
||||
}
|
||||
|
||||
/// Cancelling kills the child, but not a grandchild holding the pipe --
|
||||
/// `start_kill` only reaches the child. The bounded drain is what actually
|
||||
/// lets a cancel return, so cancellation and the grace are one mechanism.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pump_child_cancellation_returns_even_with_the_pipe_stranded() {
|
||||
let mut child = sh("sleep 30 & echo working; 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");
|
||||
|
||||
assert!(outcome.killed, "cancellation should report killed");
|
||||
assert!(outcome.abandoned, "the grandchild still holds the pipe");
|
||||
assert_eq!(lines, vec!["working"]);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"took {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,10 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
|
||||
use crate::powershell::read_decoded_line;
|
||||
use crate::powershell::{pump_child, DRAIN_GRACE};
|
||||
|
||||
/// `hermes update` exit code meaning "another hermes process is holding the
|
||||
/// venv shim open / dirty precondition" — see _cmd_update_impl in
|
||||
@@ -825,39 +824,34 @@ async fn run_streamed(
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("spawning {} {:?}: {e}", program.display(), args))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
// Same non-UTF-8-safe decode path as powershell::run_script (#67193).
|
||||
let mut out = BufReader::new(stdout);
|
||||
let mut err = BufReader::new(stderr);
|
||||
let mut out_buf = Vec::new();
|
||||
let mut err_buf = Vec::new();
|
||||
|
||||
// Same non-UTF-8-safe decode path as powershell::run_script (#67193), and
|
||||
// the same rule about pipe EOF: `hermes update` is precisely the shape that
|
||||
// leaves resident descendants holding an inherited stdout handle, and every
|
||||
// stage this drives sits downstream of the read.
|
||||
let stage_owned = stage.map(|s| s.to_string());
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = read_decoded_line(&mut out, &mut out_buf) => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l),
|
||||
Ok(None) => break,
|
||||
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
|
||||
},
|
||||
line = read_decoded_line(&mut err, &mut err_buf) => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l),
|
||||
Ok(None) => {}
|
||||
Err(e) => { tracing::warn!("stderr read error: {e}"); }
|
||||
},
|
||||
}
|
||||
}
|
||||
while let Ok(Some(l)) = read_decoded_line(&mut out, &mut out_buf).await {
|
||||
emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l);
|
||||
}
|
||||
while let Ok(Some(l)) = read_decoded_line(&mut err, &mut err_buf).await {
|
||||
emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l);
|
||||
let outcome = pump_child(
|
||||
&mut child,
|
||||
|l| emit_log(app, stage_owned.as_deref(), LogStream::Stdout, l),
|
||||
|l| emit_log(app, stage_owned.as_deref(), LogStream::Stderr, l),
|
||||
&mut None,
|
||||
DRAIN_GRACE,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("streaming {} {:?}: {e}", program.display(), args))?;
|
||||
|
||||
if outcome.abandoned {
|
||||
let note = format!(
|
||||
"{} exited but a surviving descendant still holds its stdout/stderr; \
|
||||
gave up on the last {}s of output (#90455)",
|
||||
program.display(),
|
||||
DRAIN_GRACE.as_secs()
|
||||
);
|
||||
tracing::warn!("{note}");
|
||||
emit_log(app, stage_owned.as_deref(), LogStream::Stderr, ¬e);
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?;
|
||||
Ok(CmdResult {
|
||||
exit_code: status.code(),
|
||||
exit_code: outcome.exit_code,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user