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}