Skip to main content

callisto_cli/
runner.rs

1use std::io::Read;
2use std::path::Path;
3use std::process::Stdio;
4use std::time::{Duration, Instant};
5
6use callisto_model::{CommandError, CommandOutput, CommandRunner};
7
8/// Caps how much of a subprocess's stdout/stderr this process will retain
9/// in memory. The pipe is still drained in full past this point (bytes
10/// beyond the cap are read and discarded, never accumulated) so a chatty
11/// child can never deadlock waiting on a full OS pipe buffer -- this
12/// bounds memory, not duration (`PUBLISH_TIMEOUT_SECS` bounds that
13/// separately).
14const MAX_CAPTURED_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
15
16pub struct CliCommandRunner;
17
18impl CommandRunner for CliCommandRunner {
19    fn run(&self, program: &str, args: &[&str], cwd: &Path) -> Result<CommandOutput, CommandError> {
20        let output = std::process::Command::new(program)
21            .args(args)
22            .current_dir(cwd)
23            .stdin(Stdio::null())
24            .stdout(Stdio::piped())
25            .stderr(Stdio::piped())
26            .output();
27
28        match output {
29            Ok(o) => {
30                let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
31                // Print stderr to the terminal before constructing the output
32                // so the caller sees it even if they don't inspect the field.
33                if !stderr.is_empty() {
34                    eprint!("{stderr}");
35                }
36                Ok(CommandOutput {
37                    exit_code: o.status.code(),
38                    stdout: String::from_utf8_lossy(&o.stdout).into_owned(),
39                    stderr,
40                })
41            }
42            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(CommandError::NotFound {
43                program: program.to_string(),
44            }),
45            Err(e) => Err(CommandError::Io {
46                program: program.to_string(),
47                message: e.to_string(),
48            }),
49        }
50    }
51
52    fn run_with_timeout(
53        &self,
54        program: &str,
55        args: &[&str],
56        cwd: &Path,
57        timeout: Duration,
58    ) -> Result<CommandOutput, CommandError> {
59        run_with_timeout_impl(program, args, cwd, timeout, StderrMode::Live)
60    }
61
62    fn run_quiet(
63        &self,
64        program: &str,
65        args: &[&str],
66        cwd: &Path,
67        timeout: Duration,
68    ) -> Result<CommandOutput, CommandError> {
69        run_with_timeout_impl(program, args, cwd, timeout, StderrMode::Quiet)
70    }
71}
72
73/// Controls whether [`run_with_timeout_impl`]'s stderr reader thread prints
74/// each line live as it arrives. Captured `CommandOutput.stderr` is
75/// identical either way -- this only affects what's echoed to the
76/// terminal while the subprocess runs.
77#[derive(Clone, Copy, PartialEq, Eq)]
78enum StderrMode {
79    Live,
80    Quiet,
81}
82
83fn run_with_timeout_impl(
84    program: &str,
85    args: &[&str],
86    cwd: &Path,
87    timeout: Duration,
88    stderr_mode: StderrMode,
89) -> Result<CommandOutput, CommandError> {
90    let mut child = std::process::Command::new(program)
91        .args(args)
92        .current_dir(cwd)
93        .stdin(Stdio::null())
94        .stdout(Stdio::piped())
95        .stderr(Stdio::piped())
96        .spawn()
97        .map_err(|e| {
98            if e.kind() == std::io::ErrorKind::NotFound {
99                CommandError::NotFound {
100                    program: program.to_string(),
101                }
102            } else {
103                CommandError::Io {
104                    program: program.to_string(),
105                    message: e.to_string(),
106                }
107            }
108        })?;
109
110    // Read stdout/stderr in separate threads to prevent pipe-buffer deadlock
111    // when the child produces output while we wait for it to exit.
112    let stdout_handle = child.stdout.take().unwrap();
113    let stderr_handle = child.stderr.take().unwrap();
114
115    // The reader threads signal completion over a channel rather than
116    // being joined directly. Killing (or the natural exit of) the
117    // direct child does NOT close pipe fds held open by a descendant
118    // process that inherited them (common for npm/cargo/python publish
119    // lifecycle scripts spawning subprocesses). If that happens, the
120    // blocking `read()` inside these threads never returns. Using
121    // `recv_timeout` below lets us bound how long we wait for them
122    // without ever blocking the caller indefinitely.
123    let (stdout_tx, stdout_rx) = std::sync::mpsc::channel::<String>();
124    let (stderr_tx, stderr_rx) = std::sync::mpsc::channel::<String>();
125
126    std::thread::spawn(move || {
127        let mut reader = stdout_handle;
128        let mut captured: Vec<u8> = Vec::new();
129        let mut truncated = false;
130        let mut chunk = [0u8; 65536];
131        loop {
132            match reader.read(&mut chunk) {
133                Ok(0) => break,
134                Ok(n) => {
135                    // Bytes past the cap are still read (draining the
136                    // pipe so the child never blocks on a full buffer)
137                    // but not retained.
138                    if !truncated {
139                        let remaining = MAX_CAPTURED_OUTPUT_BYTES - captured.len();
140                        let take = n.min(remaining);
141                        captured.extend_from_slice(&chunk[..take]);
142                        if take < n {
143                            truncated = true;
144                        }
145                    }
146                }
147                Err(_) => break,
148            }
149        }
150        let mut text = String::from_utf8_lossy(&captured).into_owned();
151        if truncated {
152            text.push_str("\n...[output truncated]\n");
153        }
154        drop(stdout_tx.send(text));
155    });
156    // Stream stderr line-by-line so publish progress (cargo/npm/twine
157    // write to stderr) appears in real time rather than after the
158    // process exits, while capping the accumulated text returned to
159    // the caller. Deliberately reads raw bytes rather than using
160    // `BufReader::lines()`: that iterator has no size bound on a
161    // single line, so a flood with no newlines at all would still
162    // buffer unboundedly inside it before ever yielding a line to cap.
163    // `stderr_mode` gates only the live eprintln -- captured output is
164    // identical either way.
165    std::thread::spawn(move || {
166        let mut reader = stderr_handle;
167        let mut captured: Vec<u8> = Vec::new();
168        let mut truncated = false;
169        let mut pending_line: Vec<u8> = Vec::new();
170        let mut chunk = [0u8; 65536];
171        loop {
172            match reader.read(&mut chunk) {
173                Ok(0) => break,
174                Ok(n) => {
175                    let data = &chunk[..n];
176                    for &b in data {
177                        if b == b'\n' {
178                            if pending_line.last() == Some(&b'\r') {
179                                pending_line.pop();
180                            }
181                            if stderr_mode == StderrMode::Live {
182                                eprintln!("{}", String::from_utf8_lossy(&pending_line));
183                            }
184                            pending_line.clear();
185                        } else if pending_line.len() < MAX_CAPTURED_OUTPUT_BYTES {
186                            pending_line.push(b);
187                        }
188                    }
189                    if !truncated {
190                        let remaining = MAX_CAPTURED_OUTPUT_BYTES - captured.len();
191                        let take = data.len().min(remaining);
192                        captured.extend_from_slice(&data[..take]);
193                        if take < data.len() {
194                            truncated = true;
195                        }
196                    }
197                }
198                Err(_) => break,
199            }
200        }
201        if !pending_line.is_empty() {
202            if pending_line.last() == Some(&b'\r') {
203                pending_line.pop();
204            }
205            if stderr_mode == StderrMode::Live {
206                eprintln!("{}", String::from_utf8_lossy(&pending_line));
207            }
208        }
209        let mut text = String::from_utf8_lossy(&captured).into_owned();
210        if truncated {
211            text.push_str("\n...[output truncated]\n");
212        }
213        drop(stderr_tx.send(text));
214    });
215
216    // Grace period to wait for the reader threads after the direct
217    // child has exited (or been killed). Bounded so a lingering
218    // descendant holding the pipe open can never hang the caller.
219    const READER_GRACE: Duration = Duration::from_secs(3);
220
221    let deadline = Instant::now() + timeout;
222    let status = loop {
223        match child.try_wait().map_err(|e| CommandError::Io {
224            program: program.to_string(),
225            message: e.to_string(),
226        })? {
227            Some(s) => break s,
228            None => {
229                if Instant::now() >= deadline {
230                    drop(child.kill());
231                    drop(child.wait());
232                    // Reader threads are intentionally not joined here:
233                    // if a descendant process is still holding a pipe
234                    // fd open, join() could block forever. We wait a
235                    // bounded grace period for output, then abandon the
236                    // threads (they leak until the descendant
237                    // eventually exits and closes the fd -- a thread
238                    // leak, not a memory-safety issue).
239                    let stdout = stdout_rx.recv_timeout(READER_GRACE).ok();
240                    let stderr = stderr_rx.recv_timeout(READER_GRACE).ok();
241                    if stdout.is_none() || stderr.is_none() {
242                        eprintln!(
243                            "warning: `{program}` timed out and a descendant process \
244                                 appears to still hold its output pipes open; captured \
245                                 output may be incomplete"
246                        );
247                    }
248                    return Err(CommandError::TimedOut {
249                        program: program.to_string(),
250                        seconds: timeout.as_secs(),
251                    });
252                }
253                std::thread::sleep(Duration::from_millis(50));
254            }
255        }
256    };
257
258    // The direct child has exited on its own. Even so, a descendant
259    // process can still hold the pipe fds open (e.g. a backgrounded
260    // job spawned by a shell script), so bound the wait here too.
261    let stdout_result = stdout_rx.recv_timeout(READER_GRACE);
262    let stderr_result = stderr_rx.recv_timeout(READER_GRACE);
263    if stdout_result.is_err() || stderr_result.is_err() {
264        eprintln!(
265            "warning: `{program}` exited but a descendant process appears to still \
266                 hold its output pipes open; captured output may be incomplete"
267        );
268    }
269
270    Ok(CommandOutput {
271        exit_code: status.code(),
272        stdout: stdout_result.unwrap_or_default(),
273        stderr: stderr_result.unwrap_or_default(),
274    })
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn run_with_timeout_kills_slow_process_and_returns_timed_out() {
283        let runner = CliCommandRunner;
284        // sleep for 1 second with a 100ms timeout — process must be killed.
285        let err = runner
286            .run_with_timeout("sleep", &["1"], std::path::Path::new("."), Duration::from_millis(100))
287            .unwrap_err();
288        assert!(
289            matches!(err, CommandError::TimedOut { .. }),
290            "expected TimedOut, got: {err:?}"
291        );
292    }
293
294    #[test]
295    fn run_with_timeout_returns_output_for_fast_process() {
296        let runner = CliCommandRunner;
297        let out = runner
298            .run_with_timeout("true", &[], std::path::Path::new("."), Duration::from_secs(5))
299            .unwrap();
300        assert!(out.success());
301    }
302
303    /// Regression test for a hung-descendant deadlock: killing the direct
304    /// child does not close pipe fds inherited by a grandchild process that
305    /// outlives it. The direct child (`sh`) exits almost immediately, but a
306    /// backgrounded grandchild (`sleep 30`) keeps stderr open well past
307    /// that. If the reader threads are joined unconditionally, this call
308    /// hangs for ~30s regardless of the requested timeout. The fix bounds
309    /// how long we wait on the reader threads so the call always returns
310    /// promptly.
311    #[test]
312    fn run_with_timeout_bounds_reader_join_when_descendant_holds_pipe_open() {
313        let runner = CliCommandRunner;
314        let start = Instant::now();
315        let result = runner.run_with_timeout(
316            "sh",
317            &["-c", "sleep 30 >&2 & exit 0"],
318            std::path::Path::new("."),
319            Duration::from_secs(2),
320        );
321        let elapsed = start.elapsed();
322        assert!(
323            elapsed < Duration::from_secs(6),
324            "run_with_timeout must not block on a descendant process holding \
325             stdio pipes open; took {elapsed:?}, result: {result:?}"
326        );
327        assert!(
328            result.is_ok(),
329            "expected Ok despite a lingering descendant holding the pipe open, got: {result:?}"
330        );
331    }
332
333    /// F-007: subprocess stderr must be streamed line-by-line rather than
334    /// buffered until process exit. The fix: switch from `read_to_string`
335    /// (accumulates everything) to a `BufReader::lines` loop that emits each
336    /// line to the terminal immediately while still accumulating the full
337    /// text for caller analysis.
338    ///
339    /// We verify that `CommandOutput::stderr` still contains the full stderr
340    /// text after streaming (so callers can still classify errors from it).
341    #[test]
342    fn run_with_timeout_stderr_is_fully_captured_after_streaming() {
343        let runner = CliCommandRunner;
344        // A process that prints multiple lines to stderr, one at a time.
345        // Using sh -c with printf to emit to stderr.
346        let out = runner
347            .run_with_timeout(
348                "sh",
349                &["-c", "echo line1 >&2; echo line2 >&2"],
350                std::path::Path::new("."),
351                Duration::from_secs(5),
352            )
353            .unwrap();
354
355        assert!(out.success());
356        assert!(
357            out.stderr.contains("line1") && out.stderr.contains("line2"),
358            "stderr must contain all emitted lines even when streamed; got: {:?}",
359            out.stderr
360        );
361    }
362
363    /// Regression test for the timeout-KILL branch's own grace-period logic
364    /// (runner.rs:124-146), which is structurally similar to but
365    /// independently written from the normal-exit branch's grace-period
366    /// logic (runner.rs:153-163) covered by
367    /// `run_with_timeout_bounds_reader_join_when_descendant_holds_pipe_open`.
368    /// That existing test's direct child (`sh`) exits almost immediately, so
369    /// it only ever exercises the exit branch. Here the direct child itself
370    /// (`sh -c "sleep 30 >&2 & sleep 10"`) outlives the configured timeout,
371    /// so `run_with_timeout` must actually kill it, while a backgrounded
372    /// grandchild (`sleep 30 >&2`) keeps stderr open independently of the
373    /// kill.
374    ///
375    /// Because the warning text is emitted via `eprintln!` from inside
376    /// `run_with_timeout` itself (not captured in `CommandOutput`), this
377    /// test re-execs the current test binary as a child process with
378    /// `--nocapture` so the real OS-level stderr can be captured and
379    /// asserted on, rather than being swallowed by the outer test harness.
380    #[test]
381    fn run_with_timeout_warns_on_timeout_branch_when_descendant_holds_pipe_open() {
382        const CHILD_ENV: &str = "CALLISTO_RUN_TIMEOUT_KILL_CHILD";
383
384        if std::env::var(CHILD_ENV).is_ok() {
385            let runner = CliCommandRunner;
386            // `sh` on this platform forks a separate process for the
387            // foreground `sleep 10` rather than exec-replacing itself (it
388            // must remain able to reap the backgrounded job), so killing
389            // the direct child does not kill either descendant. Both the
390            // backgrounded `sleep 30` and the foreground `sleep 10` inherit
391            // the piped stdout/stderr fds unless redirected away, so each
392            // is redirected explicitly: `sleep 30`'s stdout goes to
393            // /dev/null so only its stderr lingers (the condition under
394            // test), and `sleep 10`'s stdout/stderr both go to /dev/null so
395            // it doesn't *also* hold either pipe open, which would stack a
396            // second sequential 3s grace wait on top of the first.
397            drop(runner.run_with_timeout(
398                "sh",
399                &["-c", "sleep 30 >&2 1>/dev/null & sleep 10 >/dev/null 2>&1"],
400                std::path::Path::new("."),
401                Duration::from_millis(800),
402            ));
403            return;
404        }
405
406        let exe = std::env::current_exe().expect("current_exe should be available in tests");
407        let start = Instant::now();
408        let output = std::process::Command::new(exe)
409            .arg("--exact")
410            .arg("runner::tests::run_with_timeout_warns_on_timeout_branch_when_descendant_holds_pipe_open")
411            .arg("--nocapture")
412            .env(CHILD_ENV, "1")
413            .output()
414            .expect("failed to re-exec test binary");
415        let elapsed = start.elapsed();
416
417        assert!(
418            elapsed < Duration::from_secs(6),
419            "run_with_timeout's timeout-kill branch must not block past its \
420             bounded grace period even when a descendant holds stdio pipes \
421             open; took {elapsed:?}"
422        );
423
424        let stderr = String::from_utf8_lossy(&output.stderr);
425        assert!(
426            stderr.contains("timed out and a descendant process appears to still hold its output pipes open"),
427            "expected the timeout branch's specific warning text in child stderr, got: {stderr}"
428        );
429    }
430
431    /// A subprocess that writes far more than `MAX_CAPTURED_OUTPUT_BYTES`
432    /// to stdout must not have all of it retained in `CommandOutput` (an
433    /// unbounded accumulation is a memory-exhaustion DoS before the
434    /// wall-clock publish timeout ever fires), but the pipe must still be
435    /// drained in full -- proven here by the call completing well within
436    /// its timeout instead of hanging (a reader that stops calling
437    /// `read()` after the cap would leave the child blocked writing to a
438    /// full OS pipe buffer).
439    #[test]
440    fn run_with_timeout_caps_accumulated_stdout_and_still_drains_the_pipe() {
441        let runner = CliCommandRunner;
442        let over_cap = MAX_CAPTURED_OUTPUT_BYTES + 1_000_000;
443        let start = Instant::now();
444        let out = runner
445            .run_with_timeout(
446                "sh",
447                &["-c", &format!("head -c {over_cap} /dev/zero")],
448                std::path::Path::new("."),
449                Duration::from_secs(30),
450            )
451            .unwrap();
452        let elapsed = start.elapsed();
453
454        assert!(out.success());
455        assert!(
456            elapsed < Duration::from_secs(15),
457            "must not hang waiting for the child to finish writing past the cap; took {elapsed:?}"
458        );
459        assert!(
460            out.stdout.len() < over_cap,
461            "captured stdout must be bounded, not the full {over_cap} bytes written; got {} bytes",
462            out.stdout.len()
463        );
464        assert!(
465            out.stdout.contains("[output truncated]"),
466            "truncated output must say so"
467        );
468    }
469
470    /// Same as the stdout case, but for stderr -- and deliberately with NO
471    /// newlines at all, so the flood can't be capped by a per-line
472    /// mechanism (a `BufReader::lines()`-based reader has no size bound on
473    /// a single line and would buffer the entire flood internally before
474    /// ever handing a line back, defeating any cap applied only after a
475    /// line is yielded).
476    #[test]
477    fn run_with_timeout_caps_accumulated_stderr_with_no_newlines_and_still_drains_the_pipe() {
478        let runner = CliCommandRunner;
479        let over_cap = MAX_CAPTURED_OUTPUT_BYTES + 1_000_000;
480        let start = Instant::now();
481        let out = runner
482            .run_with_timeout(
483                "sh",
484                &["-c", &format!("head -c {over_cap} /dev/zero >&2")],
485                std::path::Path::new("."),
486                Duration::from_secs(30),
487            )
488            .unwrap();
489        let elapsed = start.elapsed();
490
491        assert!(out.success());
492        assert!(
493            elapsed < Duration::from_secs(15),
494            "must not hang waiting for the child to finish writing past the cap; took {elapsed:?}"
495        );
496        assert!(
497            out.stderr.len() < over_cap,
498            "captured stderr must be bounded, not the full {over_cap} bytes written; got {} bytes",
499            out.stderr.len()
500        );
501        assert!(
502            out.stderr.contains("[output truncated]"),
503            "truncated output must say so"
504        );
505    }
506
507    /// Output comfortably under the cap must be completely unaffected --
508    /// no truncation marker, byte-for-byte length preserved.
509    #[test]
510    fn run_with_timeout_does_not_truncate_output_under_the_cap() {
511        let runner = CliCommandRunner;
512        let out = runner
513            .run_with_timeout(
514                "sh",
515                &["-c", "printf 'hello stdout'; printf 'hello stderr' >&2"],
516                std::path::Path::new("."),
517                Duration::from_secs(5),
518            )
519            .unwrap();
520
521        assert_eq!(out.stdout, "hello stdout");
522        assert!(!out.stdout.contains("[output truncated]"));
523        assert!(out.stderr.contains("hello stderr"));
524        assert!(!out.stderr.contains("[output truncated]"));
525    }
526
527    /// `run_quiet` must still fully capture stderr into the returned
528    /// `CommandOutput` -- only the live terminal echo is suppressed, not
529    /// the capture callers rely on to classify probe results (e.g.
530    /// `npm view`'s 404-shaped "not published yet" text).
531    #[test]
532    fn run_quiet_still_captures_stderr_in_output() {
533        let runner = CliCommandRunner;
534        let out = runner
535            .run_quiet(
536                "sh",
537                &["-c", "echo captured-probe-text >&2"],
538                std::path::Path::new("."),
539                Duration::from_secs(5),
540            )
541            .unwrap();
542        assert!(
543            out.stderr.contains("captured-probe-text"),
544            "run_quiet must still capture stderr, got: {:?}",
545            out.stderr
546        );
547    }
548
549    /// The actual point of `run_quiet`: it must not echo stderr live to the
550    /// terminal the way `run_with_timeout` does. Because the live echo goes
551    /// via `eprintln!` from inside `run_quiet` itself (not captured in
552    /// `CommandOutput`), this re-execs the current test binary as a child
553    /// process with `--nocapture` so the real OS-level stderr can be
554    /// observed and asserted on -- same pattern as
555    /// `run_with_timeout_warns_on_timeout_branch_when_descendant_holds_pipe_open`.
556    #[test]
557    fn run_quiet_does_not_stream_stderr_live() {
558        const CHILD_ENV: &str = "CALLISTO_RUN_QUIET_CHILD";
559
560        if std::env::var(CHILD_ENV).is_ok() {
561            let runner = CliCommandRunner;
562            let out = runner
563                .run_quiet(
564                    "sh",
565                    &["-c", "echo should-not-appear-live >&2"],
566                    std::path::Path::new("."),
567                    Duration::from_secs(5),
568                )
569                .expect("run_quiet must succeed");
570            // Positive proof the call actually ran and captured correctly,
571            // not just "produced no live output" -- a crashed or no-op
572            // child would leave the negative assertion below vacuously
573            // true. Printed to the child's own real stdout, entirely
574            // separate from anything `run_quiet` itself captures or emits.
575            assert!(
576                out.stderr.contains("should-not-appear-live"),
577                "run_quiet must still capture the text it doesn't stream live"
578            );
579            println!("CHILD_REACHED_AND_VERIFIED_CAPTURE");
580            return;
581        }
582
583        let exe = std::env::current_exe().expect("current_exe should be available in tests");
584        let output = std::process::Command::new(exe)
585            .arg("--exact")
586            .arg("runner::tests::run_quiet_does_not_stream_stderr_live")
587            .arg("--nocapture")
588            .env(CHILD_ENV, "1")
589            .output()
590            .expect("failed to re-exec test binary");
591
592        assert!(
593            output.status.success(),
594            "child process must exit successfully, got: {:?}, stderr: {}",
595            output.status,
596            String::from_utf8_lossy(&output.stderr)
597        );
598        let stdout = String::from_utf8_lossy(&output.stdout);
599        assert!(
600            stdout.contains("CHILD_REACHED_AND_VERIFIED_CAPTURE"),
601            "child must have actually reached and verified the run_quiet call, not crashed \
602             or no-op'd before it; got stdout: {stdout}"
603        );
604
605        let stderr = String::from_utf8_lossy(&output.stderr);
606        assert!(
607            !stderr.contains("should-not-appear-live"),
608            "run_quiet must not stream stderr live to the terminal, got: {stderr}"
609        );
610    }
611
612    /// Sibling regression guard: `run_with_timeout` (the live-streaming
613    /// path `run_quiet` is deliberately different from) must still echo
614    /// live -- otherwise this pair of tests could both pass by accident if
615    /// `stderr_mode` were wired backwards.
616    #[test]
617    fn run_with_timeout_still_streams_stderr_live() {
618        const CHILD_ENV: &str = "CALLISTO_RUN_TIMEOUT_LIVE_CHILD";
619
620        if std::env::var(CHILD_ENV).is_ok() {
621            let runner = CliCommandRunner;
622            drop(runner.run_with_timeout(
623                "sh",
624                &["-c", "echo should-appear-live >&2"],
625                std::path::Path::new("."),
626                Duration::from_secs(5),
627            ));
628            return;
629        }
630
631        let exe = std::env::current_exe().expect("current_exe should be available in tests");
632        let output = std::process::Command::new(exe)
633            .arg("--exact")
634            .arg("runner::tests::run_with_timeout_still_streams_stderr_live")
635            .arg("--nocapture")
636            .env(CHILD_ENV, "1")
637            .output()
638            .expect("failed to re-exec test binary");
639
640        let stderr = String::from_utf8_lossy(&output.stderr);
641        assert!(
642            stderr.contains("should-appear-live"),
643            "run_with_timeout must still stream stderr live, got: {stderr}"
644        );
645    }
646}