1use 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
17pub const PROCESS_GROUP_TERMINATION_GRACE: Duration = Duration::from_secs(2);
22
23const GROUP_PROBE_INTERVAL: Duration = Duration::from_millis(10);
24
25#[derive(Debug)]
27pub enum CancellableCommandOutput {
28 Completed(Output),
30 Cancelled,
32}
33
34#[derive(Debug, Error)]
36pub enum ProcessGroupError {
37 #[error("process-group containment is unsupported on this operating system")]
39 Unsupported,
40 #[error("contained command could not be spawned: {source}")]
42 Spawn {
43 #[source]
45 source: io::Error,
46 },
47 #[error("contained command spawned without a process id")]
49 MissingProcessId,
50 #[error("contained command process id {pid} is outside the supported range")]
52 ProcessIdOutOfRange {
53 pid: u32,
55 },
56 #[error("contained command did not expose its piped {stream}")]
58 MissingPipe {
59 stream: &'static str,
61 },
62 #[error("failed to read contained command {stream}: {source}")]
64 Read {
65 stream: &'static str,
67 #[source]
69 source: io::Error,
70 },
71 #[error("failed to reap contained command: {source}")]
73 Reap {
74 #[source]
76 source: io::Error,
77 },
78 #[cfg(unix)]
80 #[error("failed to send {signal} to process group {process_group}: {source}")]
81 Signal {
82 process_group: i32,
84 signal: &'static str,
86 #[source]
88 source: nix::errno::Errno,
89 },
90 #[cfg(unix)]
92 #[error("failed to probe process group {process_group}: {source}")]
93 Probe {
94 process_group: i32,
96 #[source]
98 source: nix::errno::Errno,
99 },
100 #[error(
102 "process group {process_group} remained alive {grace:?} after SIGKILL; cancellation is not complete"
103 )]
104 GroupStillAlive {
105 process_group: i32,
107 grace: Duration,
109 },
110 #[error("{original}; mandatory process-group cleanup also failed: {cleanup}")]
112 CleanupAfterFailure {
113 original: Box<ProcessGroupError>,
115 cleanup: Box<ProcessGroupError>,
117 },
118}
119
120pub 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}