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