Skip to main content

optative_process_pool/
process.rs

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;
7use std::time::{Duration, Instant};
8
9use optative::Lifecycle;
10
11use super::{StreamItem, StreamKind};
12
13/// How long [`Lifecycle::exit`] waits for SIGTERM before escalating to SIGKILL.
14pub const SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(10);
15
16/// Stable identity for a process: uniquely identifies which process to manage.
17/// Used as the key in `Lifecycle` so that `OptativeSet` can track processes by identity.
18#[derive(Hash, Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
19pub struct ProcessIdentity {
20    pub bin: String,
21    pub key: String,
22}
23
24// NOTE: env uses BTreeMap (not HashMap) for deterministic ordering; HashMap doesn't implement Hash.
25#[derive(Clone, Debug)]
26pub struct ProcessSource {
27    pub identity: ProcessIdentity,
28    pub args: Vec<String>,
29    pub env: BTreeMap<String, String>,
30    pub current_dir: Option<PathBuf>,
31    pub props: Option<serde_json::Value>,
32}
33
34pub struct ProcessState {
35    pub child: std::process::Child,
36    pub event_tx: mpsc::Sender<serde_json::Value>,
37    pub last_sent_props: Option<serde_json::Value>,
38}
39
40/// Error type for process spawning failures.
41#[derive(Debug, thiserror::Error)]
42pub enum SpawnError {
43    #[error("failed to spawn {bin}: {source}")]
44    ProcessSpawnFailed {
45        bin: String,
46        #[source]
47        source: std::io::Error,
48    },
49    #[error("failed to resolve resource: {source}")]
50    ResourceResolutionFailed {
51        #[source]
52        source: std::io::Error,
53    },
54}
55
56fn spawn_stdout_thread(
57    stdout: std::process::ChildStdout,
58    identity: ProcessIdentity,
59    tx: mpsc::Sender<StreamItem>,
60) {
61    thread::spawn(move || {
62        let reader = std::io::BufReader::new(stdout);
63        for line in reader.lines() {
64            match line {
65                Ok(l) => {
66                    let item = StreamItem {
67                        key: identity.clone(),
68                        stream: StreamKind::Stdout,
69                        line: l,
70                    };
71                    if tx.send(item).is_err() {
72                        break;
73                    }
74                }
75                Err(_) => break,
76            }
77        }
78    });
79}
80
81fn spawn_stderr_thread(stderr: std::process::ChildStderr, bin_name: String) {
82    thread::spawn(move || {
83        let reader = std::io::BufReader::new(stderr);
84        for line in reader.lines() {
85            match line {
86                Ok(l) => tracing::warn!(module = %bin_name, "{l}"),
87                Err(_) => break,
88            }
89        }
90    });
91}
92
93fn spawn_stdin_thread(
94    mut stdin: std::process::ChildStdin,
95    event_rx: mpsc::Receiver<serde_json::Value>,
96) {
97    thread::spawn(move || {
98        while let Ok(event) = event_rx.recv() {
99            let line = serde_json::to_string(&event).unwrap_or_default() + "\n";
100            if stdin.write_all(line.as_bytes()).is_err() {
101                break;
102            }
103        }
104    });
105}
106
107fn expand_tilde(path: &str) -> String {
108    if path.starts_with("~/") {
109        let home = std::env::var("HOME").unwrap_or_default();
110        format!("{}{}", home, &path[1..])
111    } else if path == "~" {
112        std::env::var("HOME").unwrap_or_default()
113    } else {
114        path.to_string()
115    }
116}
117
118pub(super) fn spawn_process(
119    spec: ProcessSource,
120    tx: &mpsc::Sender<StreamItem>,
121) -> Result<ProcessState, SpawnError> {
122    let bin = expand_tilde(&spec.identity.bin);
123    let mut cmd = std::process::Command::new(&bin);
124    cmd.args(&spec.args);
125    for (k, v) in &spec.env {
126        cmd.env(k, v);
127    }
128    if let Some(ref dir) = spec.current_dir {
129        cmd.current_dir(dir);
130    }
131
132    cmd.stdout(Stdio::piped());
133    cmd.stderr(Stdio::piped());
134    cmd.stdin(Stdio::piped());
135
136    let mut child = match cmd.spawn() {
137        Ok(c) => c,
138        Err(e) => {
139            return Err(SpawnError::ProcessSpawnFailed { bin, source: e });
140        }
141    };
142
143    if let Some(stdout) = child.stdout.take() {
144        spawn_stdout_thread(stdout, spec.identity.clone(), tx.clone());
145    }
146    if let Some(stderr) = child.stderr.take() {
147        spawn_stderr_thread(stderr, spec.identity.bin.clone());
148    }
149    let (event_tx, event_rx) = mpsc::channel::<serde_json::Value>();
150    if let Some(stdin) = child.stdin.take() {
151        spawn_stdin_thread(stdin, event_rx);
152    }
153
154    Ok(ProcessState {
155        child,
156        event_tx,
157        last_sent_props: None,
158    })
159}
160
161impl std::fmt::Display for ProcessSource {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        write!(f, "{}", self.identity.bin)
164    }
165}
166
167impl Lifecycle for ProcessSource {
168    type Key = ProcessIdentity;
169    type State = ProcessState;
170    type Context = ();
171    type Output = mpsc::Sender<StreamItem>;
172    type Error = SpawnError;
173
174    fn key(&self) -> ProcessIdentity {
175        self.identity.clone()
176    }
177
178    fn enter(self, _ctx: &mut (), output: &mut Self::Output) -> Result<Self::State, Self::Error> {
179        let props = self.props.clone();
180        let mut state = spawn_process(self, output)?;
181        if let Some(p) = props {
182            let _ = state.event_tx.send(p.clone());
183            state.last_sent_props = Some(p);
184        }
185        Ok(state)
186    }
187
188    #[allow(clippy::collapsible_if)]
189    fn reconcile_self(
190        self,
191        state: &mut Self::State,
192        _ctx: &mut (),
193        output: &mut Self::Output,
194    ) -> Result<(), Self::Error> {
195        if matches!(state.child.try_wait(), Ok(Some(_))) {
196            tracing::warn!(bin = %self.identity.bin, "process exited");
197            let props = self.props.clone();
198            let mut new_state = spawn_process(self, output)?;
199            if let Some(p) = props {
200                let _ = new_state.event_tx.send(p.clone());
201                new_state.last_sent_props = Some(p);
202            }
203            *state = new_state;
204        } else if let Some(p) = self.props {
205            if state.last_sent_props.as_ref() != Some(&p) {
206                let _ = state.event_tx.send(p.clone());
207                state.last_sent_props = Some(p);
208            }
209        }
210        Ok(())
211    }
212
213    fn exit(
214        mut state: Self::State,
215        _ctx: &mut (),
216        _output: &mut Self::Output,
217    ) -> Result<(), Self::Error> {
218        // ESRCH if the child is already gone is fine; the poll loop reaps it.
219        let _ = nix::sys::signal::kill(
220            nix::unistd::Pid::from_raw(state.child.id() as i32),
221            nix::sys::signal::Signal::SIGTERM,
222        );
223
224        let deadline = Instant::now() + SHUTDOWN_GRACE_PERIOD;
225        while Instant::now() < deadline {
226            match state.child.try_wait() {
227                Ok(Some(_)) => return Ok(()),
228                Ok(None) => thread::sleep(Duration::from_millis(50)),
229                Err(_) => break,
230            }
231        }
232
233        let _ = state.child.kill();
234        let _ = state.child.wait();
235        Ok(())
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::{ProcessIdentity, ProcessSource};
242    use optative::Lifecycle;
243    use std::collections::BTreeMap;
244
245    fn make_source(bin: &str) -> ProcessSource {
246        ProcessSource {
247            identity: ProcessIdentity {
248                bin: bin.to_string(),
249                key: bin.to_string(),
250            },
251            args: vec![],
252            env: BTreeMap::new(),
253            current_dir: None,
254            props: None,
255        }
256    }
257
258    #[test]
259    fn process_identity_has_bin_and_key_fields() {
260        let id = ProcessIdentity {
261            bin: "mybin".to_string(),
262            key: "mykey".to_string(),
263        };
264        assert_eq!(id.bin, "mybin");
265        assert_eq!(id.key, "mykey");
266    }
267
268    #[test]
269    fn process_identity_derives_hash_eq_partialeq_clone() {
270        use std::collections::HashSet;
271        let a = ProcessIdentity {
272            bin: "bin".to_string(),
273            key: "k".to_string(),
274        };
275        let b = a.clone();
276        assert_eq!(a, b);
277        let mut set = HashSet::new();
278        set.insert(a);
279        assert!(!set.insert(b));
280    }
281
282    #[test]
283    fn process_source_has_identity_fields() {
284        let spec = ProcessSource {
285            identity: ProcessIdentity {
286                bin: "/bin/sh".to_string(),
287                key: "my-key".to_string(),
288            },
289            args: vec!["--flag".to_string()],
290            env: BTreeMap::new(),
291            current_dir: None,
292            props: None,
293        };
294        assert_eq!(spec.identity.bin, "/bin/sh");
295        assert_eq!(spec.identity.key, "my-key");
296    }
297
298    #[test]
299    fn lifecycle_key_returns_identity() {
300        let id = ProcessIdentity {
301            bin: "/usr/bin/cat".to_string(),
302            key: "cat-key".to_string(),
303        };
304        let returned: ProcessIdentity = make_source("/usr/bin/cat").key();
305        assert_eq!(returned.bin, id.bin);
306    }
307
308    mod spawn_process {
309        use super::super::{SpawnError, spawn_process};
310        use std::sync::mpsc;
311
312        #[test]
313        fn nonexistent_binary_returns_process_spawn_failed() {
314            let (tx, _rx) = mpsc::channel();
315            let result = spawn_process(
316                super::make_source("/nonexistent/binary/that/cannot/exist"),
317                &tx,
318            );
319            match result {
320                Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
321                    assert_eq!(bin, "/nonexistent/binary/that/cannot/exist");
322                }
323                _ => panic!("expected ProcessSpawnFailed"),
324            }
325        }
326
327        #[test]
328        fn tilde_bin_is_expanded_to_home_dir() {
329            let home = std::env::var("HOME").expect("HOME must be set");
330            let (tx, _rx) = mpsc::channel();
331            let result = spawn_process(super::make_source("~/nonexistent-tilde-test-binary"), &tx);
332            match result {
333                Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
334                    assert!(
335                        !bin.starts_with('~'),
336                        "bin must not contain literal ~; got: {bin}"
337                    );
338                    assert!(
339                        bin.starts_with(&home),
340                        "bin must start with HOME ({home}); got: {bin}"
341                    );
342                }
343                _ => panic!("expected ProcessSpawnFailed"),
344            }
345        }
346    }
347
348    mod lifecycle {
349        use super::super::{ProcessIdentity, ProcessSource, SHUTDOWN_GRACE_PERIOD, SpawnError};
350        use optative::Lifecycle;
351        use std::collections::BTreeMap;
352        use std::sync::mpsc;
353        use std::time::{Duration, Instant};
354
355        #[test]
356        fn reconcile_self_propagates_err_when_restart_spawn_fails() {
357            let (mut tx, _rx) = mpsc::channel();
358
359            let mut state = ProcessSource {
360                identity: ProcessIdentity {
361                    bin: "/bin/sh".to_string(),
362                    key: "t".to_string(),
363                },
364                args: vec!["-c".to_string(), "exit 0".to_string()],
365                env: BTreeMap::new(),
366                current_dir: None,
367                props: None,
368            }
369            .enter(&mut (), &mut tx)
370            .expect("enter must succeed with /bin/sh");
371
372            std::thread::sleep(Duration::from_millis(200));
373            assert!(
374                matches!(state.child.try_wait(), Ok(Some(_))),
375                "child should have exited"
376            );
377
378            let result = super::make_source("/nonexistent/binary/that/cannot/exist")
379                .reconcile_self(&mut state, &mut (), &mut tx);
380            match result {
381                Err(SpawnError::ProcessSpawnFailed { .. }) => {}
382                _ => panic!("expected ProcessSpawnFailed"),
383            }
384        }
385
386        fn trap_spec(key: &str, trap_body: &str) -> ProcessSource {
387            ProcessSource {
388                identity: ProcessIdentity {
389                    bin: "/bin/sh".to_string(),
390                    key: key.to_string(),
391                },
392                // `wait` is interruptible by signals; foreground `sleep` is not.
393                args: vec![
394                    "-c".to_string(),
395                    format!("trap '{trap_body}' TERM; sleep 60 & wait"),
396                ],
397                env: BTreeMap::new(),
398                current_dir: None,
399                props: None,
400            }
401        }
402
403        #[test]
404        fn exit_reaps_graceful_child_via_sigterm() {
405            let (mut tx, _rx) = mpsc::channel();
406            let state = trap_spec("graceful", "exit 0")
407                .enter(&mut (), &mut tx)
408                .expect("enter must succeed");
409            std::thread::sleep(Duration::from_millis(150));
410
411            let start = Instant::now();
412            ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
413            assert!(start.elapsed() < Duration::from_secs(2));
414        }
415
416        #[test]
417        #[ignore = "slow"]
418        fn exit_escalates_to_sigkill_when_child_ignores_sigterm() {
419            let (mut tx, _rx) = mpsc::channel();
420            let state = trap_spec("stubborn", "")
421                .enter(&mut (), &mut tx)
422                .expect("enter must succeed");
423            std::thread::sleep(Duration::from_millis(150));
424
425            let start = Instant::now();
426            ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
427            assert!(start.elapsed() >= SHUTDOWN_GRACE_PERIOD);
428        }
429    }
430}