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}
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 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 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}