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/// Backoff delay before the first respawn attempt after a crash.
17const INITIAL_RESPAWN_BACKOFF: Duration = Duration::from_millis(200);
18
19/// Ceiling on respawn backoff, so a permanently-broken binary still gets
20/// retried occasionally instead of backing off forever.
21const MAX_RESPAWN_BACKOFF: Duration = Duration::from_secs(5);
22
23/// A child that stays up at least this long is considered to have recovered;
24/// its next crash restarts the backoff sequence from scratch instead of
25/// continuing to escalate.
26const RESPAWN_BACKOFF_RESET_UPTIME: Duration = Duration::from_secs(1);
27
28/// Delay before the `attempt`-th respawn (1-indexed), doubling each attempt
29/// up to [`MAX_RESPAWN_BACKOFF`].
30fn respawn_backoff(attempt: u32) -> Duration {
31    let exponent = attempt.saturating_sub(1).min(6);
32    let millis = INITIAL_RESPAWN_BACKOFF.as_millis() as u64 * 2u64.saturating_pow(exponent);
33    Duration::from_millis(millis).min(MAX_RESPAWN_BACKOFF)
34}
35
36/// Stable identity for a process: uniquely identifies which process to manage.
37/// Used as the key in `Lifecycle` so that `OptativeSet` can track processes by identity.
38#[derive(Hash, Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
39pub struct ProcessIdentity {
40    pub bin: String,
41    pub key: String,
42}
43
44// NOTE: env uses BTreeMap (not HashMap) for deterministic ordering; HashMap doesn't implement Hash.
45#[derive(Clone, Debug)]
46pub struct ProcessSource {
47    pub identity: ProcessIdentity,
48    pub args: Vec<String>,
49    pub env: BTreeMap<String, String>,
50    pub current_dir: Option<PathBuf>,
51    pub props: Option<serde_json::Value>,
52}
53
54pub struct ProcessState {
55    pub child: std::process::Child,
56    pub event_tx: mpsc::Sender<serde_json::Value>,
57    pub last_sent_props: Option<serde_json::Value>,
58    /// When this child was spawned, used to decide whether a future crash
59    /// should escalate backoff or reset it.
60    spawned_at: Instant,
61    /// Consecutive crash count not yet redeemed by a stable uptime.
62    restart_count: u32,
63    /// Earliest time a respawn may happen; `None` means respawn is allowed
64    /// as soon as an exit is observed.
65    next_respawn_at: Option<Instant>,
66}
67
68/// Error type for process spawning failures.
69#[derive(Debug, thiserror::Error)]
70pub enum SpawnError {
71    #[error("failed to spawn {bin}: {source}")]
72    ProcessSpawnFailed {
73        bin: String,
74        #[source]
75        source: std::io::Error,
76    },
77    #[error("failed to resolve resource: {source}")]
78    ResourceResolutionFailed {
79        #[source]
80        source: std::io::Error,
81    },
82}
83
84fn spawn_stdout_thread(
85    stdout: std::process::ChildStdout,
86    identity: ProcessIdentity,
87    tx: mpsc::Sender<StreamItem>,
88) {
89    thread::spawn(move || {
90        let reader = std::io::BufReader::new(stdout);
91        for line in reader.lines() {
92            match line {
93                Ok(l) => {
94                    let item = StreamItem {
95                        key: identity.clone(),
96                        stream: StreamKind::Stdout,
97                        line: l,
98                    };
99                    if tx.send(item).is_err() {
100                        break;
101                    }
102                }
103                Err(_) => break,
104            }
105        }
106    });
107}
108
109fn spawn_stderr_thread(stderr: std::process::ChildStderr, bin_name: String) {
110    thread::spawn(move || {
111        let reader = std::io::BufReader::new(stderr);
112        for line in reader.lines() {
113            match line {
114                Ok(l) => tracing::warn!(module = %bin_name, "{l}"),
115                Err(_) => break,
116            }
117        }
118    });
119}
120
121fn spawn_stdin_thread(
122    mut stdin: std::process::ChildStdin,
123    event_rx: mpsc::Receiver<serde_json::Value>,
124) {
125    thread::spawn(move || {
126        while let Ok(event) = event_rx.recv() {
127            let line = serde_json::to_string(&event).unwrap_or_default() + "\n";
128            if stdin.write_all(line.as_bytes()).is_err() {
129                break;
130            }
131        }
132    });
133}
134
135fn expand_tilde(path: &str) -> String {
136    if path.starts_with("~/") {
137        let home = std::env::var("HOME").unwrap_or_default();
138        format!("{}{}", home, &path[1..])
139    } else if path == "~" {
140        std::env::var("HOME").unwrap_or_default()
141    } else {
142        path.to_string()
143    }
144}
145
146pub(super) fn spawn_process(
147    spec: ProcessSource,
148    tx: &mpsc::Sender<StreamItem>,
149) -> Result<ProcessState, SpawnError> {
150    let bin = expand_tilde(&spec.identity.bin);
151    let mut cmd = std::process::Command::new(&bin);
152    cmd.args(&spec.args);
153    for (k, v) in &spec.env {
154        cmd.env(k, v);
155    }
156    if let Some(ref dir) = spec.current_dir {
157        cmd.current_dir(dir);
158    }
159
160    cmd.stdout(Stdio::piped());
161    cmd.stderr(Stdio::piped());
162    cmd.stdin(Stdio::piped());
163
164    // Each child leads its own process group (pgid == its pid) so exit() can
165    // signal the whole group, reaching grandchildren the child doesn't forward
166    // signals to. Trade-off: terminal-generated signals (Ctrl-C) no longer
167    // reach children; teardown is exclusively exit()-driven.
168    std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
169
170    let mut child = match cmd.spawn() {
171        Ok(c) => c,
172        Err(e) => {
173            return Err(SpawnError::ProcessSpawnFailed { bin, source: e });
174        }
175    };
176
177    if let Some(stdout) = child.stdout.take() {
178        spawn_stdout_thread(stdout, spec.identity.clone(), tx.clone());
179    }
180    if let Some(stderr) = child.stderr.take() {
181        spawn_stderr_thread(stderr, spec.identity.bin.clone());
182    }
183    let (event_tx, event_rx) = mpsc::channel::<serde_json::Value>();
184    if let Some(stdin) = child.stdin.take() {
185        spawn_stdin_thread(stdin, event_rx);
186    }
187
188    Ok(ProcessState {
189        child,
190        event_tx,
191        last_sent_props: None,
192        spawned_at: Instant::now(),
193        restart_count: 0,
194        next_respawn_at: None,
195    })
196}
197
198impl std::fmt::Display for ProcessSource {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        write!(f, "{}", self.identity.bin)
201    }
202}
203
204impl Lifecycle for ProcessSource {
205    type Key = ProcessIdentity;
206    type State = ProcessState;
207    type Context = ();
208    type Output = mpsc::Sender<StreamItem>;
209    type Error = SpawnError;
210
211    fn key(&self) -> ProcessIdentity {
212        self.identity.clone()
213    }
214
215    fn enter(self, _ctx: &mut (), output: &mut Self::Output) -> Result<Self::State, Self::Error> {
216        let props = self.props.clone();
217        let mut state = spawn_process(self, output)?;
218        if let Some(p) = props {
219            let _ = state.event_tx.send(p.clone());
220            state.last_sent_props = Some(p);
221        }
222        Ok(state)
223    }
224
225    #[allow(clippy::collapsible_if)]
226    fn reconcile_self(
227        self,
228        state: &mut Self::State,
229        _ctx: &mut (),
230        output: &mut Self::Output,
231    ) -> Result<(), Self::Error> {
232        if matches!(state.child.try_wait(), Ok(Some(_))) {
233            let now = Instant::now();
234            if let Some(next_respawn_at) = state.next_respawn_at {
235                if now < next_respawn_at {
236                    // Still backing off from a prior crash; leave the dead
237                    // child in place rather than respawn-looping.
238                    return Ok(());
239                }
240            }
241
242            let uptime = now.saturating_duration_since(state.spawned_at);
243            let restart_count = if uptime >= RESPAWN_BACKOFF_RESET_UPTIME {
244                1
245            } else {
246                state.restart_count + 1
247            };
248
249            tracing::warn!(
250                bin = %self.identity.bin,
251                restart_count,
252                "process exited; respawning"
253            );
254            let props = self.props.clone();
255            let mut new_state = spawn_process(self, output)?;
256            new_state.restart_count = restart_count;
257            new_state.next_respawn_at = Some(now + respawn_backoff(restart_count));
258            if let Some(p) = props {
259                let _ = new_state.event_tx.send(p.clone());
260                new_state.last_sent_props = Some(p);
261            }
262            *state = new_state;
263        } else if let Some(p) = self.props {
264            if state.last_sent_props.as_ref() != Some(&p) {
265                let _ = state.event_tx.send(p.clone());
266                state.last_sent_props = Some(p);
267            }
268        }
269        Ok(())
270    }
271
272    fn exit(
273        mut state: Self::State,
274        _ctx: &mut (),
275        _output: &mut Self::Output,
276    ) -> Result<(), Self::Error> {
277        // The child is its own group leader (spawn sets process_group(0)), so
278        // its pid doubles as the pgid; signaling the group reaches grandchildren
279        // too. Valid until the child is reaped, and we signal before reaping.
280        // ESRCH if the group is already gone is fine; the poll loop reaps it.
281        let pgid = nix::unistd::Pid::from_raw(state.child.id() as i32);
282        let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGTERM);
283
284        let deadline = Instant::now() + SHUTDOWN_GRACE_PERIOD;
285        while Instant::now() < deadline {
286            match state.child.try_wait() {
287                Ok(Some(_)) => return Ok(()),
288                Ok(None) => thread::sleep(Duration::from_millis(50)),
289                Err(_) => break,
290            }
291        }
292
293        let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGKILL);
294        let _ = state.child.wait();
295        Ok(())
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::{ProcessIdentity, ProcessSource};
302    use optative::Lifecycle;
303    use std::collections::BTreeMap;
304
305    fn make_source(bin: &str) -> ProcessSource {
306        ProcessSource {
307            identity: ProcessIdentity {
308                bin: bin.to_string(),
309                key: bin.to_string(),
310            },
311            args: vec![],
312            env: BTreeMap::new(),
313            current_dir: None,
314            props: None,
315        }
316    }
317
318    #[test]
319    fn process_identity_has_bin_and_key_fields() {
320        let id = ProcessIdentity {
321            bin: "mybin".to_string(),
322            key: "mykey".to_string(),
323        };
324        assert_eq!(id.bin, "mybin");
325        assert_eq!(id.key, "mykey");
326    }
327
328    #[test]
329    fn process_identity_derives_hash_eq_partialeq_clone() {
330        use std::collections::HashSet;
331        let a = ProcessIdentity {
332            bin: "bin".to_string(),
333            key: "k".to_string(),
334        };
335        let b = a.clone();
336        assert_eq!(a, b);
337        let mut set = HashSet::new();
338        set.insert(a);
339        assert!(!set.insert(b));
340    }
341
342    #[test]
343    fn process_source_has_identity_fields() {
344        let spec = ProcessSource {
345            identity: ProcessIdentity {
346                bin: "/bin/sh".to_string(),
347                key: "my-key".to_string(),
348            },
349            args: vec!["--flag".to_string()],
350            env: BTreeMap::new(),
351            current_dir: None,
352            props: None,
353        };
354        assert_eq!(spec.identity.bin, "/bin/sh");
355        assert_eq!(spec.identity.key, "my-key");
356    }
357
358    #[test]
359    fn lifecycle_key_returns_identity() {
360        let id = ProcessIdentity {
361            bin: "/usr/bin/cat".to_string(),
362            key: "cat-key".to_string(),
363        };
364        let returned: ProcessIdentity = make_source("/usr/bin/cat").key();
365        assert_eq!(returned.bin, id.bin);
366    }
367
368    mod spawn_process {
369        use super::super::{SpawnError, spawn_process};
370        use std::sync::mpsc;
371
372        #[test]
373        fn nonexistent_binary_returns_process_spawn_failed() {
374            let (tx, _rx) = mpsc::channel();
375            let result = spawn_process(
376                super::make_source("/nonexistent/binary/that/cannot/exist"),
377                &tx,
378            );
379            match result {
380                Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
381                    assert_eq!(bin, "/nonexistent/binary/that/cannot/exist");
382                }
383                _ => panic!("expected ProcessSpawnFailed"),
384            }
385        }
386
387        #[test]
388        fn spawned_child_leads_its_own_process_group() {
389            let (tx, _rx) = mpsc::channel();
390            let mut spec = super::make_source("/bin/sleep");
391            spec.args = vec!["60".to_string()];
392            let mut state = spawn_process(spec, &tx).expect("spawn must succeed");
393
394            let pid = nix::unistd::Pid::from_raw(state.child.id() as i32);
395            let pgid = nix::unistd::getpgid(Some(pid));
396
397            let _ = state.child.kill();
398            let _ = state.child.wait();
399
400            assert_eq!(
401                pgid.expect("getpgid must succeed"),
402                pid,
403                "child must be the leader of its own process group"
404            );
405        }
406
407        #[test]
408        fn tilde_bin_is_expanded_to_home_dir() {
409            let home = std::env::var("HOME").expect("HOME must be set");
410            let (tx, _rx) = mpsc::channel();
411            let result = spawn_process(super::make_source("~/nonexistent-tilde-test-binary"), &tx);
412            match result {
413                Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
414                    assert!(
415                        !bin.starts_with('~'),
416                        "bin must not contain literal ~; got: {bin}"
417                    );
418                    assert!(
419                        bin.starts_with(&home),
420                        "bin must start with HOME ({home}); got: {bin}"
421                    );
422                }
423                _ => panic!("expected ProcessSpawnFailed"),
424            }
425        }
426    }
427
428    mod lifecycle {
429        use super::super::{
430            ProcessIdentity, ProcessSource, RESPAWN_BACKOFF_RESET_UPTIME, SHUTDOWN_GRACE_PERIOD,
431            SpawnError,
432        };
433        use optative::Lifecycle;
434        use std::collections::BTreeMap;
435        use std::sync::mpsc;
436        use std::time::{Duration, Instant};
437
438        fn crash_spec(key: &str) -> ProcessSource {
439            ProcessSource {
440                identity: ProcessIdentity {
441                    bin: "/bin/sh".to_string(),
442                    key: key.to_string(),
443                },
444                args: vec!["-c".to_string(), "exit 1".to_string()],
445                env: BTreeMap::new(),
446                current_dir: None,
447                props: None,
448            }
449        }
450
451        #[test]
452        fn reconcile_self_withholds_respawn_while_backing_off() {
453            let (mut tx, _rx) = mpsc::channel();
454            let spec = crash_spec("crashloop-withhold");
455
456            let mut state = spec
457                .clone()
458                .enter(&mut (), &mut tx)
459                .expect("enter must succeed");
460            std::thread::sleep(Duration::from_millis(50));
461            assert!(matches!(state.child.try_wait(), Ok(Some(_))));
462
463            // First crash respawns immediately: no backoff is active yet.
464            spec.clone()
465                .reconcile_self(&mut state, &mut (), &mut tx)
466                .expect("first respawn should succeed");
467            assert_eq!(state.restart_count, 1);
468            let respawned_pid = state.child.id();
469            std::thread::sleep(Duration::from_millis(50));
470            assert!(
471                matches!(state.child.try_wait(), Ok(Some(_))),
472                "respawned child should also have crashed"
473            );
474
475            // Reconciling again immediately must not respawn: backoff is active.
476            spec.clone()
477                .reconcile_self(&mut state, &mut (), &mut tx)
478                .expect("gated reconcile should succeed");
479            assert_eq!(
480                state.child.id(),
481                respawned_pid,
482                "respawn must be withheld during the backoff window"
483            );
484            assert_eq!(
485                state.restart_count, 1,
486                "restart count must not escalate while gated"
487            );
488        }
489
490        #[test]
491        fn reconcile_self_escalates_backoff_on_repeated_crashes() {
492            let (mut tx, _rx) = mpsc::channel();
493            let spec = crash_spec("crashloop-escalate");
494
495            let mut state = spec
496                .clone()
497                .enter(&mut (), &mut tx)
498                .expect("enter must succeed");
499            std::thread::sleep(Duration::from_millis(50));
500
501            spec.clone()
502                .reconcile_self(&mut state, &mut (), &mut tx)
503                .expect("first respawn should succeed");
504            assert_eq!(state.restart_count, 1);
505            let first_backoff_until = state.next_respawn_at;
506
507            // Wait past the (short) initial backoff so the next crash is free
508            // to respawn and escalate the streak.
509            std::thread::sleep(Duration::from_millis(300));
510            spec.reconcile_self(&mut state, &mut (), &mut tx)
511                .expect("second respawn should succeed");
512            assert_eq!(
513                state.restart_count, 2,
514                "consecutive fast crashes should escalate the restart count"
515            );
516            assert!(
517                state.next_respawn_at.unwrap() > first_backoff_until.unwrap(),
518                "backoff window should grow with repeated crashes"
519            );
520        }
521
522        #[test]
523        fn reconcile_self_resets_restart_count_after_stable_uptime() {
524            let (mut tx, _rx) = mpsc::channel();
525            let spec = crash_spec("crashloop-reset");
526
527            let mut state = spec
528                .clone()
529                .enter(&mut (), &mut tx)
530                .expect("enter must succeed");
531            // Simulate a prior crash streak on a child that has, in fact, been
532            // up long enough to count as recovered.
533            state.restart_count = 5;
534            state.spawned_at =
535                Instant::now() - RESPAWN_BACKOFF_RESET_UPTIME - Duration::from_millis(50);
536            std::thread::sleep(Duration::from_millis(50));
537            assert!(matches!(state.child.try_wait(), Ok(Some(_))));
538
539            spec.reconcile_self(&mut state, &mut (), &mut tx)
540                .expect("respawn should succeed");
541            assert_eq!(
542                state.restart_count, 1,
543                "a stable uptime should reset the crash streak instead of escalating it"
544            );
545        }
546
547        #[test]
548        fn reconcile_self_propagates_err_when_restart_spawn_fails() {
549            let (mut tx, _rx) = mpsc::channel();
550
551            let mut state = ProcessSource {
552                identity: ProcessIdentity {
553                    bin: "/bin/sh".to_string(),
554                    key: "t".to_string(),
555                },
556                args: vec!["-c".to_string(), "exit 0".to_string()],
557                env: BTreeMap::new(),
558                current_dir: None,
559                props: None,
560            }
561            .enter(&mut (), &mut tx)
562            .expect("enter must succeed with /bin/sh");
563
564            std::thread::sleep(Duration::from_millis(200));
565            assert!(
566                matches!(state.child.try_wait(), Ok(Some(_))),
567                "child should have exited"
568            );
569
570            let result = super::make_source("/nonexistent/binary/that/cannot/exist")
571                .reconcile_self(&mut state, &mut (), &mut tx);
572            match result {
573                Err(SpawnError::ProcessSpawnFailed { .. }) => {}
574                _ => panic!("expected ProcessSpawnFailed"),
575            }
576        }
577
578        fn trap_spec(key: &str, trap_body: &str) -> ProcessSource {
579            ProcessSource {
580                identity: ProcessIdentity {
581                    bin: "/bin/sh".to_string(),
582                    key: key.to_string(),
583                },
584                // `wait` is interruptible by signals; foreground `sleep` is not.
585                args: vec![
586                    "-c".to_string(),
587                    format!("trap '{trap_body}' TERM; sleep 60 & wait"),
588                ],
589                env: BTreeMap::new(),
590                current_dir: None,
591                props: None,
592            }
593        }
594
595        #[test]
596        fn exit_reaps_graceful_child_via_sigterm() {
597            let (mut tx, _rx) = mpsc::channel();
598            let state = trap_spec("graceful", "exit 0")
599                .enter(&mut (), &mut tx)
600                .expect("enter must succeed");
601            std::thread::sleep(Duration::from_millis(150));
602
603            let start = Instant::now();
604            ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
605            assert!(start.elapsed() < Duration::from_secs(2));
606        }
607
608        #[test]
609        fn exit_kills_grandchildren_spawned_by_the_child() {
610            let (mut tx, _rx) = mpsc::channel();
611            let pidfile = tempfile::NamedTempFile::new().expect("tempfile must be created");
612            let path = pidfile.path().to_str().expect("utf-8 path").to_string();
613
614            // The shell backgrounds a grandchild, records its pid, then execs
615            // into a foreground sleep — so nothing forwards signals to the
616            // grandchild.
617            let state = ProcessSource {
618                identity: ProcessIdentity {
619                    bin: "/bin/sh".to_string(),
620                    key: "grandchild".to_string(),
621                },
622                args: vec![
623                    "-c".to_string(),
624                    format!("sleep 60 & echo $! > {path}; exec sleep 60"),
625                ],
626                env: BTreeMap::new(),
627                current_dir: None,
628                props: None,
629            }
630            .enter(&mut (), &mut tx)
631            .expect("enter must succeed");
632
633            let deadline = Instant::now() + Duration::from_secs(2);
634            let grandchild_pid = loop {
635                let contents = std::fs::read_to_string(&path).unwrap_or_default();
636                if let Ok(pid) = contents.trim().parse::<i32>() {
637                    break nix::unistd::Pid::from_raw(pid);
638                }
639                assert!(Instant::now() < deadline, "grandchild pid never written");
640                std::thread::sleep(Duration::from_millis(20));
641            };
642
643            ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
644
645            // Signal 0 probes existence; ESRCH means the grandchild is gone.
646            let deadline = Instant::now() + Duration::from_secs(2);
647            while nix::sys::signal::kill(grandchild_pid, None) != Err(nix::errno::Errno::ESRCH) {
648                assert!(
649                    Instant::now() < deadline,
650                    "grandchild survived exit() as an orphan"
651                );
652                std::thread::sleep(Duration::from_millis(20));
653            }
654        }
655
656        #[test]
657        #[ignore = "slow"]
658        fn exit_escalates_to_sigkill_when_child_ignores_sigterm() {
659            let (mut tx, _rx) = mpsc::channel();
660            let state = trap_spec("stubborn", "")
661                .enter(&mut (), &mut tx)
662                .expect("enter must succeed");
663            std::thread::sleep(Duration::from_millis(150));
664
665            let start = Instant::now();
666            ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
667            assert!(start.elapsed() >= SHUTDOWN_GRACE_PERIOD);
668        }
669    }
670}