Skip to main content

aion_worker/process/
cancellable.rs

1//! Cancellable process-group containment for worker-owned commands.
2//!
3//! A command enters a fresh process group before user code runs
4//! ([`ContainedChild`], the one containment core). Cancellation sends
5//! `SIGTERM` to the whole group, waits a bounded grace period, escalates to
6//! `SIGKILL`, reaps the direct child, and returns `Cancelled` only after the
7//! group is confirmed gone.
8//!
9//! Every reap here goes through [`ContainedChild::wait`] rather than the raw
10//! child, because the core tracks reaping to decide whether signalling the
11//! group is still legitimate. Reaping behind its back is precisely how a
12//! signal comes to be aimed at a recycled process-group id.
13//!
14//! # Output arrives line by line, not at exit
15//!
16//! Both output streams are read as the command produces them: every complete
17//! line is handed to a [`CommandOutputObserver`] the moment its newline is
18//! read, while the same bytes accumulate verbatim into the [`Output`] the
19//! command's completion still carries. A command that prints for ten minutes is
20//! therefore observable throughout, and its terminal result is byte-identical
21//! to what a read-to-end capture would have produced.
22
23use std::future::Future;
24use std::process::{Output, Stdio};
25
26use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
27use tokio::process::{ChildStderr, ChildStdout, Command};
28
29use super::contained::{ContainedChild, PROCESS_GROUP_TERMINATION_GRACE, ProcessGroupError};
30
31/// Which of a contained command's two output streams a line arrived on.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum CommandStream {
34    /// The command's standard output.
35    Stdout,
36    /// The command's standard error.
37    Stderr,
38}
39
40impl CommandStream {
41    /// The stream's conventional name, used in diagnostics and in the labels an
42    /// observer attaches to what it records.
43    #[must_use]
44    pub const fn name(self) -> &'static str {
45        match self {
46            Self::Stdout => "stdout",
47            Self::Stderr => "stderr",
48        }
49    }
50}
51
52/// Observes a contained command's output one line at a time, AS IT IS WRITTEN.
53///
54/// [`Self::on_line`] is called once per complete line on either stream, with the
55/// line terminator (`\n`, or `\r\n`) already removed and invalid UTF-8 replaced
56/// exactly as the completion capture replaces it. A blank line is delivered as
57/// an empty string rather than dropped: the observer sees the command's output
58/// with the same shape the capture keeps.
59pub trait CommandOutputObserver: Send + Sync {
60    /// One complete line arrived on `stream`.
61    fn on_line(&self, stream: CommandStream, line: &str);
62}
63
64/// The observable outcome of a contained command.
65#[derive(Debug)]
66pub enum CancellableCommandOutput {
67    /// The command exited and its output pipes reached EOF.
68    Completed(Output),
69    /// Cancellation killed, reaped, and verified the entire process group.
70    Cancelled,
71}
72
73/// Run a command in a fresh process group and make cancellation tree-wide.
74///
75/// The command's stdout and stderr are captured in full and, as they arrive,
76/// streamed line by line to `observer` — so a caller can surface what the
77/// command is printing WHILE it runs, not only once it has exited. On
78/// cancellation, this function does not return
79/// [`CancellableCommandOutput::Cancelled`] until the direct child is reaped and
80/// the process group is confirmed gone; the lines observed up to that point
81/// stand.
82///
83/// # Errors
84///
85/// Returns [`ProcessGroupError`] when the command cannot be spawned or observed,
86/// when signalling fails, or when the group cannot be confirmed empty after
87/// bounded `SIGTERM`/`SIGKILL` handling.
88pub async fn run_cancellable_command<C, O>(
89    mut command: Command,
90    cancellation: C,
91    observer: &O,
92) -> Result<CancellableCommandOutput, ProcessGroupError>
93where
94    C: Future<Output = ()>,
95    O: CommandOutputObserver + ?Sized,
96{
97    command.stdout(Stdio::piped()).stderr(Stdio::piped());
98    let mut contained = ContainedChild::spawn(command)?;
99    let stdout = contained.take_stdout();
100    let stderr = contained.take_stderr();
101    tokio::pin!(cancellation);
102
103    let state = {
104        let completion = collect_output(&mut contained, stdout, stderr, observer);
105        tokio::pin!(completion);
106        tokio::select! {
107            biased;
108            () = &mut cancellation => RunState::Cancelled,
109            result = &mut completion => RunState::Completed(result),
110        }
111    };
112
113    match state {
114        RunState::Completed(Ok(output)) => {
115            // Both pipes reached EOF, so no descendant still holds a write end:
116            // the group is established empty without a probe.
117            contained.disarm();
118            Ok(CancellableCommandOutput::Completed(output))
119        }
120        RunState::Completed(Err(original)) => match stop_and_confirm(&mut contained).await {
121            Ok(()) => Err(original),
122            Err(cleanup) => Err(ProcessGroupError::CleanupAfterFailure {
123                original: Box::new(original),
124                cleanup: Box::new(cleanup),
125            }),
126        },
127        RunState::Cancelled => {
128            stop_and_confirm(&mut contained).await?;
129            Ok(CancellableCommandOutput::Cancelled)
130        }
131    }
132}
133
134/// Kill the group, then PROVE it gone before anyone is told it is.
135///
136/// The two halves are separate operations in the core precisely so that this
137/// composition is explicit: `Cancelled` is a claim about the whole tree, and it
138/// is not made until the probe supports it.
139async fn stop_and_confirm(contained: &mut ContainedChild) -> Result<(), ProcessGroupError> {
140    contained.terminate(PROCESS_GROUP_TERMINATION_GRACE).await?;
141    contained
142        .confirm_group_gone(PROCESS_GROUP_TERMINATION_GRACE)
143        .await
144}
145
146enum RunState {
147    Completed(Result<Output, ProcessGroupError>),
148    Cancelled,
149}
150
151async fn collect_output<O>(
152    contained: &mut ContainedChild,
153    stdout: Option<ChildStdout>,
154    stderr: Option<ChildStderr>,
155    observer: &O,
156) -> Result<Output, ProcessGroupError>
157where
158    O: CommandOutputObserver + ?Sized,
159{
160    let capture = async {
161        let stdout = stdout.ok_or(ProcessGroupError::MissingPipe {
162            stream: CommandStream::Stdout.name(),
163        })?;
164        let stderr = stderr.ok_or(ProcessGroupError::MissingPipe {
165            stream: CommandStream::Stderr.name(),
166        })?;
167        tokio::try_join!(
168            read_stream(stdout, CommandStream::Stdout, observer),
169            read_stream(stderr, CommandStream::Stderr, observer)
170        )
171    };
172    let (status, (stdout, stderr)) = tokio::try_join!(contained.wait(), capture)?;
173    Ok(Output {
174        status,
175        stdout,
176        stderr,
177    })
178}
179
180/// Read one output stream to EOF, announcing each complete line to `observer` as
181/// it arrives and returning every byte read, verbatim.
182///
183/// The line and the capture are the SAME memory: each read appends to the
184/// capture buffer and the line is the slice just appended, so streaming costs no
185/// second copy of the command's output and the returned bytes are exactly what a
186/// read-to-end capture would have produced — terminators, blank lines, invalid
187/// UTF-8 and all.
188///
189/// Reading stops at the first I/O error, which fails the whole command exactly as
190/// it did before: a partially observed stream is never silently accepted as a
191/// complete one.
192async fn read_stream<R, O>(
193    stream: R,
194    which: CommandStream,
195    observer: &O,
196) -> Result<Vec<u8>, ProcessGroupError>
197where
198    R: AsyncRead + Unpin,
199    O: CommandOutputObserver + ?Sized,
200{
201    let mut reader = BufReader::new(stream);
202    let mut bytes = Vec::new();
203    loop {
204        let line_start = bytes.len();
205        let read = reader
206            .read_until(b'\n', &mut bytes)
207            .await
208            .map_err(|source| ProcessGroupError::Read {
209                stream: which.name(),
210                source,
211            })?;
212        if read == 0 {
213            // EOF. A final line with no terminator was already delivered by the
214            // previous iteration, which returned it without a trailing newline.
215            return Ok(bytes);
216        }
217        let line = bytes.get(line_start..).unwrap_or_default();
218        observer.on_line(which, &String::from_utf8_lossy(strip_line_ending(line)));
219    }
220}
221
222/// Remove the line terminator from a chunk read up to and including `\n`.
223///
224/// A chunk ending the stream without a newline is returned untouched. `\r\n` is
225/// treated as one terminator so a command writing DOS line endings does not
226/// leave a stray carriage return at the end of every observed line.
227fn strip_line_ending(line: &[u8]) -> &[u8] {
228    let line = line.strip_suffix(b"\n").unwrap_or(line);
229    line.strip_suffix(b"\r").unwrap_or(line)
230}
231
232#[cfg(test)]
233mod tests {
234    use std::sync::{Mutex, PoisonError};
235    use std::time::Duration;
236
237    use super::{
238        CancellableCommandOutput, CommandOutputObserver, CommandStream, read_stream,
239        run_cancellable_command,
240    };
241    use tokio::process::Command;
242
243    /// What a test returns. Every fallible step is carried rather than
244    /// unwrapped, because the workspace denies panicking accessors in test code
245    /// as firmly as in library code.
246    type TestResult = Result<(), Box<dyn std::error::Error>>;
247
248    /// Records every observed line in arrival order.
249    #[derive(Default)]
250    struct Recorder {
251        lines: Mutex<Vec<(CommandStream, String)>>,
252    }
253
254    impl Recorder {
255        fn lines(&self) -> Vec<(CommandStream, String)> {
256            self.lines
257                .lock()
258                .unwrap_or_else(PoisonError::into_inner)
259                .clone()
260        }
261
262        /// The lines observed on one stream, in arrival order.
263        fn on(&self, stream: CommandStream) -> Vec<String> {
264            self.lines()
265                .into_iter()
266                .filter(|(observed, _)| *observed == stream)
267                .map(|(_, line)| line)
268                .collect()
269        }
270
271        fn saw(&self, stream: CommandStream, line: &str) -> bool {
272            self.on(stream).iter().any(|observed| observed == line)
273        }
274    }
275
276    impl CommandOutputObserver for Recorder {
277        fn on_line(&self, stream: CommandStream, line: &str) {
278            self.lines
279                .lock()
280                .unwrap_or_else(PoisonError::into_inner)
281                .push((stream, line.to_owned()));
282        }
283    }
284
285    /// THE PROPERTY THIS BUILD EXISTS FOR: a line written on either stream is
286    /// observable WHILE the command is still running, not when it exits.
287    ///
288    /// The command prints one line on each stream and then sleeps far longer
289    /// than the test's patience. The run future is polled concurrently with the
290    /// check, so if the lines only materialized at completion the run arm would
291    /// win and the test fails by name; a capture that never delivers them fails
292    /// on the deadline. Both failure modes are the old read-to-end behaviour.
293    #[tokio::test]
294    async fn output_lines_are_observed_before_the_command_exits() -> TestResult {
295        let recorder = Recorder::default();
296        let mut command = Command::new("sh");
297        command.arg("-c").arg("echo out; echo err >&2; sleep 30");
298        let run = run_cancellable_command(command, std::future::pending::<()>(), &recorder);
299        tokio::pin!(run);
300
301        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
302        loop {
303            tokio::select! {
304                biased;
305                result = &mut run => {
306                    drop(result);
307                    return Err(
308                        "the command ran to completion before its output was observed".into(),
309                    );
310                }
311                () = tokio::time::sleep(Duration::from_millis(5)) => {
312                    if recorder.saw(CommandStream::Stdout, "out")
313                        && recorder.saw(CommandStream::Stderr, "err")
314                    {
315                        // Both streams delivered mid-run. Dropping the pinned run
316                        // future on the way out kills and reaps the process group,
317                        // so the sleeping command is not left behind.
318                        return Ok(());
319                    }
320                    if tokio::time::Instant::now() >= deadline {
321                        return Err(format!(
322                            "the running command's output never arrived: {:?}",
323                            recorder.lines()
324                        )
325                        .into());
326                    }
327                }
328            }
329        }
330    }
331
332    /// Streaming must not change what the command's completion carries: the
333    /// captured bytes are exactly the stream's, terminators included, and the
334    /// observed lines are that same output split on its newlines.
335    #[tokio::test]
336    async fn the_completion_capture_is_unchanged_by_streaming() -> TestResult {
337        let recorder = Recorder::default();
338        let mut command = Command::new("sh");
339        command
340            .arg("-c")
341            .arg("printf 'first\\nsecond\\n'; printf 'warned\\n' >&2");
342        let outcome =
343            run_cancellable_command(command, std::future::pending::<()>(), &recorder).await?;
344        let CancellableCommandOutput::Completed(output) = outcome else {
345            return Err("the command must complete".into());
346        };
347
348        assert_eq!(output.stdout, b"first\nsecond\n");
349        assert_eq!(output.stderr, b"warned\n");
350        assert_eq!(recorder.on(CommandStream::Stdout), vec!["first", "second"]);
351        assert_eq!(recorder.on(CommandStream::Stderr), vec!["warned"]);
352        Ok(())
353    }
354
355    /// The line splitter's whole contract on one input: CRLF and LF are both one
356    /// terminator, a blank line is delivered rather than dropped, a final line
357    /// with no terminator is still delivered, invalid UTF-8 is replaced exactly
358    /// as the capture replaces it, and every byte read is returned verbatim.
359    #[tokio::test]
360    async fn every_line_shape_is_delivered_and_the_bytes_are_returned_verbatim() -> TestResult {
361        let source: &[u8] = b"plain\ncrlf\r\n\n\xffbad\nno trailing newline";
362        let recorder = Recorder::default();
363
364        let bytes = read_stream(source, CommandStream::Stdout, &recorder).await?;
365
366        assert_eq!(bytes, source, "the capture returns every byte, untouched");
367        assert_eq!(
368            recorder.on(CommandStream::Stdout),
369            vec![
370                "plain".to_owned(),
371                "crlf".to_owned(),
372                String::new(),
373                String::from_utf8_lossy(b"\xffbad").into_owned(),
374                "no trailing newline".to_owned(),
375            ]
376        );
377        Ok(())
378    }
379
380    /// A stream that produces nothing observes nothing — no phantom empty line
381    /// at EOF.
382    #[tokio::test]
383    async fn an_empty_stream_observes_nothing() -> TestResult {
384        let recorder = Recorder::default();
385        let bytes = read_stream(&b""[..], CommandStream::Stderr, &recorder).await?;
386        assert!(bytes.is_empty());
387        assert!(recorder.lines().is_empty());
388        Ok(())
389    }
390}