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}