1use 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
31struct 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 pub stdout_dropped: u64,
45 pub stderr_dropped: u64,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum StdinPolicy {
52 Null,
54 Held,
58}
59
60#[derive(Debug, Clone)]
62pub struct CapturePolicy {
63 pub stdout_limit: u64,
65 pub stderr_limit: u64,
67 pub stderr_tail: u64,
70 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#[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
103fn 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
145pub 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
164pub 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 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 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)?; 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 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}