Skip to main content

anodizer_core/run/
exec.rs

1//! The `run_*` exec API: spawn an already-built [`Command`], drain and
2//! optionally tee its streams, bound it with a watchdog, and apply the
3//! success/failure decision.
4//!
5//! Subtree isolation and reaping live in [`super::process_tree`]; this module
6//! only marks a timeout-bounded child for group isolation, registers the
7//! spawned tree, and asks for a reap when a deadline expires.
8
9use std::io::{BufRead, BufReader, Write};
10use std::process::{Child, Command, ExitStatus, Output, Stdio};
11use std::sync::Mutex;
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::time::{Duration, Instant};
14
15use anyhow::{Context as _, Result};
16
17use crate::log::StageLogger;
18use crate::retry::Retriable;
19
20#[cfg(windows)]
21use super::process_tree::windows_job;
22use super::process_tree::{
23    ChildTree, TreeRegistration, kill_child_tree, register_child_tree, set_own_process_group,
24};
25
26/// Poll cadence for the bounded-wait watchdog. Short enough that a child that
27/// exits just after a poll is reaped promptly, long enough not to spin a core.
28const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(25);
29
30/// Grace window granted to the reader threads to hit EOF AFTER the direct child
31/// has exited. The common case completes in microseconds: the child's pipe ends
32/// close on exit, the readers drain the last buffered bytes and EOF. A grace is
33/// only ever consumed when a forked grandchild inherited and still holds the
34/// pipe write-end (snapcraft → snapd, a backgrounded uploader): once it elapses
35/// the watchdog reaps the whole process group so the leaked grandchild releases
36/// the pipe and the readers EOF, instead of the drain hanging for the
37/// grandchild's full lifetime and blowing past the deadline.
38const POST_EXIT_DRAIN_GRACE: Duration = Duration::from_secs(3);
39
40/// Run an already-constructed `cmd`, capturing stdout and stderr, and route
41/// the result through [`StageLogger::check_output`].
42///
43/// - Success → returns the captured [`Output`] (the caller logs anything it
44///   needs at verbose; `check_output` already echoes stdout at verbose on the
45///   quiet path).
46/// - Non-zero exit → bails via `check_output` (tail-truncated, redacted stderr
47///   embedded in the error).
48///
49/// When `log.is_verbose()` the child's stdout/stderr are streamed live (each
50/// line redacted) while still being captured, so the failure embed keeps the
51/// full output and the live stream is not double-printed.
52///
53/// The child's stdin is detached (`Stdio::null`) inside the capture loop, so a
54/// tool that would otherwise prompt on the inherited tty (a stray `cargo login`,
55/// a git credential helper) fails fast instead of blocking on input that never
56/// arrives. Use [`run_checked_with_stdin`] to feed bytes to the child's stdin.
57pub fn run_checked(cmd: &mut Command, log: &StageLogger, label: &str) -> Result<Output> {
58    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
59    // Route BOTH verbosities through the shared `capture_inner` loop (via
60    // `run_inner`), which runs the liveness heartbeat ticker: a slow silent
61    // child (`cargo publish`, a registry push) is now narrated at DEFAULT
62    // verbosity too, not only under `-v`. Matches the former non-verbose
63    // `cmd.output()` fast-path byte-for-byte on capture and `check_output`
64    // decision; the single deliberate drift is that the verbose path's stdin is
65    // now null instead of inherited (see the stdin note in `capture_inner`), so
66    // stdin semantics no longer vary by verbosity. A single code path is worth
67    // the two reader-thread spawns per call — every production caller is a
68    // heavyweight subprocess (cargo, docker, notarytool) where thread setup is
69    // noise, and the deleted dual path is exactly where the stdin drift hid.
70    run_inner(cmd, None, log, label, None)
71}
72
73/// Like [`run_checked`], but writes `stdin` to the child's standard input
74/// (the cosign / gh / kms / email pipe-input pattern).
75///
76/// The child's stdin is set to a pipe; stdout/stderr capture and the verbose
77/// live-stream behave exactly as in [`run_checked`]. The stdin write runs on
78/// its own thread *concurrently* with the output readers, so a large stdin
79/// paired with a large stdout cannot deadlock (neither side blocks the other).
80pub fn run_checked_with_stdin(
81    cmd: &mut Command,
82    stdin: &[u8],
83    log: &StageLogger,
84    label: &str,
85) -> Result<Output> {
86    cmd.stdin(Stdio::piped())
87        .stdout(Stdio::piped())
88        .stderr(Stdio::piped());
89    run_inner(cmd, Some(stdin), log, label, None)
90}
91
92/// Like [`run_checked_with_stdin`], but bounds the child to `timeout`: if it
93/// has not exited within that window the child is **killed** (not merely
94/// abandoned) and the call returns a retriable timeout error.
95///
96/// This is the pipe-input analogue of the bounded SMTP relay timeout. A
97/// transport with no wall-clock bound — the canonical case being `sendmail -t`
98/// /
99/// `msmtp -t` blocking on an unreachable MX — would otherwise hang the caller
100/// indefinitely AND leak the child, since the per-stage and aggregate deadlines
101/// the announce stage applies live one layer up and cannot reach into a spawned
102/// subprocess. Killing on expiry releases both the worker thread and the child.
103///
104/// The timeout error is wrapped in [`Retriable`] so the announce retry profile
105/// treats a transient hang like any other network blip (one bounded retry)
106/// rather than fast-failing.
107pub fn run_checked_with_stdin_timeout(
108    cmd: &mut Command,
109    stdin: &[u8],
110    log: &StageLogger,
111    label: &str,
112    timeout: Duration,
113) -> Result<Output> {
114    cmd.stdin(Stdio::piped())
115        .stdout(Stdio::piped())
116        .stderr(Stdio::piped());
117    run_inner(cmd, Some(stdin), log, label, Some(timeout))
118}
119
120/// Like [`run_checked`] (no stdin; a non-zero exit becomes an `Err`) but bounds
121/// the child to `timeout`: if it has not exited within that window the whole
122/// process subtree is **killed** and the call returns a [`Retriable`]-wrapped
123/// timeout error. Use this for network-touching subprocesses — registry pushes,
124/// `git push` over ssh, `gh` PR submission — whose remote side can stall a
125/// connection indefinitely and would otherwise hang the entire release.
126pub fn run_checked_timeout(
127    cmd: &mut Command,
128    log: &StageLogger,
129    label: &str,
130    timeout: Duration,
131) -> Result<Output> {
132    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
133    run_inner(cmd, None, log, label, Some(timeout))
134}
135
136/// Wait for `child` to exit, killing it if it outlives `timeout`, and bound the
137/// post-exit reader drain so a leaked grandchild can't hang past the deadline.
138///
139/// Polls [`Child::try_wait`] on a short cadence. Two deadline edges:
140/// - **Child runtime** — if the direct child has not exited by `timeout`, the
141///   whole subtree is killed and `Ok(None)` is returned (a true timeout).
142/// - **Drain** — once the direct child HAS exited, the reader threads must hit
143///   EOF for the surrounding [`std::thread::scope`] to unwind. They do so
144///   immediately in the common case (the child's pipe ends closed on exit), but
145///   a forked grandchild that inherited the pipe write-end keeps them blocked.
146///   `readers_done` (incremented by each reader as it returns) is watched: when
147///   it reaches `reader_count` the call returns the child's real status
148///   promptly; if the readers are still blocked [`POST_EXIT_DRAIN_GRACE`] after
149///   the child exited, the whole process group is reaped so the leaked
150///   grandchild releases the pipe — and the child's real (success) status is
151///   STILL returned, because the child itself succeeded; only a leaked
152///   descendant was force-closed. Rewriting that into a timeout would re-publish
153///   a succeeded one-way-door publisher on retry.
154///
155/// `child` is shared with the main thread (which performs the final reaping
156/// `wait`) through a `Mutex`; the lock is held only for each non-blocking
157/// `try_wait` / `kill`, never across a sleep, so the main thread can still
158/// acquire it to drain the zombie after a kill.
159fn wait_or_kill(
160    child: &Mutex<Child>,
161    readers_done: &AtomicUsize,
162    reader_count: usize,
163    timeout: Duration,
164    tree: ChildTree,
165) -> std::io::Result<Option<ExitStatus>> {
166    let deadline = Instant::now() + timeout;
167    let mut exited: Option<ExitStatus> = None;
168    let mut drain_deadline: Option<Instant> = None;
169    loop {
170        if exited.is_none() {
171            let mut guard = child.lock().unwrap_or_else(|p| p.into_inner());
172            if let Some(status) = guard.try_wait()? {
173                exited = Some(status);
174                drain_deadline = Some(Instant::now() + POST_EXIT_DRAIN_GRACE);
175            } else if Instant::now() >= deadline {
176                // Child itself outlived the timeout: reap the whole subtree (not
177                // just the direct child) so a forked grandchild holding the
178                // inherited pipe dies too and the readers can EOF.
179                kill_child_tree(&mut guard, tree);
180                return Ok(None);
181            }
182        }
183
184        if let Some(status) = exited {
185            // Child is done. Let the readers finish draining; return promptly
186            // once they EOF.
187            if readers_done.load(Ordering::Acquire) >= reader_count {
188                return Ok(Some(status));
189            }
190            // Readers still blocked past the drain grace ⇒ a leaked grandchild
191            // is holding the pipe. Reap the subtree to force EOF (on Windows via
192            // the Job Object, which works even though the direct child has
193            // already exited), but report the child's real (success) status — it
194            // crossed its door; only the orphan was force-closed.
195            if drain_deadline.is_some_and(|d| Instant::now() >= d) {
196                let mut guard = child.lock().unwrap_or_else(|p| p.into_inner());
197                kill_child_tree(&mut guard, tree);
198                return Ok(Some(status));
199            }
200        }
201        std::thread::sleep(WAIT_POLL_INTERVAL);
202    }
203}
204
205/// Spawn `cmd` and collect its output, draining stdout and stderr
206/// concurrently. When `stdin` is `Some`, its bytes are written on a dedicated
207/// thread so the writer and the output readers run in parallel — a child that
208/// fills its stdout pipe buffer (~64 KiB) while still being fed a large
209/// stdin cannot deadlock, because the readers keep draining. At verbose, each
210/// output line is also teed live (redacted) to stderr.
211///
212/// All work happens inside one `std::thread::scope`: the optional stdin writer,
213/// the stdout reader, and the stderr reader are scoped threads that borrow
214/// `log` / `stdin` without `'static` / `Arc`, and all join before the scope
215/// returns. `wait()` runs after the readers hit EOF, so the captured buffers
216/// are complete before the success/failure decision.
217///
218/// When `timeout` is `Some`, a fourth scoped thread watches the child: if it
219/// outlives the deadline it is **killed**, which closes its pipes so the reader
220/// threads reach EOF and the scope can unwind instead of blocking forever on a
221/// hung child. A killed-for-timeout run returns a retriable timeout error
222/// rather than the child's (nonexistent) exit status.
223///
224/// Returns the raw captured [`Output`] regardless of exit status; the
225/// success/failure decision (`check_output`) is left to the caller — `run_inner`
226/// applies it, [`run_capture_timeout`] does not.
227fn capture_inner(
228    cmd: &mut Command,
229    stdin: Option<&[u8]>,
230    log: &StageLogger,
231    label: &str,
232    timeout: Option<Duration>,
233) -> Result<Output> {
234    let verbose = log.is_verbose();
235    // A timeout-bounded child runs in its own process group so the watchdog can
236    // kill its whole subtree on expiry (a forked grandchild holding the
237    // inherited pipe would otherwise keep the readers blocked past the kill).
238    if timeout.is_some() {
239        set_own_process_group(cmd);
240    }
241    // With no stdin payload, detach the child's stdin — deliberately for EVERY
242    // no-payload path, not just the former non-verbose `cmd.output()` sites
243    // (which nulled stdin implicitly). The verbose and `*_timeout` paths used
244    // to inherit the parent's stdin; that let a tool that reads fd 0 (a stray
245    // `cargo login`, a git credential helper honoring piped input) block
246    // forever on input that never arrives, and made stdin semantics differ by
247    // verbosity. Tools that genuinely prompt interactively (gpg pinentry, ssh
248    // passphrases) read `/dev/tty`, not fd 0, so they are unaffected. When
249    // `stdin` is Some the caller already set `Stdio::piped()`.
250    if stdin.is_none() {
251        cmd.stdin(Stdio::null());
252    }
253    let mut child = cmd
254        .spawn()
255        .with_context(|| format!("failed to spawn {label}"))?;
256
257    // Windows: enclose the timeout-bounded child (and every process it spawns)
258    // in a kill-on-close Job Object so the watchdog can reap the WHOLE subtree
259    // via `TerminateJobObject` even after the direct child has exited — the
260    // post-exit drain-reap case `taskkill /T` cannot serve (a terminated root is
261    // absent from the snapshot its tree walk needs). Assigned immediately after
262    // spawn; a grandchild forked in the microseconds before assignment escapes
263    // the job, but the bounded tools do real work before forking. `None` (job
264    // creation/assignment failed) falls back to the `taskkill` reap.
265    #[cfg(windows)]
266    let job = if timeout.is_some() {
267        windows_job::enclose_child(&child)
268    } else {
269        None
270    };
271
272    // The per-platform reap target shared by the timeout watchdog and the
273    // external-termination watcher (Unix pgid; Windows pid + Job Object handle).
274    let tree = ChildTree {
275        pid: child.id() as i32,
276        #[cfg(windows)]
277        job,
278    };
279
280    // Register the timeout-bounded child tree so an external SIGTERM/SIGINT
281    // (CI cancel, runner job-timeout) reaches its whole subtree before anodizer
282    // dies — otherwise a hung snapcraft/docker tree is orphaned and holds the
283    // runner open. Only the timeout path has a reapable tree (the Unix process
284    // group / Windows Job Object), so only it registers. The RAII guard
285    // deregisters (and, on Windows, closes the job handle) on every exit edge
286    // below — the pipe-take `?`s, the watchdog/stdin error returns, success, and
287    // an unwinding panic — so a recycled pid can never be reaped by a later
288    // external termination.
289    let _registration = timeout.is_some().then(|| {
290        register_child_tree(tree);
291        TreeRegistration(tree)
292    });
293
294    let child_stdin = match stdin {
295        Some(_) => Some(
296            child
297                .stdin
298                .take()
299                .with_context(|| format!("{label}: child has no stdin pipe"))?,
300        ),
301        None => None,
302    };
303    let child_stdout = child
304        .stdout
305        .take()
306        .with_context(|| format!("{label}: child has no stdout pipe"))?;
307    let child_stderr = child
308        .stderr
309        .take()
310        .with_context(|| format!("{label}: child has no stderr pipe"))?;
311
312    // Shared with the watchdog (which reaps on the runtime deadline or at the
313    // post-exit drain grace) and the post-scope reaping wait. Never held across a
314    // sleep, so both sides keep making progress. The lock IS briefly held across
315    // the reap: both kill edges in `wait_or_kill` call `kill_child_tree` under
316    // this guard. On Windows that reap is `TerminateJobObject` — a fast,
317    // non-blocking syscall — except in the rare fallback where the child could
318    // not be assigned to a job, which spawns a blocking `taskkill`. Either way no
319    // contender can be waiting: the only other acquirer is the main thread, and
320    // whenever the watchdog reaches a reap the main thread is parked in
321    // `join_capture` draining the readers, never reaching for this lock. (Unix
322    // reaps are an async-signal-safe `libc::kill`, which never blocks.)
323    let child = Mutex::new(child);
324
325    let mut out_buf: Vec<u8> = Vec::new();
326    let mut err_buf: Vec<u8> = Vec::new();
327    // Carries a non-fatal stdin-write I/O error out of the writer thread.
328    let mut stdin_err: Option<std::io::Error> = None;
329    // Set by the watchdog when it killed the child for exceeding `timeout`.
330    let mut timed_out = false;
331    // Carries an OS-level wait failure out of the watchdog thread.
332    let mut watchdog_err: Option<std::io::Error> = None;
333    // A shared reference (Copy) the watchdog can move without taking the Mutex
334    // itself, leaving `child` available for the post-scope reaping wait.
335    let child_ref = &child;
336    // Counts the stdout + stderr reader threads that have reached EOF and
337    // returned. The watchdog watches this so that, once the direct child has
338    // exited, it can tell "readers drained, return promptly" from "readers still
339    // blocked on a leaked grandchild's pipe, reap the group at the drain grace".
340    let readers_done = AtomicUsize::new(0);
341    let readers_done_ref = &readers_done;
342    std::thread::scope(|s| {
343        // Stdin writer (only when there is stdin): own thread so the readers
344        // below drain concurrently and a full stdout pipe cannot wedge the write
345        // mid-write. Dropping `pipe` after `write_all` closes stdin → EOF.
346        let stdin_handle = child_stdin.map(|mut pipe| {
347            let bytes = stdin.expect("child_stdin is Some only when stdin is Some");
348            s.spawn(move || -> std::io::Result<()> {
349                pipe.write_all(bytes)?;
350                Ok(())
351            })
352        });
353
354        let out_handle = s.spawn(move || {
355            let buf = tee_stream(child_stdout, log, false, verbose);
356            readers_done_ref.fetch_add(1, Ordering::Release);
357            buf
358        });
359        let err_handle = s.spawn(move || {
360            let buf = tee_stream(child_stderr, log, true, verbose);
361            readers_done_ref.fetch_add(1, Ordering::Release);
362            buf
363        });
364
365        // Bounded-wait watchdog: kills the child (and, at the drain grace, a
366        // leaked grandchild holding the inherited pipe) so the readers EOF and
367        // this scope can exit. `reader_count` = 2 (stdout + stderr always piped).
368        let watchdog =
369            timeout.map(|t| s.spawn(move || wait_or_kill(child_ref, readers_done_ref, 2, t, tree)));
370
371        // Heartbeat ticker: at default verbosity the tee prints nothing, so a
372        // legitimately slow child (cargo publish, a large asset upload,
373        // notarytool polling Apple) is indistinguishable from a hang. This
374        // thread emits `still running <label> (<elapsed>)` every cadence until
375        // the `stop` channel disconnects. The channel's Sender is held below and
376        // dropped on EVERY scope exit edge — normal return, the watchdog/stdin
377        // error `if let`s, AND a panic unwind — so `recv_timeout` returns
378        // `Disconnected` and the ticker terminates promptly with no risk of
379        // `thread::scope` hanging on a ticker that never stops. Off entirely
380        // outside Normal verbosity (see `heartbeat_period`). The ticker
381        // deliberately outlives the child's own exit into the pipe-drain window:
382        // a drain that spans a cadence means output is still flowing (a leaked
383        // grandchild, a large buffered tail), which IS the pipeline still
384        // running from the operator's seat.
385        let heartbeat_stop = crate::progress::heartbeat_period(log).map(|interval| {
386            let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
387            let start = Instant::now();
388            let action = format!("running {label}");
389            s.spawn(move || {
390                crate::progress::run_ticker(&stop_rx, interval, || {
391                    log.heartbeat(&crate::progress::heartbeat_message(&action, start));
392                });
393            });
394            stop_tx
395        });
396
397        // A reader-thread panic must not vanish the captured stream (it drives
398        // the failure embed). Warn loudly and fall back to an empty buffer
399        // instead of silently swallowing it.
400        out_buf = join_capture(out_handle, log, "stdout");
401        err_buf = join_capture(err_handle, log, "stderr");
402
403        if let Some(h) = watchdog {
404            match h.join() {
405                Ok(Ok(Some(_status))) => {} // child exited on its own
406                Ok(Ok(None)) => timed_out = true,
407                Ok(Err(e)) => watchdog_err = Some(e),
408                Err(_) => log.warn(&format!("{label}: timeout watchdog thread panicked")),
409            }
410        }
411
412        if let Some(h) = stdin_handle {
413            match h.join() {
414                // A broken-pipe write (child exited before reading all stdin)
415                // is benign — surface only as the captured error, not a hard
416                // fail, since the child's own exit status governs success.
417                Ok(Ok(())) => {}
418                Ok(Err(e)) => stdin_err = Some(e),
419                Err(_) => log.warn(&format!("{label}: stdin writer thread panicked")),
420            }
421        }
422
423        // Child has exited and its streams are drained: drop the heartbeat
424        // Sender so its ticker observes `Disconnected` and stops, letting the
425        // scope join return promptly instead of waiting out a full cadence.
426        drop(heartbeat_stop);
427    });
428
429    // Always reap the (now-exited-or-killed) child so no zombie leaks, even on
430    // the timeout path. Done after the scope so the watchdog has released the
431    // lock.
432    let reaped = {
433        let mut guard = child.lock().unwrap_or_else(|p| p.into_inner());
434        guard.wait()
435    };
436
437    if let Some(e) = watchdog_err {
438        return Err(anyhow::Error::new(e).context(format!("{label}: failed to wait for child")));
439    }
440
441    // Timeout takes precedence over a stdin write error (the latter is the
442    // symptom — the child stopped reading because it hung). Surface a retriable
443    // timeout so the announce retry profile treats it like a transient blip.
444    if timed_out {
445        let secs = timeout.map(|t| t.as_secs_f64()).unwrap_or_default();
446        return Err(anyhow::Error::new(Retriable::new(std::io::Error::new(
447            std::io::ErrorKind::TimedOut,
448            format!("{label}: child did not exit within {secs:.0}s; killed"),
449        ))));
450    }
451
452    // A non-broken-pipe stdin error is a real failure to deliver input.
453    if let Some(e) = stdin_err
454        && e.kind() != std::io::ErrorKind::BrokenPipe
455    {
456        return Err(anyhow::Error::new(e).context(format!("{label}: failed to write stdin")));
457    }
458
459    let status = reaped.with_context(|| format!("{label}: failed to wait for child"))?;
460
461    Ok(Output {
462        status,
463        stdout: out_buf,
464        stderr: err_buf,
465    })
466}
467
468/// Spawn `cmd` through `capture_inner` and apply the success/failure decision
469/// via `check_output` — the shared core behind [`run_checked`],
470/// [`run_checked_with_stdin`], and their timeout variants. A non-zero exit
471/// becomes an `Err`; callers that must inspect a non-zero `Output` themselves
472/// use [`run_capture_timeout`] instead.
473fn run_inner(
474    cmd: &mut Command,
475    stdin: Option<&[u8]>,
476    log: &StageLogger,
477    label: &str,
478    timeout: Option<Duration>,
479) -> Result<Output> {
480    let output = capture_inner(cmd, stdin, log, label, timeout)?;
481    if log.is_verbose() {
482        // The tee already printed both streams live; suppress check_output's
483        // own re-emit so nothing prints twice, while keeping the bail! embed.
484        log.check_output_streamed(output, label)
485    } else {
486        log.check_output(output, label)
487    }
488}
489
490/// Bound `cmd` to `timeout` and return its raw captured [`Output`] **without**
491/// treating a non-zero exit as an error: the caller inspects
492/// `status`/`stdout`/`stderr` itself. The Snap Store publish path needs this —
493/// a non-zero `snapcraft upload` may be a review-pending success or a retriable
494/// 5xx that must be classified from the body, not pre-converted to a hard fail.
495///
496/// The child runs in its own process group; if it outlives `timeout` the whole
497/// subtree is killed and a [`Retriable`]-wrapped timeout error is returned, so a
498/// transient store/network stall retries within budget instead of hanging the
499/// release indefinitely. Errors only on spawn failure, an OS-level wait
500/// failure, or the deadline kill.
501pub fn run_capture_timeout(
502    cmd: &mut Command,
503    log: &StageLogger,
504    label: &str,
505    timeout: Duration,
506) -> Result<Output> {
507    capture_impl(cmd, log, label, Some(timeout))
508}
509
510/// Capture `cmd`'s raw [`Output`] (both streams buffered) WITHOUT treating a
511/// non-zero exit as an error — the caller classifies the result — and WITHOUT a
512/// wall-clock timeout. The unbounded sibling of [`run_capture_timeout`]: for a
513/// subprocess a caller must inspect (exit code + stdout/stderr) that has no
514/// natural deadline yet can run long enough to look hung — notarytool polling
515/// Apple can take many minutes — so routing it through the shared capture loop
516/// earns the liveness heartbeat for free. Errors only on spawn / wait failure.
517///
518/// Verbose streaming, secret redaction, and heartbeat behavior are identical to
519/// every other `run_*` entry point, since all share `capture_inner`.
520pub fn run_capture(cmd: &mut Command, log: &StageLogger, label: &str) -> Result<Output> {
521    capture_impl(cmd, log, label, None)
522}
523
524/// Shared body of [`run_capture`] / [`run_capture_timeout`]: the piped-stdio
525/// setup lives once so a future stdio default cannot diverge between the
526/// bounded and unbounded entry points.
527fn capture_impl(
528    cmd: &mut Command,
529    log: &StageLogger,
530    label: &str,
531    timeout: Option<Duration>,
532) -> Result<Output> {
533    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
534    capture_inner(cmd, None, log, label, timeout)
535}
536
537/// Join a reader thread, returning its captured buffer. On a thread panic,
538/// warn via `log` (naming the `stream`) and return an empty buffer rather than
539/// silently dropping the capture — the non-crash policy is kept, but the loss
540/// is no longer invisible.
541fn join_capture(
542    handle: std::thread::ScopedJoinHandle<'_, Vec<u8>>,
543    log: &StageLogger,
544    stream: &str,
545) -> Vec<u8> {
546    match handle.join() {
547        Ok(buf) => buf,
548        Err(_) => {
549            log.warn(&format!(
550                "internal: {stream} capture thread panicked; output for this step is lost"
551            ));
552            Vec::new()
553        }
554    }
555}
556
557/// Drain `reader` line-by-line into the returned capture buffer, appending the
558/// raw bytes (line terminator included). When `tee` is set, each line is also
559/// streamed live (redacted) to stderr — `is_stderr` selects the capture level
560/// (stdout→Verbose, stderr→Error). At non-verbose verbosity `tee` is `false`,
561/// so the reader still drains the pipe (preventing deadlock) but prints
562/// nothing, leaving the captured buffer for `check_output` to surface only on
563/// failure.
564///
565/// Returns whatever was captured even if a mid-stream read errors, so a
566/// transient pipe hiccup never loses the bytes already read (the buffer
567/// still drives the failure embed).
568fn tee_stream<R: std::io::Read>(
569    reader: R,
570    log: &StageLogger,
571    is_stderr: bool,
572    tee: bool,
573) -> Vec<u8> {
574    let mut buf = BufReader::new(reader);
575    let mut capture: Vec<u8> = Vec::new();
576    let mut line: Vec<u8> = Vec::new();
577    // One redacter for the whole stream: a secret split across two reads is
578    // only recognisable to something that remembers the first half.
579    let mut redacter = tee.then(|| log.stream_redacter());
580    // A child that ends without a terminator used to have one appended per
581    // line; the terminating newline is now added once, after the last release.
582    let mut open_line = false;
583    loop {
584        line.clear();
585        match buf.read_until(b'\n', &mut line) {
586            Ok(0) => break,
587            Ok(_) => {
588                capture.extend_from_slice(&line);
589                if let Some(r) = redacter.as_mut() {
590                    // The child's REAL bytes, terminator included: a secret
591                    // whose value carries `\r\n` is only recognisable to a
592                    // redacter that receives what the child actually wrote.
593                    let released = r.push(&String::from_utf8_lossy(&line));
594                    emit_released(&released, log, is_stderr, &mut open_line);
595                }
596            }
597            Err(_) => break,
598        }
599    }
600    // Runs on the read-error path too, so a mid-stream failure cannot strand a
601    // withheld secret prefix in the buffer.
602    if let Some(r) = redacter.as_mut() {
603        let tail = r.flush();
604        emit_released(&tail, log, is_stderr, &mut open_line);
605        if open_line {
606            log.stream_child_chunk("\n", is_stderr);
607        }
608    }
609    capture
610}
611
612/// Emit already-masked child output, collapsing every `\r\n` terminator to a
613/// bare `\n` and recording whether the stream is left mid-line.
614///
615/// The collapse happens on the RELEASED text rather than on the child's input:
616/// the released text is masked, so shortening a terminator here cannot damage a
617/// secret, while doing it first hid a CRLF-valued secret from the redacter
618/// entirely.
619fn emit_released(text: &str, log: &StageLogger, is_stderr: bool, open_line: &mut bool) {
620    if text.is_empty() {
621        return;
622    }
623    let normalized = text.replace("\r\n", "\n");
624    *open_line = !normalized.ends_with('\n');
625    log.stream_child_chunk(&normalized, is_stderr);
626}