1use std::{
13 collections::HashMap,
14 path::PathBuf,
15 process::Stdio,
16 sync::{Arc, OnceLock},
17 time::Duration,
18};
19
20use saluki_error::{generic_error, ErrorContext as _, GenericError};
21use tokio::{
22 io::{AsyncBufReadExt as _, AsyncRead, BufReader},
23 process::Command,
24 sync::Mutex,
25 task::JoinHandle,
26};
27use tokio_util::sync::CancellationToken;
28use tracing::{debug, warn};
29
30#[cfg(unix)]
32const SIGTERM_GRACE: Duration = Duration::from_millis(500);
33
34const TASK_JOIN_TIMEOUT: Duration = Duration::from_secs(2);
36
37pub type ExitCodeCell = Arc<OnceLock<Option<i32>>>;
45
46#[derive(Clone)]
48pub struct UnixProcessConfig {
49 pub name: String,
51 pub binary_path: PathBuf,
53 pub args: Vec<String>,
55 pub env: HashMap<String, String>,
57}
58
59impl UnixProcessConfig {
60 pub fn new(name: impl Into<String>, binary_path: impl Into<PathBuf>) -> Self {
62 Self {
63 name: name.into(),
64 binary_path: binary_path.into(),
65 args: Vec::new(),
66 env: HashMap::new(),
67 }
68 }
69
70 pub fn with_args(mut self, args: Vec<String>) -> Self {
72 self.args = args;
73 self
74 }
75
76 pub fn with_env_map(mut self, env: HashMap<String, String>) -> Self {
78 self.env = env;
79 self
80 }
81}
82
83pub trait LogSink: Send + Sync {
88 fn push_line(&mut self, line: String, is_stderr: bool);
91}
92
93pub struct UnixProcess {
109 name: String,
110 #[cfg(unix)]
112 process_group: Option<i32>,
113 exit_code: ExitCodeCell,
114 tasks: Vec<JoinHandle<()>>,
116}
117
118impl UnixProcess {
119 pub async fn spawn(
122 config: UnixProcessConfig, log_sink: Arc<Mutex<dyn LogSink>>, exit_token: CancellationToken,
123 ) -> Result<Self, GenericError> {
124 if !config.binary_path.exists() {
125 return Err(generic_error!(
126 "Binary not found at expected path: {}",
127 config.binary_path.display()
128 ));
129 }
130
131 let mut cmd = Command::new(&config.binary_path);
132 cmd.args(&config.args)
133 .envs(&config.env)
134 .stdout(Stdio::piped())
135 .stderr(Stdio::piped())
136 .kill_on_drop(true);
137 #[cfg(unix)]
139 cmd.process_group(0);
140
141 let mut child = cmd
142 .spawn()
143 .with_error_context(|| format!("Failed to spawn '{}'.", config.binary_path.display()))?;
144
145 #[cfg(unix)]
146 let process_group = child.id().map(|pid| pid as i32);
147
148 let stdout = child
149 .stdout
150 .take()
151 .ok_or_else(|| generic_error!("Failed to capture stdout."))?;
152 let stderr = child
153 .stderr
154 .take()
155 .ok_or_else(|| generic_error!("Failed to capture stderr."))?;
156
157 let stdout_task = spawn_log_pump(stdout, log_sink.clone(), false);
158 let stderr_task = spawn_log_pump(stderr, log_sink, true);
159
160 let exit_code: ExitCodeCell = Arc::new(OnceLock::new());
164 let exit_code_for_watcher = exit_code.clone();
165 let name_for_watcher = config.name.clone();
166 let exit_task = tokio::spawn(async move {
167 match child.wait().await {
168 Ok(status) => {
169 let code = status.code();
170 debug!(name = %name_for_watcher, ?code, "Unix process exited.");
171 let _ = exit_code_for_watcher.set(code);
172 }
173 Err(e) => {
174 warn!(name = %name_for_watcher, error = %e, "Failed to wait on Unix process; treating as exited.");
175 let _ = exit_code_for_watcher.set(None);
176 }
177 }
178 exit_token.cancel();
179 });
180
181 Ok(Self {
182 name: config.name,
183 #[cfg(unix)]
184 process_group,
185 exit_code,
186 tasks: vec![exit_task, stdout_task, stderr_task],
187 })
188 }
189
190 pub fn name(&self) -> &str {
192 &self.name
193 }
194
195 pub fn exit_code_cell(&self) -> ExitCodeCell {
199 self.exit_code.clone()
200 }
201
202 pub async fn cleanup(mut self) {
209 #[cfg(unix)]
210 if let Some(pgid) = self.process_group {
211 signal_process_group(pgid, libc::SIGTERM);
212 tokio::time::sleep(SIGTERM_GRACE).await;
213 signal_process_group(pgid, libc::SIGKILL);
214 self.process_group = None;
216 }
217
218 let mut tasks = std::mem::take(&mut self.tasks);
219 let joined = tokio::time::timeout(TASK_JOIN_TIMEOUT, async {
220 for task in &mut tasks {
221 let _ = task.await;
222 }
223 })
224 .await;
225 if joined.is_err() {
226 warn!(
227 name = %self.name,
228 "Process tasks still running after SIGKILL; aborting them. A descendant outside the process group \
229 may still hold the output pipes."
230 );
231 for task in &tasks {
232 task.abort();
233 }
234 }
235 }
236}
237
238impl Drop for UnixProcess {
239 fn drop(&mut self) {
240 #[cfg(unix)]
241 if let Some(pgid) = self.process_group.take() {
242 signal_process_group(pgid, libc::SIGKILL);
244 warn!(name = %self.name, pgid, "UnixProcess dropped before cleanup; killed its process group.");
245 }
246 }
247}
248
249#[cfg(unix)]
251fn signal_process_group(pgid: i32, signal: libc::c_int) {
252 unsafe {
254 libc::killpg(pgid, signal);
255 }
256}
257
258fn spawn_log_pump<R>(reader: R, sink: Arc<Mutex<dyn LogSink>>, is_stderr: bool) -> JoinHandle<()>
259where
260 R: AsyncRead + Unpin + Send + 'static,
261{
262 let mut lines = BufReader::new(reader).lines();
263 tokio::spawn(async move {
264 loop {
265 match lines.next_line().await {
266 Ok(Some(line)) => {
267 let mut sink = sink.lock().await;
268 sink.push_line(line, is_stderr);
269 }
270 Ok(None) => break,
271 Err(e) => {
272 debug!(error = %e, "Log pump read error; stopping.");
273 break;
274 }
275 }
276 }
277 })
278}
279
280#[cfg(all(test, unix))]
281mod tests {
282 use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
283
284 use super::*;
285
286 const WAIT: Duration = Duration::from_secs(5);
288
289 struct ChannelSink(UnboundedSender<String>);
294
295 impl LogSink for ChannelSink {
296 fn push_line(&mut self, line: String, _is_stderr: bool) {
297 let _ = self.0.send(line);
298 }
299 }
300
301 struct KillGroupOnDrop(i32);
303
304 impl Drop for KillGroupOnDrop {
305 fn drop(&mut self) {
306 signal_process_group(self.0, libc::SIGKILL);
307 }
308 }
309
310 async fn spawn_shell(script: &str) -> (UnixProcess, UnboundedReceiver<String>, CancellationToken) {
312 let (tx, rx) = unbounded_channel();
313 let sink: Arc<Mutex<dyn LogSink>> = Arc::new(Mutex::new(ChannelSink(tx)));
314 let exit_token = CancellationToken::new();
315 let config = UnixProcessConfig::new("test", "/bin/sh").with_args(vec!["-c".to_string(), script.to_string()]);
316 let process = UnixProcess::spawn(config, sink, exit_token.clone())
317 .await
318 .expect("spawning /bin/sh should succeed");
319 (process, rx, exit_token)
320 }
321
322 async fn wait_for_line(rx: &mut UnboundedReceiver<String>, prefix: &str) -> String {
324 tokio::time::timeout(WAIT, async {
325 while let Some(line) = rx.recv().await {
326 if line.starts_with(prefix) {
327 return line;
328 }
329 }
330 panic!("output closed before a line starting with {prefix:?} (is the script's tool installed?)");
331 })
332 .await
333 .unwrap_or_else(|_| panic!("no line starting with {prefix:?} within {WAIT:?}"))
334 }
335
336 async fn wait_for_pipes_closed(rx: &mut UnboundedReceiver<String>) -> Vec<String> {
338 tokio::time::timeout(WAIT, async {
339 let mut lines = Vec::new();
340 while let Some(line) = rx.recv().await {
341 lines.push(line);
342 }
343 lines
344 })
345 .await
346 .expect("a process still holds the output pipes")
347 }
348
349 #[tokio::test]
352 async fn dropping_a_process_kills_its_whole_group() {
353 let (process, mut rx, exit_token) = spawn_shell("sleep 60 & echo ready; wait").await;
355 let exit_code = process.exit_code_cell();
356 wait_for_line(&mut rx, "ready").await;
357
358 drop(process);
359
360 wait_for_pipes_closed(&mut rx).await;
361 tokio::time::timeout(WAIT, exit_token.cancelled())
362 .await
363 .expect("the exit watcher should fire the exit token after the kill");
364 assert_eq!(exit_code.get(), Some(&None), "the shell should die of a signal");
365 }
366
367 #[tokio::test]
368 async fn dropping_cleanup_during_its_sigterm_grace_still_kills_the_group() {
369 let (process, mut rx, _exit_token) = spawn_shell("trap '' TERM; sleep 60 & echo ready; wait").await;
371 wait_for_line(&mut rx, "ready").await;
372
373 let cleanup = tokio::time::timeout(SIGTERM_GRACE / 2, process.cleanup()).await;
374 assert!(cleanup.is_err(), "cleanup should still be waiting out its grace");
375
376 wait_for_pipes_closed(&mut rx).await;
377 }
378
379 #[tokio::test]
380 async fn cleanup_sends_sigterm_before_sigkill() {
381 let (process, mut rx, _exit_token) =
382 spawn_shell("trap 'echo got-sigterm; exit 0' TERM; sleep 60 & echo ready; wait").await;
383 let exit_code = process.exit_code_cell();
384 wait_for_line(&mut rx, "ready").await;
385
386 tokio::time::timeout(WAIT, process.cleanup())
387 .await
388 .expect("cleanup should return");
389
390 let lines = wait_for_pipes_closed(&mut rx).await;
391 assert!(lines.iter().any(|line| line == "got-sigterm"), "lines: {lines:?}");
392 assert_eq!(exit_code.get(), Some(&Some(0)), "the trap should have exited the shell");
393 }
394
395 #[tokio::test]
396 async fn cleanup_returns_when_an_escaped_descendant_holds_the_output_pipes() {
397 let (process, mut rx, _exit_token) =
400 spawn_shell(r#"perl -e '$| = 1; setpgrp(0, 0); print "escaped $$\n"; exec @ARGV' sleep 60 & wait"#).await;
401 let line = wait_for_line(&mut rx, "escaped ").await;
402 let escaped_pid: i32 = line["escaped ".len()..].trim().parse().expect("a PID after `escaped `");
403 let _guard = KillGroupOnDrop(escaped_pid);
404 assert_eq!(
406 unsafe { libc::getpgid(escaped_pid) },
407 escaped_pid,
408 "the descendant should lead its own group"
409 );
410
411 tokio::time::timeout(SIGTERM_GRACE + TASK_JOIN_TIMEOUT + WAIT, process.cleanup())
412 .await
413 .expect("cleanup should give up on the pumps after its bound");
414
415 tokio::time::timeout(Duration::from_secs(1), async { while rx.recv().await.is_some() {} })
417 .await
418 .expect("cleanup should have aborted the log pumps");
419 }
420}