Skip to main content

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}