aion_worker/process/contained.rs
1//! The process-group containment core.
2//!
3//! Every process this distribution owns — a worker-owned command, a supervised
4//! managed worker — enters a FRESH process group before user code runs, so
5//! stopping it stops the whole tree it went on to build. There is exactly one
6//! implementation of that discipline, and it is here: a second copy is how a
7//! grandchild comes to outlive the thing that spawned it.
8//!
9//! # The rule the whole module is built around
10//!
11//! **No signal is ever sent to the group after the leading child has been
12//! reaped.** A process-group id IS the leader's pid, and a pid is free for the
13//! operating system to reuse the moment it is reaped. Signalling afterwards
14//! therefore aims at whatever took the number — on a busy machine that is not
15//! hypothetical, and the consequence would be `SIGKILL` delivered to an
16//! unrelated process group.
17//!
18//! So [`ContainedChild`] tracks whether it has reaped, both signals of the
19//! termination ladder are sent BEFORE the reap, and the drop guard stands down
20//! once reaping has happened. What survives after a reap is a read-only probe
21//! used for REPORTING, which can never act on a wrong answer.
22//!
23//! Killing the group and PROVING it empty are therefore separate operations.
24//! [`ContainedChild::terminate`] does the first; the second is either
25//! [`ContainedChild::confirm_group_gone`] — `killpg(pgid, 0)` reporting `ESRCH`
26//! (gone) or `EPERM` (equally conclusive: this process can always signal its
27//! own descendants, so being refused means the id belongs to someone else now)
28//! — or end-of-file on captured output, which no surviving descendant holding
29//! the write end could produce. A caller with pipes should prefer EOF: it
30//! cannot be confused by a recycled id at all.
31
32use std::io;
33use std::process::ExitStatus;
34use std::time::Duration;
35
36use thiserror::Error;
37use tokio::process::{Child, ChildStderr, ChildStdout, Command};
38
39/// Grace allowed after `SIGTERM` before the ladder escalates to `SIGKILL`.
40///
41/// Also the window a caller is given to confirm the group empty after the
42/// signals have been sent.
43pub const PROCESS_GROUP_TERMINATION_GRACE: Duration = Duration::from_secs(2);
44
45/// Cadence for the two polling loops: waiting for the leader to exit, and
46/// confirming the group empty. A mechanism detail of the ladder, not an
47/// operator knob.
48const GROUP_PROBE_INTERVAL: Duration = Duration::from_millis(10);
49
50/// A failure to start, observe, or completely stop a contained command.
51#[derive(Debug, Error)]
52pub enum ProcessGroupError {
53 /// Process-group containment is unavailable on this target.
54 #[error("process-group containment is unsupported on this operating system")]
55 Unsupported,
56 /// The command could not be spawned.
57 #[error("contained command could not be spawned: {source}")]
58 Spawn {
59 /// Underlying spawn failure.
60 #[source]
61 source: io::Error,
62 },
63 /// The spawned child did not expose a process id.
64 #[error("contained command spawned without a process id")]
65 MissingProcessId,
66 /// The platform process id did not fit the Unix `pid_t` representation.
67 #[error("contained command process id {pid} is outside the supported range")]
68 ProcessIdOutOfRange {
69 /// Process id returned by Tokio.
70 pid: u32,
71 },
72 /// A configured output pipe was unexpectedly unavailable.
73 #[error("contained command did not expose its piped {stream}")]
74 MissingPipe {
75 /// Name of the missing stream.
76 stream: &'static str,
77 },
78 /// Reading a captured output stream failed.
79 #[error("failed to read contained command {stream}: {source}")]
80 Read {
81 /// Name of the failed stream.
82 stream: &'static str,
83 /// Underlying read failure.
84 #[source]
85 source: io::Error,
86 },
87 /// Waiting for or reaping the direct child failed.
88 #[error("failed to reap contained command: {source}")]
89 Reap {
90 /// Underlying wait failure.
91 #[source]
92 source: io::Error,
93 },
94 /// Sending a signal to the process group failed.
95 #[cfg(unix)]
96 #[error("failed to send {signal} to process group {process_group}: {source}")]
97 Signal {
98 /// Process group that should have received the signal.
99 process_group: i32,
100 /// Signal being sent.
101 signal: &'static str,
102 /// Underlying Unix error.
103 #[source]
104 source: nix::errno::Errno,
105 },
106 /// Probing process-group existence failed.
107 #[cfg(unix)]
108 #[error("failed to probe process group {process_group}: {source}")]
109 Probe {
110 /// Process group being probed.
111 process_group: i32,
112 /// Underlying Unix error.
113 #[source]
114 source: nix::errno::Errno,
115 },
116 /// The group remained observable after the signals and the bounded wait.
117 #[error(
118 "process group {process_group} was still observable {grace:?} after termination; \
119 it could not be confirmed empty"
120 )]
121 GroupStillAlive {
122 /// Process group that failed to disappear.
123 process_group: i32,
124 /// Confirmation interval that elapsed.
125 grace: Duration,
126 },
127 /// Command observation failed and the mandatory cleanup also failed.
128 #[error("{original}; mandatory process-group cleanup also failed: {cleanup}")]
129 CleanupAfterFailure {
130 /// Original command observation failure.
131 original: Box<ProcessGroupError>,
132 /// Cleanup failure proving cancellation could not be confirmed.
133 cleanup: Box<ProcessGroupError>,
134 },
135}
136
137/// The process group a [`ContainedChild`] leads.
138#[cfg(unix)]
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
140pub struct ProcessGroupId(nix::unistd::Pid);
141
142/// The process group a [`ContainedChild`] leads. Never constructed on a target
143/// without process groups: [`ContainedChild::spawn`] refuses first.
144#[cfg(not(unix))]
145#[derive(Clone, Copy, Debug, Eq, PartialEq)]
146pub struct ProcessGroupId;
147
148#[cfg(unix)]
149impl ProcessGroupId {
150 /// The raw Unix process-group id.
151 #[must_use]
152 pub const fn as_raw(self) -> i32 {
153 self.0.as_raw()
154 }
155}
156
157/// A spawned child leading its own process group, with tree-wide termination.
158///
159/// While the leader is unreaped and the handle is armed, dropping it `SIGKILL`s
160/// the group — the containment guarantee under task abort. Once reaped, the
161/// guard stands down: the id is no longer safely ours to signal.
162#[derive(Debug)]
163pub struct ContainedChild {
164 child: Child,
165 process_group: ProcessGroupId,
166 armed: bool,
167 reaped: bool,
168 exit_status: Option<ExitStatus>,
169}
170
171impl ContainedChild {
172 /// Spawn `command` as the leader of a fresh process group.
173 ///
174 /// The caller configures stdio before calling: piping it captures output,
175 /// inheriting it sends the child's output wherever this process's goes.
176 ///
177 /// # Errors
178 ///
179 /// Returns [`ProcessGroupError::Unsupported`] on a target without process
180 /// groups, [`ProcessGroupError::Spawn`] when the command cannot start, and
181 /// [`ProcessGroupError::MissingProcessId`] or
182 /// [`ProcessGroupError::ProcessIdOutOfRange`] when the started child cannot
183 /// be addressed as a group.
184 pub fn spawn(command: Command) -> Result<Self, ProcessGroupError> {
185 #[cfg(unix)]
186 {
187 spawn_unix(command)
188 }
189 #[cfg(not(unix))]
190 {
191 drop(command);
192 Err(ProcessGroupError::Unsupported)
193 }
194 }
195
196 /// The direct child's process id, while it has not been reaped.
197 #[must_use]
198 pub fn id(&self) -> Option<u32> {
199 self.child.id()
200 }
201
202 /// The raw process-group id, on a target that has process groups.
203 ///
204 /// `None` off Unix, where [`Self::spawn`] refuses in the first place.
205 #[must_use]
206 pub const fn process_group_id(&self) -> Option<i32> {
207 #[cfg(unix)]
208 {
209 Some(self.process_group.as_raw())
210 }
211 #[cfg(not(unix))]
212 {
213 None
214 }
215 }
216
217 /// The exit status recorded by whichever call reaped the leading child.
218 #[must_use]
219 pub const fn exit_status(&self) -> Option<ExitStatus> {
220 self.exit_status
221 }
222
223 /// Take the captured stdout pipe, when the command was configured with one.
224 pub fn take_stdout(&mut self) -> Option<ChildStdout> {
225 self.child.stdout.take()
226 }
227
228 /// Take the captured stderr pipe, when the command was configured with one.
229 pub fn take_stderr(&mut self) -> Option<ChildStderr> {
230 self.child.stderr.take()
231 }
232
233 /// Wait for the DIRECT child to exit, REAPING it.
234 ///
235 /// After this returns, the process-group id is no longer safely ours: see
236 /// the module header. Callers that still need to stop the tree must use
237 /// [`Self::wait_for_exit`] instead, which observes the exit without
238 /// consuming it.
239 ///
240 /// # Errors
241 ///
242 /// Returns [`ProcessGroupError::Reap`] when waiting fails.
243 pub async fn wait(&mut self) -> Result<ExitStatus, ProcessGroupError> {
244 let status = self
245 .child
246 .wait()
247 .await
248 .map_err(|source| ProcessGroupError::Reap { source })?;
249 self.reaped = true;
250 self.exit_status = Some(status);
251 Ok(status)
252 }
253
254 /// Stop the whole group, then reap the leading child.
255 ///
256 /// `SIGTERM` → `grace` → `SIGKILL`, BOTH sent while the leader is unreaped,
257 /// so the group being signalled is provably the one this handle created.
258 /// Once the leader has already been reaped no signal is sent at all: the id
259 /// may belong to anyone by then, and a stop that cannot be aimed is a stop
260 /// that must not be attempted.
261 ///
262 /// The `SIGKILL` is unconditional rather than conditional on surviving the
263 /// `SIGTERM`. Checking first would mean either reaping the leader (which
264 /// forfeits the right to signal) or reading a probe that a live-or-zombie
265 /// leader always answers "alive" to — so the check cannot work, and an
266 /// extra signal to an empty group costs nothing.
267 ///
268 /// This says nothing about whether the group ended up EMPTY. Proving that
269 /// is the caller's job: [`Self::confirm_group_gone`] where there is nothing
270 /// else to go on, or end-of-file on captured output, which no surviving
271 /// descendant holding the write end could produce.
272 ///
273 /// # Errors
274 ///
275 /// Returns [`ProcessGroupError::Unsupported`] off Unix,
276 /// [`ProcessGroupError::Signal`] when the OS refuses to signal, and
277 /// [`ProcessGroupError::Reap`] when the leader cannot be reaped.
278 pub async fn terminate(&mut self, grace: Duration) -> Result<(), ProcessGroupError> {
279 #[cfg(unix)]
280 {
281 if !self.reaped {
282 signal_group(
283 self.process_group.0,
284 nix::sys::signal::Signal::SIGTERM,
285 "SIGTERM",
286 )?;
287 tokio::time::sleep(grace).await;
288 signal_group(
289 self.process_group.0,
290 nix::sys::signal::Signal::SIGKILL,
291 "SIGKILL",
292 )?;
293 // The status is kept on the handle by `wait`; callers read it
294 // back through `exit_status`.
295 self.wait().await?;
296 }
297 self.armed = false;
298 Ok(())
299 }
300 #[cfg(not(unix))]
301 {
302 drop(grace);
303 Err(ProcessGroupError::Unsupported)
304 }
305 }
306
307 /// Poll until the group is provably gone, or the window closes.
308 ///
309 /// A read, never an act — which is what makes it safe after the leader has
310 /// been reaped, where acting on a wrong answer would be the disaster.
311 ///
312 /// # Errors
313 ///
314 /// Returns [`ProcessGroupError::Unsupported`] off Unix,
315 /// [`ProcessGroupError::Probe`] when the probe itself fails, and
316 /// [`ProcessGroupError::GroupStillAlive`] when the window closes with the
317 /// group still observable.
318 pub async fn confirm_group_gone(&self, within: Duration) -> Result<(), ProcessGroupError> {
319 #[cfg(unix)]
320 {
321 let deadline = tokio::time::Instant::now() + within;
322 loop {
323 if group_is_gone(self.process_group.0)? {
324 return Ok(());
325 }
326 let now = tokio::time::Instant::now();
327 if now >= deadline {
328 return Err(ProcessGroupError::GroupStillAlive {
329 process_group: self.process_group.0.as_raw(),
330 grace: within,
331 });
332 }
333 tokio::time::sleep(GROUP_PROBE_INTERVAL.min(deadline - now)).await;
334 }
335 }
336 #[cfg(not(unix))]
337 {
338 drop(within);
339 Err(ProcessGroupError::Unsupported)
340 }
341 }
342
343 /// Release the drop guard without signalling anything.
344 ///
345 /// For a caller that has established the group is gone by other means — the
346 /// captured pipes reaching EOF, for instance, which no descendant holding
347 /// the write end could allow.
348 pub const fn disarm(&mut self) {
349 self.armed = false;
350 }
351}
352
353impl Drop for ContainedChild {
354 fn drop(&mut self) {
355 // Reaped means the id is no longer provably ours (module header), so the
356 // guard stands down rather than signalling a group it cannot identify.
357 if !self.armed || self.reaped {
358 return;
359 }
360 #[cfg(unix)]
361 match nix::sys::signal::killpg(self.process_group.0, nix::sys::signal::Signal::SIGKILL) {
362 Ok(()) | Err(nix::errno::Errno::ESRCH) => {}
363 Err(source) => tracing::error!(
364 process_group = self.process_group.0.as_raw(),
365 %source,
366 "failed to kill contained process group while dropping its owner"
367 ),
368 }
369 }
370}
371
372#[cfg(unix)]
373fn spawn_unix(mut command: Command) -> Result<ContainedChild, ProcessGroupError> {
374 use std::os::unix::process::CommandExt;
375
376 command.as_std_mut().process_group(0);
377 command.kill_on_drop(true);
378 let child = command
379 .spawn()
380 .map_err(|source| ProcessGroupError::Spawn { source })?;
381 let raw_pid = child.id().ok_or(ProcessGroupError::MissingProcessId)?;
382 let process_group = i32::try_from(raw_pid)
383 .map_err(|_| ProcessGroupError::ProcessIdOutOfRange { pid: raw_pid })?;
384 Ok(ContainedChild {
385 child,
386 process_group: ProcessGroupId(nix::unistd::Pid::from_raw(process_group)),
387 armed: true,
388 reaped: false,
389 exit_status: None,
390 })
391}
392
393#[cfg(unix)]
394fn signal_group(
395 process_group: nix::unistd::Pid,
396 signal: nix::sys::signal::Signal,
397 signal_name: &'static str,
398) -> Result<(), ProcessGroupError> {
399 match nix::sys::signal::killpg(process_group, signal) {
400 // `ESRCH` is an empty group. `EPERM` is a group with no SIGNALABLE
401 // member, which on macOS is what a group reduced to its own unreaped
402 // zombie leader reports — the overwhelmingly common shape a moment
403 // after the worker exits. Both mean the same thing to a caller trying
404 // to stop a tree: there is nothing left here for this process to kill.
405 // Reporting either as a failure to send is what turned an ordinary
406 // cancellation into a spurious error.
407 Ok(()) | Err(nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM) => Ok(()),
408 Err(source) => Err(ProcessGroupError::Signal {
409 process_group: process_group.as_raw(),
410 signal: signal_name,
411 source,
412 }),
413 }
414}
415
416#[cfg(unix)]
417fn group_is_gone(process_group: nix::unistd::Pid) -> Result<bool, ProcessGroupError> {
418 match nix::sys::signal::killpg(process_group, None::<nix::sys::signal::Signal>) {
419 Ok(()) => Ok(false),
420 // `ESRCH` is the group having departed. `EPERM` is equally conclusive:
421 // this process can always signal its own descendants, so an id it may
422 // NOT signal is an id that has been recycled by somebody else — either
423 // way the group this handle created is gone.
424 Err(nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM) => Ok(true),
425 Err(source) => Err(ProcessGroupError::Probe {
426 process_group: process_group.as_raw(),
427 source,
428 }),
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use std::process::Stdio;
435 use std::time::Duration;
436
437 use tokio::process::Command;
438
439 use super::{ContainedChild, PROCESS_GROUP_TERMINATION_GRACE};
440
441 type TestResult = Result<(), Box<dyn std::error::Error>>;
442
443 fn shell(script: &str) -> Command {
444 let mut command = Command::new("/bin/sh");
445 command.args(["-c", script]);
446 command.stdout(Stdio::null()).stderr(Stdio::null());
447 command
448 }
449
450 fn group_alive(process_group: i32) -> bool {
451 std::process::Command::new("/bin/sh")
452 .args(["-c", &format!("kill -0 -{process_group} 2>/dev/null")])
453 .status()
454 .is_ok_and(|status| status.success())
455 }
456
457 /// A grandchild dies with the group, and the group is PROVEN gone rather
458 /// than assumed so.
459 #[tokio::test]
460 async fn terminate_takes_the_whole_group() -> TestResult {
461 let mut child = ContainedChild::spawn(shell("sleep 300 & sleep 300"))?;
462 let group = child
463 .process_group_id()
464 .ok_or("a spawned child leads a group")?;
465 assert!(group_alive(group));
466 child.terminate(Duration::from_millis(20)).await?;
467 child
468 .confirm_group_gone(PROCESS_GROUP_TERMINATION_GRACE)
469 .await?;
470 assert!(!group_alive(group), "the group survived a confirmed stop");
471 assert!(
472 child.exit_status().is_some(),
473 "terminate must reap the leader it killed"
474 );
475 Ok(())
476 }
477
478 /// Once the leader has been reaped, no signal may be sent — the id could
479 /// belong to anyone. `terminate` must become a no-op rather than take aim
480 /// at a number it can no longer vouch for. The safety property is asserted
481 /// through an OBSERVABLE consequence: skipping the ladder means skipping
482 /// the grace it would otherwise sleep through.
483 #[tokio::test]
484 async fn a_reaped_child_is_never_signalled_again() -> TestResult {
485 let mut child = ContainedChild::spawn(shell("exit 0"))?;
486 let status = child.wait().await?;
487 assert_eq!(status.code(), Some(0));
488 let started = std::time::Instant::now();
489 child.terminate(Duration::from_secs(30)).await?;
490 assert!(
491 started.elapsed() < Duration::from_secs(5),
492 "a reaped child must skip the ladder entirely, not sleep through it"
493 );
494 Ok(())
495 }
496
497 /// The exit status is recorded by whichever call reaped, and a later
498 /// `terminate` neither loses it nor overwrites it.
499 ///
500 /// Deliberately driven through a NATURAL exit rather than by racing the
501 /// ladder against a short-lived command: `terminate` sends `SIGTERM`
502 /// immediately, so a command that has not managed to exit yet is signalled
503 /// and reports no exit code at all. That is correct behaviour, not a
504 /// defect, and a test that assumed otherwise would only be asserting that
505 /// the machine was idle.
506 #[tokio::test]
507 async fn the_exit_status_is_recorded_by_whichever_call_reaped() -> TestResult {
508 let mut child = ContainedChild::spawn(shell("exit 5"))?;
509 assert_eq!(child.wait().await?.code(), Some(5));
510 assert_eq!(
511 child.exit_status().and_then(|status| status.code()),
512 Some(5)
513 );
514 child.terminate(Duration::from_millis(20)).await?;
515 assert_eq!(
516 child.exit_status().and_then(|status| status.code()),
517 Some(5),
518 "a terminate after the reap must not disturb the recorded status"
519 );
520 Ok(())
521 }
522}