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}
316
317/// One streamed run's outcome: everything except stdout, which the
318/// consumer already saw chunk by chunk.
319pub struct StreamOutput {
320    pub status: ExitStatus,
321    pub stderr: Vec<u8>,
322    /// Stderr bytes discarded between the retained head and tail.
323    pub stderr_dropped: u64,
324}
325
326/// Why a streamed run did not complete. Mirrors [`CaptureError`];
327/// [`StreamError::Consumer`] carries the consumer's own error type so a
328/// parse/shape failure surfaces typed, never flattened to a message.
329#[derive(Debug)]
330pub enum StreamError<E> {
331    Spawn(String),
332    Cancelled,
333    TimedOut(Duration),
334    Failure(Failure),
335    Consumer(E),
336}
337
338enum StreamEvent {
339    Chunk(Vec<u8>),
340    Stdout(io::Result<()>),
341    Stderr(io::Result<Retained>),
342}
343
344/// Read stdout chunk by chunk and forward each; terminal events report
345/// EOF ([`StreamEvent::Stdout`]) and the drained stderr.
346fn stream_stdout(mut pipe: impl Read, tx: &std::sync::mpsc::SyncSender<StreamEvent>) {
347    let mut chunk = vec![0u8; CHUNK];
348    loop {
349        match pipe.read(&mut chunk) {
350            Ok(0) => {
351                let _ = tx.send(StreamEvent::Stdout(Ok(())));
352                return;
353            }
354            Ok(seen) => {
355                if tx.send(StreamEvent::Chunk(chunk[..seen].to_vec())).is_err() {
356                    return; // consumer gone: the supervisor is unwinding
357                }
358            }
359            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
360            Err(error) => {
361                let _ = tx.send(StreamEvent::Stdout(Err(error)));
362                return;
363            }
364        }
365    }
366}
367
368/// Run one command to completion while its stdout streams through
369/// `consume`. Supervision is identical to [`capture_with`]: own process
370/// group, cancellation SIGKILLs it, the deadline kills it, stderr is
371/// drained concurrently with bounded retention, stdin is `/dev/null`.
372/// Stdout is never retained — a bounded number of chunks is in flight
373/// between the reader thread and `consume`, so memory stays bounded by
374/// what the consumer keeps. A consumer error kills the child and
375/// surfaces as [`StreamError::Consumer`].
376pub fn stream_with<E>(
377    command: &mut Command,
378    token: &CancelToken,
379    policy: &StreamPolicy,
380    mut consume: impl FnMut(&[u8]) -> Result<(), E>,
381) -> Result<StreamOutput, StreamError<E>> {
382    let failure =
383        |kind: FailureKind, message: String| StreamError::Failure(Failure::new(kind, message));
384    std::thread::scope(|scope| {
385        command.stdout(Stdio::piped());
386        command.stderr(Stdio::piped());
387        command.stdin(Stdio::null());
388        // Owned here, INSIDE scope: unwinding kills pipes before scope joins.
389        let mut process = OwnedProcess::spawn(command, token).map_err(|failure| {
390            if failure.kind == FailureKind::Spawn {
391                StreamError::Spawn(failure.message)
392            } else {
393                StreamError::Failure(failure)
394            }
395        })?;
396        let stdout = process
397            .take_stdout()
398            .ok_or_else(|| failure(FailureKind::Protocol, "missing stdout".into()))?;
399        let stderr = process
400            .take_stderr()
401            .ok_or_else(|| failure(FailureKind::Protocol, "missing stderr".into()))?;
402        // Bounded in flight: the reader runs at most a few chunks ahead
403        // of the consumer, and the pipe itself back-pressures the child.
404        let (tx, rx) = std::sync::mpsc::sync_channel::<StreamEvent>(4);
405        let out_tx = tx.clone();
406        std::thread::Builder::new()
407            .name("stream-stdout".into())
408            .spawn_scoped(scope, move || stream_stdout(stdout, &out_tx))
409            .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
410        let err_limit = policy.stderr_limit;
411        let err_tail = policy.stderr_tail;
412        std::thread::Builder::new()
413            .name("stream-stderr".into())
414            .spawn_scoped(scope, move || {
415                let _ = tx.send(StreamEvent::Stderr(read_pipe(stderr, err_limit, err_tail)));
416            })
417            .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
418        let deadline = Instant::now() + policy.deadline;
419        let mut stdout_done = false;
420        let mut stderr: Option<Retained> = None;
421        loop {
422            if token.is_cancelled() {
423                return Err(StreamError::Cancelled);
424            }
425            if Instant::now() >= deadline {
426                return Err(StreamError::TimedOut(policy.deadline));
427            }
428            let exited = process.has_exited().map_err(StreamError::Failure)?;
429            if exited {
430                process.terminate().map_err(StreamError::Failure)?; // descendants cannot retain the pipes
431                if stdout_done {
432                    if let Some(stderr) = stderr.take() {
433                        let status = process.wait().map_err(StreamError::Failure)?;
434                        return Ok(StreamOutput {
435                            status,
436                            stderr: stderr.bytes,
437                            stderr_dropped: stderr.dropped,
438                        });
439                    }
440                }
441            }
442            let event = match rx.recv_timeout(POLL) {
443                Ok(event) => event,
444                Err(RecvTimeoutError::Timeout) => continue,
445                Err(RecvTimeoutError::Disconnected) if !exited => {
446                    std::thread::park_timeout(POLL);
447                    continue;
448                }
449                Err(error) => return Err(failure(FailureKind::Disconnected, error.to_string())),
450            };
451            match event {
452                StreamEvent::Chunk(chunk) => consume(&chunk).map_err(StreamError::Consumer)?,
453                StreamEvent::Stdout(result) => {
454                    result.map_err(|error| failure(FailureKind::Io, error.to_string()))?;
455                    stdout_done = true;
456                }
457                StreamEvent::Stderr(result) => {
458                    stderr =
459                        Some(result.map_err(|error| failure(FailureKind::Io, error.to_string()))?);
460                }
461            }
462        }
463    })
464}
465
466#[cfg(all(test, unix))]
467mod tests {
468    use super::*;
469
470    fn sh(script: &str) -> Command {
471        let mut command = Command::new("sh");
472        command.arg("-c").arg(script);
473        command
474    }
475
476    fn policy(stdin: StdinPolicy, deadline: Duration) -> CapturePolicy {
477        CapturePolicy {
478            stdin,
479            deadline,
480            ..CapturePolicy::default()
481        }
482    }
483
484    fn run_capture(
485        script: &'static str,
486        policy: CapturePolicy<'static>,
487    ) -> Result<CommandOutput, CaptureError> {
488        let (tx, rx) = channel();
489        let _owner = crate::worker::spawn(
490            "capture-oracle",
491            move |outcome| {
492                let _ = tx.send(outcome);
493            },
494            move |token| {
495                crate::worker::Outcome::Success(capture_with(&mut sh(script), &token, &policy))
496            },
497        );
498        match rx
499            .recv_timeout(Duration::from_secs(10))
500            .expect("capture settled")
501        {
502            crate::worker::Outcome::Success(result) => result,
503            _ => panic!("capture worker failed"),
504        }
505    }
506
507    #[test]
508    fn small_outputs_come_back_whole() {
509        let output = run_capture("echo out; echo err >&2", CapturePolicy::default()).unwrap();
510        assert!(output.status.success());
511        assert_eq!(output.stdout, b"out\n");
512        assert_eq!(output.stderr, b"err\n");
513        assert_eq!(output.stdout_dropped, 0);
514        assert_eq!(output.stderr_dropped, 0);
515    }
516
517    #[test]
518    fn stdout_keeps_the_head_and_counts_the_drops() {
519        let bounded = CapturePolicy {
520            stdout_limit: 1000,
521            ..CapturePolicy::default()
522        };
523        let output = run_capture("yes | head -c 200000", bounded).unwrap();
524        assert!(output.status.success());
525        assert_eq!(output.stdout.len(), 1000);
526        assert_eq!(output.stdout_dropped, 199000);
527        assert!(output
528            .stdout
529            .iter()
530            .all(|&byte| byte == b'y' || byte == b'\n'));
531    }
532
533    #[test]
534    fn stderr_tail_keeps_the_final_record() {
535        let bounded = CapturePolicy {
536            stderr_limit: 1000,
537            stderr_tail: 64,
538            ..CapturePolicy::default()
539        };
540        let script = "printf 'start'; head -c 100000 /dev/zero 1>&2; printf 'END-MARK' 1>&2";
541        let output = run_capture(script, bounded).unwrap();
542        assert!(output.status.success());
543        assert_eq!(output.stderr_dropped, 98_944);
544        assert_eq!(output.stderr.len(), 1064);
545        assert!(output.stderr.ends_with(b"END-MARK"), "{:?}", output.stderr);
546    }
547
548    #[test]
549    fn null_stdin_delivers_eof_immediately() {
550        let output = run_capture("read line; echo done", CapturePolicy::default()).unwrap();
551        assert_eq!(output.stdout, b"done\n");
552    }
553
554    #[test]
555    fn held_stdin_is_open_rather_than_eof() {
556        // `read` would return instantly on EOF; an open pipe with no
557        // data blocks, so only the deadline can end this — proving the
558        // writer was genuinely held.
559        let held = policy(StdinPolicy::Held, Duration::from_secs(2));
560        assert!(matches!(
561            run_capture("read line; echo done", held),
562            Err(CaptureError::TimedOut(_))
563        ));
564    }
565
566    fn stream_policy(deadline: Duration) -> StreamPolicy {
567        StreamPolicy {
568            stderr_limit: LIMIT,
569            stderr_tail: 0,
570            deadline,
571        }
572    }
573
574    fn run_stream<E: Send + 'static>(
575        script: &'static str,
576        policy: StreamPolicy,
577        consume: impl FnMut(&[u8]) -> Result<(), E> + Send + 'static,
578    ) -> Result<(StreamOutput, Vec<u8>), StreamError<E>>
579    where
580        StreamError<E>: Send,
581    {
582        let (tx, rx) = channel();
583        let _owner = crate::worker::spawn(
584            "stream-oracle",
585            move |outcome| {
586                let _ = tx.send(outcome);
587            },
588            move |token| {
589                let mut seen = Vec::new();
590                let mut consume = consume;
591                let result = stream_with(&mut sh(script), &token, &policy, |chunk| {
592                    seen.extend_from_slice(chunk);
593                    consume(chunk)
594                });
595                crate::worker::Outcome::Success(result.map(|output| (output, seen)))
596            },
597        );
598        match rx
599            .recv_timeout(Duration::from_secs(10))
600            .expect("stream settled")
601        {
602            crate::worker::Outcome::Success(result) => result,
603            _ => panic!("stream worker failed"),
604        }
605    }
606
607    #[test]
608    fn streaming_delivers_every_byte_whole_and_unbounded() {
609        // 2 MB — thirty times the old capture ceiling — flows through
610        // the consumer with nothing retained by the supervisor.
611        let (output, seen) = run_stream::<String>(
612            "yes | head -c 2000000; echo err >&2",
613            stream_policy(Duration::from_secs(10)),
614            |_| Ok(()),
615        )
616        .unwrap();
617        assert!(output.status.success());
618        assert_eq!(seen.len(), 2_000_000);
619        assert!(seen.iter().all(|&byte| byte == b'y' || byte == b'\n'));
620        assert_eq!(output.stderr, b"err\n");
621    }
622
623    #[test]
624    fn a_consumer_error_kills_the_child_promptly() {
625        // `yes` never ends on its own; only the consumer's refusal can
626        // end the run, and it must do so well inside the deadline.
627        let mut delivered = 0u64;
628        let result = run_stream(
629            "yes",
630            stream_policy(Duration::from_secs(30)),
631            move |chunk| {
632                delivered += chunk.len() as u64;
633                if delivered >= 100_000 {
634                    Err("enough".to_string())
635                } else {
636                    Ok(())
637                }
638            },
639        );
640        assert!(matches!(
641            result,
642            Err(StreamError::Consumer(error)) if error == "enough"
643        ));
644    }
645
646    #[test]
647    fn a_silent_child_still_times_out() {
648        let result = run_stream::<String>(
649            "sleep 30",
650            stream_policy(Duration::from_secs(1)),
651            |_| Ok(()),
652        );
653        assert!(matches!(result, Err(StreamError::TimedOut(_))));
654    }
655}