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}