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/// Manages a single script subprocess with JSON-line communication.
16///
17/// The subprocess receives [`ScriptEvent`] objects serialized as JSON lines on stdin,
18/// and emits [`ScriptCommand`] objects as JSON lines on stdout. Stderr is captured
19/// separately for error reporting.
20pub struct ScriptProcess {
21    /// The child process handle, if still alive.
22    child: Option<Child>,
23    /// Writer to the child's stdin, if still open.
24    stdin_writer: Option<std::process::ChildStdin>,
25    /// Buffer of parsed commands read from the child's stdout.
26    command_buffer: Arc<Mutex<Vec<ScriptCommand>>>,
27    /// Buffer of error lines read from the child's stderr.
28    error_buffer: Arc<Mutex<Vec<String>>>,
29    /// Handle to the background thread reading stdout.
30    _stdout_thread: Option<JoinHandle<()>>,
31    /// Handle to the background thread reading stderr.
32    _stderr_thread: Option<JoinHandle<()>>,
33}
34
35impl ScriptProcess {
36    /// Spawn a script subprocess with piped stdin/stdout/stderr.
37    ///
38    /// Starts background threads to read stdout (parsing JSON into [`ScriptCommand`])
39    /// and stderr (collecting error lines).
40    ///
41    /// # Arguments
42    /// * `command` - The command to execute (e.g., "python3").
43    /// * `args` - Arguments to pass to the command.
44    /// * `env_vars` - Additional environment variables to set for the subprocess.
45    ///
46    /// # Errors
47    /// Returns an error string if the subprocess cannot be spawned.
48    pub fn spawn(
49        command: &str,
50        args: &[&str],
51        env_vars: &HashMap<String, String>,
52    ) -> Result<Self, String> {
53        let mut cmd = Command::new(command);
54        cmd.args(args)
55            .stdin(Stdio::piped())
56            .stdout(Stdio::piped())
57            .stderr(Stdio::piped())
58            .envs(env_vars);
59
60        let mut child = cmd
61            .spawn()
62            .map_err(|e| format!("Failed to spawn '{}': {}", command, e))?;
63
64        let stdin_writer = child.stdin.take();
65        let stdout = child
66            .stdout
67            .take()
68            .ok_or_else(|| "Failed to capture stdout".to_string())?;
69        let stderr = child
70            .stderr
71            .take()
72            .ok_or_else(|| "Failed to capture stderr".to_string())?;
73
74        let command_buffer: Arc<Mutex<Vec<ScriptCommand>>> = Arc::new(Mutex::new(Vec::new()));
75        let error_buffer: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
76
77        // Stdout reader thread: parse JSON lines into ScriptCommand
78        let cmd_buf = Arc::clone(&command_buffer);
79        let stdout_thread = std::thread::spawn(move || {
80            let reader = BufReader::new(stdout);
81            for line in reader.lines() {
82                match line {
83                    Ok(text) => {
84                        if text.is_empty() {
85                            continue;
86                        }
87                        match serde_json::from_str::<ScriptCommand>(&text) {
88                            Ok(cmd) => {
89                                let mut buf = cmd_buf.lock().expect("command_buffer lock poisoned");
90                                buf.push(cmd);
91                            }
92                            Err(e) => {
93                                eprintln!(
94                                    "ScriptProcess: failed to parse stdout line as ScriptCommand: {}: {:?}",
95                                    e, text
96                                );
97                            }
98                        }
99                    }
100                    Err(e) => {
101                        eprintln!("ScriptProcess: error reading stdout: {}", e);
102                        break;
103                    }
104                }
105            }
106        });
107
108        // Stderr reader thread: collect error lines
109        let err_buf = Arc::clone(&error_buffer);
110        let stderr_thread = std::thread::spawn(move || {
111            let reader = BufReader::new(stderr);
112            for line in reader.lines() {
113                match line {
114                    Ok(text) => {
115                        if text.is_empty() {
116                            continue;
117                        }
118                        let mut buf = err_buf.lock().expect("error_buffer lock poisoned");
119                        buf.push(text);
120                    }
121                    Err(e) => {
122                        eprintln!("ScriptProcess: error reading stderr: {}", e);
123                        break;
124                    }
125                }
126            }
127        });
128
129        Ok(Self {
130            child: Some(child),
131            stdin_writer,
132            command_buffer,
133            error_buffer,
134            _stdout_thread: Some(stdout_thread),
135            _stderr_thread: Some(stderr_thread),
136        })
137    }
138
139    /// Check if the child process is still alive.
140    ///
141    /// Uses `try_wait()` to check without blocking. Returns `false` if the process
142    /// has exited or if there is no child process.
143    pub fn is_running(&mut self) -> bool {
144        match self.child.as_mut() {
145            Some(child) => match child.try_wait() {
146                Ok(Some(_status)) => false, // Process has exited
147                Ok(None) => true,           // Process still running
148                Err(_) => false,            // Error checking status
149            },
150            None => false,
151        }
152    }
153
154    /// Serialize a [`ScriptEvent`] to JSON and write it to the child's stdin as a line.
155    ///
156    /// # Errors
157    /// Returns an error if the stdin writer is not available or if the write fails.
158    pub fn send_event(&mut self, event: &ScriptEvent) -> Result<(), String> {
159        let stdin = self
160            .stdin_writer
161            .as_mut()
162            .ok_or_else(|| "stdin writer is not available".to_string())?;
163
164        let json = serde_json::to_string(event)
165            .map_err(|e| format!("Failed to serialize event: {}", e))?;
166
167        writeln!(stdin, "{}", json).map_err(|e| format!("Failed to write to stdin: {}", e))?;
168
169        stdin
170            .flush()
171            .map_err(|e| format!("Failed to flush stdin: {}", e))?;
172
173        Ok(())
174    }
175
176    /// Drain pending commands from the command buffer.
177    ///
178    /// Returns all commands that have been parsed from the child's stdout since the
179    /// last call to this method.
180    pub fn read_commands(&self) -> Vec<ScriptCommand> {
181        let mut buf = self
182            .command_buffer
183            .lock()
184            .expect("command_buffer lock poisoned");
185        buf.drain(..).collect()
186    }
187
188    /// Drain pending error lines from the error buffer.
189    ///
190    /// Returns all lines that have been read from the child's stderr since the
191    /// last call to this method.
192    pub fn read_errors(&self) -> Vec<String> {
193        let mut buf = self
194            .error_buffer
195            .lock()
196            .expect("error_buffer lock poisoned");
197        buf.drain(..).collect()
198    }
199
200    /// Stop the subprocess.
201    ///
202    /// Drops the stdin writer (sending EOF to the child), kills the child process
203    /// if it's still running, and waits for it to exit.
204    pub fn stop(&mut self) {
205        // Drop stdin to send EOF
206        self.stdin_writer.take();
207
208        if let Some(ref mut child) = self.child {
209            // Try to kill the child process
210            let _ = child.kill();
211            // Wait for the child to exit
212            let _ = child.wait();
213        }
214        self.child.take();
215    }
216}
217
218impl Drop for ScriptProcess {
219    fn drop(&mut self) {
220        self.stop();
221    }
222}