Skip to main content

par_term_scripting/
process.rs

1//! Single script subprocess management.
2//!
3//! [`ScriptProcess`] manages the lifecycle of a single script subprocess, providing
4//! piped stdin/stdout/stderr communication. Stdout lines are parsed as JSON
5//! [`ScriptCommand`] objects, and stderr lines are collected for error reporting.
6
7use std::collections::HashMap;
8use std::io::{BufRead, BufReader, Write};
9use std::process::{Child, Command, Stdio};
10use std::sync::{Arc, Mutex};
11use std::thread::JoinHandle;
12
13use super::protocol::{ScriptCommand, ScriptEvent};
14
15/// Liveness of a script process, polled each frame.
16///
17/// `Exited` is sticky: once the OS reports an exit, the outcome is remembered so
18/// the supervisor observes exactly one running→exited transition rather than a
19/// stream of identical exits every frame. [`ScriptProcess::poll_status`] reaps
20/// the child on the first `Exited` and records the outcome here.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ScriptStatus {
23    /// Still alive.
24    Running,
25    /// Exited. `success` mirrors [`std::process::ExitStatus::success`].
26    Exited { success: bool },
27}
28
29/// Manages a single script subprocess with JSON-line communication.
30///
31/// The subprocess receives [`ScriptEvent`] objects serialized as JSON lines on stdin,
32/// and emits [`ScriptCommand`] objects as JSON lines on stdout. Stderr is captured
33/// separately for error reporting.
34pub struct ScriptProcess {
35    /// The child process handle, if still alive.
36    child: Option<Child>,
37    /// Writer to the child's stdin, if still open.
38    stdin_writer: Option<std::process::ChildStdin>,
39    /// Buffer of parsed commands read from the child's stdout.
40    command_buffer: Arc<Mutex<Vec<ScriptCommand>>>,
41    /// Buffer of error lines read from the child's stderr.
42    error_buffer: Arc<Mutex<Vec<String>>>,
43    /// Handle to the background thread reading stdout.
44    _stdout_thread: Option<JoinHandle<()>>,
45    /// Handle to the background thread reading stderr.
46    _stderr_thread: Option<JoinHandle<()>>,
47    /// Sticky exit outcome. `None` while running or before the first poll.
48    exit: Option<ScriptStatus>,
49}
50
51impl ScriptProcess {
52    /// Spawn a script subprocess with piped stdin/stdout/stderr.
53    ///
54    /// Starts background threads to read stdout (parsing JSON into [`ScriptCommand`])
55    /// and stderr (collecting error lines).
56    ///
57    /// # Arguments
58    /// * `command` - The command to execute (e.g., "python3").
59    /// * `args` - Arguments to pass to the command.
60    /// * `env_vars` - Additional environment variables to set for the subprocess.
61    ///
62    /// # Errors
63    /// Returns an error string if the subprocess cannot be spawned.
64    pub fn spawn(
65        command: &str,
66        args: &[&str],
67        env_vars: &HashMap<String, String>,
68    ) -> Result<Self, String> {
69        let mut cmd = Command::new(command);
70        cmd.args(args)
71            .stdin(Stdio::piped())
72            .stdout(Stdio::piped())
73            .stderr(Stdio::piped())
74            .envs(env_vars);
75
76        let mut child = cmd
77            .spawn()
78            .map_err(|e| format!("Failed to spawn '{}': {}", command, e))?;
79
80        let stdin_writer = child.stdin.take();
81        let stdout = child
82            .stdout
83            .take()
84            .ok_or_else(|| "Failed to capture stdout".to_string())?;
85        let stderr = child
86            .stderr
87            .take()
88            .ok_or_else(|| "Failed to capture stderr".to_string())?;
89
90        let command_buffer: Arc<Mutex<Vec<ScriptCommand>>> = Arc::new(Mutex::new(Vec::new()));
91        let error_buffer: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
92
93        // Stdout reader thread: parse JSON lines into ScriptCommand
94        let cmd_buf = Arc::clone(&command_buffer);
95        let stdout_thread = std::thread::spawn(move || {
96            let reader = BufReader::new(stdout);
97            for line in reader.lines() {
98                match line {
99                    Ok(text) => {
100                        if text.is_empty() {
101                            continue;
102                        }
103                        match serde_json::from_str::<ScriptCommand>(&text) {
104                            Ok(cmd) => {
105                                let mut buf = cmd_buf.lock().unwrap_or_else(|e| {
106                                    log::warn!("command_buffer mutex poisoned, recovering");
107                                    e.into_inner()
108                                });
109                                buf.push(cmd);
110                            }
111                            Err(e) => {
112                                log::warn!(
113                                    "ScriptProcess: failed to parse stdout line as ScriptCommand: {}: {:?}",
114                                    e,
115                                    text
116                                );
117                            }
118                        }
119                    }
120                    Err(e) => {
121                        log::warn!("ScriptProcess: error reading stdout: {}", e);
122                        break;
123                    }
124                }
125            }
126        });
127
128        // Stderr reader thread: collect error lines
129        let err_buf = Arc::clone(&error_buffer);
130        let stderr_thread = std::thread::spawn(move || {
131            let reader = BufReader::new(stderr);
132            for line in reader.lines() {
133                match line {
134                    Ok(text) => {
135                        if text.is_empty() {
136                            continue;
137                        }
138                        let mut buf = err_buf.lock().unwrap_or_else(|e| {
139                            log::warn!("error_buffer mutex poisoned, recovering");
140                            e.into_inner()
141                        });
142                        buf.push(text);
143                    }
144                    Err(e) => {
145                        log::warn!("ScriptProcess: error reading stderr: {}", e);
146                        break;
147                    }
148                }
149            }
150        });
151
152        Ok(Self {
153            child: Some(child),
154            stdin_writer,
155            command_buffer,
156            error_buffer,
157            _stdout_thread: Some(stdout_thread),
158            _stderr_thread: Some(stderr_thread),
159            exit: None,
160        })
161    }
162
163    /// Poll the process liveness, classifying an exit as clean or failed.
164    ///
165    /// The first observed exit is sticky: [`ScriptStatus::Exited`] is recorded
166    /// and the child handle is dropped, so later calls are cheap and the
167    /// supervisor sees the transition exactly once. [`Self::is_running`] is
168    /// defined in terms of this.
169    pub fn poll_status(&mut self) -> ScriptStatus {
170        if let Some(status) = self.exit {
171            return status;
172        }
173        // Compute the outcome in a scope where `self.child` is borrowed, then
174        // release that borrow before mutating `self` below.
175        let polled = match self.child.as_mut() {
176            Some(child) => match child.try_wait() {
177                Ok(Some(s)) => Some(ScriptStatus::Exited {
178                    success: s.success(),
179                }),
180                Ok(None) => None,
181                Err(_) => Some(ScriptStatus::Exited { success: false }),
182            },
183            // No handle and no recorded exit: treat as a clean exit so a stray
184            // poll on an already-cleared slot does not loop.
185            None => Some(ScriptStatus::Exited { success: true }),
186        };
187        match polled {
188            Some(status) => {
189                // Drop stdin (signals EOF to the reader threads) and release the
190                // reaped child handle. The exit is now sticky in `self.exit`.
191                self.stdin_writer.take();
192                self.child.take();
193                self.exit = Some(status);
194                status
195            }
196            None => ScriptStatus::Running,
197        }
198    }
199
200    /// Check if the child process is still alive.
201    ///
202    /// Returns `false` once the process has exited (cleanly or otherwise).
203    pub fn is_running(&mut self) -> bool {
204        matches!(self.poll_status(), ScriptStatus::Running)
205    }
206
207    /// Serialize a [`ScriptEvent`] to JSON and write it to the child's stdin as a line.
208    ///
209    /// # Errors
210    /// Returns an error if the stdin writer is not available or if the write fails.
211    pub fn send_event(&mut self, event: &ScriptEvent) -> Result<(), String> {
212        let stdin = self
213            .stdin_writer
214            .as_mut()
215            .ok_or_else(|| "stdin writer is not available".to_string())?;
216
217        let json = serde_json::to_string(event)
218            .map_err(|e| format!("Failed to serialize event: {}", e))?;
219
220        writeln!(stdin, "{}", json).map_err(|e| format!("Failed to write to stdin: {}", e))?;
221
222        stdin
223            .flush()
224            .map_err(|e| format!("Failed to flush stdin: {}", e))?;
225
226        Ok(())
227    }
228
229    /// Drain pending commands from the command buffer.
230    ///
231    /// Returns all commands that have been parsed from the child's stdout since the
232    /// last call to this method.
233    pub fn read_commands(&self) -> Vec<ScriptCommand> {
234        let mut buf = self.command_buffer.lock().unwrap_or_else(|e| {
235            log::warn!("command_buffer mutex poisoned, recovering");
236            e.into_inner()
237        });
238        buf.drain(..).collect()
239    }
240
241    /// Drain pending error lines from the error buffer.
242    ///
243    /// Returns all lines that have been read from the child's stderr since the
244    /// last call to this method.
245    pub fn read_errors(&self) -> Vec<String> {
246        let mut buf = self.error_buffer.lock().unwrap_or_else(|e| {
247            log::warn!("error_buffer mutex poisoned, recovering");
248            e.into_inner()
249        });
250        buf.drain(..).collect()
251    }
252
253    /// Stop the subprocess.
254    ///
255    /// Drops the stdin writer (sending EOF to the child), kills the child process
256    /// if it's still running, and waits for it to exit.
257    pub fn stop(&mut self) {
258        // Drop stdin to send EOF
259        self.stdin_writer.take();
260
261        if let Some(ref mut child) = self.child {
262            // Try to kill the child process
263            let _ = child.kill();
264            // Wait for the child to exit
265            let _ = child.wait();
266        }
267        self.child.take();
268    }
269}
270
271impl Drop for ScriptProcess {
272    fn drop(&mut self) {
273        self.stop();
274    }
275}
276
277#[cfg(all(test, unix))]
278mod tests {
279    use super::*;
280    use std::collections::HashMap;
281    use std::time::Duration;
282
283    fn no_env() -> HashMap<String, String> {
284        HashMap::new()
285    }
286
287    #[test]
288    fn poll_status_running_for_live_process() {
289        let mut p = ScriptProcess::spawn("sleep", &["2"], &no_env()).expect("spawn sleep");
290        assert_eq!(p.poll_status(), ScriptStatus::Running);
291        p.stop();
292    }
293
294    #[test]
295    fn poll_status_exited_clean_for_exit_zero() {
296        let mut p = ScriptProcess::spawn("true", &[], &no_env()).expect("spawn true");
297        std::thread::sleep(Duration::from_millis(50));
298        assert_eq!(p.poll_status(), ScriptStatus::Exited { success: true });
299        // sticky: a second poll reports the same outcome without re-querying the OS
300        assert_eq!(p.poll_status(), ScriptStatus::Exited { success: true });
301    }
302
303    #[test]
304    fn poll_status_exited_failure_for_nonzero_exit() {
305        let mut p = ScriptProcess::spawn("false", &[], &no_env()).expect("spawn false");
306        std::thread::sleep(Duration::from_millis(50));
307        assert_eq!(p.poll_status(), ScriptStatus::Exited { success: false });
308    }
309
310    #[test]
311    fn is_running_false_after_exit() {
312        let mut p = ScriptProcess::spawn("true", &[], &no_env()).expect("spawn true");
313        std::thread::sleep(Duration::from_millis(50));
314        assert!(!p.is_running());
315    }
316}