Skip to main content

devscout_rs/graph/compiler_facts/
run.rs

1// The local-acquisition entry point: spawns the engine located by
2// `SCOUT_COMPILER_ENGINE`, captures its stdout under a byte cap and a
3// wall-clock timeout, and hands the captured bytes to `admit`. No code path
4// here writes to the artifact path directly -- only the publish step does,
5// and only once `admit` returns `Ok`.
6//
7// Process-spawn safety: the command line is built entirely from an
8// already-validated local path plus caller-supplied flags, never passed
9// through a shell (`std::process::Command` invoked directly), matching the
10// crate's one other subprocess site, `manifest::git_head`'s own `git`
11// shell-out, which avoids a shell too. Bounded output capture keeps an
12// unbounded or adversarial engine (or a corrupted build) from exhausting
13// memory -- this is itself one of the refusal classes below, not merely a
14// hardening afterthought.
15
16use std::io::Read;
17use std::process::{Child, Command, Stdio};
18use std::sync::mpsc;
19use std::time::{Duration, Instant};
20
21use super::reasons::RefusalReason;
22
23/// The default wall-clock budget a local engine run is given before it is
24/// killed and refused as `engine-timeout`. Generous because a Roslyn/MSBuild
25/// load is the dominant cost this crate has never measured before; tighten
26/// once real cold/warm figures exist.
27pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
28
29/// The default stdout byte cap a local engine run is held to before it is
30/// killed and refused as `output-bounded`. 256 MiB comfortably exceeds any
31/// fixture this crate ships while still bounding an adversarial or
32/// corrupted engine's memory cost.
33pub const DEFAULT_OUTPUT_CAP: usize = 256 * 1024 * 1024;
34
35enum ReaderMsg {
36    Done(Vec<u8>),
37    CapExceeded,
38    ReadError,
39}
40
41fn spawn_reader(mut stdout: impl Read + Send + 'static, cap: usize) -> mpsc::Receiver<ReaderMsg> {
42    let (tx, rx) = mpsc::channel();
43    std::thread::spawn(move || {
44        let mut buf = Vec::new();
45        let mut chunk = [0u8; 64 * 1024];
46        loop {
47            match stdout.read(&mut chunk) {
48                Ok(0) => {
49                    let _ = tx.send(ReaderMsg::Done(buf));
50                    return;
51                }
52                Ok(n) => {
53                    buf.extend_from_slice(&chunk[..n]);
54                    if buf.len() > cap {
55                        let _ = tx.send(ReaderMsg::CapExceeded);
56                        return;
57                    }
58                }
59                Err(_) => {
60                    let _ = tx.send(ReaderMsg::ReadError);
61                    return;
62                }
63            }
64        }
65    });
66    rx
67}
68
69fn kill_and_wait(child: &mut Child) {
70    let _ = child.kill();
71    let _ = child.wait();
72}
73
74/// Runs `command` to completion, capturing stdout under `output_cap` bytes
75/// and `timeout` wall-clock time. `command`'s stdin is closed and stderr is
76/// discarded -- only stdout carries the candidate artifact bytes.
77///
78/// Returns the captured bytes on a clean, in-budget exit; otherwise one of
79/// the three broken-run reasons this function alone can produce
80/// (`engine-killed`, `engine-timeout`, `output-bounded`). Never touches the
81/// filesystem.
82pub fn run_engine(
83    mut command: Command,
84    timeout: Duration,
85    output_cap: usize,
86) -> Result<Vec<u8>, RefusalReason> {
87    command
88        .stdin(Stdio::null())
89        .stdout(Stdio::piped())
90        .stderr(Stdio::null());
91    let mut child = command.spawn().map_err(|_| RefusalReason::EngineKilled)?;
92    let stdout = child.stdout.take().expect("stdout was piped");
93    let rx = spawn_reader(stdout, output_cap);
94    let deadline = Instant::now() + timeout;
95
96    loop {
97        match rx.recv_timeout(Duration::from_millis(25)) {
98            Ok(ReaderMsg::Done(bytes)) => {
99                let status = match child.wait() {
100                    Ok(s) => s,
101                    Err(_) => return Err(RefusalReason::EngineKilled),
102                };
103                return if status.success() {
104                    Ok(bytes)
105                } else {
106                    Err(RefusalReason::EngineKilled)
107                };
108            }
109            Ok(ReaderMsg::CapExceeded) => {
110                kill_and_wait(&mut child);
111                return Err(RefusalReason::OutputBounded);
112            }
113            Ok(ReaderMsg::ReadError) => {
114                kill_and_wait(&mut child);
115                return Err(RefusalReason::EngineKilled);
116            }
117            Err(mpsc::RecvTimeoutError::Timeout) => {
118                if Instant::now() >= deadline {
119                    kill_and_wait(&mut child);
120                    return Err(RefusalReason::EngineTimeout);
121                }
122            }
123            Err(mpsc::RecvTimeoutError::Disconnected) => {
124                return Err(RefusalReason::EngineKilled);
125            }
126        }
127    }
128}