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 // `ESRCH` is the group having departed, and `EPERM` is equally
361 // conclusive — the module's own doctrine ([`confirm_group_gone`],
362 // [`signal_group`]): this process can always signal its own live
363 // descendants, so a permission refusal means only unsignalable
364 // remnants (zombies awaiting the reap) remain. Both are the group
365 // being gone, not a failed kill; reporting `EPERM` as an error here
366 // once dressed a routine server shutdown in a spurious ERROR line.
367 #[cfg(unix)]
368 match nix::sys::signal::killpg(self.process_group.0, nix::sys::signal::Signal::SIGKILL) {
369 Ok(()) | Err(nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM) => {}
370 Err(source) => tracing::error!(
371 process_group = self.process_group.0.as_raw(),
372 %source,
373 "failed to kill contained process group while dropping its owner"
374 ),
375 }
376 }
377}
378
379#[cfg(unix)]
380fn spawn_unix(mut command: Command) -> Result<ContainedChild, ProcessGroupError> {
381 use std::os::unix::process::CommandExt;
382
383 command.as_std_mut().process_group(0);
384 command.kill_on_drop(true);
385 let child = command
386 .spawn()
387 .map_err(|source| ProcessGroupError::Spawn { source })?;
388 let raw_pid = child.id().ok_or(ProcessGroupError::MissingProcessId)?;
389 let process_group = i32::try_from(raw_pid)
390 .map_err(|_| ProcessGroupError::ProcessIdOutOfRange { pid: raw_pid })?;
391 Ok(ContainedChild {
392 child,
393 process_group: ProcessGroupId(nix::unistd::Pid::from_raw(process_group)),
394 armed: true,
395 reaped: false,
396 exit_status: None,
397 })
398}
399
400#[cfg(unix)]
401fn signal_group(
402 process_group: nix::unistd::Pid,
403 signal: nix::sys::signal::Signal,
404 signal_name: &'static str,
405) -> Result<(), ProcessGroupError> {
406 match nix::sys::signal::killpg(process_group, signal) {
407 // `ESRCH` is an empty group. `EPERM` is a group with no SIGNALABLE
408 // member, which on macOS is what a group reduced to its own unreaped
409 // zombie leader reports — the overwhelmingly common shape a moment
410 // after the worker exits. Both mean the same thing to a caller trying
411 // to stop a tree: there is nothing left here for this process to kill.
412 // Reporting either as a failure to send is what turned an ordinary
413 // cancellation into a spurious error.
414 Ok(()) | Err(nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM) => Ok(()),
415 Err(source) => Err(ProcessGroupError::Signal {
416 process_group: process_group.as_raw(),
417 signal: signal_name,
418 source,
419 }),
420 }
421}
422
423#[cfg(unix)]
424fn group_is_gone(process_group: nix::unistd::Pid) -> Result<bool, ProcessGroupError> {
425 match nix::sys::signal::killpg(process_group, None::<nix::sys::signal::Signal>) {
426 Ok(()) => Ok(false),
427 // `ESRCH` is the group having departed. `EPERM` is equally conclusive:
428 // this process can always signal its own descendants, so an id it may
429 // NOT signal is an id that has been recycled by somebody else — either
430 // way the group this handle created is gone.
431 Err(nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM) => Ok(true),
432 Err(source) => Err(ProcessGroupError::Probe {
433 process_group: process_group.as_raw(),
434 source,
435 }),
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use std::process::Stdio;
442 use std::time::Duration;
443
444 use tokio::process::Command;
445
446 use super::{ContainedChild, PROCESS_GROUP_TERMINATION_GRACE};
447
448 type TestResult = Result<(), Box<dyn std::error::Error>>;
449
450 fn shell(script: &str) -> Command {
451 let mut command = Command::new("/bin/sh");
452 command.args(["-c", script]);
453 command.stdout(Stdio::null()).stderr(Stdio::null());
454 command
455 }
456
457 fn group_alive(process_group: i32) -> bool {
458 std::process::Command::new("/bin/sh")
459 .args(["-c", &format!("kill -0 -{process_group} 2>/dev/null")])
460 .status()
461 .is_ok_and(|status| status.success())
462 }
463
464 /// A grandchild dies with the group, and the group is PROVEN gone rather
465 /// than assumed so.
466 #[tokio::test]
467 async fn terminate_takes_the_whole_group() -> TestResult {
468 let mut child = ContainedChild::spawn(shell("sleep 300 & sleep 300"))?;
469 let group = child
470 .process_group_id()
471 .ok_or("a spawned child leads a group")?;
472 assert!(group_alive(group));
473 child.terminate(Duration::from_millis(20)).await?;
474 child
475 .confirm_group_gone(PROCESS_GROUP_TERMINATION_GRACE)
476 .await?;
477 assert!(!group_alive(group), "the group survived a confirmed stop");
478 assert!(
479 child.exit_status().is_some(),
480 "terminate must reap the leader it killed"
481 );
482 Ok(())
483 }
484
485 /// Once the leader has been reaped, no signal may be sent — the id could
486 /// belong to anyone. `terminate` must become a no-op rather than take aim
487 /// at a number it can no longer vouch for. The safety property is asserted
488 /// through an OBSERVABLE consequence: skipping the ladder means skipping
489 /// the grace it would otherwise sleep through.
490 #[tokio::test]
491 async fn a_reaped_child_is_never_signalled_again() -> TestResult {
492 let mut child = ContainedChild::spawn(shell("exit 0"))?;
493 let status = child.wait().await?;
494 assert_eq!(status.code(), Some(0));
495 let started = std::time::Instant::now();
496 child.terminate(Duration::from_secs(30)).await?;
497 assert!(
498 started.elapsed() < Duration::from_secs(5),
499 "a reaped child must skip the ladder entirely, not sleep through it"
500 );
501 Ok(())
502 }
503
504 /// The exit status is recorded by whichever call reaped, and a later
505 /// `terminate` neither loses it nor overwrites it.
506 ///
507 /// Deliberately driven through a NATURAL exit rather than by racing the
508 /// ladder against a short-lived command: `terminate` sends `SIGTERM`
509 /// immediately, so a command that has not managed to exit yet is signalled
510 /// and reports no exit code at all. That is correct behaviour, not a
511 /// defect, and a test that assumed otherwise would only be asserting that
512 /// the machine was idle.
513 #[tokio::test]
514 async fn the_exit_status_is_recorded_by_whichever_call_reaped() -> TestResult {
515 let mut child = ContainedChild::spawn(shell("exit 5"))?;
516 assert_eq!(child.wait().await?.code(), Some(5));
517 assert_eq!(
518 child.exit_status().and_then(|status| status.code()),
519 Some(5)
520 );
521 child.terminate(Duration::from_millis(20)).await?;
522 assert_eq!(
523 child.exit_status().and_then(|status| status.code()),
524 Some(5),
525 "a terminate after the reap must not disturb the recorded status"
526 );
527 Ok(())
528 }
529}