Skip to main content

systemprompt_loader/subprocess/
mod.rs

1//! Spawning and reaping the detached agent and MCP children the supervisor
2//! 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, where the platform offers it, asks
8//! the kernel to `SIGTERM` the child if this process dies, so a crash, panic,
9//! or `SIGKILL` of the supervisor cannot strand an agent holding a port. The
10//! spawner thread is started lazily; a failure to start it is returned to the
11//! caller and retried on the next spawn rather than cached for the life of
12//! the process.
13//!
14//! # Identity
15//!
16//! The environment markers and the pure parsers that read them back live in
17//! [`systemprompt_models::subprocess`]; the platform probes here
18//! ([`live_pid_is_subprocess`], [`is_zombie`]) are what execute them against
19//! `/proc` or `sysctl`.
20//!
21//! # Platform support
22//!
23//! The two halves of supervision have different reach, and conflating them is
24//! what stranded ports on macOS:
25//!
26//! - **Identity and reap checks** ([`live_pid_is_subprocess`], [`is_zombie`])
27//!   work on Linux, via `/proc`, and on macOS, via `sysctl(KERN_PROCARGS2)` and
28//!   `proc_pidinfo`. Report the platform's coverage with
29//!   [`identity_verification_supported`](systemprompt_models::subprocess::identity_verification_supported);
30//!   where it is absent the checks are
31//!   fail-closed stubs that never confirm an identity, so no process is ever
32//!   signalled on a guess.
33//! - **Parent-death prevention** is `prctl(PR_SET_PDEATHSIG)` and therefore
34//!   Linux-only. macOS has no equivalent that survives `execve`, and the kqueue
35//!   and pipe-EOF alternatives all require cooperation from the child binary —
36//!   which is an arbitrary MCP server or agent executable here. A `SIGKILL`ed
37//!   supervisor on macOS therefore leaves its children reparented to `launchd`
38//!   and still holding their ports; the identity check above is what lets the
39//!   next start reclaim them instead of erroring out.
40//!
41//! Copyright (c) systemprompt.io — Business Source License 1.1.
42//! See <https://systemprompt.io> for licensing details.
43
44
45use std::process::Command;
46use std::sync::mpsc::{Sender, channel};
47use std::sync::{Mutex, PoisonError};
48
49#[cfg(target_os = "linux")]
50mod linux;
51#[cfg(target_os = "linux")]
52pub use linux::{is_zombie, live_pid_is_subprocess};
53
54#[cfg(target_os = "macos")]
55mod darwin;
56#[cfg(target_os = "macos")]
57pub use darwin::{is_zombie, live_pid_is_subprocess};
58
59#[cfg(not(any(target_os = "linux", target_os = "macos")))]
60mod unsupported;
61#[cfg(not(any(target_os = "linux", target_os = "macos")))]
62pub use unsupported::{is_zombie, live_pid_is_subprocess};
63
64type SpawnReply = Sender<std::io::Result<std::process::Child>>;
65type SpawnRequest = (Command, SpawnReply);
66
67static SPAWNER: Mutex<Option<Sender<SpawnRequest>>> = Mutex::new(None);
68
69pub fn spawn_supervised(cmd: Command) -> std::io::Result<u32> {
70    let child = spawn_owned_supervised(cmd)?;
71    let pid = child.id();
72    drop(child);
73    Ok(pid)
74}
75
76pub fn spawn_owned_supervised(cmd: Command) -> std::io::Result<std::process::Child> {
77    let sender = spawner()?;
78    let (reply_tx, reply_rx) = channel();
79    sender
80        .send((cmd, reply_tx))
81        .map_err(|error| std::io::Error::other(error.to_string()))?;
82    reply_rx
83        .recv()
84        .map_err(|error| std::io::Error::other(error.to_string()))?
85}
86
87fn spawner() -> std::io::Result<Sender<SpawnRequest>> {
88    let mut slot = SPAWNER.lock().unwrap_or_else(PoisonError::into_inner);
89    if let Some(sender) = slot.as_ref() {
90        return Ok(sender.clone());
91    }
92    let sender = start_spawner_thread()?;
93    Ok(slot.insert(sender).clone())
94}
95
96fn start_spawner_thread() -> std::io::Result<Sender<SpawnRequest>> {
97    let (tx, rx) = channel::<SpawnRequest>();
98    std::thread::Builder::new()
99        .name("subprocess-spawner".to_owned())
100        .spawn(move || {
101            while let Ok((mut cmd, reply)) = rx.recv() {
102                let outcome = spawn_on_this_thread(&mut cmd);
103                if let Err(undelivered) = reply.send(outcome)
104                    && let Ok(mut child) = undelivered.0
105                {
106                    if let Err(error) = child.kill() {
107                        tracing::warn!(error = %error, "Failed to stop unclaimed subprocess");
108                    }
109                    if let Err(error) = child.wait() {
110                        tracing::warn!(error = %error, "Failed to reap unclaimed subprocess");
111                    }
112                }
113            }
114        })
115        .map(|_handle| tx)
116}
117
118fn spawn_on_this_thread(cmd: &mut Command) -> std::io::Result<std::process::Child> {
119    #[cfg(target_os = "linux")]
120    linux::arm_parent_death_signal(cmd);
121    cmd.spawn()
122}
123
124// Why: On Unix, process group 0 assigns the child's PID as its process group
125// ID.
126#[cfg(unix)]
127pub fn place_in_own_process_group(command: &mut Command) {
128    use std::os::unix::process::CommandExt;
129    command.process_group(0);
130}
131
132#[cfg(windows)]
133pub fn place_in_own_process_group(command: &mut Command) {
134    use std::os::windows::process::CommandExt;
135    const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
136    command.creation_flags(CREATE_NEW_PROCESS_GROUP);
137}