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}