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 detached daemon child. The child has NUL stdio, a sanitized
110    /// handle list, and survives the returned [`DaemonChild`] being
111    /// dropped. To terminate, call [`DaemonChild::kill`].
112    ///
113    /// The originator env var is deliberately **stripped**, never injected.
114    ///
115    /// A daemon outlives the process that happened to start it — that is the
116    /// entire point of spawning one. Tagging it `TOOL:<spawner pid>` makes it
117    /// indistinguishable from an abandoned descendant, so as soon as the
118    /// spawner exits, any originator-based reaper sees a live process whose
119    /// originator is dead and kills it. Build-cache daemons (zccache, sccache)
120    /// are the common casualty: they are started lazily by whichever compiler
121    /// invocation happens to run first, inherit that session's tag, and get
122    /// reaped when it ends — taking the warm cache down with them.
123    ///
124    /// This also restores the intent of the daemon environment policy.
125    /// [`crate::EnvironmentPolicy::Auto`] resolves to `UserBaseline` for
126    /// daemons, which rebuilds the environment from the user's login identity
127    /// and drops process-local variables. Explicit `command.env(...)` entries
128    /// are applied *last*, so injecting here silently defeated that policy for
129    /// this one variable.
130    pub fn spawn_daemon(&self, command: &mut Command) -> Result<DaemonChild, std::io::Error> {
131        Self::strip_originator_env(command);
132        free_spawn_daemon(command)
133    }
134
135    /// Remove the originator tag from `command`. Split out from
136    /// [`Self::spawn_daemon`] so the policy is unit-testable without
137    /// spawning a real detached process.
138    fn strip_originator_env(command: &mut Command) {
139        command.env_remove(ORIGINATOR_ENV_VAR);
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn originator_entry(command: &Command) -> Option<Option<&std::ffi::OsStr>> {
148        let key = std::ffi::OsStr::new(ORIGINATOR_ENV_VAR);
149        command.get_envs().find(|(k, _)| *k == key).map(|(_, v)| v)
150    }
151
152    /// The two halves must compose: a daemon drops the originator tag *and*
153    /// carries the positive marker. Absence alone is ambiguous — it also
154    /// describes a process whose environment was clobbered — so a reaper
155    /// needs the declaration, not just the missing tag (clud#522).
156    #[test]
157    fn daemon_declares_itself_and_drops_the_originator_tag() {
158        let mut cmd = Command::new("echo");
159        cmd.env(ORIGINATOR_ENV_VAR, "CLUD:12345");
160        ContainedProcessGroup::strip_originator_env(&mut cmd);
161        crate::spawn::mark_as_daemon(&mut cmd);
162
163        assert_eq!(
164            originator_entry(&cmd),
165            Some(None),
166            "the originator tag must still be removed"
167        );
168        let marker = std::ffi::OsStr::new(crate::DAEMON_MARKER_ENV_VAR);
169        assert_eq!(
170            cmd.get_envs()
171                .find(|(k, _)| *k == marker)
172                .and_then(|(_, v)| v),
173            Some(std::ffi::OsStr::new("1")),
174            "a daemon must positively declare itself"
175        );
176    }
177
178    /// A daemon must not carry its spawner's originator tag: it outlives the
179    /// spawner, so the tag turns it into reaper bait the moment the spawner
180    /// exits. `env_remove` must win even when the caller explicitly set it.
181    #[test]
182    fn spawn_daemon_strips_originator_env() {
183        let mut cmd = Command::new("echo");
184        cmd.env(ORIGINATOR_ENV_VAR, "CLUD:12345");
185        ContainedProcessGroup::strip_originator_env(&mut cmd);
186        assert_eq!(
187            originator_entry(&cmd),
188            Some(None),
189            "daemon command must carry an explicit removal of {ORIGINATOR_ENV_VAR}"
190        );
191    }
192
193    /// The stripping must be unconditional — a group *with* an originator is
194    /// exactly the case that used to inject the tag into daemons.
195    #[test]
196    fn spawn_daemon_strips_originator_even_for_tagged_group() {
197        let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
198        assert!(group.originator().is_some());
199        let mut cmd = Command::new("echo");
200        ContainedProcessGroup::strip_originator_env(&mut cmd);
201        assert_eq!(originator_entry(&cmd), Some(None));
202    }
203
204    /// Contrast: *contained* children keep the tag. That is what makes the
205    /// on-exit reaper able to find genuinely abandoned descendants.
206    #[test]
207    fn contained_spawn_still_injects_originator_env() {
208        let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
209        let mut cmd = Command::new("echo");
210        group.inject_originator_env(&mut cmd);
211        let expected = format!("CLUD:{}", std::process::id());
212        assert_eq!(
213            originator_entry(&cmd),
214            Some(Some(std::ffi::OsStr::new(expected.as_str())))
215        );
216    }
217
218    #[test]
219    fn contained_process_group_creates_successfully() {
220        let group = ContainedProcessGroup::new();
221        assert!(group.is_ok());
222    }
223
224    #[test]
225    fn with_originator_creates_successfully() {
226        let group = ContainedProcessGroup::with_originator("CLUD");
227        assert!(group.is_ok());
228        let group = group.unwrap();
229        assert_eq!(group.originator(), Some("CLUD"));
230    }
231
232    #[test]
233    fn originator_value_format() {
234        let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
235        let value = group.originator_value().unwrap();
236        let expected = format!("CLUD:{}", std::process::id());
237        assert_eq!(value, expected);
238    }
239
240    #[test]
241    fn no_originator_returns_none() {
242        let group = ContainedProcessGroup::new().unwrap();
243        assert!(group.originator().is_none());
244        assert!(group.originator_value().is_none());
245    }
246
247    #[test]
248    fn format_originator_value_correct() {
249        let value = format_originator_value("JUPYTER");
250        let parts: Vec<&str> = value.splitn(2, ':').collect();
251        assert_eq!(parts.len(), 2);
252        assert_eq!(parts[0], "JUPYTER");
253        assert_eq!(parts[1], std::process::id().to_string());
254    }
255}