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//! Overflowing either limit truncates (head, plus tail for stderr) and
17//! reports the dropped byte count on [`CommandOutput`] rather than
18//! failing: callers decide whether truncation invalidates the result.
19use super::OwnedProcess;
20use crate::worker::{CancelToken, Failure, FailureKind};
21use std::io::{self, Read, Write};
22use std::process::{ChildStdin, Command, ExitStatus, Stdio};
23use std::sync::mpsc::{channel, RecvTimeoutError};
24use std::time::{Duration, Instant};
25
26const LIMIT: u64 = 64 * 1024;
27const DEADLINE: Duration = Duration::from_secs(30);
28const POLL: Duration = Duration::from_millis(20);
29const CHUNK: usize = 64 * 1024;
30
31/// One captured pipe: the retained bytes and how many arrived beyond
32/// the retention window.
33struct Retained {
34    bytes: Vec<u8>,
35    dropped: u64,
36}
37
38pub struct CommandOutput {
39    pub status: ExitStatus,
40    pub stdout: Vec<u8>,
41    pub stderr: Vec<u8>,
42    /// Stdout bytes that arrived beyond `stdout_limit` and were
43    /// discarded. Zero means nothing was dropped.
44    pub stdout_dropped: u64,
45    /// Stderr bytes discarded between the retained head and tail.
46    pub stderr_dropped: u64,
47    /// Input delivery failure, retained alongside the child's diagnostic output.
48    pub stdin_error: Option<io::Error>,
49}
50
51/// What capture does with the child's stdin.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum StdinPolicy<'a> {
54    /// The child reads `/dev/null` and sees EOF immediately.
55    Null,
56    /// The child's stdin is a pipe whose local writer is held open for
57    /// the whole capture and dropped when the child has exited. The
58    /// child sees an open stdin with no data — never an early EOF.
59    Held,
60    /// Deliver borrowed chunks concurrently, then hold the lifetime lease open.
61    HeldInput(&'a [&'a [u8]]),
62}
63
64/// Bounds and stdin handling for one capture.
65#[derive(Debug, Clone)]
66pub struct CapturePolicy<'a> {
67    /// Retained head of stdout.
68    pub stdout_limit: u64,
69    /// Retained head of stderr.
70    pub stderr_limit: u64,
71    /// Additional bytes retained from the *end* of stderr, after any
72    /// dropped middle. Zero disables the tail.
73    pub stderr_tail: u64,
74    /// Wall-clock budget for the whole exchange.
75    pub deadline: Duration,
76    pub stdin: StdinPolicy<'a>,
77}
78
79impl Default for CapturePolicy<'_> {
80    fn default() -> Self {
81        Self {
82            stdout_limit: LIMIT,
83            stderr_limit: LIMIT,
84            stderr_tail: 0,
85            deadline: DEADLINE,
86            stdin: StdinPolicy::Null,
87        }
88    }
89}
90
91/// Why a capture did not produce output. `capture` folds these back
92/// into the historical `Failure` spellings; richer callers (remote
93/// exec) match on them directly.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum CaptureError {
96    Spawn(String),
97    Cancelled,
98    TimedOut(Duration),
99    Failure(Failure),
100}
101
102enum Stream {
103    Stdout(io::Result<Retained>),
104    Stderr(io::Result<Retained>),
105    Stdin(io::Result<ChildStdin>),
106}
107
108/// Keep the first `head` bytes and, when `tail` is non-zero, also the
109/// last `tail` bytes; count everything dropped in between.
110fn read_pipe(mut pipe: impl Read, head: u64, tail: u64) -> io::Result<Retained> {
111    let mut kept = Vec::new();
112    let mut tail_window: Vec<u8> = Vec::new();
113    let mut dropped: u64 = 0;
114    let mut chunk = vec![0u8; CHUNK];
115    loop {
116        match pipe.read(&mut chunk) {
117            Ok(0) => break,
118            Ok(seen) => {
119                let mut data = &chunk[..seen];
120                let head_room = (head as usize).saturating_sub(kept.len());
121                if head_room > 0 {
122                    let take = head_room.min(data.len());
123                    kept.extend_from_slice(&data[..take]);
124                    data = &data[take..];
125                }
126                if !data.is_empty() {
127                    if tail > 0 {
128                        tail_window.extend_from_slice(data);
129                        let over = tail_window.len().saturating_sub(tail as usize);
130                        if over > 0 {
131                            tail_window.drain(..over);
132                            dropped += over as u64;
133                        }
134                    } else {
135                        dropped += data.len() as u64;
136                    }
137                }
138            }
139            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
140            Err(error) => return Err(error),
141        }
142    }
143    kept.extend_from_slice(&tail_window);
144    Ok(Retained {
145        bytes: kept,
146        dropped,
147    })
148}
149
150/// At most 64 KiB per pipe, 30 seconds, stdin from `/dev/null`. No
151/// terminal input/output is inherited.
152pub fn capture(command: &mut Command, token: &CancelToken) -> Result<CommandOutput, Failure> {
153    capture_with(command, token, &CapturePolicy::default()).map_err(|error| match error {
154        CaptureError::Spawn(message) => Failure::new(FailureKind::Spawn, message),
155        CaptureError::Cancelled => {
156            Failure::new(FailureKind::Unavailable, "configuration command cancelled")
157        }
158        CaptureError::TimedOut(deadline) => Failure::new(
159            FailureKind::Wait,
160            format!(
161                "configuration command timed out after {} seconds",
162                deadline.as_secs()
163            ),
164        ),
165        CaptureError::Failure(failure) => failure,
166    })
167}
168
169/// Run one command to completion under explicit bounds. The child runs
170/// in its own process group owned by [`OwnedProcess`]; cancellation
171/// SIGKILLs it, the deadline kills it, and both pipes are drained
172/// concurrently with bounded retention.
173pub fn capture_with(
174    command: &mut Command,
175    token: &CancelToken,
176    policy: &CapturePolicy,
177) -> Result<CommandOutput, CaptureError> {
178    let failure =
179        |kind: FailureKind, message: String| CaptureError::Failure(Failure::new(kind, message));
180    std::thread::scope(|scope| {
181        command.stdout(Stdio::piped());
182        command.stderr(Stdio::piped());
183        match policy.stdin {
184            StdinPolicy::Null => command.stdin(Stdio::null()),
185            StdinPolicy::Held | StdinPolicy::HeldInput(_) => command.stdin(Stdio::piped()),
186        };
187        // Owned here, INSIDE scope: unwinding kills pipes before scope joins.
188        let mut process = OwnedProcess::spawn(command, token).map_err(|failure| {
189            if failure.kind == FailureKind::Spawn {
190                CaptureError::Spawn(failure.message)
191            } else {
192                CaptureError::Failure(failure)
193            }
194        })?;
195        // Held: keep the writer alive until the child has exited; its
196        // drop afterwards is pure pipe hygiene.
197        let mut lease = if !matches!(policy.stdin, StdinPolicy::Null) {
198            process.take_stdin()
199        } else {
200            None
201        };
202        let stdout = process
203            .take_stdout()
204            .ok_or_else(|| failure(FailureKind::Protocol, "missing stdout".into()))?;
205        let stderr = process
206            .take_stderr()
207            .ok_or_else(|| failure(FailureKind::Protocol, "missing stderr".into()))?;
208        let (tx, rx) = channel();
209        let out_tx = tx.clone();
210        let out_limit = policy.stdout_limit;
211        let err_limit = policy.stderr_limit;
212        let err_tail = policy.stderr_tail;
213        let mut input_finished = !matches!(policy.stdin, StdinPolicy::HeldInput(_));
214        let mut stdin_error = None;
215        if let StdinPolicy::HeldInput(chunks) = policy.stdin {
216            let mut stdin = lease
217                .take()
218                .ok_or_else(|| failure(FailureKind::Protocol, "missing stdin".into()))?;
219            let input_tx = tx.clone();
220            std::thread::Builder::new()
221                .name("capture-stdin".into())
222                .spawn_scoped(scope, move || {
223                    let written = chunks.iter().try_for_each(|chunk| stdin.write_all(chunk));
224                    let _ = input_tx.send(Stream::Stdin(written.map(|()| stdin)));
225                })
226                .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
227        }
228        std::thread::Builder::new()
229            .name("capture-stdout".into())
230            .spawn_scoped(scope, move || {
231                let _ = out_tx.send(Stream::Stdout(read_pipe(stdout, out_limit, 0)));
232            })
233            .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
234        std::thread::Builder::new()
235            .name("capture-stderr".into())
236            .spawn_scoped(scope, move || {
237                let _ = tx.send(Stream::Stderr(read_pipe(stderr, err_limit, err_tail)));
238            })
239            .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
240        let deadline = Instant::now() + policy.deadline;
241        let mut stdout: Option<Retained> = None;
242        let mut stderr: Option<Retained> = None;
243        loop {
244            if token.is_cancelled() {
245                return Err(CaptureError::Cancelled);
246            }
247            if Instant::now() >= deadline {
248                return Err(CaptureError::TimedOut(policy.deadline));
249            }
250            let exited = process.has_exited().map_err(CaptureError::Failure)?;
251            if exited {
252                process.terminate().map_err(CaptureError::Failure)?; // descendants cannot retain the pipes
253                match (stdout.take(), stderr.take()) {
254                    (Some(stdout), Some(stderr)) if input_finished => {
255                        let status = process.wait().map_err(CaptureError::Failure)?;
256                        drop(lease);
257                        return Ok(CommandOutput {
258                            status,
259                            stdout: stdout.bytes,
260                            stderr: stderr.bytes,
261                            stdout_dropped: stdout.dropped,
262                            stderr_dropped: stderr.dropped,
263                            stdin_error,
264                        });
265                    }
266                    (out, err) => {
267                        stdout = out;
268                        stderr = err;
269                    }
270                }
271            }
272            let event = match rx.recv_timeout(POLL) {
273                Ok(event) => event,
274                Err(RecvTimeoutError::Timeout) => continue,
275                Err(RecvTimeoutError::Disconnected) if !exited => {
276                    std::thread::park_timeout(POLL);
277                    continue;
278                }
279                Err(error) => return Err(failure(FailureKind::Disconnected, error.to_string())),
280            };
281            let (slot, result) = match event {
282                Stream::Stdout(result) => (&mut stdout, result),
283                Stream::Stderr(result) => (&mut stderr, result),
284                Stream::Stdin(result) => {
285                    input_finished = true;
286                    match result {
287                        Ok(stdin) => lease = Some(stdin),
288                        Err(error) => stdin_error = Some(error),
289                    }
290                    continue;
291                }
292            };
293            *slot = Some(result.map_err(|error| failure(FailureKind::Io, error.to_string()))?);
294        }
295    })
296}
297
298#[cfg(all(test, unix))]
299mod tests {
300    use super::*;
301
302    fn sh(script: &str) -> Command {
303        let mut command = Command::new("sh");
304        command.arg("-c").arg(script);
305        command
306    }
307
308    fn policy(stdin: StdinPolicy, deadline: Duration) -> CapturePolicy {
309        CapturePolicy {
310            stdin,
311            deadline,
312            ..CapturePolicy::default()
313        }
314    }
315
316    fn run_capture(
317        script: &'static str,
318        policy: CapturePolicy<'static>,
319    ) -> Result<CommandOutput, CaptureError> {
320        let (tx, rx) = channel();
321        let _owner = crate::worker::spawn(
322            "capture-oracle",
323            move |outcome| {
324                let _ = tx.send(outcome);
325            },
326            move |token| {
327                crate::worker::Outcome::Success(capture_with(&mut sh(script), &token, &policy))
328            },
329        );
330        match rx
331            .recv_timeout(Duration::from_secs(10))
332            .expect("capture settled")
333        {
334            crate::worker::Outcome::Success(result) => result,
335            _ => panic!("capture worker failed"),
336        }
337    }
338
339    #[test]
340    fn small_outputs_come_back_whole() {
341        let output = run_capture("echo out; echo err >&2", CapturePolicy::default()).unwrap();
342        assert!(output.status.success());
343        assert_eq!(output.stdout, b"out\n");
344        assert_eq!(output.stderr, b"err\n");
345        assert_eq!(output.stdout_dropped, 0);
346        assert_eq!(output.stderr_dropped, 0);
347    }
348
349    #[test]
350    fn stdout_keeps_the_head_and_counts_the_drops() {
351        let bounded = CapturePolicy {
352            stdout_limit: 1000,
353            ..CapturePolicy::default()
354        };
355        let output = run_capture("yes | head -c 200000", bounded).unwrap();
356        assert!(output.status.success());
357        assert_eq!(output.stdout.len(), 1000);
358        assert_eq!(output.stdout_dropped, 199000);
359        assert!(output
360            .stdout
361            .iter()
362            .all(|&byte| byte == b'y' || byte == b'\n'));
363    }
364
365    #[test]
366    fn stderr_tail_keeps_the_final_record() {
367        let bounded = CapturePolicy {
368            stderr_limit: 1000,
369            stderr_tail: 64,
370            ..CapturePolicy::default()
371        };
372        let script = "printf 'start'; head -c 100000 /dev/zero 1>&2; printf 'END-MARK' 1>&2";
373        let output = run_capture(script, bounded).unwrap();
374        assert!(output.status.success());
375        assert_eq!(output.stderr_dropped, 98_944);
376        assert_eq!(output.stderr.len(), 1064);
377        assert!(output.stderr.ends_with(b"END-MARK"), "{:?}", output.stderr);
378    }
379
380    #[test]
381    fn null_stdin_delivers_eof_immediately() {
382        let output = run_capture("read line; echo done", CapturePolicy::default()).unwrap();
383        assert_eq!(output.stdout, b"done\n");
384    }
385
386    #[test]
387    fn held_stdin_is_open_rather_than_eof() {
388        // `read` would return instantly on EOF; an open pipe with no
389        // data blocks, so only the deadline can end this — proving the
390        // writer was genuinely held.
391        let held = policy(StdinPolicy::Held, Duration::from_secs(2));
392        assert!(matches!(
393            run_capture("read line; echo done", held),
394            Err(CaptureError::TimedOut(_))
395        ));
396    }
397}