1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
//! What:
//! Starting one package-manager program for a worktree repair: finding it on a search path, running it in a directory with
//! its output captured and a time limit, and saying how it ended.
//! Why:
//! The repairs of a new worktree (`package_repair.rs`) start tools the repository uses (pnpm, Cargo, mpm-shadow). Each one
//! must end in a plain verdict the add can word for the person: the program is missing, it failed with a reason, it ran
//! past its limit, or it worked. Nothing here decides which tool runs or what its output means.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const program = findProgram('pnpm', process.env.PATH);
//! const run = await runTool(program, { arguments: ['install'], directory, set: [] }, limit);
//! ```
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
/// What:
/// One program start: its arguments, the directory it runs in, and the variables added to its environment.
/// Why:
/// The program path is found separately, so a test can name a fixture and a person's search path is read once.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ToolCommand = { arguments: string[]; directory: string; set: [string, string][] };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolCommand {
/// The arguments after the program name.
pub arguments: Vec<OsString>,
/// The working directory.
pub directory: PathBuf,
/// Variables added to the inherited environment.
pub set: Vec<(OsString, OsString)>,
}
/// What:
/// How a started program ended.
/// Why:
/// The repair words each case differently, so they stay apart.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ToolEnd = { kind: 'succeeded' } | { kind: 'failed'; reason: string } | { kind: 'timed-out' };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ToolEnd {
/// Exit status zero.
Succeeded,
/// The program did not start, or ended with a failure status; the text names the status and the tail of its output.
Failed(String),
/// The program was still running when its limit passed, and was stopped.
TimedOut,
}
/// What:
/// The end of one run and how long it took.
/// Why:
/// The time is reported to the person and measured by the docs.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ToolRun = { end: ToolEnd; millis: number; stdout: Uint8Array };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolRun {
/// How the program ended.
pub end: ToolEnd,
/// Wall time from start to end, in milliseconds.
pub millis: u128,
/// What the program wrote to its standard output; empty when it did not run to its end.
pub stdout: Vec<u8>,
}
/// The most bytes of a program's output kept for its failure text.
const OUTPUT_TAIL_BYTES: usize = 600;
/// How often a running program is checked against its limit.
const POLL_INTERVAL: Duration = Duration::from_millis(10);
/// What:
/// The first executable file named `name` in a search path.
/// Why:
/// The program is the one the person's environment resolves; a missing one is a verdict, not a failure to start.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function findProgram(name: string, searchPath?: string): string | undefined;
/// ```
pub fn find_program(name: &str, search_path: Option<&OsStr>) -> Option<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let search: &OsStr = search_path?;
for directory in std::env::split_paths(search) {
// An empty entry means the current directory in a shell; a repair never runs a program found there.
if directory.as_os_str().is_empty() {
continue;
}
let candidate: PathBuf = directory.join(name);
let runnable: bool = std::fs::metadata(&candidate).is_ok_and(|found: std::fs::Metadata| { return found.is_file() && found.permissions().mode() & 0o111 != 0; });
if runnable {
return Some(candidate);
}
}
return None;
}
/// What:
/// The last bytes of what a program printed, as text, with the cut ragged edge dropped.
/// Why:
/// A failure is worded with the end of the output, where tools print their reason; the start is progress noise.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function tailOf(bytes: Uint8Array): string;
/// ```
fn tail_of(bytes: &[u8]) -> String {
let cut: usize = bytes.len().saturating_sub(OUTPUT_TAIL_BYTES);
let text: String = String::from_utf8_lossy(&bytes[cut..]).into_owned();
// Lines that only say a tool waited for another process say nothing about why it failed.
let informative: String = text.lines().filter(|line: &&str| { return !line.trim_start().starts_with("Blocking waiting for file lock"); }).collect::<Vec<&str>>().join("\n");
let trimmed: &str = informative.trim();
if cut == 0 {
return trimmed.to_owned();
}
return trimmed.split_once('\n').map_or(trimmed, |(_ragged, rest): (&str, &str)| { return rest; }).trim().to_owned();
}
/// What:
/// Read a pipe to its end on a thread of its own and hand back everything it carried.
/// Why:
/// A program that fills a pipe nobody reads stops; reading both of its pipes at once keeps it running.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const collected = collect(child.stdout);
/// ```
fn collect<R: std::io::Read + Send + 'static>(mut pipe: R) -> std::thread::JoinHandle<Vec<u8>> {
return std::thread::spawn(move || {
let mut bytes: Vec<u8> = Vec::new();
// A read that fails ends the collection; what was read before it is still the program's output.
let _ended = pipe.read_to_end(&mut bytes);
return bytes;
});
}
/// What:
/// How a finished program's status is worded.
/// Why:
/// A signal has no code, and the person should see which of the two ended the program.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function describeStatus(status: ExitStatus): string;
/// ```
fn describe_status(status: std::process::ExitStatus) -> String {
use std::os::unix::process::ExitStatusExt;
return match (status.code(), status.signal()) {
(Some(code), _) => format!("exit status {code}"),
(None, Some(signal)) => format!("stopped by signal {signal}"),
(None, None) => String::from("ended without a status"),
};
}
/// What:
/// Run a program to its end or its limit.
/// Why:
/// The add must never wait without bound on a tool, and must keep the tail of its output for the log. The program leads a
/// process group of its own, so the stop at the limit reaches the children it started too.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function runTool(program: string, command: ToolCommand, limit: number): Promise<ToolRun>;
/// ```
pub fn run_tool(program: &Path, command: &ToolCommand, limit: Duration) -> ToolRun {
use std::os::unix::process::CommandExt;
let started: Instant = Instant::now();
let mut starting: Command = Command::new(program);
starting.args(&command.arguments).current_dir(&command.directory).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).process_group(0);
for (name, value) in &command.set {
starting.env(name, value);
}
let mut child: Child = match starting.spawn() {
Ok(running) => running,
Err(error) => return ToolRun { end: ToolEnd::Failed(format!("{} could not start: {error}", program.display())), millis: started.elapsed().as_millis(), stdout: Vec::new() },
};
let out_reader: Option<std::thread::JoinHandle<Vec<u8>>> = child.stdout.take().map(collect);
let error_reader: Option<std::thread::JoinHandle<Vec<u8>>> = child.stderr.take().map(collect);
let status: Option<std::process::ExitStatus> = loop {
match child.try_wait() {
Ok(Some(done)) => break Some(done),
Ok(None) if started.elapsed() >= limit => break None,
Ok(None) => std::thread::sleep(POLL_INTERVAL),
Err(error) => return ToolRun { end: ToolEnd::Failed(format!("{} could not be waited for: {error}", program.display())), millis: started.elapsed().as_millis(), stdout: Vec::new() },
}
};
let Some(finished) = status else {
stop_group(&child);
// The group is gone, so the pipes close and the readers end; the reaped child leaves no zombie.
let _reaped = child.wait();
for reader in [out_reader, error_reader].into_iter().flatten() {
let _output = reader.join();
}
return ToolRun { end: ToolEnd::TimedOut, millis: started.elapsed().as_millis(), stdout: Vec::new() };
};
let stdout: Vec<u8> = out_reader.map(|reader| { return reader.join().unwrap_or_default(); }).unwrap_or_default();
let errors: Vec<u8> = error_reader.map(|reader| { return reader.join().unwrap_or_default(); }).unwrap_or_default();
let millis: u128 = started.elapsed().as_millis();
if finished.success() {
return ToolRun { end: ToolEnd::Succeeded, millis, stdout };
}
let combined: Vec<u8> = [stdout.as_slice(), errors.as_slice()].concat();
let text: String = tail_of(combined.as_slice());
return ToolRun { end: ToolEnd::Failed(format!("{}: {text}", describe_status(finished))), millis, stdout: Vec::new() };
}
/// What:
/// Stop the child's whole process group.
/// Why:
/// pnpm and Cargo start helpers; stopping only the leader would leave them running against the worktree.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// process.kill(-child.pid, 'SIGKILL');
/// ```
fn stop_group(child: &Child) {
let Ok(leader) = libc::pid_t::try_from(child.id()) else { return };
// SAFETY: `kill` takes a process-group id and a signal number and touches no memory; the group was made by this process.
let _stopped = unsafe { libc::kill(-leader, libc::SIGKILL) };
}
/// Command controls stay out of the release executable.
#[cfg(test)]
#[path = "package_repair_command_tests.rs"]
mod tests;