1use std::collections::BTreeMap;
2use std::io::{BufRead, Write as IoWrite};
3use std::path::PathBuf;
4use std::process::Stdio;
5use std::sync::mpsc;
6use std::thread;
7
8use optative::Lifecycle;
9
10use super::{StreamItem, StreamKind};
11
12#[derive(Hash, Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
15pub struct ProcessIdentity {
16 pub bin: String,
17 pub key: String,
18}
19
20#[derive(Clone, Debug)]
22pub struct ProcessSource {
23 pub identity: ProcessIdentity,
24 pub args: Vec<String>,
25 pub env: BTreeMap<String, String>,
26 pub current_dir: Option<PathBuf>,
27 pub props: Option<serde_json::Value>,
28}
29
30pub struct ProcessState {
31 pub child: std::process::Child,
32 pub event_tx: mpsc::Sender<serde_json::Value>,
33 pub last_sent_props: Option<serde_json::Value>,
34}
35
36#[derive(Debug, thiserror::Error)]
38pub enum SpawnError {
39 #[error("failed to spawn {bin}: {source}")]
40 ProcessSpawnFailed {
41 bin: String,
42 #[source]
43 source: std::io::Error,
44 },
45}
46
47fn spawn_stdout_thread(
48 stdout: std::process::ChildStdout,
49 identity: ProcessIdentity,
50 tx: mpsc::Sender<StreamItem>,
51) {
52 thread::spawn(move || {
53 let reader = std::io::BufReader::new(stdout);
54 for line in reader.lines() {
55 match line {
56 Ok(l) => {
57 let item = StreamItem {
58 key: identity.clone(),
59 stream: StreamKind::Stdout,
60 line: l,
61 };
62 if tx.send(item).is_err() {
63 break;
64 }
65 }
66 Err(_) => break,
67 }
68 }
69 });
70}
71
72fn spawn_stderr_thread(stderr: std::process::ChildStderr, bin_name: String) {
73 thread::spawn(move || {
74 let reader = std::io::BufReader::new(stderr);
75 for line in reader.lines() {
76 match line {
77 Ok(l) => tracing::warn!(module = %bin_name, "{l}"),
78 Err(_) => break,
79 }
80 }
81 });
82}
83
84fn spawn_stdin_thread(
85 mut stdin: std::process::ChildStdin,
86 event_rx: mpsc::Receiver<serde_json::Value>,
87) {
88 thread::spawn(move || {
89 while let Ok(event) = event_rx.recv() {
90 let line = serde_json::to_string(&event).unwrap_or_default() + "\n";
91 if stdin.write_all(line.as_bytes()).is_err() {
92 break;
93 }
94 }
95 });
96}
97
98fn expand_tilde(path: &str) -> String {
99 if path.starts_with("~/") {
100 let home = std::env::var("HOME").unwrap_or_default();
101 format!("{}{}", home, &path[1..])
102 } else if path == "~" {
103 std::env::var("HOME").unwrap_or_default()
104 } else {
105 path.to_string()
106 }
107}
108
109pub(super) fn spawn_process(
110 spec: ProcessSource,
111 tx: &mpsc::Sender<StreamItem>,
112) -> Result<ProcessState, SpawnError> {
113 let bin = expand_tilde(&spec.identity.bin);
114 let mut cmd = std::process::Command::new(&bin);
115 cmd.args(&spec.args);
116 for (k, v) in &spec.env {
117 cmd.env(k, v);
118 }
119 if let Some(ref dir) = spec.current_dir {
120 cmd.current_dir(dir);
121 }
122
123 cmd.stdout(Stdio::piped());
124 cmd.stderr(Stdio::piped());
125 cmd.stdin(Stdio::piped());
126
127 let mut child = match cmd.spawn() {
128 Ok(c) => c,
129 Err(e) => {
130 return Err(SpawnError::ProcessSpawnFailed { bin, source: e });
131 }
132 };
133
134 if let Some(stdout) = child.stdout.take() {
135 spawn_stdout_thread(stdout, spec.identity.clone(), tx.clone());
136 }
137 if let Some(stderr) = child.stderr.take() {
138 spawn_stderr_thread(stderr, spec.identity.bin.clone());
139 }
140 let (event_tx, event_rx) = mpsc::channel::<serde_json::Value>();
141 if let Some(stdin) = child.stdin.take() {
142 spawn_stdin_thread(stdin, event_rx);
143 }
144
145 Ok(ProcessState {
146 child,
147 event_tx,
148 last_sent_props: None,
149 })
150}
151
152impl std::fmt::Display for ProcessSource {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 write!(f, "{}", self.identity.bin)
155 }
156}
157
158impl Lifecycle for ProcessSource {
159 type Key = ProcessIdentity;
160 type State = ProcessState;
161 type Context = ();
162 type Output = mpsc::Sender<StreamItem>;
163 type Error = SpawnError;
164
165 fn key(&self) -> ProcessIdentity {
166 self.identity.clone()
167 }
168
169 fn enter(self, _ctx: &mut (), output: &mut Self::Output) -> Result<Self::State, Self::Error> {
170 let props = self.props.clone();
171 let mut state = spawn_process(self, output)?;
172 if let Some(p) = props {
173 let _ = state.event_tx.send(p.clone());
174 state.last_sent_props = Some(p);
175 }
176 Ok(state)
177 }
178
179 #[allow(clippy::collapsible_if)]
180 fn reconcile_self(
181 self,
182 state: &mut Self::State,
183 _ctx: &mut (),
184 output: &mut Self::Output,
185 ) -> Result<(), Self::Error> {
186 if matches!(state.child.try_wait(), Ok(Some(_))) {
187 tracing::warn!(bin = %self.identity.bin, "process exited");
188 let props = self.props.clone();
189 let mut new_state = spawn_process(self, output)?;
190 if let Some(p) = props {
191 let _ = new_state.event_tx.send(p.clone());
192 new_state.last_sent_props = Some(p);
193 }
194 *state = new_state;
195 } else if let Some(p) = self.props {
196 if state.last_sent_props.as_ref() != Some(&p) {
197 let _ = state.event_tx.send(p.clone());
198 state.last_sent_props = Some(p);
199 }
200 }
201 Ok(())
202 }
203
204 fn exit(
205 mut state: Self::State,
206 _ctx: &mut (),
207 _output: &mut Self::Output,
208 ) -> Result<(), Self::Error> {
209 let _ = state.child.kill();
210 let _ = state.child.wait();
211 Ok(())
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::{ProcessIdentity, ProcessSource};
218 use optative::Lifecycle;
219 use std::collections::BTreeMap;
220
221 fn make_source(bin: &str) -> ProcessSource {
222 ProcessSource {
223 identity: ProcessIdentity {
224 bin: bin.to_string(),
225 key: bin.to_string(),
226 },
227 args: vec![],
228 env: BTreeMap::new(),
229 current_dir: None,
230 props: None,
231 }
232 }
233
234 #[test]
235 fn process_identity_has_bin_and_key_fields() {
236 let id = ProcessIdentity {
237 bin: "mybin".to_string(),
238 key: "mykey".to_string(),
239 };
240 assert_eq!(id.bin, "mybin");
241 assert_eq!(id.key, "mykey");
242 }
243
244 #[test]
245 fn process_identity_derives_hash_eq_partialeq_clone() {
246 use std::collections::HashSet;
247 let a = ProcessIdentity {
248 bin: "bin".to_string(),
249 key: "k".to_string(),
250 };
251 let b = a.clone();
252 assert_eq!(a, b);
253 let mut set = HashSet::new();
254 set.insert(a);
255 assert!(!set.insert(b));
256 }
257
258 #[test]
259 fn process_source_has_identity_fields() {
260 let spec = ProcessSource {
261 identity: ProcessIdentity {
262 bin: "/bin/sh".to_string(),
263 key: "my-key".to_string(),
264 },
265 args: vec!["--flag".to_string()],
266 env: BTreeMap::new(),
267 current_dir: None,
268 props: None,
269 };
270 assert_eq!(spec.identity.bin, "/bin/sh");
271 assert_eq!(spec.identity.key, "my-key");
272 }
273
274 #[test]
275 fn lifecycle_key_returns_identity() {
276 let id = ProcessIdentity {
277 bin: "/usr/bin/cat".to_string(),
278 key: "cat-key".to_string(),
279 };
280 let returned: ProcessIdentity = make_source("/usr/bin/cat").key();
281 assert_eq!(returned.bin, id.bin);
282 }
283
284 mod spawn_process {
285 use super::super::{SpawnError, spawn_process};
286 use std::sync::mpsc;
287
288 #[test]
289 fn nonexistent_binary_returns_process_spawn_failed() {
290 let (tx, _rx) = mpsc::channel();
291 let result = spawn_process(
292 super::make_source("/nonexistent/binary/that/cannot/exist"),
293 &tx,
294 );
295 match result {
296 Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
297 assert_eq!(bin, "/nonexistent/binary/that/cannot/exist");
298 }
299 Ok(_) => panic!("expected Err, got Ok"),
300 }
301 }
302
303 #[test]
304 fn tilde_bin_is_expanded_to_home_dir() {
305 let home = std::env::var("HOME").expect("HOME must be set");
306 let (tx, _rx) = mpsc::channel();
307 let result = spawn_process(super::make_source("~/nonexistent-tilde-test-binary"), &tx);
308 match result {
309 Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
310 assert!(
311 !bin.starts_with('~'),
312 "bin must not contain literal ~; got: {bin}"
313 );
314 assert!(
315 bin.starts_with(&home),
316 "bin must start with HOME ({home}); got: {bin}"
317 );
318 }
319 Ok(_) => panic!("expected Err, got Ok"),
320 }
321 }
322 }
323
324 mod lifecycle {
325 use super::super::{ProcessIdentity, ProcessSource, SpawnError};
326 use optative::Lifecycle;
327 use std::collections::BTreeMap;
328 use std::sync::mpsc;
329
330 #[test]
331 fn reconcile_self_propagates_err_when_restart_spawn_fails() {
332 let (mut tx, _rx) = mpsc::channel();
333
334 let mut state = ProcessSource {
335 identity: ProcessIdentity {
336 bin: "/bin/sh".to_string(),
337 key: "t".to_string(),
338 },
339 args: vec!["-c".to_string(), "exit 0".to_string()],
340 env: BTreeMap::new(),
341 current_dir: None,
342 props: None,
343 }
344 .enter(&mut (), &mut tx)
345 .expect("enter must succeed with /bin/sh");
346
347 std::thread::sleep(std::time::Duration::from_millis(200));
348 assert!(
349 matches!(state.child.try_wait(), Ok(Some(_))),
350 "child should have exited"
351 );
352
353 let result = super::make_source("/nonexistent/binary/that/cannot/exist")
354 .reconcile_self(&mut state, &mut (), &mut tx);
355 match result {
356 Err(SpawnError::ProcessSpawnFailed { .. }) => {}
357 Ok(_) => panic!("expected Err, got Ok"),
358 }
359 }
360 }
361}