1use std::collections::HashMap;
13use std::collections::hash_map::Entry;
14use std::future::Future;
15use std::panic::{AssertUnwindSafe, catch_unwind};
16use std::pin::Pin;
17use std::sync::{Arc, Mutex, MutexGuard, OnceLock, mpsc};
18use std::task::{Context, Poll};
19use std::thread;
20
21use tokio::signal::unix::{Signal, SignalKind};
22use tokio::sync::{oneshot, watch};
23
24use crate::error::{AgentdError, AgentdResult};
25
26static PROCESS_MANAGER: OnceLock<Arc<ProcessManager>> = OnceLock::new();
31
32const REAP_BATCH_SIZE: usize = 64;
37
38#[derive(Debug)]
44pub struct ProcessManager {
45 state: Mutex<ProcessManagerState>,
46 startup_error: OnceLock<String>,
47 failure_tx: watch::Sender<Option<String>>,
48}
49
50#[derive(Debug)]
51struct ProcessManagerState {
52 processes: HashMap<i32, TrackedProcess>,
53 next_generation: u64,
54 terminal_error: Option<String>,
55}
56
57#[derive(Debug)]
58struct TrackedProcess {
59 generation: u64,
60 exit_tx: Option<oneshot::Sender<i32>>,
61}
62
63pub struct ProcessSpawnGuard<'a> {
65 state: MutexGuard<'a, ProcessManagerState>,
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub struct ProcessIdentity {
74 pid: i32,
75 generation: u64,
76}
77
78pub struct ProcessExitWatcher {
84 identity: ProcessIdentity,
85 receiver: oneshot::Receiver<i32>,
86}
87
88impl ProcessManager {
93 pub fn get() -> AgentdResult<Arc<Self>> {
103 if let Some(manager) = PROCESS_MANAGER.get() {
104 return manager.result();
105 }
106
107 let candidate = Arc::new(Self::new());
108 let manager = PROCESS_MANAGER.get_or_init(move || {
109 candidate.launch_thread();
110 candidate
111 });
112 manager.result()
113 }
114
115 fn new() -> Self {
116 let (failure_tx, _) = watch::channel(None);
117 Self {
118 state: Mutex::new(ProcessManagerState::new()),
119 startup_error: OnceLock::new(),
120 failure_tx,
121 }
122 }
123
124 pub fn spawn_guard(&self) -> AgentdResult<ProcessSpawnGuard<'_>> {
134 let state = self
135 .state
136 .lock()
137 .unwrap_or_else(|poisoned| poisoned.into_inner());
138 if let Some(error) = state.terminal_error.as_ref() {
139 return Err(AgentdError::ExecSession(error.clone()));
140 }
141 Ok(ProcessSpawnGuard { state })
142 }
143
144 pub fn subscribe_failure(&self) -> AgentdResult<watch::Receiver<Option<String>>> {
149 let receiver = self.failure_tx.subscribe();
150 if let Some(error) = self
151 .state
152 .lock()
153 .unwrap_or_else(|poisoned| poisoned.into_inner())
154 .terminal_error
155 .as_ref()
156 {
157 return Err(AgentdError::ExecSession(error.clone()));
158 }
159 Ok(receiver)
160 }
161
162 pub fn signal_process_group(&self, identity: ProcessIdentity, signum: i32) -> AgentdResult<()> {
167 let state = self
168 .state
169 .lock()
170 .unwrap_or_else(|poisoned| poisoned.into_inner());
171 let Some(process) = state.processes.get(&identity.pid) else {
172 return Ok(());
173 };
174 if process.generation != identity.generation {
175 return Ok(());
176 }
177
178 if process.exit_tx.is_some() {
179 signal_process_group_or_process(identity.pid, signum)
180 } else {
181 signal_process_group_only(identity.pid, signum)
185 }
186 }
187
188 pub(crate) fn release(&self, identity: ProcessIdentity) {
190 let mut state = self
191 .state
192 .lock()
193 .unwrap_or_else(|poisoned| poisoned.into_inner());
194 if state.matches(identity) {
195 state.processes.remove(&identity.pid);
196 }
197 }
198
199 fn launch_thread(self: &Arc<Self>) {
200 let (startup_tx, startup_rx) = mpsc::sync_channel(1);
201 let manager = Arc::clone(self);
202 let spawn_result = thread::Builder::new()
203 .name("agentd-process-manager".to_string())
204 .spawn(move || {
205 let failure_manager = Arc::clone(&manager);
206 let result = catch_unwind(AssertUnwindSafe(|| {
207 run_process_manager_thread(manager, startup_tx)
208 }));
209 let error = match result {
210 Ok(Err(error)) => error,
211 Ok(Ok(())) => "process manager thread stopped unexpectedly".to_string(),
212 Err(_) => "process manager thread panicked".to_string(),
213 };
214 failure_manager.fail(error);
215 });
216
217 let startup_result = match spawn_result {
218 Ok(_) => startup_rx
219 .recv()
220 .unwrap_or_else(|error| Err(format!("receive thread startup: {error}"))),
221 Err(error) => Err(format!("spawn process manager thread: {error}")),
222 };
223 if let Err(error) = startup_result {
224 let _ = self.startup_error.set(error);
225 }
226 }
227
228 fn result(self: &Arc<Self>) -> AgentdResult<Arc<Self>> {
229 if let Some(error) = self.startup_error.get() {
230 return Err(AgentdError::ExecSession(format!(
231 "start process manager: {error}"
232 )));
233 }
234 match self
235 .state
236 .lock()
237 .unwrap_or_else(|poisoned| poisoned.into_inner())
238 .terminal_error
239 .as_ref()
240 {
241 Some(error) => Err(AgentdError::ExecSession(error.clone())),
242 None => Ok(Arc::clone(self)),
243 }
244 }
245
246 fn fail(&self, error: String) {
247 let mut state = self
248 .state
249 .lock()
250 .unwrap_or_else(|poisoned| poisoned.into_inner());
251 if state.terminal_error.is_some() {
252 return;
253 }
254
255 state.terminal_error = Some(error.clone());
256 for (pid, process) in &state.processes {
257 if process.exit_tx.is_some() {
258 let _ = signal_process_group_or_process(*pid, libc::SIGKILL);
259 } else {
260 let _ = signal_process_group_only(*pid, libc::SIGKILL);
261 }
262 }
263 state.processes.clear();
264 drop(state);
265
266 self.failure_tx.send_replace(Some(error));
268 }
269
270 async fn run(self: Arc<Self>, mut signal: Signal) -> Result<(), String> {
271 self.reap_until_idle()?;
272 while signal.recv().await.is_some() {
273 self.reap_until_idle()?;
274 }
275 Err("process manager SIGCHLD listener closed".to_string())
276 }
277
278 fn reap_until_idle(&self) -> Result<(), String> {
279 while self.reap_exited_batch()? {
280 thread::yield_now();
283 }
284 Ok(())
285 }
286
287 fn reap_exited_batch(&self) -> Result<bool, String> {
288 let mut state = self
289 .state
290 .lock()
291 .unwrap_or_else(|poisoned| poisoned.into_inner());
292 state
293 .reap_exited_batch()
294 .map_err(|error| format!("waitpid failed while reaping processes: {error}"))
295 }
296}
297
298impl ProcessManagerState {
299 fn new() -> Self {
300 Self {
301 processes: HashMap::new(),
302 next_generation: 1,
303 terminal_error: None,
304 }
305 }
306
307 fn track(&mut self, pid: i32) -> AgentdResult<ProcessExitWatcher> {
308 if pid <= 0 {
309 return Err(AgentdError::ExecSession(format!(
310 "cannot track invalid process PID {pid}"
311 )));
312 }
313
314 let generation = self.next_generation;
315 self.next_generation = self.next_generation.checked_add(1).ok_or_else(|| {
316 AgentdError::ExecSession("process registration generation exhausted".to_string())
317 })?;
318 let identity = ProcessIdentity { pid, generation };
319 let (exit_tx, receiver) = oneshot::channel();
320 match self.processes.entry(pid) {
321 Entry::Vacant(entry) => {
322 entry.insert(TrackedProcess {
323 generation,
324 exit_tx: Some(exit_tx),
325 });
326 Ok(ProcessExitWatcher { identity, receiver })
327 }
328 Entry::Occupied(mut entry) if entry.get().exit_tx.is_none() => {
329 entry.insert(TrackedProcess {
333 generation,
334 exit_tx: Some(exit_tx),
335 });
336 Ok(ProcessExitWatcher { identity, receiver })
337 }
338 Entry::Occupied(_) => Err(AgentdError::ExecSession(format!(
339 "process PID {pid} is already tracked"
340 ))),
341 }
342 }
343
344 fn matches(&self, identity: ProcessIdentity) -> bool {
345 self.processes
346 .get(&identity.pid)
347 .is_some_and(|process| process.generation == identity.generation)
348 }
349
350 fn reap_exited_batch(&mut self) -> std::io::Result<bool> {
356 let mut reaped = 0;
357 while reaped < REAP_BATCH_SIZE {
358 let mut status = 0;
359 let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
360 if pid > 0 {
361 reaped += 1;
362 let mut remove = false;
363 if let Some(process) = self.processes.get_mut(&pid)
364 && let Some(exit_tx) = process.exit_tx.take()
365 {
366 remove = exit_tx.send(exit_code(status)).is_err();
370 }
371 if remove {
372 self.processes.remove(&pid);
373 }
374 continue;
375 }
376 if pid == 0 {
377 return Ok(false);
378 }
379
380 let error = std::io::Error::last_os_error();
381 if error.raw_os_error() == Some(libc::EINTR) {
382 continue;
383 }
384 if error.raw_os_error() == Some(libc::ECHILD) {
385 return Ok(false);
386 }
387 return Err(error);
388 }
389 Ok(true)
390 }
391}
392
393impl ProcessSpawnGuard<'_> {
394 pub fn track(mut self, pid: i32) -> AgentdResult<ProcessExitWatcher> {
405 match self.state.track(pid) {
406 Ok(exit_watcher) => Ok(exit_watcher),
407 Err(error) => {
408 if pid > 0 {
411 let _ = signal_process_group_or_process(pid, libc::SIGKILL);
412 }
413 Err(error)
414 }
415 }
416 }
417}
418
419impl ProcessIdentity {
420 pub fn pid(self) -> i32 {
422 self.pid
423 }
424}
425
426impl ProcessExitWatcher {
427 pub fn identity(&self) -> ProcessIdentity {
429 self.identity
430 }
431}
432
433impl Future for ProcessExitWatcher {
438 type Output = i32;
439
440 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
441 match Pin::new(&mut self.receiver).poll(cx) {
442 Poll::Ready(Ok(code)) => Poll::Ready(code),
443 Poll::Ready(Err(error)) => {
444 eprintln!(
445 "agentd: process manager dropped the exit notification for PID {}: {error}",
446 self.identity.pid
447 );
448 Poll::Ready(-1)
449 }
450 Poll::Pending => Poll::Pending,
451 }
452 }
453}
454
455fn run_process_manager_thread(
460 manager: Arc<ProcessManager>,
461 startup: mpsc::SyncSender<Result<(), String>>,
462) -> Result<(), String> {
463 let runtime = match tokio::runtime::Builder::new_current_thread()
464 .enable_all()
465 .build()
466 {
467 Ok(runtime) => runtime,
468 Err(error) => {
469 let error = format!("build process manager Tokio runtime: {error}");
470 let _ = startup.send(Err(error.clone()));
471 return Err(error);
472 }
473 };
474
475 runtime.block_on(async move {
476 match tokio::signal::unix::signal(SignalKind::child()) {
477 Ok(signal) => {
478 let _ = startup.send(Ok(()));
479 manager.run(signal).await
480 }
481 Err(error) => {
482 let error = format!("install process manager SIGCHLD listener: {error}");
483 let _ = startup.send(Err(error.clone()));
484 Err(error)
485 }
486 }
487 })
488}
489
490fn signal_process_group_or_process(pid: i32, signum: i32) -> AgentdResult<()> {
491 let group_result = unsafe { libc::kill(-pid, signum) };
492 if group_result == 0 {
493 return Ok(());
494 }
495
496 let group_error = std::io::Error::last_os_error();
497 if group_error.raw_os_error() != Some(libc::ESRCH) {
498 return Err(group_error.into());
499 }
500
501 let process_result = unsafe { libc::kill(pid, signum) };
505 if process_result == 0 {
506 return Ok(());
507 }
508
509 let process_error = std::io::Error::last_os_error();
510 if process_error.raw_os_error() == Some(libc::ESRCH) {
511 Ok(())
512 } else {
513 Err(process_error.into())
514 }
515}
516
517fn signal_process_group_only(pid: i32, signum: i32) -> AgentdResult<()> {
518 let result = unsafe { libc::kill(-pid, signum) };
519 if result == 0 {
520 return Ok(());
521 }
522
523 let error = std::io::Error::last_os_error();
524 if error.raw_os_error() == Some(libc::ESRCH) {
525 Ok(())
526 } else {
527 Err(error.into())
528 }
529}
530
531fn exit_code(status: i32) -> i32 {
532 if libc::WIFEXITED(status) {
533 libc::WEXITSTATUS(status)
534 } else {
535 -1
536 }
537}
538
539#[cfg(test)]
544mod tests {
545 use std::io::Read;
546 use std::process::{Command, Stdio};
547 use std::sync::Arc;
548 use std::thread;
549 use std::time::{Duration, Instant};
550
551 use super::*;
552
553 const HELPER_ENV: &str = "MSB_AGENTD_PROCESS_MANAGER_HELPER";
554 const HELPER_SENTINEL: &str = "process-manager-helper-passed";
555 const TEST_NAME: &str = "process::tests::reaping_is_batched_and_tracks_exit_codes";
556
557 #[test]
558 fn reaping_is_batched_and_tracks_exit_codes() {
559 if std::env::var_os(HELPER_ENV).is_some() {
560 run_batched_reap_scenario();
561 println!("{HELPER_SENTINEL}");
562 return;
563 }
564
565 let mut helper = Command::new(std::env::current_exe().expect("current test binary"))
566 .args(["--exact", TEST_NAME, "--nocapture"])
567 .env(HELPER_ENV, "1")
568 .stdout(Stdio::piped())
569 .spawn()
570 .expect("spawn isolated process manager test");
571 let mut output = String::new();
572 helper
573 .stdout
574 .take()
575 .expect("helper stdout")
576 .read_to_string(&mut output)
577 .expect("read helper stdout");
578
579 match helper.wait() {
580 Ok(status) => assert!(status.success(), "helper failed: {status}\n{output}"),
581 Err(error) if error.raw_os_error() == Some(libc::ECHILD) => {}
582 Err(error) => panic!("wait for helper: {error}"),
583 }
584 assert!(
585 output.contains(HELPER_SENTINEL),
586 "helper did not complete the reap scenario:\n{output}"
587 );
588 }
589
590 #[test]
591 fn invalid_pids_are_rejected() {
592 let manager = ProcessManager::new();
593 for pid in [-1, 0] {
594 let error = match manager
595 .spawn_guard()
596 .expect("acquire process spawn guard")
597 .track(pid)
598 {
599 Ok(_) => panic!("invalid PID should be rejected"),
600 Err(error) => error,
601 };
602 assert!(error.to_string().contains(&pid.to_string()));
603 }
604 }
605
606 #[test]
607 fn terminal_failure_rejects_spawns_and_wakes_exits() {
608 const UNUSED_PID: i32 = i32::MAX;
609
610 let manager = Arc::new(ProcessManager::new());
611 let mut failure_rx = manager
612 .subscribe_failure()
613 .expect("subscribe to process manager failure");
614 let exit_watcher = manager
615 .spawn_guard()
616 .expect("acquire process spawn guard")
617 .track(UNUSED_PID)
618 .expect("track test PID");
619
620 manager.fail("process manager test failure".to_string());
621
622 assert!(manager.result().is_err());
623 assert!(manager.spawn_guard().is_err());
624 let runtime = tokio::runtime::Builder::new_current_thread()
625 .build()
626 .expect("test runtime");
627 runtime
628 .block_on(failure_rx.changed())
629 .expect("receive process manager failure");
630 assert_eq!(
631 failure_rx.borrow().as_deref(),
632 Some("process manager test failure")
633 );
634 assert_eq!(runtime.block_on(exit_watcher), -1);
635 }
636
637 #[test]
638 fn stale_identity_does_not_match_reused_pid() {
639 const PID: i32 = i32::MAX;
640
641 let manager = ProcessManager::new();
642 let first = manager
643 .spawn_guard()
644 .expect("acquire first process spawn guard")
645 .track(PID)
646 .expect("track first PID generation");
647 let first_identity = first.identity();
648 let first_exit_tx = manager
649 .state
650 .lock()
651 .unwrap_or_else(|poisoned| poisoned.into_inner())
652 .processes
653 .get_mut(&PID)
654 .expect("first process registration")
655 .exit_tx
656 .take()
657 .expect("first exit sender");
658 first_exit_tx.send(0).expect("send first exit code");
659
660 assert!(
661 manager
662 .state
663 .lock()
664 .unwrap_or_else(|poisoned| poisoned.into_inner())
665 .matches(first_identity)
666 );
667
668 let second = manager
669 .spawn_guard()
670 .expect("acquire second process spawn guard")
671 .track(PID)
672 .expect("track reused PID generation");
673 let second_identity = second.identity();
674 assert_ne!(first_identity, second_identity);
675
676 let state = manager
677 .state
678 .lock()
679 .unwrap_or_else(|poisoned| poisoned.into_inner());
680 assert!(!state.matches(first_identity));
681 assert!(state.matches(second_identity));
682 drop(state);
683
684 manager.release(first_identity);
687 assert!(
688 manager
689 .state
690 .lock()
691 .unwrap_or_else(|poisoned| poisoned.into_inner())
692 .matches(second_identity)
693 );
694 manager.release(second_identity);
695 assert!(
696 !manager
697 .state
698 .lock()
699 .unwrap_or_else(|poisoned| poisoned.into_inner())
700 .matches(second_identity)
701 );
702 }
703
704 fn run_batched_reap_scenario() {
705 let manager = Arc::new(ProcessManager::new());
706 let mut tracked = Vec::with_capacity(REAP_BATCH_SIZE + 1);
707 for offset in 0..=REAP_BATCH_SIZE {
708 let code = 10 + (offset % 50) as i32;
709 let guard = manager.spawn_guard().expect("acquire process spawn guard");
710 let child = Command::new("/bin/sh")
711 .args(["-c", &format!("exit {code}")])
712 .spawn()
713 .expect("spawn tracked child");
714 let pid = child.id() as i32;
715 drop(child);
716 let exit_watcher = guard.track(pid).expect("track child");
717 tracked.push((pid, code, exit_watcher));
718 }
719
720 for (pid, _, _) in &tracked {
721 wait_until_exited_without_reaping(*pid);
722 }
723
724 assert!(
725 manager
726 .reap_exited_batch()
727 .expect("reap first bounded batch")
728 );
729 drop(
730 manager
731 .spawn_guard()
732 .expect("spawn lock should be released between reap batches"),
733 );
734 assert!(
735 !manager
736 .reap_exited_batch()
737 .expect("reap remaining children")
738 );
739
740 let runtime = tokio::runtime::Builder::new_current_thread()
741 .build()
742 .expect("test runtime");
743 for (_, expected_code, exit_watcher) in tracked {
744 assert_eq!(runtime.block_on(exit_watcher), expected_code);
745 }
746
747 let orphan = Command::new("/bin/sh")
748 .args(["-c", "exit 43"])
749 .spawn()
750 .expect("spawn untracked child");
751 let orphan_pid = orphan.id() as i32;
752 drop(orphan);
753 wait_until_exited_without_reaping(orphan_pid);
754
755 assert!(!manager.reap_exited_batch().expect("reap untracked child"));
756 assert_already_reaped(orphan_pid);
757 }
758
759 fn wait_until_exited_without_reaping(pid: i32) {
760 let deadline = Instant::now() + Duration::from_secs(5);
761 while Instant::now() < deadline {
762 let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
763 let ret = unsafe {
764 libc::waitid(
765 libc::P_PID,
766 pid as libc::id_t,
767 &mut info,
768 libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
769 )
770 };
771 assert_eq!(ret, 0, "waitid failed: {}", std::io::Error::last_os_error());
772 if unsafe { info.si_pid() } == pid {
773 return;
774 }
775 thread::sleep(Duration::from_millis(10));
776 }
777
778 panic!("child {pid} did not exit");
779 }
780
781 fn assert_already_reaped(pid: i32) {
782 let ret = unsafe { libc::waitpid(pid, std::ptr::null_mut(), libc::WNOHANG) };
783 assert_eq!(ret, -1);
784 assert_eq!(
785 std::io::Error::last_os_error().raw_os_error(),
786 Some(libc::ECHILD)
787 );
788 }
789}