Skip to main content

aion_worker/
process.rs

1//! Cancellable process-group containment for worker-owned commands.
2//!
3//! A command enters a fresh process group before user code runs. Cancellation
4//! sends `SIGTERM` to the whole group, waits a bounded grace period, escalates
5//! to `SIGKILL`, reaps the direct child, and returns `Cancelled` only after an
6//! `ESRCH` probe confirms that the group is empty.
7
8use std::future::Future;
9use std::io;
10use std::process::{Output, Stdio};
11use std::time::Duration;
12
13use thiserror::Error;
14use tokio::io::{AsyncRead, AsyncReadExt};
15use tokio::process::{Child, ChildStderr, ChildStdout, Command};
16
17/// Grace allowed after each group signal before cancellation fails loudly.
18///
19/// `SIGTERM` receives this interval before escalation. `SIGKILL` receives the
20/// same interval for OS-level disappearance and direct-child reaping.
21pub const PROCESS_GROUP_TERMINATION_GRACE: Duration = Duration::from_secs(2);
22
23const GROUP_PROBE_INTERVAL: Duration = Duration::from_millis(10);
24
25/// The observable outcome of a contained command.
26#[derive(Debug)]
27pub enum CancellableCommandOutput {
28    /// The command exited and its output pipes reached EOF.
29    Completed(Output),
30    /// Cancellation killed, reaped, and verified the entire process group.
31    Cancelled,
32}
33
34/// A failure to start, observe, or completely stop a contained command.
35#[derive(Debug, Error)]
36pub enum ProcessGroupError {
37    /// Process-group containment is unavailable on this target.
38    #[error("process-group containment is unsupported on this operating system")]
39    Unsupported,
40    /// The command could not be spawned.
41    #[error("contained command could not be spawned: {source}")]
42    Spawn {
43        /// Underlying spawn failure.
44        #[source]
45        source: io::Error,
46    },
47    /// The spawned child did not expose a process id.
48    #[error("contained command spawned without a process id")]
49    MissingProcessId,
50    /// The platform process id did not fit the Unix `pid_t` representation.
51    #[error("contained command process id {pid} is outside the supported range")]
52    ProcessIdOutOfRange {
53        /// Process id returned by Tokio.
54        pid: u32,
55    },
56    /// A configured output pipe was unexpectedly unavailable.
57    #[error("contained command did not expose its piped {stream}")]
58    MissingPipe {
59        /// Name of the missing stream.
60        stream: &'static str,
61    },
62    /// Reading a captured output stream failed.
63    #[error("failed to read contained command {stream}: {source}")]
64    Read {
65        /// Name of the failed stream.
66        stream: &'static str,
67        /// Underlying read failure.
68        #[source]
69        source: io::Error,
70    },
71    /// Waiting for or reaping the direct child failed.
72    #[error("failed to reap contained command: {source}")]
73    Reap {
74        /// Underlying wait failure.
75        #[source]
76        source: io::Error,
77    },
78    /// Sending a signal to the process group failed.
79    #[cfg(unix)]
80    #[error("failed to send {signal} to process group {process_group}: {source}")]
81    Signal {
82        /// Process group that should have received the signal.
83        process_group: i32,
84        /// Signal being sent.
85        signal: &'static str,
86        /// Underlying Unix error.
87        #[source]
88        source: nix::errno::Errno,
89    },
90    /// Probing process-group existence failed.
91    #[cfg(unix)]
92    #[error("failed to probe process group {process_group}: {source}")]
93    Probe {
94        /// Process group being probed.
95        process_group: i32,
96        /// Underlying Unix error.
97        #[source]
98        source: nix::errno::Errno,
99    },
100    /// The group remained observable after `SIGKILL` and the verification bound.
101    #[error(
102        "process group {process_group} remained alive {grace:?} after SIGKILL; cancellation is not complete"
103    )]
104    GroupStillAlive {
105        /// Process group that failed to disappear.
106        process_group: i32,
107        /// Verification interval after `SIGKILL`.
108        grace: Duration,
109    },
110    /// Command observation failed and the mandatory cleanup also failed.
111    #[error("{original}; mandatory process-group cleanup also failed: {cleanup}")]
112    CleanupAfterFailure {
113        /// Original command observation failure.
114        original: Box<ProcessGroupError>,
115        /// Cleanup failure proving cancellation could not be confirmed.
116        cleanup: Box<ProcessGroupError>,
117    },
118}
119
120/// Run a command in a fresh process group and make cancellation tree-wide.
121///
122/// The command's stdout and stderr are captured. On cancellation, this function
123/// does not return [`CancellableCommandOutput::Cancelled`] until the direct
124/// child is reaped and a signal-zero group probe reports `ESRCH`.
125///
126/// # Errors
127///
128/// Returns [`ProcessGroupError`] when the command cannot be spawned or observed,
129/// when signalling fails, or when the group cannot be confirmed empty after
130/// bounded `SIGTERM`/`SIGKILL` handling.
131pub async fn run_cancellable_command<C>(
132    command: Command,
133    cancellation: C,
134) -> Result<CancellableCommandOutput, ProcessGroupError>
135where
136    C: Future<Output = ()>,
137{
138    #[cfg(unix)]
139    {
140        run_unix(command, cancellation).await
141    }
142    #[cfg(not(unix))]
143    {
144        let _ = (command, cancellation);
145        Err(ProcessGroupError::Unsupported)
146    }
147}
148
149#[cfg(unix)]
150async fn run_unix<C>(
151    mut command: Command,
152    cancellation: C,
153) -> Result<CancellableCommandOutput, ProcessGroupError>
154where
155    C: Future<Output = ()>,
156{
157    use std::os::unix::process::CommandExt;
158
159    command.as_std_mut().process_group(0);
160    command
161        .kill_on_drop(true)
162        .stdout(Stdio::piped())
163        .stderr(Stdio::piped());
164    let mut child = command
165        .spawn()
166        .map_err(|source| ProcessGroupError::Spawn { source })?;
167    let raw_pid = child.id().ok_or(ProcessGroupError::MissingProcessId)?;
168    let process_group = i32::try_from(raw_pid)
169        .map_err(|_| ProcessGroupError::ProcessIdOutOfRange { pid: raw_pid })?;
170    let process_group = nix::unistd::Pid::from_raw(process_group);
171    let mut guard = ProcessGroupGuard::new(process_group);
172    let stdout = child.stdout.take();
173    let stderr = child.stderr.take();
174    tokio::pin!(cancellation);
175
176    let state = {
177        let completion = collect_output(&mut child, stdout, stderr);
178        tokio::pin!(completion);
179        tokio::select! {
180            biased;
181            () = &mut cancellation => RunState::Cancelled,
182            result = &mut completion => RunState::Completed(result),
183        }
184    };
185
186    match state {
187        RunState::Completed(Ok(output)) => {
188            guard.disarm();
189            Ok(CancellableCommandOutput::Completed(output))
190        }
191        RunState::Completed(Err(original)) => {
192            match terminate_process_group(&mut child, process_group).await {
193                Ok(()) => {
194                    guard.disarm();
195                    Err(original)
196                }
197                Err(cleanup) => Err(ProcessGroupError::CleanupAfterFailure {
198                    original: Box::new(original),
199                    cleanup: Box::new(cleanup),
200                }),
201            }
202        }
203        RunState::Cancelled => {
204            terminate_process_group(&mut child, process_group).await?;
205            guard.disarm();
206            Ok(CancellableCommandOutput::Cancelled)
207        }
208    }
209}
210
211#[cfg(unix)]
212enum RunState {
213    Completed(Result<Output, ProcessGroupError>),
214    Cancelled,
215}
216
217async fn collect_output(
218    child: &mut Child,
219    stdout: Option<ChildStdout>,
220    stderr: Option<ChildStderr>,
221) -> Result<Output, ProcessGroupError> {
222    let wait = async {
223        child
224            .wait()
225            .await
226            .map_err(|source| ProcessGroupError::Reap { source })
227    };
228    let capture = async {
229        let stdout = stdout.ok_or(ProcessGroupError::MissingPipe { stream: "stdout" })?;
230        let stderr = stderr.ok_or(ProcessGroupError::MissingPipe { stream: "stderr" })?;
231        tokio::try_join!(read_stream(stdout, "stdout"), read_stream(stderr, "stderr"))
232    };
233    let (status, (stdout, stderr)) = tokio::try_join!(wait, capture)?;
234    Ok(Output {
235        status,
236        stdout,
237        stderr,
238    })
239}
240
241async fn read_stream<R>(mut stream: R, name: &'static str) -> Result<Vec<u8>, ProcessGroupError>
242where
243    R: AsyncRead + Unpin,
244{
245    let mut bytes = Vec::new();
246    stream
247        .read_to_end(&mut bytes)
248        .await
249        .map_err(|source| ProcessGroupError::Read {
250            stream: name,
251            source,
252        })?;
253    Ok(bytes)
254}
255
256#[cfg(unix)]
257async fn terminate_process_group(
258    child: &mut Child,
259    process_group: nix::unistd::Pid,
260) -> Result<(), ProcessGroupError> {
261    use nix::sys::signal::Signal;
262
263    signal_group(process_group, Signal::SIGTERM, "SIGTERM")?;
264    let deadline = tokio::time::Instant::now() + PROCESS_GROUP_TERMINATION_GRACE;
265    if wait_for_group_to_disappear(child, process_group, deadline).await? {
266        return Ok(());
267    }
268
269    signal_group(process_group, Signal::SIGKILL, "SIGKILL")?;
270    let deadline = tokio::time::Instant::now() + PROCESS_GROUP_TERMINATION_GRACE;
271    if wait_for_group_to_disappear(child, process_group, deadline).await? {
272        return Ok(());
273    }
274    Err(ProcessGroupError::GroupStillAlive {
275        process_group: process_group.as_raw(),
276        grace: PROCESS_GROUP_TERMINATION_GRACE,
277    })
278}
279
280#[cfg(unix)]
281async fn wait_for_group_to_disappear(
282    child: &mut Child,
283    process_group: nix::unistd::Pid,
284    deadline: tokio::time::Instant,
285) -> Result<bool, ProcessGroupError> {
286    loop {
287        child
288            .try_wait()
289            .map_err(|source| ProcessGroupError::Reap { source })?;
290        if group_is_gone(process_group)? {
291            return Ok(true);
292        }
293        let now = tokio::time::Instant::now();
294        if now >= deadline {
295            return Ok(false);
296        }
297        tokio::time::sleep(GROUP_PROBE_INTERVAL.min(deadline - now)).await;
298    }
299}
300
301#[cfg(unix)]
302fn signal_group(
303    process_group: nix::unistd::Pid,
304    signal: nix::sys::signal::Signal,
305    signal_name: &'static str,
306) -> Result<(), ProcessGroupError> {
307    match nix::sys::signal::killpg(process_group, signal) {
308        Ok(()) | Err(nix::errno::Errno::ESRCH) => Ok(()),
309        Err(source) => Err(ProcessGroupError::Signal {
310            process_group: process_group.as_raw(),
311            signal: signal_name,
312            source,
313        }),
314    }
315}
316
317#[cfg(unix)]
318fn group_is_gone(process_group: nix::unistd::Pid) -> Result<bool, ProcessGroupError> {
319    match nix::sys::signal::killpg(process_group, None::<nix::sys::signal::Signal>) {
320        Ok(()) => Ok(false),
321        Err(nix::errno::Errno::ESRCH) => Ok(true),
322        Err(source) => Err(ProcessGroupError::Probe {
323            process_group: process_group.as_raw(),
324            source,
325        }),
326    }
327}
328
329#[cfg(unix)]
330struct ProcessGroupGuard {
331    process_group: nix::unistd::Pid,
332    armed: bool,
333}
334
335#[cfg(unix)]
336impl ProcessGroupGuard {
337    const fn new(process_group: nix::unistd::Pid) -> Self {
338        Self {
339            process_group,
340            armed: true,
341        }
342    }
343
344    const fn disarm(&mut self) {
345        self.armed = false;
346    }
347}
348
349#[cfg(unix)]
350impl Drop for ProcessGroupGuard {
351    fn drop(&mut self) {
352        if !self.armed {
353            return;
354        }
355        match nix::sys::signal::killpg(self.process_group, nix::sys::signal::Signal::SIGKILL) {
356            Ok(()) | Err(nix::errno::Errno::ESRCH) => {}
357            Err(source) => tracing::error!(
358                process_group = self.process_group.as_raw(),
359                %source,
360                "failed to kill contained process group while dropping its owner"
361            ),
362        }
363    }
364}