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
16#[derive(Hash, Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
19pub struct ProcessIdentity {
20 pub bin: String,
21 pub key: String,
22}
23
24#[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#[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 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 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 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 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 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}