Skip to main content

aria_primitives/process/
sidecar.rs

1//! A supervised child process that answers one JSON line per request.
2//!
3//! The program is handed to its interpreter on the command line rather than
4//! read from disk, so the source cannot drift from the code that talks to it.
5//! Replies arrive on their own thread, which is what lets a caller give up:
6//! killing the child ends the read, so cancelling never waits for the work.
7//!
8//! The frames carry no meaning here. What a reply says, and which failures are
9//! recoverable, belongs to whoever speaks the protocol on top.
10
11use std::io::{self, BufRead, BufReader, Read, Write};
12use std::path::{Path, PathBuf};
13use std::process::{ChildStderr, ChildStdin, ChildStdout, Command, Stdio};
14use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
15use std::thread;
16
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19
20use super::POLL_INTERVAL;
21use super::child::ChildProcess;
22use crate::file_inspection::{InspectedFile, InspectionError};
23use crate::user_home::{self, UserHomeError};
24
25// A reply past this is a broken child, not a long answer, and reading on
26// would be unbounded memory.
27const MAX_REPLY_BYTES: u64 = 64 * 1024;
28
29/// What to run, and the name its log lines and threads carry.
30pub struct Script<'a> {
31    pub name: &'static str,
32    pub interpreter: &'a Path,
33    pub source: &'a str,
34    pub argument: &'a str,
35    /// Variables copied from the parent. The child also receives HOME and
36    /// PYTHONSAFEPATH; the remaining environment is cleared.
37    pub inherited_variables: &'a [&'a str],
38}
39
40pub struct Sidecar {
41    name: &'static str,
42    process: ChildProcess,
43    requests: ChildStdin,
44    replies: Receiver<io::Result<String>>,
45    /// A stopped sidecar is never reused, even when stopping it failed.
46    stopped: bool,
47}
48
49impl Sidecar {
50    pub fn start(script: &Script<'_>) -> Result<Self, SidecarError> {
51        let mut command = Command::new(script.interpreter);
52        command
53            .arg("-c")
54            .arg(script.source)
55            .arg(script.argument)
56            .stdin(Stdio::piped())
57            .stdout(Stdio::piped())
58            .stderr(Stdio::piped());
59        restrict_environment(&mut command, script.inherited_variables)?;
60
61        let mut process = ChildProcess::spawn(&mut command).map_err(SidecarError::Spawn)?;
62        let (Some(requests), Some(stdout), Some(stderr)) = (
63            process.take_stdin(),
64            process.take_stdout(),
65            process.take_stderr(),
66        ) else {
67            return Err(SidecarError::Pipes);
68        };
69        log_stderr(script.name, stderr);
70
71        Ok(Self {
72            name: script.name,
73            process,
74            requests,
75            replies: read_replies(script.name, stdout),
76            stopped: false,
77        })
78    }
79
80    pub fn send(&mut self, request: &impl Serialize) -> Result<(), SidecarError> {
81        let line = serde_json::to_string(request).map_err(SidecarError::Encode)?;
82        writeln!(self.requests, "{line}")
83            .and_then(|()| self.requests.flush())
84            .map_err(SidecarError::Write)
85    }
86
87    /// Reads one reply, killing the child if the caller gives up first.
88    pub fn receive<R: DeserializeOwned>(
89        &mut self,
90        should_cancel: &(impl Fn() -> bool + Sync),
91    ) -> Result<R, SidecarError> {
92        let line = self.read_line(should_cancel)?;
93        serde_json::from_str(line.trim()).map_err(SidecarError::Decode)
94    }
95
96    /// Whether the child is still there to serve another request.
97    pub fn is_running(&mut self) -> bool {
98        !self.stopped && matches!(self.process.try_wait(), Ok(None))
99    }
100
101    fn read_line(
102        &mut self,
103        should_cancel: &(impl Fn() -> bool + Sync),
104    ) -> Result<String, SidecarError> {
105        loop {
106            match self.replies.recv_timeout(POLL_INTERVAL) {
107                Ok(reply) => return reply.map_err(SidecarError::Read),
108                Err(RecvTimeoutError::Disconnected) => return Err(SidecarError::Closed),
109                Err(RecvTimeoutError::Timeout) => {}
110            }
111            if should_cancel() {
112                self.stop();
113                return Err(SidecarError::Cancelled);
114            }
115        }
116    }
117
118    fn stop(&mut self) {
119        self.stopped = true;
120        if let Err(error) = self.process.terminate_in_place() {
121            tracing::error!(
122                error = %crate::error_chain(&error),
123                sidecar = self.name,
124                "failed to stop the sidecar"
125            );
126        }
127    }
128}
129
130#[derive(Debug, thiserror::Error)]
131pub enum SidecarError {
132    #[error("the sidecar interpreter cannot be used")]
133    Interpreter(#[source] InspectionError),
134
135    #[error("a sidecar needs HOME to start")]
136    Home(#[source] UserHomeError),
137
138    #[error("failed to start the sidecar process")]
139    Spawn(#[source] io::Error),
140
141    #[error("the sidecar was started without all three of its pipes")]
142    Pipes,
143
144    #[error("failed to encode a request")]
145    Encode(#[source] serde_json::Error),
146
147    #[error("failed to send a request")]
148    Write(#[source] io::Error),
149
150    #[error("failed to read a reply")]
151    Read(#[source] io::Error),
152
153    #[error("the sidecar sent a reply that could not be understood")]
154    Decode(#[source] serde_json::Error),
155
156    #[error("the sidecar stopped before replying")]
157    Closed,
158
159    #[error("the caller gave up waiting")]
160    Cancelled,
161}
162
163/// Checks the interpreter the path resolves to, but keeps the configured path
164/// to run: a virtual environment is only itself when entered through its own
165/// `bin/python3`, and the canonical target is the bare interpreter.
166pub fn validate_interpreter(path: PathBuf) -> Result<PathBuf, SidecarError> {
167    InspectedFile::inspect(path.clone())
168        .and_then(InspectedFile::require_executable)
169        .and_then(InspectedFile::require_trusted)
170        .map_err(SidecarError::Interpreter)?;
171    Ok(path)
172}
173
174/// The child inherits nothing but `HOME` and the variables its caller named.
175fn restrict_environment(
176    command: &mut Command,
177    inherited_variables: &[&str],
178) -> Result<(), SidecarError> {
179    let home = user_home::resolve().map_err(SidecarError::Home)?;
180    // `python -c` puts the working directory on sys.path, so a file there
181    // named like an imported package would shadow it.
182    command
183        .env_clear()
184        .env("HOME", home)
185        .env("PYTHONSAFEPATH", "1");
186    for name in inherited_variables {
187        if let Some(value) = std::env::var_os(name) {
188            command.env(name, value);
189        }
190    }
191    Ok(())
192}
193
194/// Reads replies on their own thread so a blocked read never pins the caller.
195/// The thread ends when the child closes its output, which killing it does.
196fn read_replies(name: &'static str, stdout: ChildStdout) -> Receiver<io::Result<String>> {
197    let (sender, receiver) = mpsc::channel();
198    spawn_helper(format!("{name}-replies"), move || {
199        let mut reader = BufReader::new(stdout);
200        while let Some(reply) = next_reply(&mut reader) {
201            let failed = reply.is_err();
202            if sender.send(reply).is_err() || failed {
203                return;
204            }
205        }
206    });
207    receiver
208}
209
210/// One reply, or `None` once the child closes its output. Blank lines carry no
211/// frame, so they are skipped rather than delivered.
212fn next_reply(reader: &mut BufReader<ChildStdout>) -> Option<io::Result<String>> {
213    loop {
214        let mut line = String::new();
215        match reader.by_ref().take(MAX_REPLY_BYTES).read_line(&mut line) {
216            Ok(0) => return None,
217            Ok(size) if size as u64 == MAX_REPLY_BYTES => {
218                return Some(Err(io::Error::other(
219                    "sidecar reply exceeded the maximum frame size",
220                )));
221            }
222            Ok(_size) if line.trim().is_empty() => {}
223            Ok(_size) => return Some(Ok(line)),
224            Err(error) => return Some(Err(error)),
225        }
226    }
227}
228
229/// The child's own output is diagnostics, not protocol, so it joins the daemon log.
230fn log_stderr(name: &'static str, stderr: ChildStderr) {
231    spawn_helper(format!("{name}-stderr"), move || {
232        for line in BufReader::new(stderr).lines().map_while(Result::ok) {
233            if !line.trim().is_empty() {
234                tracing::warn!(sidecar = name, "{line}");
235            }
236        }
237    });
238}
239
240/// Both helper threads are best-effort. One that cannot start is logged, and
241/// the caller meets the missing output as a sidecar that stopped replying.
242fn spawn_helper(name: String, body: impl FnOnce() + Send + 'static) {
243    thread::Builder::new()
244        .name(name.clone())
245        .spawn(body)
246        .map(|_detached| ())
247        .unwrap_or_else(|error| {
248            tracing::error!(%error, thread = name, "failed to start a sidecar thread");
249        });
250}
251
252#[cfg(test)]
253mod tests {
254    use std::time::{Duration, Instant};
255
256    use super::*;
257
258    #[derive(Serialize)]
259    struct Ping {
260        op: &'static str,
261    }
262
263    #[derive(serde::Deserialize)]
264    struct Pong {
265        text: String,
266    }
267
268    // /bin/sh takes its script from -c the same way a Python child does, so
269    // the transport can be exercised without Python or a model.
270    fn stub(source: &str) -> Sidecar {
271        Sidecar::start(&Script {
272            name: "stub",
273            interpreter: Path::new("/bin/sh"),
274            source,
275            argument: "unused",
276            inherited_variables: &[],
277        })
278        .expect("the stub should start")
279    }
280
281    fn ping(sidecar: &mut Sidecar) -> Result<Pong, SidecarError> {
282        sidecar.send(&Ping { op: "ping" })?;
283        sidecar.receive(&|| false)
284    }
285
286    // The child exits on its own schedule, so poll instead of checking once.
287    fn stops_running(sidecar: &mut Sidecar) -> bool {
288        let deadline = Instant::now() + Duration::from_secs(5);
289        while Instant::now() < deadline {
290            if !sidecar.is_running() {
291                return true;
292            }
293            thread::sleep(POLL_INTERVAL);
294        }
295        false
296    }
297
298    #[test]
299    fn a_reply_is_read_back_from_the_child() {
300        let mut sidecar = stub(r#"read line; echo '{"text":"hello there"}'"#);
301
302        let pong = ping(&mut sidecar).expect("the stub should answer");
303
304        assert_eq!(pong.text, "hello there");
305    }
306
307    #[test]
308    fn the_child_is_told_to_keep_cwd_off_the_python_path() {
309        let mut sidecar = stub(r#"read line; echo "{\"text\":\"$PYTHONSAFEPATH\"}""#);
310
311        let pong = ping(&mut sidecar).expect("the stub should answer");
312
313        assert_eq!(pong.text, "1");
314    }
315
316    #[test]
317    fn blank_lines_are_not_replies() {
318        let mut sidecar = stub(r#"read line; echo; echo; echo '{"text":"after the blanks"}'"#);
319
320        let pong = ping(&mut sidecar).expect("the stub should answer past the blank lines");
321
322        assert_eq!(pong.text, "after the blanks");
323    }
324
325    #[test]
326    fn a_child_that_keeps_serving_is_still_running() {
327        let mut sidecar = stub(r#"while read line; do echo '{"text":"again"}'; done"#);
328
329        ping(&mut sidecar).expect("the stub should answer");
330
331        assert!(sidecar.is_running());
332    }
333
334    #[test]
335    fn a_cancelled_wait_stops_the_child_instead_of_blocking() {
336        let mut sidecar = stub("sleep 600");
337
338        sidecar.send(&Ping { op: "ping" }).expect("the send lands");
339        let outcome = sidecar.receive::<Pong>(&|| true);
340
341        assert!(matches!(outcome, Err(SidecarError::Cancelled)));
342        assert!(
343            !sidecar.is_running(),
344            "a cancelled sidecar was killed and must not be reused"
345        );
346    }
347
348    #[test]
349    fn a_child_that_exits_is_reported_rather_than_waited_on() {
350        let mut sidecar = stub("exit 0");
351
352        let outcome = ping(&mut sidecar);
353
354        assert!(
355            matches!(
356                outcome,
357                Err(SidecarError::Closed) | Err(SidecarError::Write(_))
358            ),
359            "a dead sidecar must surface as an error"
360        );
361        assert!(
362            stops_running(&mut sidecar),
363            "a dead sidecar must stop reporting itself as running"
364        );
365        assert!(
366            !sidecar.is_running(),
367            "a dead sidecar must stay dead on a second look"
368        );
369    }
370
371    #[test]
372    fn a_reply_that_is_not_json_is_a_loud_error() {
373        let mut sidecar = stub("read line; echo 'not json'");
374
375        let outcome = ping(&mut sidecar);
376
377        assert!(matches!(outcome, Err(SidecarError::Decode(_))));
378    }
379
380    #[test]
381    fn an_oversized_reply_is_refused_rather_than_buffered() {
382        let mut sidecar = stub(&format!(
383            "read line; head -c {} /dev/zero | tr '\\0' 'a'; echo",
384            MAX_REPLY_BYTES + 1
385        ));
386
387        let outcome = ping(&mut sidecar);
388
389        assert!(matches!(outcome, Err(SidecarError::Read(_))));
390    }
391
392    #[test]
393    fn a_relative_interpreter_is_rejected() {
394        let error = validate_interpreter(PathBuf::from("relative/python3"))
395            .expect_err("a relative interpreter must be rejected");
396
397        assert!(matches!(
398            error,
399            SidecarError::Interpreter(InspectionError::NotAbsolute { .. })
400        ));
401    }
402
403    #[test]
404    fn a_missing_interpreter_is_rejected() {
405        let error = validate_interpreter(PathBuf::from("/nonexistent/python3"))
406            .expect_err("a missing interpreter must be rejected");
407
408        assert!(matches!(
409            error,
410            SidecarError::Interpreter(InspectionError::Inspect { .. })
411        ));
412    }
413
414    #[test]
415    fn a_file_that_cannot_be_run_is_not_an_interpreter() {
416        let error = validate_interpreter(PathBuf::from("/etc/hosts"))
417            .expect_err("a data file must be rejected");
418
419        assert!(matches!(
420            error,
421            SidecarError::Interpreter(InspectionError::NotExecutable { .. })
422        ));
423    }
424}