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;