Skip to main content

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}