1use 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
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 pub stdin_error: Option<io::Error>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum StdinPolicy<'a> {
54 Null,
56 Held,
60 HeldInput(&'a [&'a [u8]]),
62}
63
64#[derive(Debug, Clone)]
66pub struct CapturePolicy<'a> {
67 pub stdout_limit: u64,
69 pub stderr_limit: u64,
71 pub stderr_tail: u64,
74 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#[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
108fn 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
150pub 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
169pub 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 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 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)?; 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 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}