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 child the group spawns,
11//! - dispatching to either [`crate::spawn`] or [`crate::spawn_daemon`].
12//!
13//! # `RUNNING_PROCESS_ORIGINATOR` environment variable
14//!
15//! When an `originator` is set on a `ContainedProcessGroup`, all spawned child
16//! processes inherit the environment variable `RUNNING_PROCESS_ORIGINATOR` with
17//! the format `TOOL:PID`, where:
18//!
19//! - **TOOL** is the originator name (e.g., `"CLUD"`, `"JUPYTER"`)
20//! - **PID** is the process ID of the parent that spawned the group
21//!
22//! Example value: `RUNNING_PROCESS_ORIGINATOR=CLUD:12345`
23//!
24//! ## Purpose
25//!
26//! This env var enables **cross-process session discovery** after crashes.
27//!
28//! ## Example
29//!
30//! ```no_run
31//! use running_process::{ContainedProcessGroup, SpawnStdio};
32//!
33//! let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
34//! let mut cmd = std::process::Command::new("sleep");
35//! cmd.arg("60");
36//! let _child = group.spawn(&mut cmd, SpawnStdio::default()).unwrap();
37//! ```
38
39use std::process::Command;
40
41use crate::spawn::{
42    spawn as free_spawn, spawn_daemon as free_spawn_daemon, DaemonChild, SpawnStdio, SpawnedChild,
43};
44
45/// The environment variable name injected into child processes for
46/// cross-process session discovery.
47pub const ORIGINATOR_ENV_VAR: &str = "RUNNING_PROCESS_ORIGINATOR";
48
49/// A logical group of spawned processes that share an originator label.
50///
51/// Each [`ContainedProcessGroup::spawn`] call builds its own OS-level
52/// containment (Job Object on Windows, process-group on Unix), so the
53/// group itself is just metadata.
54pub struct ContainedProcessGroup {
55    originator: Option<String>,
56}
57
58/// Format the originator env var value: `TOOL:PID`.
59fn format_originator_value(tool: &str) -> String {
60    format!("{}:{}", tool, std::process::id())
61}
62
63impl ContainedProcessGroup {
64    /// Create a new process group without an originator.
65    pub fn new() -> Result<Self, std::io::Error> {
66        Ok(Self { originator: None })
67    }
68
69    /// Create a new process group with an originator name.
70    pub fn with_originator(originator: &str) -> Result<Self, std::io::Error> {
71        Ok(Self {
72            originator: Some(originator.to_string()),
73        })
74    }
75
76    /// Returns the originator name, if set.
77    pub fn originator(&self) -> Option<&str> {
78        self.originator.as_deref()
79    }
80
81    /// Returns the full originator env var value (`TOOL:PID`), if set.
82    pub fn originator_value(&self) -> Option<String> {
83        self.originator.as_ref().map(|o| format_originator_value(o))
84    }
85
86    fn inject_originator_env(&self, command: &mut Command) {
87        if let Some(ref originator) = self.originator {
88            command.env(ORIGINATOR_ENV_VAR, format_originator_value(originator));
89        }
90    }
91
92    /// Spawn a contained child process. The child is contained by its own
93    /// Job Object on Windows / process group on Unix and is killed when
94    /// the returned [`SpawnedChild`] is dropped.
95    pub fn spawn(
96        &self,
97        command: &mut Command,
98        stdio: SpawnStdio<'_>,
99    ) -> Result<SpawnedChild, std::io::Error> {
100        self.inject_originator_env(command);
101        free_spawn(command, stdio)
102    }
103
104    /// Spawn a detached daemon child. The child has NUL stdio, a sanitized
105    /// handle list, and survives the returned [`DaemonChild`] being
106    /// dropped. To terminate, call [`DaemonChild::kill`].
107    ///
108    /// The parent-child association (this group's originator env var)
109    /// is injected into the child before the spawn so cross-process
110    /// tracking can resolve the spawned daemon back to its parent.
111    pub fn spawn_daemon(&self, command: &mut Command) -> Result<DaemonChild, std::io::Error> {
112        self.inject_originator_env(command);
113        free_spawn_daemon(command)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn contained_process_group_creates_successfully() {
123        let group = ContainedProcessGroup::new();
124        assert!(group.is_ok());
125    }
126
127    #[test]
128    fn with_originator_creates_successfully() {
129        let group = ContainedProcessGroup::with_originator("CLUD");
130        assert!(group.is_ok());
131        let group = group.unwrap();
132        assert_eq!(group.originator(), Some("CLUD"));
133    }
134
135    #[test]
136    fn originator_value_format() {
137        let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
138        let value = group.originator_value().unwrap();
139        let expected = format!("CLUD:{}", std::process::id());
140        assert_eq!(value, expected);
141    }
142
143    #[test]
144    fn no_originator_returns_none() {
145        let group = ContainedProcessGroup::new().unwrap();
146        assert!(group.originator().is_none());
147        assert!(group.originator_value().is_none());
148    }
149
150    #[test]
151    fn format_originator_value_correct() {
152        let value = format_originator_value("JUPYTER");
153        let parts: Vec<&str> = value.splitn(2, ':').collect();
154        assert_eq!(parts.len(), 2);
155        assert_eq!(parts[0], "JUPYTER");
156        assert_eq!(parts[1], std::process::id().to_string());
157    }
158}