choir_queue/local.rs
1//! The local subprocess executor: the second implementation of the D18
2//! seam, and the one that ships with it.
3//!
4//! It runs each job as an ordinary child process on this machine. No
5//! virtual machine, no shared cache, no isolation beyond what the OS
6//! gives a subprocess — which makes it unsuitable for untrusted code
7//! and exactly right as the backend that keeps the seam honest. A
8//! conformance suite with one implementation has never been tested
9//! against disagreement.
10//!
11//! # Examples
12//!
13//! ```
14//! use choir_queue::executor::{CiExecutor, Job, Verdict};
15//! use choir_queue::local::LocalRunner;
16//! use choir_hash::ContentHash;
17//!
18//! let mut ci = LocalRunner::new();
19//! let job = Job::new(ContentHash::blake3(b"tree"), vec!["true".into()]);
20//! assert_eq!(ci.run(&[job]).unwrap(), vec![Verdict::Passed]);
21//! ```
22
23use crate::executor::{CiExecutor, ExecutorError, ExecutorInfo, Job, Verdict, PROTOCOL};
24use std::process::{Command, Stdio};
25use std::time::{Duration, Instant};
26
27/// How often a running child is checked against its deadline.
28const POLL: Duration = Duration::from_millis(10);
29
30/// Runs jobs as child processes of this one.
31#[derive(Debug, Default)]
32pub struct LocalRunner {
33 /// Most jobs to have in flight at once. `None` means one thread per
34 /// job.
35 parallelism: Option<usize>,
36}
37
38impl LocalRunner {
39 /// A runner that gives every job its own thread.
40 #[must_use]
41 pub fn new() -> Self {
42 Self { parallelism: None }
43 }
44
45 /// A runner that keeps at most `n` jobs in flight.
46 ///
47 /// # Panics
48 ///
49 /// If `n` is zero, which would accept jobs and never run them.
50 #[must_use]
51 pub fn with_parallelism(n: usize) -> Self {
52 assert!(n > 0, "a runner with no parallelism would never run a job");
53 Self {
54 parallelism: Some(n),
55 }
56 }
57}
58
59/// Runs one job to completion, or to its deadline.
60///
61/// Every failure to *start* is [`Verdict::Errored`] and every completed
62/// run is `Passed`/`Failed`. That split is the seam's central claim, so
63/// it is made in one place rather than at each call site.
64fn run_one(job: &Job) -> Verdict {
65 let Some((program, args)) = job.command.split_first() else {
66 return Verdict::Errored {
67 provider: "local".to_string(),
68 detail: "job has no command".to_string(),
69 };
70 };
71 let mut command = Command::new(program);
72 command
73 .args(args)
74 .env_clear()
75 .envs(&job.environment)
76 .stdin(Stdio::null())
77 .stdout(Stdio::null())
78 .stderr(Stdio::null());
79 if let Some(dir) = &job.directory {
80 // A directory that does not exist makes the spawn fail, which
81 // lands in the `Err` arm below as `Errored` — correct, and the
82 // reason this is not checked separately here. It is our
83 // misconfiguration, not the change's fault.
84 command.current_dir(dir);
85 }
86 let spawned = command.spawn();
87 let mut child = match spawned {
88 Ok(c) => c,
89 // A command that does not exist is our problem, not the
90 // change's: it means the job was configured wrong or the
91 // executor's image is missing a tool.
92 Err(e) => {
93 return Verdict::Errored {
94 provider: "local".to_string(),
95 detail: format!("could not start `{program}`: {e}"),
96 }
97 }
98 };
99
100 let started = Instant::now();
101 loop {
102 match child.try_wait() {
103 Ok(Some(status)) => {
104 return if status.success() {
105 Verdict::Passed
106 } else {
107 Verdict::Failed {
108 exit_code: status.code(),
109 }
110 }
111 }
112 Ok(None) => {
113 if started.elapsed() >= job.deadline {
114 // Kill *and reap*. A timed-out job that leaves a
115 // zombie holding the tree would make the next run
116 // fail for a reason that has nothing to do with it.
117 let _ = child.kill();
118 let _ = child.wait();
119 return Verdict::TimedOut;
120 }
121 std::thread::sleep(POLL);
122 }
123 Err(e) => {
124 let _ = child.kill();
125 let _ = child.wait();
126 return Verdict::Errored {
127 provider: "local".to_string(),
128 detail: format!("could not wait on `{program}`: {e}"),
129 };
130 }
131 }
132 }
133}
134
135impl CiExecutor for LocalRunner {
136 fn info(&mut self) -> Result<ExecutorInfo, ExecutorError> {
137 Ok(ExecutorInfo {
138 name: "local".to_string(),
139 protocol: PROTOCOL,
140 })
141 }
142
143 fn run(&mut self, jobs: &[Job]) -> Result<Vec<Verdict>, ExecutorError> {
144 let width = self.parallelism.unwrap_or(jobs.len()).max(1);
145 let mut out = Vec::with_capacity(jobs.len());
146 for chunk in jobs.chunks(width) {
147 // Scoped threads so the jobs may borrow, and so a panicking
148 // job cannot outlive this call.
149 let results: Vec<Verdict> = std::thread::scope(|s| {
150 // The `collect` is load-bearing and clippy wants it gone.
151 // Without it the iterator is lazy: each `join` runs
152 // immediately after its own `spawn`, and the batch
153 // executes one job at a time. That is the exact defect
154 // this seam was redesigned to make impossible, it is
155 // invisible in every ordering assertion, and
156 // `the_runner_runs_a_batch_concurrently` is the test that
157 // fails if this line is "simplified".
158 #[allow(clippy::needless_collect)]
159 let handles = chunk
160 .iter()
161 .map(|j| s.spawn(|| run_one(j)))
162 .collect::<Vec<_>>();
163 handles
164 .into_iter()
165 .map(|h| {
166 h.join().unwrap_or(Verdict::Errored {
167 provider: "local".to_string(),
168 detail: "the job thread panicked".to_string(),
169 })
170 })
171 .collect()
172 });
173 out.extend(results);
174 }
175 Ok(out)
176 }
177}