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
13pub const SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(10);
15
16const INITIAL_RESPAWN_BACKOFF: Duration = Duration::from_millis(200);
18
19const MAX_RESPAWN_BACKOFF: Duration = Duration::from_secs(5);
22
23const RESPAWN_BACKOFF_RESET_UPTIME: Duration = Duration::from_secs(1);
27
28fn 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#[derive(Hash, Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
39pub struct ProcessIdentity {
40 pub bin: String,
41 pub key: String,
42}
43
44#[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 spawned_at: Instant,
61 restart_count: u32,
63 next_respawn_at: Option<Instant>,
66}
67
68#[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 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 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 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 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 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 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 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 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 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 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}