Skip to main content

systemprompt_loader/subprocess/
spawn.rs

1//! The one sanctioned way to start an agent or MCP child, and the in-place
2//! re-exec that stamps the API server with its identity marker.
3//!
4//! Every spawn runs on one dedicated thread and, where the platform offers
5//! it, the kernel is asked to `SIGTERM` the child if this process dies. The
6//! parent-death signal follows the thread that forked the child, which is why
7//! the spawner is a single long-lived thread rather than whichever runtime
8//! worker happened to call: a worker that exits would take its children with
9//! it. The thread is started lazily; a failure to start it is returned to the
10//! caller and retried on the next spawn rather than cached.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use std::process::{Child, Command};
16use std::sync::mpsc::{Sender, channel};
17use std::sync::{Mutex, PoisonError};
18
19use systemprompt_identifiers::ServiceName;
20
21use super::ChildKind;
22
23type SpawnReply = Sender<std::io::Result<Child>>;
24type SpawnRequest = (Command, SpawnReply);
25
26static SPAWNER: Mutex<Option<Sender<SpawnRequest>>> = Mutex::new(None);
27
28/// How [`stamp_api_server`] returned instead of re-executing.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ApiServerStamp {
31    Stamped,
32    Unverifiable,
33}
34
35pub fn mark_child(cmd: &mut Command, kind: ChildKind, service: &ServiceName) {
36    match kind {
37        ChildKind::Agent | ChildKind::Mcp => {
38            cmd.env(systemprompt_models::subprocess::SUBPROCESS_MARKER_ENV, "1");
39        },
40        ChildKind::Api => {},
41    }
42    cmd.env(kind.marker_env(), service.as_str());
43}
44
45// Why: the API server is launched by an operator, a service manager or a
46// container runtime, never spawned by us, so the marker that later proves its
47// identity cannot be stamped at spawn. `execve` keeps the pid, so replacing
48// the image once with the marker added is invisible to whatever supervises
49// the process. An environment that cannot be read back (no platform probe, a
50// hardened binary) is left unstamped rather than re-executed in a loop.
51#[cfg(unix)]
52pub fn stamp_api_server() -> std::io::Result<ApiServerStamp> {
53    use std::os::unix::process::CommandExt;
54
55    let service = super::api_server_service();
56    let Some(environ) = super::live_environ(std::process::id())
57        .filter(|environ| systemprompt_models::subprocess::environ_has_entries(environ))
58    else {
59        return Ok(ApiServerStamp::Unverifiable);
60    };
61    if ChildKind::Api.identifies(&environ, &service) {
62        return Ok(ApiServerStamp::Stamped);
63    }
64    let mut cmd = Command::new(std::env::current_exe()?);
65    cmd.args(std::env::args_os().skip(1));
66    mark_child(&mut cmd, ChildKind::Api, &service);
67    Err(cmd.exec())
68}
69
70#[cfg(not(unix))]
71pub const fn stamp_api_server() -> std::io::Result<ApiServerStamp> {
72    Ok(ApiServerStamp::Unverifiable)
73}
74
75pub fn spawn_supervised(cmd: Command) -> std::io::Result<u32> {
76    let child = spawn_owned_supervised(cmd)?;
77    let pid = child.id();
78    drop(child);
79    Ok(pid)
80}
81
82pub fn spawn_owned_supervised(cmd: Command) -> std::io::Result<Child> {
83    let sender = spawner()?;
84    let (reply_tx, reply_rx) = channel();
85    sender
86        .send((cmd, reply_tx))
87        .map_err(std::io::Error::other)?;
88    reply_rx.recv().map_err(std::io::Error::other)?
89}
90
91fn spawner() -> std::io::Result<Sender<SpawnRequest>> {
92    let mut slot = SPAWNER.lock().unwrap_or_else(PoisonError::into_inner);
93    if let Some(sender) = slot.as_ref() {
94        return Ok(sender.clone());
95    }
96    let sender = start_spawner_thread()?;
97    Ok(slot.insert(sender).clone())
98}
99
100fn start_spawner_thread() -> std::io::Result<Sender<SpawnRequest>> {
101    let (tx, rx) = channel::<SpawnRequest>();
102    std::thread::Builder::new()
103        .name("subprocess-spawner".to_owned())
104        .spawn(move || {
105            while let Ok((mut cmd, reply)) = rx.recv() {
106                let outcome = spawn_on_this_thread(&mut cmd);
107                if let Err(undelivered) = reply.send(outcome)
108                    && let Ok(mut child) = undelivered.0
109                {
110                    if let Err(error) = child.kill() {
111                        tracing::warn!(error = %error, "Failed to stop unclaimed subprocess");
112                    }
113                    if let Err(error) = child.wait() {
114                        tracing::warn!(error = %error, "Failed to reap unclaimed subprocess");
115                    }
116                }
117            }
118        })
119        .map(|_handle| tx)
120}
121
122fn spawn_on_this_thread(cmd: &mut Command) -> std::io::Result<Child> {
123    #[cfg(target_os = "linux")]
124    super::linux::arm_parent_death_signal(cmd);
125    cmd.spawn()
126}
127
128// Why: On Unix, process group 0 assigns the child's PID as its process group
129// ID.
130#[cfg(unix)]
131pub fn place_in_own_process_group(command: &mut Command) {
132    use std::os::unix::process::CommandExt;
133    command.process_group(0);
134}
135
136#[cfg(windows)]
137pub fn place_in_own_process_group(command: &mut Command) {
138    use std::os::windows::process::CommandExt;
139    const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
140    command.creation_flags(CREATE_NEW_PROCESS_GROUP);
141}