Skip to main content

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}