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}