Skip to main content

systemprompt_models/
subprocess.rs

1//! Spawning, identifying, and reaping the detached agent and MCP children the
2//! supervisor owns.
3//!
4//! # Spawning
5//!
6//! [`spawn_supervised`] is the only sanctioned way to start a child. It runs
7//! every spawn on one dedicated thread and asks the kernel to `SIGTERM` the
8//! child if this process dies, so a crash, panic, or `SIGKILL` of the
9//! supervisor cannot strand an agent holding a port.
10//!
11//! # Identity
12//!
13//! The supervisor stamps environment markers at spawn time; shutdown and
14//! reconciliation read them back from `/proc/<pid>/environ` to confirm a
15//! registry PID still names *this* installation's child before signalling it.
16//! PIDs are recycled, and group-signalling a stale PID (`kill(-pid)`) could
17//! reach an unrelated session leader — so a row is only ever signalled once
18//! both the subprocess marker and the exact `name_key=service_name` pairing
19//! are found.
20//!
21//! # Platform support
22//!
23//! Child supervision is **Linux-only**, matching where the server actually
24//! runs. Both the identity check ([`live_pid_is_subprocess`]) and the reap
25//! check ([`is_zombie`]) read `/proc`, and the parent-death signal is
26//! `prctl(PR_SET_PDEATHSIG)`. Elsewhere they degrade to fail-closed stubs that
27//! never confirm an identity, so no child is ever signalled and orphans must be
28//! cleared by hand. The code compiles and runs on other platforms; it does not
29//! supervise on them.
30//!
31//! Copyright (c) systemprompt.io — Business Source License 1.1.
32//! See <https://systemprompt.io> for licensing details.
33
34use std::process::Command;
35use std::sync::OnceLock;
36use std::sync::mpsc::{Sender, channel};
37
38pub const SUBPROCESS_MARKER_ENV: &str = "SYSTEMPROMPT_SUBPROCESS";
39pub const AGENT_NAME_ENV: &str = "AGENT_NAME";
40pub const MCP_SERVICE_ID_ENV: &str = "MCP_SERVICE_ID";
41
42type SpawnReply = Sender<std::io::Result<u32>>;
43
44/// Spawns `cmd` as a detached child that the kernel terminates if this process
45/// dies, and returns its PID.
46///
47/// Every child is spawned on one dedicated thread. That is load-bearing, not
48/// tidiness: `PR_SET_PDEATHSIG` is delivered when the *thread* that forked
49/// exits, not when the process does. Forking from a tokio worker would tie a
50/// live agent's lifetime to whichever worker happened to poll the spawn, and a
51/// worker exiting early would kill a healthy child. This thread is created once
52/// and never joined, so the signal fires only on real process death.
53///
54/// The child's `Child` handle is dropped without waiting, so it is never
55/// reaped here — see [`is_zombie`], which liveness probes must consult.
56///
57/// On non-Linux targets this is a plain spawn: there is no parent-death signal,
58/// so a supervisor that dies without draining leaves the child running.
59pub fn spawn_supervised(cmd: Command) -> std::io::Result<u32> {
60    let sender = spawner()
61        .as_ref()
62        .map_err(|e| std::io::Error::other(e.clone()))?;
63
64    let (reply_tx, reply_rx) = channel();
65    sender
66        .send((cmd, reply_tx))
67        .map_err(|disconnected| std::io::Error::other(disconnected.to_string()))?;
68    reply_rx
69        .recv()
70        .map_err(|disconnected| std::io::Error::other(disconnected.to_string()))?
71}
72
73/// The spawner thread, started on first use and never joined.
74///
75/// The error is stored rather than returned so the thread is attempted exactly
76/// once: if it cannot start, every later spawn fails with the same reason
77/// instead of retrying a thread the OS has already refused.
78fn spawner() -> &'static Result<Sender<(Command, SpawnReply)>, String> {
79    static SPAWNER: OnceLock<Result<Sender<(Command, SpawnReply)>, String>> = OnceLock::new();
80    SPAWNER.get_or_init(|| {
81        let (tx, rx) = channel::<(Command, SpawnReply)>();
82        std::thread::Builder::new()
83            .name("subprocess-spawner".to_owned())
84            .spawn(move || {
85                while let Ok((mut cmd, reply)) = rx.recv() {
86                    let outcome = spawn_on_this_thread(&mut cmd);
87                    if reply.send(outcome).is_err() {
88                        tracing::warn!(
89                            "Spawn requester vanished before collecting the child pid; the child \
90                             is unregistered and will only be cleaned up by its parent-death signal"
91                        );
92                    }
93                }
94            })
95            .map(|_handle| tx)
96            .map_err(|e| format!("could not start the subprocess spawner thread: {e}"))
97    })
98}
99
100fn spawn_on_this_thread(cmd: &mut Command) -> std::io::Result<u32> {
101    #[cfg(target_os = "linux")]
102    arm_parent_death_signal(cmd);
103
104    let child = cmd.spawn()?;
105    let pid = child.id();
106    #[expect(
107        clippy::mem_forget,
108        reason = "detached child: skip Child's drop-time wait so it keeps running after this \
109                  returns; reaping is the caller's business via is_zombie"
110    )]
111    std::mem::forget(child);
112    Ok(pid)
113}
114
115/// Asks the kernel to `SIGTERM` the child when this thread dies, closing the
116/// window in which a `SIGKILL`ed or panicking supervisor strands its children.
117///
118/// This is the only `unsafe` in the crate graph. It buys an unconditional
119/// guarantee: the alternative — having each child arm its own death signal at
120/// startup — is safe code but only protects children that cooperate, and MCP
121/// server binaries come from extension crates this repo does not own.
122#[cfg(target_os = "linux")]
123#[expect(
124    unsafe_code,
125    reason = "std::os::unix::process::CommandExt::pre_exec is an unsafe fn; there is no safe way \
126              to run code in the forked child before exec, and the parent-death signal must be \
127              armed there to cover children that never opt in"
128)]
129fn arm_parent_death_signal(cmd: &mut Command) {
130    use std::os::unix::process::CommandExt;
131
132    let supervisor = std::process::id();
133
134    // SAFETY: the closure runs in the forked child between `fork` and `execve`,
135    // where only async-signal-safe calls are permitted. `prctl`, `getppid`, and
136    // `_exit` are all on that list; nothing here allocates, locks, or logs.
137    unsafe {
138        cmd.pre_exec(move || {
139            if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) != 0 {
140                return Err(std::io::Error::last_os_error());
141            }
142            // Why: if the supervisor died between `fork` and the `prctl` above,
143            // the death signal has already been missed and this child would
144            // outlive it forever. `getppid` no longer matching means exactly
145            // that — the child has been reparented — so leave immediately.
146            if libc::getppid() != supervisor as libc::pid_t {
147                libc::_exit(0);
148            }
149            Ok(())
150        });
151    }
152}
153
154/// Convert an OS process id into the signed form `kill(2)` expects, rejecting
155/// any value that would target more than that single process.
156///
157/// A `u32` above `i32::MAX` wraps to a negative `i32`, and `kill(2)` reads a
158/// negative pid as a *process group* — `-1` broadcasts to **every** process the
159/// caller may signal, and `0` means the caller's own group. Routing every pid
160/// through this guard turns those cases into a no-op (`None`) instead of
161/// letting a single-PID request escalate into a group or session-wide kill.
162#[must_use]
163pub fn signalable_pid(pid: u32) -> Option<i32> {
164    if pid == 0 {
165        return None;
166    }
167    i32::try_from(pid).ok()
168}
169
170#[must_use]
171pub fn environ_identifies_child(environ: &[u8], name_key: &str, service_name: &str) -> bool {
172    let marker = format!("{SUBPROCESS_MARKER_ENV}=1");
173    let expected_name = format!("{name_key}={service_name}");
174
175    let mut has_marker = false;
176    let mut has_name = false;
177    for entry in environ.split(|&b| b == 0) {
178        if entry == marker.as_bytes() {
179            has_marker = true;
180        } else if entry == expected_name.as_bytes() {
181            has_name = true;
182        }
183    }
184
185    has_marker && has_name
186}
187
188/// Confirm a *live* PID still names this installation's child by reading its
189/// `/proc/<pid>/environ` and matching the spawn markers.
190///
191/// Fail-closed: an unreadable environ — or any non-Linux target, where
192/// `/proc` does not exist — yields `false`, so an unverified PID is never
193/// signalled. Callers must use this before any `kill`/`kill(-pid)` on a PID
194/// loaded from the persisted service registry, because those PIDs outlive the
195/// processes that minted them and are recycled by the kernel.
196#[cfg(target_os = "linux")]
197#[must_use]
198pub fn live_pid_is_subprocess(pid: u32, name_key: &str, service_name: &str) -> bool {
199    match std::fs::read(format!("/proc/{pid}/environ")) {
200        Ok(environ) => environ_identifies_child(&environ, name_key, service_name),
201        Err(e) => {
202            tracing::warn!(pid, error = %e, "Could not read process environ to verify child identity");
203            false
204        },
205    }
206}
207
208#[cfg(not(target_os = "linux"))]
209#[must_use]
210pub fn live_pid_is_subprocess(pid: u32, _name_key: &str, service_name: &str) -> bool {
211    tracing::warn!(
212        pid,
213        service = %service_name,
214        "Child identity cannot be verified on this platform (no /proc), so this process will \
215         not be signalled; supervision is Linux-only and the child must be stopped by hand"
216    );
217    false
218}
219
220/// Reports whether `pid` is a zombie — terminated but not yet reaped.
221///
222/// The supervisor never reaps the children it spawns (their `Child` handle is
223/// forgotten), so a terminated child still answers `kill(pid, 0)`; liveness and
224/// shutdown probes must consult this to avoid treating a dead child as alive.
225/// Non-Linux targets have no `/proc` and always return `false`.
226#[cfg(target_os = "linux")]
227#[must_use]
228pub fn is_zombie(pid: u32) -> bool {
229    let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
230        return false;
231    };
232    // Why: The comm field is parenthesised and may contain spaces or `)`, so the
233    // state char is the first token after the final `)`.
234    let Some((_, after_comm)) = stat.rsplit_once(')') else {
235        return false;
236    };
237    after_comm.split_whitespace().next() == Some("Z")
238}
239
240#[cfg(not(target_os = "linux"))]
241#[must_use]
242pub fn is_zombie(_pid: u32) -> bool {
243    false
244}