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 pub hold_stdin: bool,
317}
318
319pub struct StreamOutput {
322 pub status: ExitStatus,
323 pub stderr: Vec<u8>,
324 pub stderr_dropped: u64,
326}
327
328#[derive(Debug)]
332pub enum StreamError<E> {
333 Spawn(String),
334 Cancelled,
335 TimedOut(Duration),
336 Failure(Failure),
337 Consumer(E),
338}
339
340enum StreamEvent {
341 Chunk(Vec<u8>),
342 Stdout(io::Result<()>),
343 Stderr(io::Result<Retained>),
344}
345
346fn stream_stdout(mut pipe: impl Read, tx: &std::sync::mpsc::SyncSender<StreamEvent>) {
349 let mut chunk = vec![0u8; CHUNK];
350 loop {
351 match pipe.read(&mut chunk) {
352 Ok(0) => {
353 let _ = tx.send(StreamEvent::Stdout(Ok(())));
354 return;
355 }
356 Ok(seen) => {
357 if tx.send(StreamEvent::Chunk(chunk[..seen].to_vec())).is_err() {
358 return; }
360 }
361 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
362 Err(error) => {
363 let _ = tx.send(StreamEvent::Stdout(Err(error)));
364 return;
365 }
366 }
367 }
368}
369
370pub fn stream_with<E>(
379 command: &mut Command,
380 token: &CancelToken,
381 policy: &StreamPolicy,
382 mut consume: impl FnMut(&[u8]) -> Result<(), E>,
383) -> Result<StreamOutput, StreamError<E>> {
384 let failure =
385 |kind: FailureKind, message: String| StreamError::Failure(Failure::new(kind, message));
386 std::thread::scope(|scope| {
387 command.stdout(Stdio::piped());
388 command.stderr(Stdio::piped());
389 command.stdin(if policy.hold_stdin {
390 Stdio::piped()
391 } else {
392 Stdio::null()
393 });
394 let mut process = OwnedProcess::spawn(command, token).map_err(|failure| {
396 if failure.kind == FailureKind::Spawn {
397 StreamError::Spawn(failure.message)
398 } else {
399 StreamError::Failure(failure)
400 }
401 })?;
402 let _stdin_lease = if policy.hold_stdin {
403 Some(
404 process
405 .take_stdin()
406 .ok_or_else(|| failure(FailureKind::Protocol, "missing stdin lease".into()))?,
407 )
408 } else {
409 None
410 };
411 let stdout = process
412 .take_stdout()
413 .ok_or_else(|| failure(FailureKind::Protocol, "missing stdout".into()))?;
414 let stderr = process
415 .take_stderr()
416 .ok_or_else(|| failure(FailureKind::Protocol, "missing stderr".into()))?;
417 let (tx, rx) = std::sync::mpsc::sync_channel::<StreamEvent>(4);
420 let out_tx = tx.clone();
421 std::thread::Builder::new()
422 .name("stream-stdout".into())
423 .spawn_scoped(scope, move || stream_stdout(stdout, &out_tx))
424 .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
425 let err_limit = policy.stderr_limit;
426 let err_tail = policy.stderr_tail;
427 std::thread::Builder::new()
428 .name("stream-stderr".into())
429 .spawn_scoped(scope, move || {
430 let _ = tx.send(StreamEvent::Stderr(read_pipe(stderr, err_limit, err_tail)));
431 })
432 .map_err(|error| failure(FailureKind::ThreadStart, error.to_string()))?;
433 let deadline = Instant::now() + policy.deadline;
434 let mut stdout_done = false;
435 let mut stderr: Option<Retained> = None;
436 loop {
437 if token.is_cancelled() {
438 return Err(StreamError::Cancelled);
439 }
440 if Instant::now() >= deadline {
441 return Err(StreamError::TimedOut(policy.deadline));
442 }
443 let exited = process.has_exited().map_err(StreamError::Failure)?;
444 if exited {
445 process.terminate().map_err(StreamError::Failure)?; if stdout_done {
447 if let Some(stderr) = stderr.take() {
448 let status = process.wait().map_err(StreamError::Failure)?;
449 return Ok(StreamOutput {
450 status,
451 stderr: stderr.bytes,
452 stderr_dropped: stderr.dropped,
453 });
454 }
455 }
456 }
457 let event = match rx.recv_timeout(POLL) {
458 Ok(event) => event,
459 Err(RecvTimeoutError::Timeout) => continue,
460 Err(RecvTimeoutError::Disconnected) if !exited => {
461 std::thread::park_timeout(POLL);
462 continue;
463 }
464 Err(error) => return Err(failure(FailureKind::Disconnected, error.to_string())),
465 };
466 match event {
467 StreamEvent::Chunk(chunk) => consume(&chunk).map_err(StreamError::Consumer)?,
468 StreamEvent::Stdout(result) => {
469 result.map_err(|error| failure(FailureKind::Io, error.to_string()))?;
470 stdout_done = true;
471 }
472 StreamEvent::Stderr(result) => {
473 stderr =
474 Some(result.map_err(|error| failure(FailureKind::Io, error.to_string()))?);
475 }
476 }
477 }
478 })
479}
480
481#[cfg(all(test, unix))]
482mod tests {
483 use super::*;
484
485 fn sh(script: &str) -> Command {
486 let mut command = Command::new("sh");
487 command.arg("-c").arg(script);
488 command
489 }
490
491 fn policy(stdin: StdinPolicy, deadline: Duration) -> CapturePolicy {
492 CapturePolicy {
493 stdin,
494 deadline,
495 ..CapturePolicy::default()
496 }
497 }
498
499 fn run_capture(
500 script: &'static str,
501 policy: CapturePolicy<'static>,
502 ) -> Result<CommandOutput, CaptureError> {
503 let (tx, rx) = channel();
504 let _owner = crate::worker::spawn(
505 "capture-oracle",
506 move |outcome| {
507 let _ = tx.send(outcome);
508 },
509 move |token| {
510 crate::worker::Outcome::Success(capture_with(&mut sh(script), &token, &policy))
511 },
512 );
513 match rx
514 .recv_timeout(Duration::from_secs(10))
515 .expect("capture settled")
516 {
517 crate::worker::Outcome::Success(result) => result,
518 _ => panic!("capture worker failed"),
519 }
520 }
521
522 #[test]
523 fn small_outputs_come_back_whole() {
524 let output = run_capture("echo out; echo err >&2", CapturePolicy::default()).unwrap();
525 assert!(output.status.success());
526 assert_eq!(output.stdout, b"out\n");
527 assert_eq!(output.stderr, b"err\n");
528 assert_eq!(output.stdout_dropped, 0);
529 assert_eq!(output.stderr_dropped, 0);
530 }
531
532 #[test]
533 fn stdout_keeps_the_head_and_counts_the_drops() {
534 let bounded = CapturePolicy {
535 stdout_limit: 1000,
536 ..CapturePolicy::default()
537 };
538 let output = run_capture("yes | head -c 200000", bounded).unwrap();
539 assert!(output.status.success());
540 assert_eq!(output.stdout.len(), 1000);
541 assert_eq!(output.stdout_dropped, 199000);
542 assert!(output
543 .stdout
544 .iter()
545 .all(|&byte| byte == b'y' || byte == b'\n'));
546 }
547
548 #[test]
549 fn stderr_tail_keeps_the_final_record() {
550 let bounded = CapturePolicy {
551 stderr_limit: 1000,
552 stderr_tail: 64,
553 ..CapturePolicy::default()
554 };
555 let script = "printf 'start'; head -c 100000 /dev/zero 1>&2; printf 'END-MARK' 1>&2";
556 let output = run_capture(script, bounded).unwrap();
557 assert!(output.status.success());
558 assert_eq!(output.stderr_dropped, 98_944);
559 assert_eq!(output.stderr.len(), 1064);
560 assert!(output.stderr.ends_with(b"END-MARK"), "{:?}", output.stderr);
561 }
562
563 #[test]
564 fn null_stdin_delivers_eof_immediately() {
565 let output = run_capture("read line; echo done", CapturePolicy::default()).unwrap();
566 assert_eq!(output.stdout, b"done\n");
567 }
568
569 #[test]
570 fn held_stdin_is_open_rather_than_eof() {
571 let held = policy(StdinPolicy::Held, Duration::from_secs(2));
575 assert!(matches!(
576 run_capture("read line; echo done", held),
577 Err(CaptureError::TimedOut(_))
578 ));
579 }
580
581 fn stream_policy(deadline: Duration) -> StreamPolicy {
582 StreamPolicy {
583 stderr_limit: LIMIT,
584 stderr_tail: 0,
585 deadline,
586 hold_stdin: false,
587 }
588 }
589
590 fn run_stream<E: Send + 'static>(
591 script: &'static str,
592 policy: StreamPolicy,
593 consume: impl FnMut(&[u8]) -> Result<(), E> + Send + 'static,
594 ) -> Result<(StreamOutput, Vec<u8>), StreamError<E>>
595 where
596 StreamError<E>: Send,
597 {
598 let (tx, rx) = channel();
599 let _owner = crate::worker::spawn(
600 "stream-oracle",
601 move |outcome| {
602 let _ = tx.send(outcome);
603 },
604 move |token| {
605 let mut seen = Vec::new();
606 let mut consume = consume;
607 let result = stream_with(&mut sh(script), &token, &policy, |chunk| {
608 seen.extend_from_slice(chunk);
609 consume(chunk)
610 });
611 crate::worker::Outcome::Success(result.map(|output| (output, seen)))
612 },
613 );
614 match rx
615 .recv_timeout(Duration::from_secs(10))
616 .expect("stream settled")
617 {
618 crate::worker::Outcome::Success(result) => result,
619 _ => panic!("stream worker failed"),
620 }
621 }
622
623 #[test]
624 fn streaming_delivers_every_byte_whole_and_unbounded() {
625 let (output, seen) = run_stream::<String>(
628 "yes | head -c 2000000; echo err >&2",
629 stream_policy(Duration::from_secs(10)),
630 |_| Ok(()),
631 )
632 .unwrap();
633 assert!(output.status.success());
634 assert_eq!(seen.len(), 2_000_000);
635 assert!(seen.iter().all(|&byte| byte == b'y' || byte == b'\n'));
636 assert_eq!(output.stderr, b"err\n");
637 }
638
639 #[test]
640 fn a_consumer_error_kills_the_child_promptly() {
641 let mut delivered = 0u64;
644 let result = run_stream(
645 "yes",
646 stream_policy(Duration::from_secs(30)),
647 move |chunk| {
648 delivered += chunk.len() as u64;
649 if delivered >= 100_000 {
650 Err("enough".to_string())
651 } else {
652 Ok(())
653 }
654 },
655 );
656 assert!(matches!(
657 result,
658 Err(StreamError::Consumer(error)) if error == "enough"
659 ));
660 }
661
662 #[test]
663 fn a_silent_child_still_times_out() {
664 let result = run_stream::<String>(
665 "sleep 30",
666 stream_policy(Duration::from_secs(1)),
667 |_| Ok(()),
668 );
669 assert!(matches!(result, Err(StreamError::TimedOut(_))));
670 }
671}