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 let mut child = match cmd.spawn() {
137 Ok(c) => c,
138 Err(e) => {
139 return Err(SpawnError::ProcessSpawnFailed { bin, source: e });
140 }
141 };
142
143 if let Some(stdout) = child.stdout.take() {
144 spawn_stdout_thread(stdout, spec.identity.clone(), tx.clone());
145 }
146 if let Some(stderr) = child.stderr.take() {
147 spawn_stderr_thread(stderr, spec.identity.bin.clone());
148 }
149 let (event_tx, event_rx) = mpsc::channel::<serde_json::Value>();
150 if let Some(stdin) = child.stdin.take() {
151 spawn_stdin_thread(stdin, event_rx);
152 }
153
154 Ok(ProcessState {
155 child,
156 event_tx,
157 last_sent_props: None,
158 })
159}
160
161impl std::fmt::Display for ProcessSource {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 write!(f, "{}", self.identity.bin)
164 }
165}
166
167impl Lifecycle for ProcessSource {
168 type Key = ProcessIdentity;
169 type State = ProcessState;
170 type Context = ();
171 type Output = mpsc::Sender<StreamItem>;
172 type Error = SpawnError;
173
174 fn key(&self) -> ProcessIdentity {
175 self.identity.clone()
176 }
177
178 fn enter(self, _ctx: &mut (), output: &mut Self::Output) -> Result<Self::State, Self::Error> {
179 let props = self.props.clone();
180 let mut state = spawn_process(self, output)?;
181 if let Some(p) = props {
182 let _ = state.event_tx.send(p.clone());
183 state.last_sent_props = Some(p);
184 }
185 Ok(state)
186 }
187
188 #[allow(clippy::collapsible_if)]
189 fn reconcile_self(
190 self,
191 state: &mut Self::State,
192 _ctx: &mut (),
193 output: &mut Self::Output,
194 ) -> Result<(), Self::Error> {
195 if matches!(state.child.try_wait(), Ok(Some(_))) {
196 tracing::warn!(bin = %self.identity.bin, "process exited");
197 let props = self.props.clone();
198 let mut new_state = spawn_process(self, output)?;
199 if let Some(p) = props {
200 let _ = new_state.event_tx.send(p.clone());
201 new_state.last_sent_props = Some(p);
202 }
203 *state = new_state;
204 } else if let Some(p) = self.props {
205 if state.last_sent_props.as_ref() != Some(&p) {
206 let _ = state.event_tx.send(p.clone());
207 state.last_sent_props = Some(p);
208 }
209 }
210 Ok(())
211 }
212
213 fn exit(
214 mut state: Self::State,
215 _ctx: &mut (),
216 _output: &mut Self::Output,
217 ) -> Result<(), Self::Error> {
218 let _ = nix::sys::signal::kill(
220 nix::unistd::Pid::from_raw(state.child.id() as i32),
221 nix::sys::signal::Signal::SIGTERM,
222 );
223
224 let deadline = Instant::now() + SHUTDOWN_GRACE_PERIOD;
225 while Instant::now() < deadline {
226 match state.child.try_wait() {
227 Ok(Some(_)) => return Ok(()),
228 Ok(None) => thread::sleep(Duration::from_millis(50)),
229 Err(_) => break,
230 }
231 }
232
233 let _ = state.child.kill();
234 let _ = state.child.wait();
235 Ok(())
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::{ProcessIdentity, ProcessSource};
242 use optative::Lifecycle;
243 use std::collections::BTreeMap;
244
245 fn make_source(bin: &str) -> ProcessSource {
246 ProcessSource {
247 identity: ProcessIdentity {
248 bin: bin.to_string(),
249 key: bin.to_string(),
250 },
251 args: vec![],
252 env: BTreeMap::new(),
253 current_dir: None,
254 props: None,
255 }
256 }
257
258 #[test]
259 fn process_identity_has_bin_and_key_fields() {
260 let id = ProcessIdentity {
261 bin: "mybin".to_string(),
262 key: "mykey".to_string(),
263 };
264 assert_eq!(id.bin, "mybin");
265 assert_eq!(id.key, "mykey");
266 }
267
268 #[test]
269 fn process_identity_derives_hash_eq_partialeq_clone() {
270 use std::collections::HashSet;
271 let a = ProcessIdentity {
272 bin: "bin".to_string(),
273 key: "k".to_string(),
274 };
275 let b = a.clone();
276 assert_eq!(a, b);
277 let mut set = HashSet::new();
278 set.insert(a);
279 assert!(!set.insert(b));
280 }
281
282 #[test]
283 fn process_source_has_identity_fields() {
284 let spec = ProcessSource {
285 identity: ProcessIdentity {
286 bin: "/bin/sh".to_string(),
287 key: "my-key".to_string(),
288 },
289 args: vec!["--flag".to_string()],
290 env: BTreeMap::new(),
291 current_dir: None,
292 props: None,
293 };
294 assert_eq!(spec.identity.bin, "/bin/sh");
295 assert_eq!(spec.identity.key, "my-key");
296 }
297
298 #[test]
299 fn lifecycle_key_returns_identity() {
300 let id = ProcessIdentity {
301 bin: "/usr/bin/cat".to_string(),
302 key: "cat-key".to_string(),
303 };
304 let returned: ProcessIdentity = make_source("/usr/bin/cat").key();
305 assert_eq!(returned.bin, id.bin);
306 }
307
308 mod spawn_process {
309 use super::super::{SpawnError, spawn_process};
310 use std::sync::mpsc;
311
312 #[test]
313 fn nonexistent_binary_returns_process_spawn_failed() {
314 let (tx, _rx) = mpsc::channel();
315 let result = spawn_process(
316 super::make_source("/nonexistent/binary/that/cannot/exist"),
317 &tx,
318 );
319 match result {
320 Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
321 assert_eq!(bin, "/nonexistent/binary/that/cannot/exist");
322 }
323 _ => panic!("expected ProcessSpawnFailed"),
324 }
325 }
326
327 #[test]
328 fn tilde_bin_is_expanded_to_home_dir() {
329 let home = std::env::var("HOME").expect("HOME must be set");
330 let (tx, _rx) = mpsc::channel();
331 let result = spawn_process(super::make_source("~/nonexistent-tilde-test-binary"), &tx);
332 match result {
333 Err(SpawnError::ProcessSpawnFailed { bin, .. }) => {
334 assert!(
335 !bin.starts_with('~'),
336 "bin must not contain literal ~; got: {bin}"
337 );
338 assert!(
339 bin.starts_with(&home),
340 "bin must start with HOME ({home}); got: {bin}"
341 );
342 }
343 _ => panic!("expected ProcessSpawnFailed"),
344 }
345 }
346 }
347
348 mod lifecycle {
349 use super::super::{ProcessIdentity, ProcessSource, SHUTDOWN_GRACE_PERIOD, SpawnError};
350 use optative::Lifecycle;
351 use std::collections::BTreeMap;
352 use std::sync::mpsc;
353 use std::time::{Duration, Instant};
354
355 #[test]
356 fn reconcile_self_propagates_err_when_restart_spawn_fails() {
357 let (mut tx, _rx) = mpsc::channel();
358
359 let mut state = ProcessSource {
360 identity: ProcessIdentity {
361 bin: "/bin/sh".to_string(),
362 key: "t".to_string(),
363 },
364 args: vec!["-c".to_string(), "exit 0".to_string()],
365 env: BTreeMap::new(),
366 current_dir: None,
367 props: None,
368 }
369 .enter(&mut (), &mut tx)
370 .expect("enter must succeed with /bin/sh");
371
372 std::thread::sleep(Duration::from_millis(200));
373 assert!(
374 matches!(state.child.try_wait(), Ok(Some(_))),
375 "child should have exited"
376 );
377
378 let result = super::make_source("/nonexistent/binary/that/cannot/exist")
379 .reconcile_self(&mut state, &mut (), &mut tx);
380 match result {
381 Err(SpawnError::ProcessSpawnFailed { .. }) => {}
382 _ => panic!("expected ProcessSpawnFailed"),
383 }
384 }
385
386 fn trap_spec(key: &str, trap_body: &str) -> ProcessSource {
387 ProcessSource {
388 identity: ProcessIdentity {
389 bin: "/bin/sh".to_string(),
390 key: key.to_string(),
391 },
392 args: vec![
394 "-c".to_string(),
395 format!("trap '{trap_body}' TERM; sleep 60 & wait"),
396 ],
397 env: BTreeMap::new(),
398 current_dir: None,
399 props: None,
400 }
401 }
402
403 #[test]
404 fn exit_reaps_graceful_child_via_sigterm() {
405 let (mut tx, _rx) = mpsc::channel();
406 let state = trap_spec("graceful", "exit 0")
407 .enter(&mut (), &mut tx)
408 .expect("enter must succeed");
409 std::thread::sleep(Duration::from_millis(150));
410
411 let start = Instant::now();
412 ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
413 assert!(start.elapsed() < Duration::from_secs(2));
414 }
415
416 #[test]
417 #[ignore = "slow"]
418 fn exit_escalates_to_sigkill_when_child_ignores_sigterm() {
419 let (mut tx, _rx) = mpsc::channel();
420 let state = trap_spec("stubborn", "")
421 .enter(&mut (), &mut tx)
422 .expect("enter must succeed");
423 std::thread::sleep(Duration::from_millis(150));
424
425 let start = Instant::now();
426 ProcessSource::exit(state, &mut (), &mut tx).expect("exit must succeed");
427 assert!(start.elapsed() >= SHUTDOWN_GRACE_PERIOD);
428 }
429 }
430}