git_policy_cli/bounded_child.rs
1//! What:
2//! Run one child program to completion with a time bound and output caps:
3//! its
4//! input comes from a file,
5//! its two outputs go to files,
6//! and the parent polls it.
7//! Why:
8//! The Markdown policy starts the linter once per candidate,
9//! and a child that hangs,
10//! floods its output or exits early must never hang or exhaust the wrapper.
11//! Files
12//! instead of pipes need no reader or writer threads:
13//! a child that stops reading its
14//! input cannot block the parent,
15//! a child that writes more than the cap is stopped
16//! when the parent sees the file grow past it,
17//! and a grandchild that keeps an output
18//! open cannot delay the parent once the child itself has exited.
19//! Every file is an
20//! anonymous temporary file that the operating system removes when it is closed.
21//!
22//! In TS you'd write (pseudocode):
23//! ```ts
24//! // const run = await runBoundedChild({ executable, args, cwd, env, input, timeoutMs, maxStdout, maxStderr });
25//! ```
26
27/// `OsString` is owned operating-system text:
28/// arguments and environment values need not be UTF-8.
29use std::ffi::OsString;
30/// What:
31/// `File` is an open file;
32/// `Read`,
33/// `Seek` and `Write` add reading,
34/// repositioning and
35/// writing;
36/// `SeekFrom::Start(0)` names the first byte.
37/// Why:
38/// The input file is written before the child starts,
39/// and the output files are read
40/// back after it ends.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// import { open } from 'node:fs/promises';
45/// ```
46use std::fs::File;
47/// `Read`,
48/// `Seek` and `Write` are the traits that add reading,
49/// rewinding and writing to a file.
50use std::io::{Read, Seek, SeekFrom, Write};
51/// `Path` is a borrowed filesystem path.
52use std::path::Path;
53/// What:
54/// `Command` describes a child before it starts;
55/// `Child` is a running one;
56/// `ExitStatus` is how it ended;
57/// `Stdio` says where each stream goes.
58/// Why:
59/// The child is started directly,
60/// never through a shell.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// import { spawn } from 'node:child_process';
65/// ```
66use std::process::{Child, Command, ExitStatus, Stdio};
67/// `Duration` is a span of time;
68/// `Instant` is a point on a clock that never goes backwards.
69use std::time::{Duration, Instant};
70
71/// What:
72/// The first and the longest pause between two looks at a running child.
73/// Why:
74/// A short child,
75/// such as the linter on one file (about 11 milliseconds measured on
76/// the host),
77/// is noticed within a few milliseconds of ending;
78/// the pause doubles so a
79/// long child costs a look every 16 milliseconds at most.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// const FIRST_PAUSE_MS = 1; const LONGEST_PAUSE_MS = 16;
84/// ```
85const FIRST_PAUSE: Duration = Duration::from_millis(1);
86/// See `FIRST_PAUSE`.
87const LONGEST_PAUSE: Duration = Duration::from_millis(16);
88
89/// What:
90/// The bounds of one child.
91/// `usize` is an unsigned size in bytes.
92/// `#[derive(...)]` generates copying,
93/// debug printing and `==`.
94/// Why:
95/// The caller chooses them,
96/// so tests can use bounds small enough to reach quickly.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// type ChildLimits = { timeoutMs: number; maxStdout: number; maxStderr: number };
101/// ```
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub struct ChildLimits {
104 /// How long the child may run before it is killed.
105 pub timeout: Duration,
106 /// The most bytes of standard output kept;
107 /// one more stops the child.
108 pub max_stdout: usize,
109 /// The most bytes of standard error kept;
110 /// one more stops the child.
111 pub max_stderr: usize,
112}
113
114/// What:
115/// Everything one child needs.
116/// `'a` names how long the borrowed parts last;
117/// `&[(OsString, OsString)]` is the complete environment,
118/// as name and value pairs.
119/// Why:
120/// The child gets exactly this environment and nothing inherited by accident.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// type ChildRequest = { executable: string; args: string[]; cwd: string; env: [string, string][]; input: Uint8Array; scratch: string; limits: ChildLimits };
125/// ```
126#[derive(Clone, Copy, Debug)]
127pub struct ChildRequest<'a> {
128 /// The program,
129 /// by path.
130 pub executable: &'a Path,
131 /// Its arguments,
132 /// after the program name.
133 pub arguments: &'a [OsString],
134 /// Its working directory.
135 pub directory: &'a Path,
136 /// Its whole environment.
137 pub environment: &'a [(OsString, OsString)],
138 /// The bytes it reads as standard input.
139 pub input: &'a [u8],
140 /// The directory the anonymous stream files are made in,
141 /// normally the system's
142 /// temporary directory.
143 pub scratch: &'a Path,
144 /// Its time bound and output caps.
145 pub limits: ChildLimits,
146}
147
148/// What:
149/// How a finished child ended:
150/// its exit code,
151/// or the signal that ended it.
152/// Why:
153/// A child ended by a signal has no exit code,
154/// and the message must say which.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// type ChildExit = { kind: 'code'; code: number } | { kind: 'signal'; signal: number } | { kind: 'unknown' };
159/// ```
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub enum ChildExit {
162 /// The child exited with this code.
163 Code(
164 /// The exit code.
165 i32,
166 ),
167 /// A signal ended the child.
168 Signal(
169 /// The signal number.
170 i32,
171 ),
172 /// Neither is known;
173 /// a platform without signals reported no code.
174 Unknown,
175}
176
177/// What:
178/// A child that ran to its end within its bounds,
179/// with what it wrote.
180/// Why:
181/// Interpreting the output is the caller's job;
182/// this only reports it exactly.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// type FinishedChild = { exit: ChildExit; stdout: Uint8Array; stderr: Uint8Array };
187/// ```
188#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct FinishedChild {
190 /// How it ended.
191 pub exit: ChildExit,
192 /// Its exact standard output.
193 pub stdout: Vec<u8>,
194 /// Its exact standard error.
195 pub stderr: Vec<u8>,
196}
197
198/// What:
199/// Why a child produced no usable result.
200/// `String` carries the operating system's
201/// own words where it gave any.
202/// Why:
203/// Each cause gets its own sentence for the person who ran the command.
204///
205/// In TS you'd write (pseudocode):
206/// ```ts
207/// type ChildFailure = { kind: 'files' | 'start' | 'wait'; reason: string } | { kind: 'timed-out' } | { kind: 'too-large'; stream: string; limit: number };
208/// ```
209#[derive(Clone, Debug, Eq, PartialEq)]
210pub enum ChildFailure {
211 /// A temporary file for the child's streams could not be made or used.
212 Files(
213 /// The operating system's reason.
214 String,
215 ),
216 /// The child could not be started.
217 Start(
218 /// The operating system's reason.
219 String,
220 ),
221 /// The child could not be waited for.
222 Wait(
223 /// The operating system's reason.
224 String,
225 ),
226 /// The child was still running at its time bound and was killed.
227 TimedOut(
228 /// The bound it exceeded.
229 Duration,
230 ),
231 /// The child wrote more than its cap to one stream and was killed.
232 TooLarge {
233 /// `standard output` or `standard error`.
234 stream: &'static str,
235 /// The cap it exceeded,
236 /// in bytes.
237 limit: usize,
238 },
239}
240
241/// What:
242/// The child's three stream files.
243/// Each is an anonymous temporary file.
244/// Why:
245/// One record keeps them together for the child's whole run.
246///
247/// In TS you'd write (pseudocode):
248/// ```ts
249/// type StreamFiles = { input: FileHandle; stdout: FileHandle; stderr: FileHandle };
250/// ```
251struct StreamFiles {
252 /// The child's standard input,
253 /// already written and rewound.
254 input: File,
255 /// The child's standard output.
256 stdout: File,
257 /// The child's standard error.
258 stderr: File,
259}
260
261/// What:
262/// The text of an operating-system failure.
263/// `&std::io::Error` borrows the failure.
264/// Why:
265/// A named conversion for the `map_err` calls below.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// const files = (error: Error) => ({ kind: 'files', reason: error.message });
270/// ```
271fn files_failure(error: std::io::Error) -> ChildFailure {
272 return ChildFailure::Files(error.to_string());
273}
274
275/// What:
276/// Make the three stream files and write the input into the first.
277/// The trailing `?` returns a failure to the caller at once.
278/// Why:
279/// The input is complete before the child starts,
280/// so the child can read it at any
281/// pace or not at all.
282///
283/// In TS you'd write (pseudocode):
284/// ```ts
285/// async function streamFiles(input: Uint8Array, scratch: string): Promise<StreamFiles>;
286/// ```
287fn stream_files(input: &[u8], scratch: &Path) -> Result<StreamFiles, ChildFailure> {
288 let mut written: File = tempfile::tempfile_in(scratch).map_err(files_failure)?;
289 written.write_all(input).map_err(files_failure)?;
290 written.seek(SeekFrom::Start(0)).map_err(files_failure)?;
291 return Ok(StreamFiles {
292 input: written,
293 stdout: tempfile::tempfile_in(scratch).map_err(files_failure)?,
294 stderr: tempfile::tempfile_in(scratch).map_err(files_failure)?,
295 });
296}
297
298/// What:
299/// Whether an output of `size` bytes is past a cap of `limit` bytes.
300/// `u64` is an
301/// unsigned 64-bit size.
302/// Why:
303/// A size equal to the cap is still within it.
304///
305/// In TS you'd write (pseudocode):
306/// ```ts
307/// const exceeds = (size: number, limit: number) => size > limit;
308/// ```
309fn exceeds(size: u64, limit: usize) -> bool {
310 // `as u64` widens the cap to the size's type.
311 return size > limit as u64;
312}
313
314/// What:
315/// Whether `elapsed` has reached the time bound `timeout`.
316/// Why:
317/// A child still running at exactly its bound has used all of it.
318///
319/// In TS you'd write (pseudocode):
320/// ```ts
321/// const deadlineReached = (elapsed: number, timeout: number) => elapsed >= timeout;
322/// ```
323fn deadline_reached(elapsed: Duration, timeout: Duration) -> bool {
324 return elapsed >= timeout;
325}
326
327/// What:
328/// The pause after `pause`:
329/// twice as long,
330/// but never longer than `LONGEST_PAUSE`.
331/// Why:
332/// Looks come quickly at first and settle at one every 16 milliseconds.
333///
334/// In TS you'd write (pseudocode):
335/// ```ts
336/// const nextPause = (pause: number) => Math.min(pause * 2, LONGEST_PAUSE_MS);
337/// ```
338fn next_pause(pause: Duration) -> Duration {
339 return (pause * 2).min(LONGEST_PAUSE);
340}
341
342/// What:
343/// How long to sleep now:
344/// the current pause,
345/// or what is left of the bound if less.
346/// Why:
347/// The parent never sleeps past the bound,
348/// so a stopped child is reported on time.
349///
350/// In TS you'd write (pseudocode):
351/// ```ts
352/// const sleepFor = (pause: number, remaining: number) => Math.min(pause, remaining);
353/// ```
354fn sleep_for(pause: Duration, remaining: Duration) -> Duration {
355 return pause.min(remaining);
356}
357
358/// What:
359/// The stream that has grown past its cap,
360/// if any.
361/// `&File` borrows an output file.
362/// Why:
363/// The parent looks at both sizes on every poll;
364/// a file whose size cannot be read is
365/// treated as unbounded,
366/// because nothing proves it stayed small.
367///
368/// In TS you'd write (pseudocode):
369/// ```ts
370/// function overflowed(files, limits): ChildFailure | undefined;
371/// ```
372fn overflowed(files: &StreamFiles, limits: ChildLimits) -> Option<ChildFailure> {
373 for (file, stream, limit) in [
374 (&files.stdout, "standard output", limits.max_stdout),
375 (&files.stderr, "standard error", limits.max_stderr),
376 ] {
377 let size: u64 = match file.metadata() {
378 Ok(metadata) => metadata.len(),
379 Err(_) => u64::MAX,
380 };
381 if exceeds(size, limit) {
382 return Some(ChildFailure::TooLarge { stream, limit });
383 }
384 }
385 return None;
386}
387
388/// What:
389/// Kill a child and collect it.
390/// `&mut Child` lends the running child.
391/// Why:
392/// A killed child must still be waited for,
393/// or it stays behind as a zombie.
394/// A child
395/// that already ended makes `kill` fail harmlessly,
396/// which is ignored.
397///
398/// In TS you'd write (pseudocode):
399/// ```ts
400/// child.kill('SIGKILL'); await once(child, 'exit');
401/// ```
402fn stop(child: &mut Child) {
403 // What: `let _ = ...` discards both results on purpose.
404 // Why: The child is being abandoned; its exit is not reported, only its cause.
405 //
406 // In TS you'd write (pseudocode):
407 // ```ts
408 // child.kill('SIGKILL');
409 // ```
410 let _ = child.kill();
411 let _ = child.wait();
412}
413
414/// What:
415/// Wait for a child within its bound,
416/// stopping it at the first overflow or at the
417/// bound.
418/// `Result<ExitStatus, ChildFailure>` is how it ended or why it was stopped.
419/// Why:
420/// `std` has no timed wait,
421/// so the parent looks with `try_wait`,
422/// which never blocks,
423/// and sleeps a growing pause between looks.
424///
425/// In TS you'd write (pseudocode):
426/// ```ts
427/// async function waitBounded(child, files, limits): Promise<number>;
428/// ```
429fn wait_bounded(
430 child: &mut Child,
431 files: &StreamFiles,
432 limits: ChildLimits,
433) -> Result<ExitStatus, ChildFailure> {
434 let started: Instant = Instant::now();
435 let mut pause: Duration = FIRST_PAUSE;
436 loop {
437 let waited: Option<ExitStatus> = match child.try_wait() {
438 Ok(found) => found,
439 Err(error) => {
440 stop(child);
441 return Err(ChildFailure::Wait(error.to_string()));
442 }
443 };
444 if let Some(failure) = overflowed(files, limits) {
445 stop(child);
446 return Err(failure);
447 }
448 if let Some(status) = waited {
449 return Ok(status);
450 }
451 let elapsed: Duration = started.elapsed();
452 if deadline_reached(elapsed, limits.timeout) {
453 stop(child);
454 return Err(ChildFailure::TimedOut(limits.timeout));
455 }
456 // `.saturating_sub` is what is left of the bound, never less than nothing.
457 std::thread::sleep(sleep_for(pause, limits.timeout.saturating_sub(elapsed)));
458 pause = next_pause(pause);
459 }
460}
461
462/// What:
463/// Read one output file back from its start.
464/// `&mut File` lends it.
465/// Why:
466/// The overflow check already proved the file is within its cap.
467///
468/// In TS you'd write (pseudocode):
469/// ```ts
470/// async function readBack(file: FileHandle): Promise<Uint8Array>;
471/// ```
472fn read_back(file: &mut File) -> Result<Vec<u8>, ChildFailure> {
473 let mut bytes: Vec<u8> = Vec::new();
474 file.seek(SeekFrom::Start(0)).map_err(files_failure)?;
475 file.read_to_end(&mut bytes).map_err(files_failure)?;
476 return Ok(bytes);
477}
478
479/// What:
480/// How an exit status reads:
481/// its code,
482/// else its signal.
483/// `#[cfg(unix)]` compiles the
484/// signal lookup only where signals exist.
485/// Why:
486/// The message names a signal when there is one instead of "no exit code".
487///
488/// In TS you'd write (pseudocode):
489/// ```ts
490/// const childExit = (code, signal) => code !== null ? { kind: 'code', code } : signal !== null ? { kind: 'signal', signal } : { kind: 'unknown' };
491/// ```
492fn child_exit(status: ExitStatus) -> ChildExit {
493 if let Some(code) = status.code() {
494 return ChildExit::Code(code);
495 }
496 #[cfg(unix)]
497 {
498 /// The trait adds `.signal()`;
499 /// `use` inside a block scopes it to this block.
500 use std::os::unix::process::ExitStatusExt;
501 if let Some(signal) = status.signal() {
502 return ChildExit::Signal(signal);
503 }
504 }
505 return ChildExit::Unknown;
506}
507
508/// What:
509/// Run the child of `request` to its end,
510/// or stop it at its bound or its cap.
511/// Why:
512/// See the module comment.
513/// The environment is cleared first,
514/// so the child sees
515/// exactly the pairs the caller passed.
516///
517/// In TS you'd write (pseudocode):
518/// ```ts
519/// async function runBoundedChild(request: ChildRequest): Promise<FinishedChild>; // throws ChildFailure
520/// ```
521pub fn run_bounded_child(request: &ChildRequest<'_>) -> Result<FinishedChild, ChildFailure> {
522 let mut files: StreamFiles = stream_files(request.input, request.scratch)?;
523 // What: `.try_clone()` makes a second handle on the same open file.
524 // Why: The child gets one handle of each output; the parent keeps the other to watch
525 // its size and read it back.
526 //
527 // In TS you'd write (pseudocode):
528 // ```ts
529 // const childStdout = await dup(stdout);
530 // ```
531 let child_input: File = files.input.try_clone().map_err(files_failure)?;
532 let child_stdout: File = files.stdout.try_clone().map_err(files_failure)?;
533 let child_stderr: File = files.stderr.try_clone().map_err(files_failure)?;
534 let mut command: Command = Command::new(request.executable);
535 command
536 .args(request.arguments)
537 .current_dir(request.directory)
538 .env_clear()
539 .stdin(Stdio::from(child_input))
540 .stdout(Stdio::from(child_stdout))
541 .stderr(Stdio::from(child_stderr));
542 for (name, value) in request.environment {
543 command.env(name, value);
544 }
545 let mut child: Child = match command.spawn() {
546 Ok(started) => started,
547 Err(error) => return Err(ChildFailure::Start(error.to_string())),
548 };
549 let status: ExitStatus = wait_bounded(&mut child, &files, request.limits)?;
550 return Ok(FinishedChild {
551 exit: child_exit(status),
552 stdout: read_back(&mut files.stdout)?,
553 stderr: read_back(&mut files.stderr)?,
554 });
555}
556
557/// Bound,
558/// cap and stream controls stay out of the release executable.
559#[cfg(test)]
560#[path = "bounded_child_tests.rs"]
561mod tests;