Skip to main content

agentbridge/
subprocess.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared subprocess-tracking helper for `CliAdapter` implementations.
5//!
6//! Every adapter that shells out to a CLI tool via `std::process::Command`
7//! (Claude Code, Copilot, Codex, ...) needs the same thing: know the PID of
8//! whatever child is currently running so `CliAdapter::kill_in_flight` can
9//! reach it during shutdown, instead of leaving it orphaned when the
10//! listener process exits out from under it. `TrackedSubprocess` is that
11//! logic, written once, so each adapter wires it in with one field and a
12//! one-line `kill_in_flight` override instead of duplicating PID-tracking
13//! per adapter.
14
15use std::io;
16use std::process::{Command, Output};
17use std::sync::Mutex;
18
19/// Tracks the PID of a subprocess for as long as it's running, so it can be
20/// killed on demand from anywhere holding a reference to this struct.
21#[derive(Default)]
22pub struct TrackedSubprocess {
23    active_pid: Mutex<Option<u32>>,
24}
25
26impl TrackedSubprocess {
27    pub fn new() -> Self {
28        Self::default()
29    }
30
31    /// Spawn `cmd`, remember its PID for the duration of the call, run it to
32    /// completion, and return its output — same shape as `Command::output()`,
33    /// but with the child's PID reachable via `kill()` while it's running.
34    pub fn output(&self, cmd: &mut Command) -> io::Result<Output> {
35        let child = cmd.spawn()?;
36
37        if let Ok(mut active) = self.active_pid.lock() {
38            *active = Some(child.id());
39        }
40
41        let result = child.wait_with_output();
42
43        if let Ok(mut active) = self.active_pid.lock() {
44            *active = None;
45        }
46
47        result
48    }
49
50    /// Best-effort: terminate whatever child is currently tracked, if any. A
51    /// no-op if nothing is running right now.
52    pub fn kill(&self) {
53        let pid = match self.active_pid.lock() {
54            Ok(active) => *active,
55            Err(_) => None,
56        };
57        if let Some(pid) = pid {
58            terminate(pid);
59        }
60    }
61}
62
63/// SIGTERM, so the child still gets to run its own shutdown path.
64#[cfg(unix)]
65fn terminate(pid: u32) {
66    // SAFETY: `pid` is a plain integer recorded from `Child::id()` moments
67    // ago; passing it to `kill(2)` cannot violate memory safety even if the
68    // process has since exited (that just makes the call a harmless no-op,
69    // reported as ESRCH).
70    unsafe {
71        libc::kill(pid as libc::pid_t, libc::SIGTERM);
72    }
73}
74
75/// Windows has no signals reachable from another process, so the equivalent
76/// is `TerminateProcess` — abrupt, with no shutdown path for the child.
77#[cfg(windows)]
78fn terminate(pid: u32) {
79    use windows_sys::Win32::Foundation::CloseHandle;
80    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
81
82    // SAFETY: `OpenProcess` returns null rather than a bogus handle when the
83    // process has already exited, which is what the check below covers; the
84    // handle is used only while open and closed exactly once.
85    unsafe {
86        let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
87        if !handle.is_null() {
88            TerminateProcess(handle, 1);
89            CloseHandle(handle);
90        }
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::sync::Arc;
98
99    /// Exits successfully straight away.
100    #[cfg(unix)]
101    fn exits_now() -> Command {
102        Command::new("true")
103    }
104
105    #[cfg(windows)]
106    fn exits_now() -> Command {
107        let mut cmd = Command::new("cmd");
108        cmd.args(["/C", "exit", "0"]);
109        cmd
110    }
111
112    /// Stays alive long enough to be killed out from under the test.
113    #[cfg(unix)]
114    fn stays_alive() -> Command {
115        let mut cmd = Command::new("sleep");
116        cmd.arg("30");
117        cmd
118    }
119
120    #[cfg(windows)]
121    fn stays_alive() -> Command {
122        let mut cmd = Command::new("ping");
123        cmd.args(["-n", "31", "127.0.0.1"]);
124        cmd
125    }
126
127    #[test]
128    fn output_runs_command_and_clears_pid_after() {
129        let tracked = TrackedSubprocess::new();
130        let mut cmd = exits_now();
131        let output = tracked.output(&mut cmd).expect("spawn should succeed");
132        assert!(output.status.success());
133        assert!(tracked.active_pid.lock().unwrap().is_none());
134    }
135
136    #[test]
137    fn output_surfaces_spawn_failure_without_stranding_a_pid() {
138        let tracked = TrackedSubprocess::new();
139        let mut cmd = Command::new("agentbridge-no-such-binary");
140        assert!(tracked.output(&mut cmd).is_err());
141        assert!(tracked.active_pid.lock().unwrap().is_none());
142    }
143
144    #[test]
145    fn kill_on_idle_tracker_is_a_harmless_no_op() {
146        let tracked = TrackedSubprocess::new();
147        tracked.kill(); // must not panic
148    }
149
150    #[test]
151    fn kill_terminates_a_running_child() {
152        let tracked = TrackedSubprocess::new();
153        let mut child = stays_alive().spawn().expect("spawn long-running child");
154        let pid = child.id();
155        *tracked
156            .active_pid
157            .lock()
158            .expect("fresh tracker is not poisoned") = Some(pid);
159        tracked.kill();
160        let status = child.wait().expect("wait after kill");
161        assert!(!status.success());
162    }
163
164    #[test]
165    fn a_poisoned_tracker_still_runs_and_still_shuts_down() {
166        let tracked = Arc::new(TrackedSubprocess::new());
167        let poisoner = Arc::clone(&tracked);
168        let _ = std::thread::spawn(move || {
169            let _held = poisoner.active_pid.lock().unwrap();
170            panic!("poison the tracker's lock");
171        })
172        .join();
173        assert!(
174            tracked.active_pid.lock().is_err(),
175            "lock should be poisoned"
176        );
177
178        // Both paths skip the pid bookkeeping rather than propagating the
179        // poison: the command still runs, and shutdown still returns.
180        let mut cmd = exits_now();
181        let output = tracked.output(&mut cmd).expect("command still runs");
182        assert!(output.status.success());
183        tracked.kill();
184    }
185}