running_process_core/containment.rs
1//! Process containment via OS-level mechanisms.
2//!
3//! `ContainedProcessGroup` ensures all child processes die when the group is
4//! dropped — even on a crash.
5//!
6//! - **Windows**: Uses a Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`.
7//! Dropping the group closes the handle, and Windows automatically terminates
8//! every process still assigned to the job.
9//! - **Linux**: Uses `setpgid(0, 0)` to place children in a new process group
10//! and `PR_SET_PDEATHSIG(SIGKILL)` via `prctl()` so the kernel kills the
11//! child when the parent thread exits.
12//! **Caveat**: `PR_SET_PDEATHSIG` is reset on `execve` of a set-uid/set-gid
13//! binary and is tied to the *thread* that called `fork`, not the process
14//! leader. If the spawning thread exits before the parent process, children
15//! receive the signal prematurely.
16//! - **macOS**: Uses `setpgid(0, 0)` for process grouping. `PR_SET_PDEATHSIG`
17//! is not available; parent-death notification is best-effort via polling
18//! `getppid()` in the child (not implemented here — the Drop-based SIGKILL
19//! to the process group is the primary mechanism).
20//!
21//! `Containment::Detached` spawns a process that intentionally survives the
22//! group's lifetime (daemon pattern).
23//!
24//! # `RUNNING_PROCESS_ORIGINATOR` environment variable
25//!
26//! When an `originator` is set on a `ContainedProcessGroup`, all spawned child
27//! processes inherit the environment variable `RUNNING_PROCESS_ORIGINATOR` with
28//! the format `TOOL:PID`, where:
29//!
30//! - **TOOL** is the originator name (e.g., `"CLUD"`, `"JUPYTER"`)
31//! - **PID** is the process ID of the parent that spawned the group
32//!
33//! Example value: `RUNNING_PROCESS_ORIGINATOR=CLUD:12345`
34//!
35//! ## Purpose
36//!
37//! This env var enables **cross-process session discovery** after crashes.
38//!
39//! ## Example
40//!
41//! ```no_run
42//! use running_process_core::ContainedProcessGroup;
43//!
44//! let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
45//! let mut cmd = std::process::Command::new("sleep");
46//! cmd.arg("60");
47//! let child = group.spawn(&mut cmd).unwrap();
48//! ```
49
50use std::process::{Child, Command};
51
52/// The environment variable name injected into child processes for
53/// cross-process session discovery.
54pub const ORIGINATOR_ENV_VAR: &str = "RUNNING_PROCESS_ORIGINATOR";
55
56/// Containment policy for a spawned process.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub enum Containment {
59 /// The process is contained: it will be killed when the group is dropped,
60 /// and (on Linux) when the parent thread dies.
61 #[default]
62 Contained,
63 /// The process is detached: it will survive the group being dropped.
64 /// Useful for daemon processes.
65 Detached,
66}
67
68/// A group of processes that are killed together when the group is dropped.
69///
70/// On Windows this wraps a Job Object; on Unix it tracks a process-group ID
71/// and sends `SIGKILL` to the group on drop.
72pub struct ContainedProcessGroup {
73 originator: Option<String>,
74
75 #[cfg(windows)]
76 job: super::WindowsJobHandle,
77
78 #[cfg(unix)]
79 pgid: std::sync::Mutex<Option<i32>>,
80
81 #[cfg(unix)]
82 child_pids: std::sync::Mutex<Vec<u32>>,
83}
84
85/// A handle to a process spawned inside a `ContainedProcessGroup`.
86pub struct ContainedChild {
87 pub child: Child,
88 pub containment: Containment,
89}
90
91/// Format the originator env var value: `TOOL:PID`.
92fn format_originator_value(tool: &str) -> String {
93 format!("{}:{}", tool, std::process::id())
94}
95
96impl ContainedProcessGroup {
97 /// Create a new process group without an originator.
98 pub fn new() -> Result<Self, std::io::Error> {
99 Self::build(None)
100 }
101
102 /// Create a new process group with an originator name.
103 pub fn with_originator(originator: &str) -> Result<Self, std::io::Error> {
104 Self::build(Some(originator.to_string()))
105 }
106
107 fn build(originator: Option<String>) -> Result<Self, std::io::Error> {
108 #[cfg(windows)]
109 {
110 Self::new_windows(originator)
111 }
112 #[cfg(unix)]
113 {
114 Ok(Self {
115 originator,
116 pgid: std::sync::Mutex::new(None),
117 child_pids: std::sync::Mutex::new(Vec::new()),
118 })
119 }
120 }
121
122 /// Returns the originator name, if set.
123 pub fn originator(&self) -> Option<&str> {
124 self.originator.as_deref()
125 }
126
127 /// Returns the full originator env var value (`TOOL:PID`), if set.
128 pub fn originator_value(&self) -> Option<String> {
129 self.originator.as_ref().map(|o| format_originator_value(o))
130 }
131
132 fn inject_originator_env(&self, command: &mut Command) {
133 if let Some(ref originator) = self.originator {
134 command.env(ORIGINATOR_ENV_VAR, format_originator_value(originator));
135 }
136 }
137
138 /// Spawn a contained child process. The child will be killed when this
139 /// group is dropped.
140 pub fn spawn(&self, command: &mut Command) -> Result<ContainedChild, std::io::Error> {
141 self.spawn_with_containment(command, Containment::Contained)
142 }
143
144 /// Spawn a detached child process. The child will survive this group
145 /// being dropped.
146 pub fn spawn_detached(&self, command: &mut Command) -> Result<ContainedChild, std::io::Error> {
147 self.spawn_with_containment(command, Containment::Detached)
148 }
149
150 /// Spawn a child process with the given containment policy.
151 pub fn spawn_with_containment(
152 &self,
153 command: &mut Command,
154 containment: Containment,
155 ) -> Result<ContainedChild, std::io::Error> {
156 self.inject_originator_env(command);
157
158 #[cfg(windows)]
159 {
160 self.spawn_windows(command, containment)
161 }
162 #[cfg(unix)]
163 {
164 self.spawn_unix(command, containment)
165 }
166 }
167
168 /// Spawn a detached child with **no orphaned inheritable handles** from
169 /// the parent's table. Use this for daemon launchers — especially when
170 /// the spawning process may have ancestors that redirected stdio to a
171 /// pipe (Python `subprocess.Popen(stdout=PIPE)`, IDE language-server
172 /// hosts, CI runners, etc.).
173 ///
174 /// The returned [`super::SanitizedChild`] does NOT belong to this
175 /// group's Job Object on Windows and survives the group being dropped,
176 /// matching `Containment::Detached` semantics. Stdio is always
177 /// connected to the platform null device.
178 ///
179 /// See [`super::sanitized`] for the cross-platform mechanism. Issue
180 /// <https://github.com/zackees/running-process/issues/110>.
181 pub fn spawn_sanitized(
182 &self,
183 command: &mut Command,
184 ) -> Result<super::SanitizedChild, std::io::Error> {
185 self.inject_originator_env(command);
186 super::sanitized::spawn(command)
187 }
188}
189
190// ── Windows implementation ──────────────────────────────────────────────────
191
192#[cfg(windows)]
193impl ContainedProcessGroup {
194 fn new_windows(originator: Option<String>) -> Result<Self, std::io::Error> {
195 use std::mem::zeroed;
196 use winapi::shared::minwindef::FALSE;
197 use winapi::um::handleapi::INVALID_HANDLE_VALUE;
198 use winapi::um::jobapi2::{CreateJobObjectW, SetInformationJobObject};
199 use winapi::um::winnt::{
200 JobObjectExtendedLimitInformation, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
201 JOB_OBJECT_LIMIT_BREAKAWAY_OK, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
202 };
203
204 let job = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
205 if job.is_null() || job == INVALID_HANDLE_VALUE {
206 return Err(std::io::Error::last_os_error());
207 }
208
209 let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
210 info.BasicLimitInformation.LimitFlags =
211 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
212 let ok = unsafe {
213 SetInformationJobObject(
214 job,
215 JobObjectExtendedLimitInformation,
216 (&mut info as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
217 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
218 )
219 };
220 if ok == FALSE {
221 let err = std::io::Error::last_os_error();
222 unsafe { winapi::um::handleapi::CloseHandle(job) };
223 return Err(err);
224 }
225
226 Ok(Self {
227 originator,
228 job: super::WindowsJobHandle(job as usize),
229 })
230 }
231
232 fn spawn_windows(
233 &self,
234 command: &mut Command,
235 containment: Containment,
236 ) -> Result<ContainedChild, std::io::Error> {
237 use winapi::shared::minwindef::FALSE;
238 use winapi::um::jobapi2::AssignProcessToJobObject;
239
240 match containment {
241 Containment::Contained => {
242 // Spawn the child, then assign it to our Job Object.
243 let child = command.spawn()?;
244 let handle = {
245 use std::os::windows::io::AsRawHandle;
246 child.as_raw_handle()
247 };
248 let ok = unsafe {
249 AssignProcessToJobObject(
250 self.job.0 as winapi::shared::ntdef::HANDLE,
251 handle.cast(),
252 )
253 };
254 if ok == FALSE {
255 return Err(std::io::Error::last_os_error());
256 }
257 Ok(ContainedChild { child, containment })
258 }
259 Containment::Detached => {
260 // Detached: simply do NOT assign the child to the Job
261 // Object. The child will survive when the job handle is
262 // closed (and contained siblings are killed).
263 //
264 // NOTE: `CREATE_BREAKAWAY_FROM_JOB` is only useful when
265 // the *spawning* process is already inside a job and wants
266 // to launch a child outside it. Here, our spawning process
267 // is not in the job, so we just skip assignment.
268 let child = command.spawn()?;
269 Ok(ContainedChild { child, containment })
270 }
271 }
272 }
273}
274
275// ── Unix implementation ─────────────────────────────────────────────────────
276
277#[cfg(unix)]
278impl ContainedProcessGroup {
279 fn spawn_unix(
280 &self,
281 command: &mut Command,
282 containment: Containment,
283 ) -> Result<ContainedChild, std::io::Error> {
284 use std::os::unix::process::CommandExt;
285
286 match containment {
287 Containment::Contained => {
288 let pgid_lock = self.pgid.lock().expect("pgid mutex poisoned");
289 let target_pgid = *pgid_lock;
290 drop(pgid_lock);
291
292 unsafe {
293 command.pre_exec(move || {
294 // Place child into the group's process group, or create
295 // a new one if this is the first child.
296 let pgid = target_pgid.unwrap_or(0);
297 if libc::setpgid(0, pgid) == -1 {
298 return Err(std::io::Error::last_os_error());
299 }
300
301 // Linux-only: ask the kernel to send SIGKILL to this
302 // child when the parent thread exits.
303 // NOTE: PR_SET_PDEATHSIG is tied to the calling
304 // *thread*, not the process. If the thread that spawned
305 // this child exits, the child receives the signal even
306 // if the parent process is still alive.
307 #[cfg(target_os = "linux")]
308 {
309 if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 {
310 return Err(std::io::Error::last_os_error());
311 }
312 // Re-check that the parent hasn't already died
313 // between fork() and prctl().
314 if libc::getppid() == 1 {
315 // Parent already exited; init adopted us.
316 libc::_exit(1);
317 }
318 }
319
320 Ok(())
321 });
322 }
323
324 let child = command.spawn()?;
325 let pid = child.id();
326
327 // Record the process group ID.
328 let mut pgid_lock = self.pgid.lock().expect("pgid mutex poisoned");
329 let group_pgid = if let Some(existing) = *pgid_lock {
330 existing
331 } else {
332 // First child becomes the process group leader.
333 *pgid_lock = Some(pid as i32);
334 pid as i32
335 };
336 drop(pgid_lock);
337
338 // Parent-side setpgid: the standard double-setpgid pattern.
339 // Both parent and child call setpgid so the group assignment
340 // is guaranteed regardless of scheduling order. EACCES is
341 // expected (child already exec'd) and harmless.
342 unsafe {
343 libc::setpgid(pid as i32, group_pgid);
344 }
345
346 self.child_pids
347 .lock()
348 .expect("child_pids mutex poisoned")
349 .push(pid);
350
351 Ok(ContainedChild { child, containment })
352 }
353 Containment::Detached => {
354 unsafe {
355 command.pre_exec(|| {
356 // Create a new session so the child is fully detached.
357 if libc::setsid() == -1 {
358 return Err(std::io::Error::last_os_error());
359 }
360 Ok(())
361 });
362 }
363 let child = command.spawn()?;
364 Ok(ContainedChild { child, containment })
365 }
366 }
367 }
368}
369
370#[cfg(unix)]
371impl Drop for ContainedProcessGroup {
372 fn drop(&mut self) {
373 let pgid = self.pgid.lock().expect("pgid mutex poisoned");
374 if let Some(pgid) = *pgid {
375 // Send SIGKILL to the entire process group. Negative PID targets
376 // the group. Errors are ignored (processes may have already exited).
377 unsafe {
378 libc::killpg(pgid, libc::SIGKILL);
379 }
380 }
381 drop(pgid);
382
383 // Fallback: kill each tracked PID individually, in case any child
384 // failed to join the process group (e.g. race between fork and exec).
385 let pids = self.child_pids.lock().expect("child_pids mutex poisoned");
386 for &pid in pids.iter() {
387 unsafe {
388 libc::kill(pid as i32, libc::SIGKILL);
389 }
390 }
391
392 // Reap zombie children. After SIGKILL, child processes remain as
393 // zombies in the process table until waitpid() is called. Without
394 // reaping, kill(pid, 0) still reports them as alive and they consume
395 // a slot in the process table. SIGKILL is unblockable so blocking
396 // waitpid returns essentially immediately. If the PID is not our
397 // child (or was already reaped), waitpid returns -1/ECHILD which we
398 // safely ignore.
399 for &pid in pids.iter() {
400 unsafe {
401 libc::waitpid(pid as i32, std::ptr::null_mut(), 0);
402 }
403 }
404 }
405}
406
407// Windows: Job Object handle is closed by WindowsJobHandle::drop, which
408// triggers JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE automatically.
409
410// ── Default trait ───────────────────────────────────────────────────────────
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn containment_default_is_contained() {
418 assert_eq!(Containment::default(), Containment::Contained);
419 }
420
421 #[test]
422 fn containment_clone_and_copy() {
423 let c = Containment::Contained;
424 let c2 = c;
425 assert_eq!(c, c2);
426 }
427
428 #[test]
429 fn containment_debug_format() {
430 assert_eq!(format!("{:?}", Containment::Contained), "Contained");
431 assert_eq!(format!("{:?}", Containment::Detached), "Detached");
432 }
433
434 #[test]
435 fn contained_process_group_creates_successfully() {
436 let group = ContainedProcessGroup::new();
437 assert!(group.is_ok());
438 }
439
440 #[test]
441 fn with_originator_creates_successfully() {
442 let group = ContainedProcessGroup::with_originator("CLUD");
443 assert!(group.is_ok());
444 let group = group.unwrap();
445 assert_eq!(group.originator(), Some("CLUD"));
446 }
447
448 #[test]
449 fn originator_value_format() {
450 let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
451 let value = group.originator_value().unwrap();
452 let expected = format!("CLUD:{}", std::process::id());
453 assert_eq!(value, expected);
454 }
455
456 #[test]
457 fn no_originator_returns_none() {
458 let group = ContainedProcessGroup::new().unwrap();
459 assert!(group.originator().is_none());
460 assert!(group.originator_value().is_none());
461 }
462
463 #[test]
464 fn format_originator_value_correct() {
465 let value = format_originator_value("JUPYTER");
466 let parts: Vec<&str> = value.splitn(2, ':').collect();
467 assert_eq!(parts.len(), 2);
468 assert_eq!(parts[0], "JUPYTER");
469 assert_eq!(parts[1], std::process::id().to_string());
470 }
471}