Skip to main content

strop_core/process/
capture.rs

1//! Bounded capture for short subprocesses. The owning worker drains
2//! both pipes concurrently and never relinquishes cancellation to a
3//! running child.
4//!
5//! Two policies generalize the original 64 KiB/30 s profile:
6//!
7//! - [`StdinPolicy::Held`] keeps the child's stdin writer open for the
8//!   whole capture — required when the pipe is a lifetime lease (the
9//!   remote supervisor treats SSH stdin close as *cancel*, so closing
10//!   it early would kill a healthy command). A held stdin never
11//!   delivers data; it simply does not deliver EOF.
12//! - [`CapturePolicy::stderr_tail`] reserves bytes at the *end* of
13//!   stderr in addition to the head, so a final status record survives
14//!   a chatty worker.
15//!
16//! [`stream_with`] is the unbounded-stdout counterpart: stdout flows
17//! through a caller's consumer chunk by chunk instead of being retained,
18//! so transfers larger than any sensible retention limit stay
19//! memory-bounded by the consumer, not by the pipe. Stderr retention,
20//! the deadline and cancellation behave exactly as in [`capture_with`].
21//!
22//! Overflowing either limit truncates (head, plus tail for stderr) and
23//! reports the dropped byte count on [`CommandOutput`] rather than
24//! failing: callers decide whether truncation invalidates the result.
25use super::OwnedProcess;
26use crate::worker::{CancelToken, Failure, FailureKind};
27use std::io::{self, Read, Write};
28use std::process::{ChildStdin, Command, ExitStatus, Stdio};
29use std::sync::mpsc::{channel, RecvTimeoutError};
30use std::time::{Duration, Instant};
31
32const LIMIT: u64 = 64 * 1024;
33const DEADLINE: Duration = Duration::from_secs(30);
34const POLL: Duration = Duration::from_millis(20);
35const CHUNK: usize = 64 * 1024;
36
37/// One captured pipe: the retained bytes and how many arrived beyond
38/// the retention window.
39struct Retained {
40    bytes: Vec<u8>,
41    dropped: u64,
42}
43
44pub struct CommandOutput {
45    pub status: ExitStatus,
46    pub stdout: Vec<u8>,
47    pub stderr: Vec<u8>,
48    /// Stdout bytes that arrived beyond `stdout_limit` and were
49    /// discarded. Zero means nothing was dropped.
50    pub stdout_dropped: u64,
51    /// Stderr bytes discarded between the retained head and tail.
52    pub stderr_dropped: u64,
53    /// Input delivery failure, retained alongside the child's diagnostic output.
54    pub stdin_error: Option<io::Error>,
55}
56
57/// What capture does with the child's stdin.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum StdinPolicy<'a> {
60    /// The child reads `/dev/null` and sees EOF immediately.
61    Null,
62    /// The child's stdin is a pipe whose local writer is held open for
63    /// the whole capture and dropped when the child has exited. The
64    /// child sees an open stdin with no data — never an early EOF.
65    Held,
66    /// Deliver borrowed chunks concurrently, then hold the lifetime lease open.
67    HeldInput(&'a [&'a [u8]]),
68}
69
70/// Bounds and stdin handling for one capture.
71#[derive(Debug, Clone)]
72pub struct CapturePolicy<'a> {
73    /// Retained head of stdout.
74    pub stdout_limit: u64,
75    /// Retained head of stderr.
76    pub stderr_limit: u64,
77    /// Additional bytes retained from the *end* of stderr, after any
78    /// dropped middle. Zero disables the tail.
79    pub stderr_tail: u64,
80    /// Wall-clock budget for the whole exchange.
81    pub deadline: Duration,
82    pub stdin: StdinPolicy<'a>,
83}
84
85impl Default for CapturePolicy<'_> {
86    fn default() -> Self {
87        Self {
88            stdout_limit: LIMIT,
89            stderr_limit: LIMIT,
90            stderr_tail: 0,
91            deadline: DEADLINE,
92            stdin: StdinPolicy::Null,
93        }
94    }
95}
96
97/// Why a capture did not produce output. `capture` folds these back
98/// into the historical `Failure` spellings; richer callers (remote
99/// exec) match on them directly.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum CaptureError {
102    Spawn(String),
103    Cancelled,
104    TimedOut(Duration),
105    Failure(Failure),
106}
107
108enum Stream {
109    Stdout(io::Result<Retained>),
110    Stderr(io::Result<Retained>),
111    Stdin(io::Result<ChildStdin>),
112}
113
114/// Keep the first `head` bytes and, when `tail` is non-zero, also the
115/// last `tail` bytes; count everything dropped in between.
116fn read_pipe(mut pipe: impl Read, head: u64, tail: u64) -> io::Result<Retained> {
117    let mut kept = Vec::new();
118    let mut tail_window: Vec<u8> = Vec::new();
119    let mut dropped: u64 = 0;
120    let mut chunk = vec![0u8; CHUNK];
121    loop {
122        match pipe.read(&mut chunk) {
123            Ok(0) => break,
124            Ok(seen) => {
125                let mut data = &chunk[..seen];
126                let head_room = (head as usize).saturating_sub(kept.len());
127                if head_room > 0 {
128                    let take = head_room.min(data.len());
129                    kept.extend_from_slice(&data[..take]);
130                    data = &data[take..];
131                }
132                if !data.is_empty() {
133                    if tail > 0 {
134                        tail_window.extend_from_slice(data);
135                        let over = tail_window.len().saturating_sub(tail as usize);
136                        if over > 0 {
137                            tail_window.drain(..over);
138                            dropped += over as u64;
139                        }
140                    } else {
141                        dropped += data.len() as u64;
142                    }
143                }
144            }
145            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
146            Err(error) => return Err(error),
147        }
148    }
149    kept.extend_from_slice(&tail_window);
150    Ok(Retained {
151        bytes: kept,
152        dropped,
153    })
154}
155
156/// At most 64 KiB per pipe, 30 seconds, stdin from `/dev/null`. No
157/// terminal input/output is inherited.
158pub fn capture(command: &mut Command, token: &CancelToken) -> Result<CommandOutput, Failure> {
159    capture_with(command, token, &CapturePolicy::default()).map_err(|error| match error {
160        CaptureError::Spawn(message) => Failure::new(FailureKind::Spawn, message),
161        CaptureError::Cancelled => {
162            Failure::new(FailureKind::Unavailable, "configuration command cancelled")
163        }
164        CaptureError::TimedOut(deadline) => Failure::new(
165            FailureKind::Wait,
166            format!(
167                "configuration command timed out after {} seconds",
168                deadline.as_secs()
169            ),
170        ),
171        CaptureError::Failure(failure) => failure,
172    })
173}
174
175/// Run one command to completion under explicit bounds. The child runs
176/// in its own process group owned by [`OwnedProcess`]; cancellation
177/// SIGKILLs it, the deadline kills it, and both pipes are drained
178/// concurrently with bounded retention.
179pub fn capture_with(
180    command: &mut Command,
181    token: &CancelToken,
182    policy: &CapturePolicy,
183) -> Result<CommandOutput, CaptureError> {
184    let failure =
185        |kind: FailureKind, message: String| CaptureError::Failure(Failure::new(kind, message));
186    std::thread::scope(|scope| {
187        command.stdout(Stdio::piped());
188        command.stderr(Stdio::piped());
189        match policy.stdin {
190            StdinPolicy::Null => command.stdin(Stdio::null()),
191            StdinPolicy::Held | StdinPolicy::HeldInput(_) => command.stdin(Stdio::piped()),
192        };
193        // Owned here, INSIDE scope: unwinding kills pipes before scope joins.
194        let mut process = OwnedProcess::spawn(command, token).map_err(|failure| {
195            if failure.kind == FailureKind::Spawn {
196                CaptureError::Spawn(failure.message)
197            } else {
198                CaptureError::Failure(failure)
199            }
200        })?;
201        // Held: keep the writer alive until the child has exited; its
202        // drop afterwards is pure pipe hygiene.
203        let mut lease = if !matches!(policy.stdin, StdinPolicy::Null) {
204            process.take_stdin()
205        } else {
206            None
207        };
208        let stdout = process
209            .take_stdout()
210            .ok_or_else(|| failure(FailureKind::Protocol, "missing stdout".into()))?;
211        let stderr = process
212            .take_stderr()
213            .ok_or_else(|| failure(FailureKind::Protocol, "missing stderr".into()))?;
214        let (tx, rx) = channel();
215        let out_tx = tx.clone();
216        let out_limit = policy.stdout_limit;
217        let err_limit = policy.stderr_limit;
218        let err_tail = policy.stderr_tail;
219        let mut input_finished = !matches!(policy.stdin, StdinPolicy::HeldInput(_));
220        let mut stdin_error = None;
221        if let StdinPolicy::HeldInput(chunks) = policy.stdin {
222            let mut stdin = lease
223                .take()
224                .ok_or_else(|| failure(FailureKind::Protocol, "missing stdin".into()))?;
225            let input_tx = tx.clone();
226            std::thread::Builder::new()
227                .name("capture-stdin".into())
228                .spawn_scoped(scope, move || {
229                    let written = chunks.iter().try_for_each(|chunk| stdin.write_all(chunk));
230                    let _ = input_tx.send(Stream::Stdin(written.map(|()| stdin)));
231                })
232                .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
233        }
234        std::thread::Builder::new()
235            .name("capture-stdout".into())
236            .spawn_scoped(scope, move || {
237                let _ = out_tx.send(Stream::Stdout(read_pipe(stdout, out_limit, 0)));
238            })
239            .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
240        std::thread::Builder::new()
241            .name("capture-stderr".into())
242            .spawn_scoped(scope, move || {
243                let _ = tx.send(Stream::Stderr(read_pipe(stderr, err_limit, err_tail)));
244            })
245            .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
246        let deadline = Instant::now() + policy.deadline;
247        let mut stdout: Option<Retained> = None;
248        let mut stderr: Option<Retained> = None;
249        loop {
250            if token.is_cancelled() {
251                return Err(CaptureError::Cancelled);
252            }
253            if Instant::now() >= deadline {
254                return Err(CaptureError::TimedOut(policy.deadline));
255            }
256            let exited = process.has_exited().map_err(CaptureError::Failure)?;
257            if exited {
258                process.terminate().map_err(CaptureError::Failure)?; // descendants cannot retain the pipes
259                match (stdout.take(), stderr.take()) {
260                    (Some(stdout), Some(stderr)) if input_finished => {
261                        let status = process.wait().map_err(CaptureError::Failure)?;
262                        drop(lease);
263                        return Ok(CommandOutput {
264                            status,
265                            stdout: stdout.bytes,
266                            stderr: stderr.bytes,
267                            stdout_dropped: stdout.dropped,
268                            stderr_dropped: stderr.dropped,
269                            stdin_error,
270                        });
271                    }
272                    (out, err) => {
273                        stdout = out;
274                        stderr = err;
275                    }
276                }
277            }
278            let event = match rx.recv_timeout(POLL) {
279                Ok(event) => event,
280                Err(RecvTimeoutError::Timeout) => continue,
281                Err(RecvTimeoutError::Disconnected) if !exited => {
282                    std::thread::park_timeout(POLL);
283                    continue;
284                }
285                Err(error) => return Err(failure(FailureKind::Disconnected, error.to_string())),
286            };
287            let (slot, result) = match event {
288                Stream::Stdout(result) => (&mut stdout, result),
289                Stream::Stderr(result) => (&mut stderr, result),
290                Stream::Stdin(result) => {
291                    input_finished = true;
292                    match result {
293                        Ok(stdin) => lease = Some(stdin),
294                        Err(error) => stdin_error = Some(error),
295                    }
296                    continue;
297                }
298            };
299            *slot = Some(result.map_err(|error| failure(FailureKind::Io, error.to_string()))?);
300        }
301    })
302}
303
304/// Bounds for one streamed run. Stdout has no retention limit by
305/// construction: the consumer, not the pipe, decides what to keep.
306#[derive(Debug, Clone)]
307pub struct StreamPolicy {
308    /// Retained head of stderr.
309    pub stderr_limit: u64,
310    /// Additional bytes retained from the *end* of stderr, after any
311    /// dropped middle. Zero disables the tail.
312    pub stderr_tail: u64,
313    /// Wall-clock budget for the whole exchange.
314    pub deadline: Duration,
315    /// Keep the stdin lifetime lease open while consuming stdout.
316    pub hold_stdin: bool,
317}
318
319/// One streamed run's outcome: everything except stdout, which the
320/// consumer already saw chunk by chunk.
321pub struct StreamOutput {
322    pub status: ExitStatus,
323    pub stderr: Vec<u8>,
324    /// Stderr bytes discarded between the retained head and tail.
325    pub stderr_dropped: u64,
326}
327
328/// Why a streamed run did not complete. Mirrors [`CaptureError`];
329/// [`StreamError::Consumer`] carries the consumer's own error type so a
330/// parse/shape failure surfaces typed, never flattened to a message.
331#[derive(Debug)]
332pub enum StreamError<E> {
333    Spawn(String),
334    Cancelled,
335    TimedOut(Duration),
336    Failure(Failure),
337    Consumer(E),
338}
339
340enum StreamEvent {
341    Chunk(Vec<u8>),
342    Stdout(io::Result<()>),
343    Stderr(io::Result<Retained>),
344}
345
346/// Read stdout chunk by chunk and forward each; terminal events report
347/// EOF ([`StreamEvent::Stdout`]) and the drained stderr.
348fn stream_stdout(mut pipe: impl Read, tx: &std::sync::mpsc::SyncSender<StreamEvent>) {
349    let mut chunk = vec![0u8; CHUNK];
350    loop {
351        match pipe.read(&mut chunk) {
352            Ok(0) => {
353                let _ = tx.send(StreamEvent::Stdout(Ok(())));
354                return;
355            }
356            Ok(seen) => {
357                if tx.send(StreamEvent::Chunk(chunk[..seen].to_vec())).is_err() {
358                    return; // consumer gone: the supervisor is unwinding
359                }
360            }
361            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
362            Err(error) => {
363                let _ = tx.send(StreamEvent::Stdout(Err(error)));
364                return;
365            }
366        }
367    }
368}
369
370/// Run one command to completion while its stdout streams through
371/// `consume`. Supervision is identical to [`capture_with`]: own process
372/// group, cancellation SIGKILLs it, the deadline kills it, stderr is
373/// drained concurrently with bounded retention, stdin is `/dev/null`.
374/// Stdout is never retained — a bounded number of chunks is in flight
375/// between the reader thread and `consume`, so memory stays bounded by
376/// what the consumer keeps. A consumer error kills the child and
377/// surfaces as [`StreamError::Consumer`].
378pub fn stream_with<E>(
379    command: &mut Command,
380    token: &CancelToken,
381    policy: &StreamPolicy,
382    mut consume: impl FnMut(&[u8]) -> Result<(), E>,
383) -> Result<StreamOutput, StreamError<E>> {
384    let failure =
385        |kind: FailureKind, message: String| StreamError::Failure(Failure::new(kind, message));
386    std::thread::scope(|scope| {
387        command.stdout(Stdio::piped());
388        command.stderr(Stdio::piped());
389        command.stdin(if policy.hold_stdin {
390            Stdio::piped()
391        } else {
392            Stdio::null()
393        });
394        // Owned here, INSIDE scope: unwinding kills pipes before scope joins.
395        let mut process = OwnedProcess::spawn(command, token).map_err(|failure| {
396            if failure.kind == FailureKind::Spawn {
397                StreamError::Spawn(failure.message)
398            } else {
399                StreamError::Failure(failure)
400            }
401        })?;
402        let _stdin_lease = if policy.hold_stdin {
403            Some(
404                process
405                    .take_stdin()
406                    .ok_or_else(|| failure(FailureKind::Protocol, "missing stdin lease".into()))?,
407            )
408        } else {
409            None
410        };
411        let stdout = process
412            .take_stdout()
413            .ok_or_else(|| failure(FailureKind::Protocol, "missing stdout".into()))?;
414        let stderr = process
415            .take_stderr()
416            .ok_or_else(|| failure(FailureKind::Protocol, "missing stderr".into()))?;
417        // Bounded in flight: the reader runs at most a few chunks ahead
418        // of the consumer, and the pipe itself back-pressures the child.
419        let (tx, rx) = std::sync::mpsc::sync_channel::<StreamEvent>(4);
420        let out_tx = tx.clone();
421        std::thread::Builder::new()
422            .name("stream-stdout".into())
423            .spawn_scoped(scope, move || stream_stdout(stdout, &out_tx))
424            .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
425        let err_limit = policy.stderr_limit;
426        let err_tail = policy.stderr_tail;
427        std::thread::Builder::new()
428            .name("stream-stderr".into())
429            .spawn_scoped(scope, move || {
430                let _ = tx.send(StreamEvent::Stderr(read_pipe(stderr, err_limit, err_tail)));
431            })
432            .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
433        let deadline = Instant::now() + policy.deadline;
434        let mut stdout_done = false;
435        let mut stderr: Option<Retained> = None;
436        loop {
437            if token.is_cancelled() {
438                return Err(StreamError::Cancelled);
439            }
440            if Instant::now() >= deadline {
441                return Err(StreamError::TimedOut(policy.deadline));
442            }
443            let exited = process.has_exited().map_err(StreamError::Failure)?;
444            if exited {
445                process.terminate().map_err(StreamError::Failure)?; // descendants cannot retain the pipes
446                if stdout_done {
447                    if let Some(stderr) = stderr.take() {
448                        let status = process.wait().map_err(StreamError::Failure)?;
449                        return Ok(StreamOutput {
450                            status,
451                            stderr: stderr.bytes,
452                            stderr_dropped: stderr.dropped,
453                        });
454                    }
455                }
456            }
457            let event = match rx.recv_timeout(POLL) {
458                Ok(event) => event,
459                Err(RecvTimeoutError::Timeout) => continue,
460                Err(RecvTimeoutError::Disconnected) if !exited => {
461                    std::thread::park_timeout(POLL);
462                    continue;
463                }
464                Err(error) => return Err(failure(FailureKind::Disconnected, error.to_string())),
465            };
466            match event {
467                StreamEvent::Chunk(chunk) => consume(&chunk).map_err(StreamError::Consumer)?,
468                StreamEvent::Stdout(result) => {
469                    result.map_err(|error| failure(FailureKind::Io, error.to_string()))?;
470                    stdout_done = true;
471                }
472                StreamEvent::Stderr(result) => {
473                    stderr =
474                        Some(result.map_err(|error| failure(FailureKind::Io, error.to_string()))?);
475                }
476            }
477        }
478    })
479}
480
481#[cfg(all(test, unix))]
482mod tests {
483    use super::*;
484
485    fn sh(script: &str) -> Command {
486        let mut command = Command::new("sh");
487        command.arg("-c").arg(script);
488        command
489    }
490
491    fn policy(stdin: StdinPolicy, deadline: Duration) -> CapturePolicy {
492        CapturePolicy {
493            stdin,
494            deadline,
495            ..CapturePolicy::default()
496        }
497    }
498
499    fn run_capture(
500        script: &'static str,
501        policy: CapturePolicy<'static>,
502    ) -> Result<CommandOutput, CaptureError> {
503        let (tx, rx) = channel();
504        let _owner = crate::worker::spawn(
505            "capture-oracle",
506            move |outcome| {
507                let _ = tx.send(outcome);
508            },
509            move |token| {
510                crate::worker::Outcome::Success(capture_with(&mut sh(script), &token, &policy))
511            },
512        );
513        match rx
514            .recv_timeout(Duration::from_secs(10))
515            .expect("capture settled")
516        {
517            crate::worker::Outcome::Success(result) => result,
518            _ => panic!("capture worker failed"),
519        }
520    }
521
522    #[test]
523    fn small_outputs_come_back_whole() {
524        let output = run_capture("echo out; echo err >&2", CapturePolicy::default()).unwrap();
525        assert!(output.status.success());
526        assert_eq!(output.stdout, b"out\n");
527        assert_eq!(output.stderr, b"err\n");
528        assert_eq!(output.stdout_dropped, 0);
529        assert_eq!(output.stderr_dropped, 0);
530    }
531
532    #[test]
533    fn stdout_keeps_the_head_and_counts_the_drops() {
534        let bounded = CapturePolicy {
535            stdout_limit: 1000,
536            ..CapturePolicy::default()
537        };
538        let output = run_capture("yes | head -c 200000", bounded).unwrap();
539        assert!(output.status.success());
540        assert_eq!(output.stdout.len(), 1000);
541        assert_eq!(output.stdout_dropped, 199000);
542        assert!(output
543            .stdout
544            .iter()
545            .all(|&byte| byte == b'y' || byte == b'\n'));
546    }
547
548    #[test]
549    fn stderr_tail_keeps_the_final_record() {
550        let bounded = CapturePolicy {
551            stderr_limit: 1000,
552            stderr_tail: 64,
553            ..CapturePolicy::default()
554        };
555        let script = "printf 'start'; head -c 100000 /dev/zero 1>&2; printf 'END-MARK' 1>&2";
556        let output = run_capture(script, bounded).unwrap();
557        assert!(output.status.success());
558        assert_eq!(output.stderr_dropped, 98_944);
559        assert_eq!(output.stderr.len(), 1064);
560        assert!(output.stderr.ends_with(b"END-MARK"), "{:?}", output.stderr);
561    }
562
563    #[test]
564    fn null_stdin_delivers_eof_immediately() {
565        let output = run_capture("read line; echo done", CapturePolicy::default()).unwrap();
566        assert_eq!(output.stdout, b"done\n");
567    }
568
569    #[test]
570    fn held_stdin_is_open_rather_than_eof() {
571        // `read` would return instantly on EOF; an open pipe with no
572        // data blocks, so only the deadline can end this — proving the
573        // writer was genuinely held.
574        let held = policy(StdinPolicy::Held, Duration::from_secs(2));
575        assert!(matches!(
576            run_capture("read line; echo done", held),
577            Err(CaptureError::TimedOut(_))
578        ));
579    }
580
581    fn stream_policy(deadline: Duration) -> StreamPolicy {
582        StreamPolicy {
583            stderr_limit: LIMIT,
584            stderr_tail: 0,
585            deadline,
586            hold_stdin: false,
587        }
588    }
589
590    fn run_stream<E: Send + 'static>(
591        script: &'static str,
592        policy: StreamPolicy,
593        consume: impl FnMut(&[u8]) -> Result<(), E> + Send + 'static,
594    ) -> Result<(StreamOutput, Vec<u8>), StreamError<E>>
595    where
596        StreamError<E>: Send,
597    {
598        let (tx, rx) = channel();
599        let _owner = crate::worker::spawn(
600            "stream-oracle",
601            move |outcome| {
602                let _ = tx.send(outcome);
603            },
604            move |token| {
605                let mut seen = Vec::new();
606                let mut consume = consume;
607                let result = stream_with(&mut sh(script), &token, &policy, |chunk| {
608                    seen.extend_from_slice(chunk);
609                    consume(chunk)
610                });
611                crate::worker::Outcome::Success(result.map(|output| (output, seen)))
612            },
613        );
614        match rx
615            .recv_timeout(Duration::from_secs(10))
616            .expect("stream settled")
617        {
618            crate::worker::Outcome::Success(result) => result,
619            _ => panic!("stream worker failed"),
620        }
621    }
622
623    #[test]
624    fn streaming_delivers_every_byte_whole_and_unbounded() {
625        // 2 MB — thirty times the old capture ceiling — flows through
626        // the consumer with nothing retained by the supervisor.
627        let (output, seen) = run_stream::<String>(
628            "yes | head -c 2000000; echo err >&2",
629            stream_policy(Duration::from_secs(10)),
630            |_| Ok(()),
631        )
632        .unwrap();
633        assert!(output.status.success());
634        assert_eq!(seen.len(), 2_000_000);
635        assert!(seen.iter().all(|&byte| byte == b'y' || byte == b'\n'));
636        assert_eq!(output.stderr, b"err\n");
637    }
638
639    #[test]
640    fn a_consumer_error_kills_the_child_promptly() {
641        // `yes` never ends on its own; only the consumer's refusal can
642        // end the run, and it must do so well inside the deadline.
643        let mut delivered = 0u64;
644        let result = run_stream(
645            "yes",
646            stream_policy(Duration::from_secs(30)),
647            move |chunk| {
648                delivered += chunk.len() as u64;
649                if delivered >= 100_000 {
650                    Err("enough".to_string())
651                } else {
652                    Ok(())
653                }
654            },
655        );
656        assert!(matches!(
657            result,
658            Err(StreamError::Consumer(error)) if error == "enough"
659        ));
660    }
661
662    #[test]
663    fn a_silent_child_still_times_out() {
664        let result = run_stream::<String>(
665            "sleep 30",
666            stream_policy(Duration::from_secs(1)),
667            |_| Ok(()),
668        );
669        assert!(matches!(result, Err(StreamError::TimedOut(_))));
670    }
671}