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        #[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}