git_policy_cli/process/output_capture.rs
1//! What:
2//! Output of a child process captured into two anonymous files instead of two pipes, and read back from the start whenever the
3//! caller asks.
4//! Why:
5//! A pipe lives only as long as the process that holds its read end. A step the wrapper leaves running (a write in place that
6//! cannot be run twice and has outlasted its bound) would meet a closed pipe when it next wrote, if the command that started it
7//! had exited: the write fails with `EPIPE` or the process dies of `SIGPIPE`, which is the cut the owner ruled out ("Never cut a
8//! branch write (Recommended)"). A file the child holds open cannot be closed by the command, so the child outlives the command
9//! and no thread has to read for it. The same holds for a repair tool that starts a helper which keeps its inherited output open
10//! after the tool has exited: with files, no reader waits for the helper. The files are anonymous (no name to remove, nothing left
11//! behind), the parent reads them by position so the offset the child moves is not disturbed, and a child that is still running
12//! after the command has gone is reaped by the system's init or subreaper.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const capture = new Capture(); spawn(command, { stdio: ['null', capture.out, capture.err] }); await exit(child); const { stdout, stderr } = capture.read();
17//! ```
18
19use std::fs::File;
20use std::os::unix::fs::FileExt;
21use std::process::Stdio;
22
23/// The most bytes of one stream a snapshot of a step's output holds, the last ones. Exempt from a citation under `LQM` (class: display
24/// cap): it limits how much of the output of a step the command keeps in memory for its messages, and no result depends on it; the
25/// steps that use it write a few lines (a reference update, a merge) or print a reason at the end (the repair tools).
26pub const STEP_OUTPUT_LIMIT: usize = 8 * 1024 * 1024;
27
28/// Two anonymous files that collect a child's standard output and standard error.
29pub struct Capture {
30 /// Standard output.
31 out: File,
32 /// Standard error.
33 err: File,
34}
35
36/// One anonymous file: a `memfd`, where the system has them.
37fn anonymous_file() -> std::io::Result<File> {
38 #[cfg(target_os = "linux")]
39 {
40 use std::os::unix::io::FromRawFd;
41 // SAFETY: the name is a NUL-terminated literal; the call returns a new descriptor or -1.
42 let descriptor: libc::c_int = unsafe { libc::memfd_create(c"cli-git-output".as_ptr(), libc::MFD_CLOEXEC) };
43 if descriptor < 0 {
44 return Err(std::io::Error::last_os_error());
45 }
46 // SAFETY: the descriptor is new, open, and owned by nothing else.
47 return Ok(unsafe { File::from_raw_fd(descriptor) });
48 }
49 // UNTESTED ON OTHER SYSTEMS: only Linux has `memfd_create`, and the owner said to ignore macOS until told. An unnamed file made by
50 // C's `tmpfile` would be made in `/tmp`, which this repository never uses (the device behind it is unknowable), so there is none.
51 #[cfg(not(target_os = "linux"))]
52 {
53 return Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "anonymous output files need memfd_create, which this system lacks"));
54 }
55}
56
57impl Capture {
58 /// Two new empty anonymous files.
59 pub fn new() -> std::io::Result<Self> {
60 return Ok(Self { out: anonymous_file()?, err: anonymous_file()? });
61 }
62
63 /// The two files as the standard output and standard error of a child.
64 pub fn stdio(&self) -> std::io::Result<(Stdio, Stdio)> {
65 return Ok((Stdio::from(self.out.try_clone()?), Stdio::from(self.err.try_clone()?)));
66 }
67
68 /// What:
69 /// A snapshot of what the child wrote so far to each stream: the last `limit` bytes of what was there when the snapshot began,
70 /// and a note for everything that was left out.
71 /// Why:
72 /// A descendant that keeps appending, or a child that writes more than anyone wants, must not make the reader chase the end
73 /// of a file that grows ahead of it, nor hold the whole of it in memory (sol-1 and sol-2 round 2): the length is read once and
74 /// that many bytes are read at most, from the end of the snapshot backwards by `limit`. A read that fails is not the end of
75 /// the output: the stream's note says it could not be read whole. The tail is kept because a tool prints its reason last.
76 ///
77 /// In TS you'd write (pseudocode):
78 /// ```ts
79 /// const { stdout, stderr, notes } = capture.snapshot(limit);
80 /// ```
81 pub fn snapshot(&self, limit: usize) -> Captured {
82 let mut notes: Vec<String> = Vec::new();
83 let stdout: Vec<u8> = snapshot_of(&self.out, "standard output", limit, &mut notes);
84 let stderr: Vec<u8> = snapshot_of(&self.err, "standard error", limit, &mut notes);
85 return Captured { stdout, stderr, notes };
86 }
87
88 /// Free what the files hold in memory. The child's offset is its own, so what it writes afterwards leaves a hole before it and
89 /// costs only the pages it writes; nothing reads the files after this.
90 pub fn discard(&self) {
91 let _out: std::io::Result<()> = self.out.set_len(0);
92 let _err: std::io::Result<()> = self.err.set_len(0);
93 }
94}
95
96/// What a snapshot of a child's output holds.
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct Captured {
99 /// The tail of what was written to standard output.
100 pub stdout: Vec<u8>,
101 /// The tail of what was written to standard error.
102 pub stderr: Vec<u8>,
103 /// What was left out or could not be read, in words for the person.
104 pub notes: Vec<String>,
105}
106
107/// The last `limit` bytes of the first `length` bytes of `file`, where `length` is the size when the call began; a note for the bytes
108/// left out and for a read that failed.
109fn snapshot_of(file: &File, name: &str, limit: usize, notes: &mut Vec<String>) -> Vec<u8> {
110 let length: u64 = match file.metadata() {
111 Ok(found) => found.len(),
112 Err(error) => {
113 notes.push(format!("the {name} of the step could not be measured ({error}), so none of it is shown"));
114 return Vec::new();
115 }
116 };
117 let wanted: u64 = length.min(limit as u64);
118 let start: u64 = length - wanted;
119 if start > 0 {
120 notes.push(format!("{start} bytes at the start of the {name} of the step are left out; the last {wanted} bytes are shown"));
121 }
122 let mut collected: Vec<u8> = Vec::with_capacity(usize::try_from(wanted).unwrap_or(0));
123 let mut buffer: [u8; 8192] = [0; 8192];
124 let mut position: u64 = start;
125 while position < length {
126 let room: usize = usize::try_from((length - position).min(buffer.len() as u64)).unwrap_or(buffer.len());
127 match file.read_at(&mut buffer[..room], position) {
128 Ok(0) => break,
129 Ok(read) => {
130 collected.extend_from_slice(&buffer[..read]);
131 position += read as u64;
132 }
133 Err(error) => {
134 notes.push(format!("the {name} of the step could not be read whole ({error}); {} of {wanted} bytes are shown", collected.len()));
135 return collected;
136 }
137 }
138 }
139 if (collected.len() as u64) < wanted {
140 notes.push(format!("the {name} of the step ended early: {} of {wanted} bytes are shown", collected.len()));
141 }
142 return collected;
143}
144
145/// Capture controls stay out of the release executable.
146#[cfg(test)]
147#[path = "output_capture_tests.rs"]
148mod tests;