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 } else {
90 command.env_remove(ORIGINATOR_ENV_VAR);
91 }
92 }
93
94 /// Spawn a contained child process. The child is contained by its own
95 /// Job Object on Windows / process group on Unix and is killed when
96 /// the returned [`SpawnedChild`] is dropped.
97 pub fn spawn(
98 &self,
99 command: &mut Command,
100 stdio: SpawnStdio<'_>,
101 ) -> Result<SpawnedChild, std::io::Error> {
102 self.inject_originator_env(command);
103 free_spawn(command, stdio)
104 }
105
106 /// Spawn a detached daemon child. The child has NUL stdio, a sanitized
107 /// handle list, and survives the returned [`DaemonChild`] being
108 /// dropped. To terminate, call [`DaemonChild::kill`].
109 ///
110 /// The parent-child association (this group's originator env var)
111 /// is injected into the child before the spawn so cross-process
112 /// tracking can resolve the spawned daemon back to its parent.
113 pub fn spawn_daemon(&self, command: &mut Command) -> Result<DaemonChild, std::io::Error> {
114 self.inject_originator_env(command);
115 free_spawn_daemon(command)
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn contained_process_group_creates_successfully() {
125 let group = ContainedProcessGroup::new();
126 assert!(group.is_ok());
127 }
128
129 #[test]
130 fn with_originator_creates_successfully() {
131 let group = ContainedProcessGroup::with_originator("CLUD");
132 assert!(group.is_ok());
133 let group = group.unwrap();
134 assert_eq!(group.originator(), Some("CLUD"));
135 }
136
137 #[test]
138 fn originator_value_format() {
139 let group = ContainedProcessGroup::with_originator("CLUD").unwrap();
140 let value = group.originator_value().unwrap();
141 let expected = format!("CLUD:{}", std::process::id());
142 assert_eq!(value, expected);
143 }
144
145 #[test]
146 fn no_originator_returns_none() {
147 let group = ContainedProcessGroup::new().unwrap();
148 assert!(group.originator().is_none());
149 assert!(group.originator_value().is_none());
150 }
151
152 #[test]
153 fn format_originator_value_correct() {
154 let value = format_originator_value("JUPYTER");
155 let parts: Vec<&str> = value.splitn(2, ':').collect();
156 assert_eq!(parts.len(), 2);
157 assert_eq!(parts[0], "JUPYTER");
158 assert_eq!(parts[1], std::process::id().to_string());
159 }
160}