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    // Each child leads its own process group (pgid == its pid) so exit() can
137    // signal the whole group, reaching grandchildren the child doesn't forward
138    // signals to. Trade-off: terminal-generated signals (Ctrl-C) no longer
139    // reach children; teardown is exclusively exit()-driven.
140    std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
141
142    let mut child = match cmd.spawn() {
143        Ok(c) => c,
144        Err(e) => {
145            return Err(SpawnError::ProcessSpawnFailed { bin, source: e });
146        }
147    };
148
149    if let Some(stdout) = child.stdout.take() {
150        spawn_stdout_thread(stdout, spec.identity.clone(), tx.clone());
151    }
152    if let Some(stderr) = child.stderr.take() {
153        spawn_stderr_thread(stderr, spec.identity.bin.clone());
154    }
155    let (event_tx, event_rx) = mpsc::channel::<serde_json::Value>();
156    if let Some(stdin) = child.stdin.take() {
157        spawn_stdin_thread(stdin, event_rx);
158    }
159
160    Ok(ProcessState {
161        child,
162        event_tx,
163        last_sent_props: None,
164    })
165}
166
167impl std::fmt::Display for ProcessSource {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        write!(f, "{}", self.identity.bin)
170    }
171}
172
173impl Lifecycle for ProcessSource {
174    type Key = ProcessIdentity;
175    type State = ProcessState;
176    type Context = ();
177    type Output = mpsc::Sender<StreamItem>;
178    type Error = SpawnError;
179
180    fn key(&self) -> ProcessIdentity {
181        self.identity.clone()
182    }
183
184    fn enter(self, _ctx: &mut (), output: &mut Self::Output) -> Result<Self::State, Self::Error> {
185        let props = self.props.clone();
186        let mut state = spawn_process(self, output)?;
187        if let Some(p) = props {
188            let _ = state.event_tx.send(p.clone());
189            state.last_sent_props = Some(p);
190        }
191        Ok(state)
192    }
193
194    #[allow(clippy::collapsible_if)]
195    fn reconcile_self(
196        self,
197        state: &mut Self::State,
198        _ctx: &mut (),
199        output: &mut Self::Output,
200    ) -> Result<(), Self::Error> {
201        if matches!(state.child.try_wait(), Ok(Some(_))) {
202            tracing::warn!(bin = %self.identity.bin, "process exited");
203            let props = self.props.clone();
204            let mut new_state = spawn_process(self, output)?;
205            if let Some(p) = props {
206                let _ = new_state.event_tx.send(p.clone());
207                new_state.last_sent_props = Some(p);
208            }
209            *state = new_state;
210        } else if let Some(p) = self.props {
211            if state.last_sent_props.as_ref() != Some(&p) {
212                let _ = state.event_tx.send(p.clone());
213                state.last_sent_props = Some(p);
214            }
215        }
216        Ok(())
217    }
218
219    fn exit(
220        mut state: Self::State,
221        _ctx: &mut (),
222        _output: &mut Self::Output,
223    ) -> Result<(), Self::Error> {
224        // The child is its own group leader (spawn sets process_group(0)), so
225        // its pid doubles as the pgid; signaling the group reaches grandchildren
226        // too. Valid until the child is reaped, and we signal before reaping.
227        // ESRCH if the group is already gone is fine; the poll loop reaps it.
228        let pgid = nix::unistd::Pid::from_raw(state.child.id() as i32);
229        let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGTERM);
230
231        let deadline = Instant::now() + SHUTDOWN_GRACE_PERIOD;
232        while Instant::now() < deadline {
233            match state.child.try_wait() {
234                Ok(Some(_)) => return Ok(()),
235                Ok(None) => thread::sleep(Duration::from_millis(50)),
236                Err(_) => break,
237            }
238        }
239
240        let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGKILL);
241        let _ = state.child.wait();
242        Ok(())
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::{ProcessIdentity, ProcessSource};
249    use optative::Lifecycle;
250    use std::collections::BTreeMap;
251
252    fn make_source(bin: &str) -> ProcessSource {
253        ProcessSource {
254            identity: ProcessIdentity {
255                bin: bin.to_string(),
256                key: bin.to_string(),
257            },
258            args: vec![],
259            env: BTreeMap::new(),
260            current_dir: None,
261            props: None,
262        }
263    }
264
265    #[test]
266    fn process_identity_has_bin_and_key_fields() {
267        let id = ProcessIdentity {
268            bin: "mybin".to_string(),
269            key: "mykey".to_string(),
270        };
271        assert_eq!(id.bin, "mybin");
272        assert_eq!(id.key, "mykey");
273    }
274
275    #[test]
276    fn process_identity_derives_hash_eq_partialeq_clone() {
277        use std::collections::HashSet;
278        let a = ProcessIdentity {
279            bin: "bin".to_string(),
280            key: "k".to_string(),
281        };
282        let b = a.clone();
283        assert_eq!(a, b);
284        let mut set = HashSet::new();
285        set.insert(a);
286        assert!(!set.insert(b));
287    }
288
289    #[test]
290    fn process_source_has_identity_fields() {
291        let spec = ProcessSource {
292            identity: ProcessIdentity {
293                bin: "/bin/sh".to_string(),
294                key: "my-key".to_string(),
295            },
296            args: vec!["--flag".to_string()],
297            env: BTreeMap::new(),
298            current_dir: None,
299            props: None,
300        };
301        assert_eq!(spec.identity.bin, "/bin/sh");
302        assert_eq!(spec.identity.key, "my-key");
303    }
304
305    #[test]
306    fn lifecycle_key_returns_identity() {
307        let id = ProcessIdentity {
308            bin: "/usr/bin/cat".to_string(),
309            key: "cat-key".to_string(),
310        };
311        let returned: ProcessIdentity = make_source("/usr/bin/cat").key();
312        assert_eq!(returned.bin, id.bin);
313    }
314
315    mod spawn_process {
316        use super::super::{SpawnError, spawn_process};
317        use std::sync::mpsc;
318
319        #[test]
320        fn nonexistent_binary_returns_process_spawn_failed() {
321            let (tx, _rx) = mpsc::channel();
322            let result = spawn_process(
323                super::make_source("/nonexistent/binary/that/cannot/exist"),
324                &tx,
325            );
326            match result {
327                Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
328                    assert_eq!(bin, "/nonexistent/binary/that/cannot/exist");
329                }
330                _ => panic!("expected ProcessSpawnFailed"),
331            }
332        }
333
334        #[test]
335        fn spawned_child_leads_its_own_process_group() {
336            let (tx, _rx) = mpsc::channel();
337            let mut spec = super::make_source("/bin/sleep");
338            spec.args = vec!["60".to_string()];
339            let mut state = spawn_process(spec, &tx).expect("spawn must succeed");
340
341            let pid = nix::unistd::Pid::from_raw(state.child.id() as i32);
342            let pgid = nix::unistd::getpgid(Some(pid));
343
344            let _ = state.child.kill();
345            let _ = state.child.wait();
346
347            assert_eq!(
348                pgid.expect("getpgid must succeed"),
349                pid,
350                "child must be the leader of its own process group"
351            );
352        }
353
354        #[test]
355        fn tilde_bin_is_expanded_to_home_dir() {
356            let home = std::env::var("HOME").expect("HOME must be set");
357            let (tx, _rx) = mpsc::channel();
358            let result = spawn_process(super::make_source("~/nonexistent-tilde-test-binary"), &tx);
359            match result {
360                Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
361                    assert!(
362                        !bin.starts_with('~'),
363                        "bin must not contain literal ~; got: {bin}"
364                    );
365                    assert!(
366                        bin.starts_with(&home),
367                        "bin must start with HOME ({home}); got: {bin}"
368                    );
369                }
370                _ => panic!("expected ProcessSpawnFailed"),
371            }
372        }
373    }
374
375    mod lifecycle {
376        use super::super::{ProcessIdentity, ProcessSource, SHUTDOWN_GRACE_PERIOD, SpawnError};
377        use optative::Lifecycle;
378        use std::collections::BTreeMap;
379        use std::sync::mpsc;
380        use std::time::{Duration, Instant};
381
382        #[test]
383        fn reconcile_self_propagates_err_when_restart_spawn_fails() {
384            let (mut tx, _rx) = mpsc::channel();
385
386            let mut state = ProcessSource {
387                identity: ProcessIdentity {
388                    bin: "/bin/sh".to_string(),
389                    key: "t".to_string(),
390                },
391                args: vec!["-c".to_string(), "exit 0".to_string()],
392                env: BTreeMap::new(),
393                current_dir: None,
394                props: None,
395            }
396            .enter(&mut (), &mut tx)
397            .expect("enter must succeed with /bin/sh");
398
399            std::thread::sleep(Duration::from_millis(200));
400            assert!(
401                matches!(state.child.try_wait(), Ok(Some(_))),
402                "child should have exited"
403            );
404
405            let result = super::make_source("/nonexistent/binary/that/cannot/exist")
406                .reconcile_self(&mut state, &mut (), &mut tx);
407            match result {
408                Err(SpawnError::ProcessSpawnFailed { .. }) => {}
409                _ => panic!("expected ProcessSpawnFailed"),
410            }
411        }
412
413        fn trap_spec(key: &str, trap_body: &str) -> ProcessSource {
414            ProcessSource {
415                identity: ProcessIdentity {
416                    bin: "/bin/sh".to_string(),
417                    key: key.to_string(),
418                },
419                // `wait` is interruptible by signals; foreground `sleep` is not.
420                args: vec![
421                    "-c".to_string(),
422                    format!("trap '{trap_body}' TERM; sleep 60 & wait"),
423                ],
424                env: BTreeMap::new(),
425                current_dir: None,
426                props: None,
427            }
428        }
429
430        #[test]
431        fn exit_reaps_graceful_child_via_sigterm() {
432            let (mut tx, _rx) = mpsc::channel();
433            let state = trap_spec("graceful", "exit 0")
434                .enter(&mut (), &mut tx)
435                .expect("enter must succeed");
436            std::thread::sleep(Duration::from_millis(150));
437
438            let start = Instant::now();
439            ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
440            assert!(start.elapsed() < Duration::from_secs(2));
441        }
442
443        #[test]
444        fn exit_kills_grandchildren_spawned_by_the_child() {
445            let (mut tx, _rx) = mpsc::channel();
446            let pidfile = tempfile::NamedTempFile::new().expect("tempfile must be created");
447            let path = pidfile.path().to_str().expect("utf-8 path").to_string();
448
449            // The shell backgrounds a grandchild, records its pid, then execs
450            // into a foreground sleep — so nothing forwards signals to the
451            // grandchild.
452            let state = ProcessSource {
453                identity: ProcessIdentity {
454                    bin: "/bin/sh".to_string(),
455                    key: "grandchild".to_string(),
456                },
457                args: vec![
458                    "-c".to_string(),
459                    format!("sleep 60 & echo $! > {path}; exec sleep 60"),
460                ],
461                env: BTreeMap::new(),
462                current_dir: None,
463                props: None,
464            }
465            .enter(&mut (), &mut tx)
466            .expect("enter must succeed");
467
468            let deadline = Instant::now() + Duration::from_secs(2);
469            let grandchild_pid = loop {
470                let contents = std::fs::read_to_string(&path).unwrap_or_default();
471                if let Ok(pid) = contents.trim().parse::<i32>() {
472                    break nix::unistd::Pid::from_raw(pid);
473                }
474                assert!(Instant::now() < deadline, "grandchild pid never written");
475                std::thread::sleep(Duration::from_millis(20));
476            };
477
478            ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
479
480            // Signal 0 probes existence; ESRCH means the grandchild is gone.
481            let deadline = Instant::now() + Duration::from_secs(2);
482            while nix::sys::signal::kill(grandchild_pid, None) != Err(nix::errno::Errno::ESRCH) {
483                assert!(
484                    Instant::now() < deadline,
485                    "grandchild survived exit() as an orphan"
486                );
487                std::thread::sleep(Duration::from_millis(20));
488            }
489        }
490
491        #[test]
492        #[ignore = "slow"]
493        fn exit_escalates_to_sigkill_when_child_ignores_sigterm() {
494            let (mut tx, _rx) = mpsc::channel();
495            let state = trap_spec("stubborn", "")
496                .enter(&mut (), &mut tx)
497                .expect("enter must succeed");
498            std::thread::sleep(Duration::from_millis(150));
499
500            let start = Instant::now();
501            ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
502            assert!(start.elapsed() >= SHUTDOWN_GRACE_PERIOD);
503        }
504    }
505}