Skip to main content

running_process/
containment.rs

1//! Process group with originator-env injection that delegates to the
2//! two-mode [`crate::spawn()`] surface.
3//!
4//! `ContainedProcessGroup` no longer carries OS-level containment state of
5//! its own (the new `spawn` builds a Job Object per-spawn on Windows and
6//! places each child in its own process group on Unix). The group's
7//! responsibility is now scoped to:
8//!
9//! - holding an optional `originator` label,
10//! - injecting [`ORIGINATOR_ENV_VAR`] into every *contained* child the group
11//!   spawns, and stripping it from every *daemon* child (a daemon outlives its
12//!   spawner, so it must not be attributable to it — see
13//!   [`ContainedProcessGroup::spawn_daemon`]),
14//! - dispatching to either [`crate::spawn()`] or [`crate::spawn_daemon`].
15//!
16//! # `RUNNING_PROCESS_ORIGINATOR` environment variable
17//!
18//! When an `originator` is set on a `ContainedProcessGroup`, all spawned child
19//! processes inherit the environment variable `RUNNING_PROCESS_ORIGINATOR` with
20//! the format `TOOL:PID`, where:
21//!
22//! - **TOOL** is the originator name (e.g., `"CLUD"`, `"JUPYTER"`)
23//! - **PID** is the process ID of the parent that spawned the group
24//!
25//! Example value: `RUNNING_PROCESS_ORIGINATOR=CLUD:12345`
26//!
27//! ## Purpose
28//!
29//! This env var enables **cross-process session discovery** after crashes.
30//!
31//! ## Example
32//!
33//! ```no_run
34//! use running_process::{ContainedProcessGroup, SpawnStdio};
35//!
36//! let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
37//! let mut cmd = std::process::Command::new("sleep");
38//! cmd.arg("60");
39//! let _child = group.spawn(&mut cmd, SpawnStdio::default()).unwrap();
40//! ```
41
42use std::process::Command;
43
44use crate::spawn::{
45    spawn as free_spawn, spawn_daemon as free_spawn_daemon, DaemonChild, SpawnStdio, SpawnedChild,
46};
47
48/// The environment variable name injected into child processes for
49/// cross-process session discovery.
50pub const ORIGINATOR_ENV_VAR: &str = "RUNNING_PROCESS_ORIGINATOR";
51
52/// A logical group of spawned processes that share an originator label.
53///
54/// Each [`ContainedProcessGroup::spawn`] call builds its own OS-level
55/// containment (Job Object on Windows, process-group on Unix), so the
56/// group itself is just metadata.
57pub struct ContainedProcessGroup {
58    originator: Option<String>,
59}
60
61/// Format the originator env var value: `TOOL:PID`.
62fn format_originator_value(tool: &str) -> String {
63    format!("{}:{}", tool, std::process::id())
64}
65
66impl ContainedProcessGroup {
67    /// Create a new process group without an originator.
68    pub fn new() -> Result<Self, std::io::Error> {
69        Ok(Self { originator: None })
70    }
71
72    /// Create a new process group with an originator name.
73    pub fn with_originator(originator: &str) -> Result<Self, std::io::Error> {
74        Ok(Self {
75            originator: Some(originator.to_string()),
76        })
77    }
78
79    /// Returns the originator name, if set.
80    pub fn originator(&self) -> Option<&str> {
81        self.originator.as_deref()
82    }
83
84    /// Returns the full originator env var value (`TOOL:PID`), if set.
85    pub fn originator_value(&self) -> Option<String> {
86        self.originator.as_ref().map(|o| format_originator_value(o))
87    }
88
89    fn inject_originator_env(&self, command: &mut Command) {
90        if let Some(ref originator) = self.originator {
91            command.env(ORIGINATOR_ENV_VAR, format_originator_value(originator));
92        } else {
93            command.env_remove(ORIGINATOR_ENV_VAR);
94        }
95    }
96
97    /// Spawn a contained child process. The child is contained by its own
98    /// Job Object on Windows / process group on Unix and is killed when
99    /// the returned [`SpawnedChild`] is dropped.
100    pub fn spawn(
101        &self,
102        command: &mut Command,
103        stdio: SpawnStdio<'_>,
104    ) -> Result<SpawnedChild, std::io::Error> {
105        self.inject_originator_env(command);
106        free_spawn(command, stdio)
107    }
108
109    /// Spawn a contained child using an explicit base environment policy.
110    pub fn spawn_with_environment_policy(
111        &self,
112        command: &mut Command,
113        stdio: SpawnStdio<'_>,
114        policy: crate::EnvironmentPolicy,
115    ) -> Result<SpawnedChild, std::io::Error> {
116        self.inject_originator_env(command);
117        crate::spawn_with_env_policy(command, stdio, policy)
118    }
119
120    /// Spawn a detached daemon child. The child has NUL stdio, a sanitized
121    /// handle list, and survives the returned [`DaemonChild`] being
122    /// dropped. To terminate, call [`DaemonChild::kill`].
123    ///
124    /// The originator env var is deliberately **stripped**, never injected.
125    ///
126    /// A daemon outlives the process that happened to start it — that is the
127    /// entire point of spawning one. Tagging it `TOOL:<spawner pid>` makes it
128    /// indistinguishable from an abandoned descendant, so as soon as the
129    /// spawner exits, any originator-based reaper sees a live process whose
130    /// originator is dead and kills it. Build-cache daemons (zccache, sccache)
131    /// are the common casualty: they are started lazily by whichever compiler
132    /// invocation happens to run first, inherit that session's tag, and get
133    /// reaped when it ends — taking the warm cache down with them.
134    ///
135    /// This also restores the intent of the daemon environment policy.
136    /// [`crate::EnvironmentPolicy::Auto`] resolves to `UserBaseline` for
137    /// daemons, which rebuilds the environment from the user's login identity
138    /// and drops process-local variables. Explicit `command.env(...)` entries
139    /// are applied *last*, so injecting here silently defeated that policy for
140    /// this one variable.
141    pub fn spawn_daemon(&self, command: &mut Command) -> Result<DaemonChild, std::io::Error> {
142        Self::strip_originator_env(command);
143        free_spawn_daemon(command)
144    }
145
146    /// Remove the originator tag from `command`. Split out from
147    /// [`Self::spawn_daemon`] so the policy is unit-testable without
148    /// spawning a real detached process.
149    fn strip_originator_env(command: &mut Command) {
150        command.env_remove(ORIGINATOR_ENV_VAR);
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn originator_entry(command: &Command) -> Option<Option<&std::ffi::OsStr>> {
159        let key = std::ffi::OsStr::new(ORIGINATOR_ENV_VAR);
160        command.get_envs().find(|(k, _)| *k == key).map(|(_, v)| v)
161    }
162
163    /// The two halves must compose: a daemon drops the originator tag *and*
164    /// carries the positive marker. Absence alone is ambiguous — it also
165    /// describes a process whose environment was clobbered — so a reaper
166    /// needs the declaration, not just the missing tag (clud#522).
167    #[test]
168    fn daemon_declares_itself_and_drops_the_originator_tag() {
169        let mut cmd = Command::new("echo");
170        cmd.env(ORIGINATOR_ENV_VAR, "CLUD:12345");
171        ContainedProcessGroup::strip_originator_env(&mut cmd);
172        crate::spawn::mark_as_daemon(&mut cmd);
173
174        assert_eq!(
175            originator_entry(&cmd),
176            Some(None),
177            "the originator tag must still be removed"
178        );
179        let marker = std::ffi::OsStr::new(crate::DAEMON_MARKER_ENV_VAR);
180        assert_eq!(
181            cmd.get_envs()
182                .find(|(k, _)| *k == marker)
183                .and_then(|(_, v)| v),
184            Some(std::ffi::OsStr::new("1")),
185            "a daemon must positively declare itself"
186        );
187    }
188
189    /// A daemon must not carry its spawner's originator tag: it outlives the
190    /// spawner, so the tag turns it into reaper bait the moment the spawner
191    /// exits. `env_remove` must win even when the caller explicitly set it.
192    #[test]
193    fn spawn_daemon_strips_originator_env() {
194        let mut cmd = Command::new("echo");
195        cmd.env(ORIGINATOR_ENV_VAR, "CLUD:12345");
196        ContainedProcessGroup::strip_originator_env(&mut cmd);
197        assert_eq!(
198            originator_entry(&cmd),
199            Some(None),
200            "daemon command must carry an explicit removal of {ORIGINATOR_ENV_VAR}"
201        );
202    }
203
204    /// The stripping must be unconditional — a group *with* an originator is
205    /// exactly the case that used to inject the tag into daemons.
206    #[test]
207    fn spawn_daemon_strips_originator_even_for_tagged_group() {
208        let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
209        assert!(group.originator().is_some());
210        let mut cmd = Command::new("echo");
211        ContainedProcessGroup::strip_originator_env(&mut cmd);
212        assert_eq!(originator_entry(&cmd), Some(None));
213    }
214
215    /// Contrast: *contained* children keep the tag. That is what makes the
216    /// on-exit reaper able to find genuinely abandoned descendants.
217    #[test]
218    fn contained_spawn_still_injects_originator_env() {
219        let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
220        let mut cmd = Command::new("echo");
221        group.inject_originator_env(&mut cmd);
222        let expected = format!("CLUD:{}", std::process::id());
223        assert_eq!(
224            originator_entry(&cmd),
225            Some(Some(std::ffi::OsStr::new(expected.as_str())))
226        );
227    }
228
229    #[test]
230    fn contained_process_group_creates_successfully() {
231        let group = ContainedProcessGroup::new();
232        assert!(group.is_ok());
233    }
234
235    #[test]
236    fn with_originator_creates_successfully() {
237        let group = ContainedProcessGroup::with_originator("CLUD");
238        assert!(group.is_ok());
239        let group = group.unwrap();
240        assert_eq!(group.originator(), Some("CLUD"));
241    }
242
243    #[test]
244    fn originator_value_format() {
245        let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
246        let value = group.originator_value().unwrap();
247        let expected = format!("CLUD:{}", std::process::id());
248        assert_eq!(value, expected);
249    }
250
251    #[test]
252    fn no_originator_returns_none() {
253        let group = ContainedProcessGroup::new().unwrap();
254        assert!(group.originator().is_none());
255        assert!(group.originator_value().is_none());
256    }
257
258    #[test]
259    fn format_originator_value_correct() {
260        let value = format_originator_value("JUPYTER");
261        let parts: Vec<&str> = value.splitn(2, ':').collect();
262        assert_eq!(parts.len(), 2);
263        assert_eq!(parts[0], "JUPYTER");
264        assert_eq!(parts[1], std::process::id().to_string());
265    }
266}