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