Skip to main content

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;