1use 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
37struct 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 pub stdout_dropped: u64,
51 pub stderr_dropped: u64,
53 pub stdin_error: Option<io::Error>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum StdinPolicy<'a> {
60 Null,
62 Held,
66 HeldInput(&'a [&'a [u8]]),
68}
69
70#[derive(Debug, Clone)]
72pub struct CapturePolicy<'a> {
73 pub stdout_limit: u64,
75 pub stderr_limit: u64,
77 pub stderr_tail: u64,
80 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#[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
114fn 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
156pub 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
175pub 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 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 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)?; 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#[derive(Debug, Clone)]
307pub struct StreamPolicy {
308 pub stderr_limit: u64,
310 pub stderr_tail: u64,
313 pub deadline: Duration,
315}
316
317pub struct StreamOutput {
320 pub status: ExitStatus,
321 pub stderr: Vec<u8>,
322 pub stderr_dropped: u64,
324}
325
326#[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
344fn 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; }
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
368pub 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 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 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)?; 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 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 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 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}