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}