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