Skip to main content

tokio_process_tools/
error.rs

1//! Error types for process operations.
2
3use std::borrow::Cow;
4use std::error::Error;
5use std::fmt;
6use std::io;
7use std::process::ExitStatus;
8use std::sync::Arc;
9use std::time::Duration;
10use thiserror::Error;
11
12use crate::ConsumerError;
13
14/// Errors that can occur when terminating a process.
15///
16/// # Brief grace window after a failed signal send
17///
18/// During each graceful phase of [`crate::ProcessHandle::terminate`], if the signal send itself
19/// fails (`EPERM` on macOS, `ESRCH` on Linux against a not-yet-reaped process group on Unix; the
20/// equivalent `ERROR_INVALID_HANDLE` / `ERROR_ACCESS_DENIED` window on Windows), the library
21/// applies a fixed 100 ms grace and re-checks for child exit before escalating. This covers the
22/// small race where the child has already exited but Tokio's SIGCHLD reaper has not yet observed
23/// it. The 100 ms grace replaces, never adds to, the user timeout for that phase. Real permission
24/// denials and other genuine signal failures still surface here as `SignalFailed` or
25/// `TerminationFailed` with the underlying `io::Error` preserved on each
26/// [`TerminationAttemptError`].
27#[derive(Debug, Error)]
28#[non_exhaustive]
29pub enum TerminationError {
30    /// Failed to manually send a graceful signal to the process.
31    #[error(
32        "Failed to send signal to process '{process_name}'.{}",
33        DisplayAttemptErrors(.attempt_errors.as_slice())
34    )]
35    SignalFailed {
36        /// The name of the process.
37        process_name: Cow<'static, str>,
38        /// Errors recorded while attempting to send the signal, in chronological order.
39        attempt_errors: Vec<TerminationAttemptError>,
40    },
41
42    /// Failed to terminate the process after trying all platform termination signals.
43    #[error(
44        "Failed to terminate process '{process_name}'.{}",
45        DisplayAttemptErrors(.attempt_errors.as_slice())
46    )]
47    TerminationFailed {
48        /// The name of the process.
49        process_name: Cow<'static, str>,
50        /// Errors recorded while attempting process termination, in chronological order.
51        attempt_errors: Vec<TerminationAttemptError>,
52    },
53}
54
55impl TerminationError {
56    /// The name of the process involved in the termination error.
57    #[must_use]
58    pub fn process_name(&self) -> &str {
59        match self {
60            Self::SignalFailed { process_name, .. }
61            | Self::TerminationFailed { process_name, .. } => process_name,
62        }
63    }
64
65    /// Errors recorded while attempting the operation, in chronological order.
66    #[must_use]
67    pub fn attempt_errors(&self) -> &[TerminationAttemptError] {
68        match self {
69            Self::SignalFailed { attempt_errors, .. }
70            | Self::TerminationFailed { attempt_errors, .. } => attempt_errors,
71        }
72    }
73}
74
75struct DisplayAttemptErrors<'a>(&'a [TerminationAttemptError]);
76
77impl fmt::Display for DisplayAttemptErrors<'_> {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        if self.0.is_empty() {
80            return write!(f, " No attempt error was recorded.");
81        }
82
83        write!(f, " Attempt errors:")?;
84        for (index, attempt_error) in self.0.iter().enumerate() {
85            write!(f, " [{}] {attempt_error}", index + 1)?;
86        }
87
88        Ok(())
89    }
90}
91
92/// A failed action recorded while attempting to terminate a process.
93#[derive(Debug, Error)]
94#[error("{action} failed")]
95#[non_exhaustive]
96pub struct TerminationAttemptError {
97    /// Action that failed while attempting termination.
98    pub action: TerminationAction,
99    /// Original source error.
100    #[source]
101    pub source: Box<dyn Error + Send + Sync + 'static>,
102}
103
104/// Termination action that failed.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106#[non_exhaustive]
107pub enum TerminationAction {
108    /// Checking whether the process has already exited failed.
109    CheckStatus,
110
111    /// Sending a signal failed.
112    SendSignal {
113        /// The name of the dispatched signal.
114        signal_name: &'static str,
115    },
116
117    /// Waiting for the process to exit failed.
118    WaitForExit,
119}
120
121impl fmt::Display for TerminationAction {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::CheckStatus => f.write_str("status check"),
125            Self::SendSignal { signal_name } => write!(f, "send signal {signal_name}"),
126            Self::WaitForExit => f.write_str("exit wait"),
127        }
128    }
129}
130
131/// Errors that can occur when waiting for process operations.
132#[derive(Debug, Error)]
133#[non_exhaustive]
134pub enum WaitError {
135    /// A general IO error occurred.
136    #[error("IO error occurred while waiting for process '{process_name}'")]
137    IoError {
138        /// The name of the process.
139        process_name: Cow<'static, str>,
140        /// The underlying IO error.
141        #[source]
142        source: io::Error,
143    },
144}
145
146/// Result of waiting for a process to complete within an explicit timeout.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148#[must_use = "Discarding the result hides whether the process completed or the wait timed out; \
149              match on the variants or call `into_completed`/`expect_completed`."]
150pub enum WaitForCompletionResult<T = ExitStatus> {
151    /// The process completed before the timeout elapsed.
152    Completed(T),
153
154    /// The timeout elapsed before the process completed.
155    Timeout {
156        /// The timeout duration that was exceeded.
157        timeout: Duration,
158    },
159}
160
161impl<T> WaitForCompletionResult<T> {
162    /// Returns the completed value, or `None` if the wait timed out.
163    #[must_use]
164    pub fn into_completed(self) -> Option<T> {
165        match self {
166            Self::Completed(value) => Some(value),
167            Self::Timeout { .. } => None,
168        }
169    }
170
171    /// Returns the completed value, panicking with `message` if the wait timed out.
172    ///
173    /// # Panics
174    ///
175    /// Panics with `message` if this result is [`WaitForCompletionResult::Timeout`].
176    pub fn expect_completed(self, message: &str) -> T {
177        self.into_completed().expect(message)
178    }
179}
180
181/// Result of waiting for a process to complete, terminating it if the wait times out.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183#[must_use = "Discarding the result hides whether the process completed naturally or was \
184              terminated after the wait timeout; match on the variants or call `into_result`."]
185pub enum WaitForCompletionOrTerminateResult<T = ExitStatus> {
186    /// The process completed before the wait timeout elapsed.
187    Completed(T),
188
189    /// The wait timeout elapsed, then cleanup termination completed successfully.
190    TerminatedAfterTimeout {
191        /// The result observed after cleanup termination.
192        result: T,
193        /// The wait timeout duration that was exceeded before cleanup began.
194        timeout: Duration,
195    },
196}
197
198impl<T> WaitForCompletionOrTerminateResult<T> {
199    /// Returns the terminal value, whether completion happened before timeout or after cleanup.
200    #[must_use]
201    pub fn into_result(self) -> T {
202        match self {
203            Self::Completed(value) | Self::TerminatedAfterTimeout { result: value, .. } => value,
204        }
205    }
206
207    /// Returns the completed value, or `None` if cleanup termination was required after timeout.
208    #[must_use]
209    pub fn into_completed(self) -> Option<T> {
210        match self {
211            Self::Completed(value) => Some(value),
212            Self::TerminatedAfterTimeout { .. } => None,
213        }
214    }
215
216    /// Returns the completed value, panicking with `message` if cleanup termination was required.
217    ///
218    /// # Panics
219    ///
220    /// Panics with `message` if this result is
221    /// [`WaitForCompletionOrTerminateResult::TerminatedAfterTimeout`].
222    pub fn expect_completed(self, message: &str) -> T {
223        self.into_completed().expect(message)
224    }
225
226    /// Maps a terminal value while preserving the timeout/cleanup outcome.
227    pub(crate) fn map<U>(self, f: impl FnOnce(T) -> U) -> WaitForCompletionOrTerminateResult<U> {
228        match self {
229            Self::Completed(value) => WaitForCompletionOrTerminateResult::Completed(f(value)),
230            Self::TerminatedAfterTimeout { result, timeout } => {
231                WaitForCompletionOrTerminateResult::TerminatedAfterTimeout {
232                    result: f(result),
233                    timeout,
234                }
235            }
236        }
237    }
238}
239
240/// Errors that can occur when waiting for a process with automatic termination on failure.
241#[derive(Debug, Error)]
242#[non_exhaustive]
243pub enum WaitOrTerminateError {
244    /// Waiting failed, but the subsequent cleanup termination succeeded.
245    #[error(
246        "Waiting for process '{process_name}' failed; cleanup termination completed with status {termination_status}"
247    )]
248    WaitFailed {
249        /// The name of the process.
250        process_name: Cow<'static, str>,
251        /// The original error returned while waiting for the process.
252        #[source]
253        wait_error: Box<WaitError>,
254        /// The status observed after cleanup termination.
255        termination_status: ExitStatus,
256    },
257
258    /// Waiting failed, and the subsequent cleanup termination also failed.
259    #[error("Termination of process '{process_name}' failed after '{wait_error}'.")]
260    TerminationFailed {
261        /// The name of the process.
262        process_name: Cow<'static, str>,
263        /// The original error returned while waiting for the process.
264        wait_error: Box<WaitError>,
265        /// The error returned while trying to terminate the process after the wait failure.
266        #[source]
267        termination_error: TerminationError,
268    },
269
270    /// Waiting timed out, and the subsequent cleanup termination failed.
271    #[error(
272        "Process '{process_name}' did not complete within {timeout:?}; cleanup termination failed"
273    )]
274    TerminationAfterTimeoutFailed {
275        /// The name of the process.
276        process_name: Cow<'static, str>,
277        /// The wait timeout duration that was exceeded before cleanup began.
278        timeout: Duration,
279        /// The error returned while trying to terminate the process after the timeout.
280        #[source]
281        termination_error: TerminationError,
282    },
283}
284
285/// Errors that can occur when waiting for a process while collecting its output, with or
286/// without automatic termination on timeout.
287///
288/// `WaitFailed` is emitted from staged-builder chains without `.or_terminate(...)`.
289/// `WaitOrTerminateFailed` is emitted from chains that include `.or_terminate(...)`. The
290/// remaining variants can be emitted by either family.
291#[derive(Debug, Error)]
292#[non_exhaustive]
293pub enum WaitWithOutputError {
294    /// Waiting for the process failed.
295    #[error("Waiting for process completion failed")]
296    WaitFailed(#[from] WaitError),
297
298    /// Waiting with automatic termination failed.
299    #[error("Wait-or-terminate operation failed")]
300    WaitOrTerminateFailed(#[from] WaitOrTerminateError),
301
302    /// Output collection did not complete before the operation timeout elapsed.
303    #[error("Output collection for process '{process_name}' did not complete within {timeout:?}")]
304    OutputCollectionTimeout {
305        /// The name of the process.
306        process_name: Cow<'static, str>,
307        /// The timeout duration that was exceeded.
308        timeout: Duration,
309    },
310
311    /// Collecting stdout or stderr failed.
312    #[error("Output collection for process '{process_name}' failed")]
313    OutputCollectionFailed {
314        /// The name of the process.
315        process_name: Cow<'static, str>,
316        /// The collector error that caused output collection to fail.
317        #[source]
318        source: ConsumerError,
319    },
320
321    /// Starting stdout or stderr output collection failed.
322    #[error("Output collection for process '{process_name}' could not start")]
323    OutputCollectionStartFailed {
324        /// The name of the process.
325        process_name: Cow<'static, str>,
326        /// The stream consumer error that prevented output collection from starting.
327        #[source]
328        source: Box<dyn Error + Send + Sync + 'static>,
329    },
330}
331
332/// Errors that can occur when spawning a process.
333#[derive(Debug, Error)]
334#[non_exhaustive]
335pub enum SpawnError {
336    /// Failed to spawn the process.
337    #[error("Failed to spawn process '{process_name}'")]
338    SpawnFailed {
339        /// The name or description of the process being spawned.
340        process_name: Cow<'static, str>,
341        /// The underlying IO error.
342        #[source]
343        source: io::Error,
344    },
345
346    /// Failed to attach the spawned child to a Windows Job Object.
347    ///
348    /// On Windows, the crate creates an anonymous Job Object after spawn and assigns the child to
349    /// it so that [`crate::ProcessHandle::kill`] (and the forceful-kill fallback inside
350    /// [`crate::ProcessHandle::terminate`]) can reach the entire process tree via
351    /// `TerminateJobObject`. If the post-spawn `CreateJobObjectW`, `OpenProcess`, or
352    /// `AssignProcessToJobObject` call fails, the just-spawned child is killed and the spawn is
353    /// rejected with this error rather than handed back without process-tree termination
354    /// guarantees.
355    #[cfg(windows)]
356    #[error("Failed to attach spawned process '{process_name}' to a Windows Job Object")]
357    JobAttachmentFailed {
358        /// The name or description of the process being spawned.
359        process_name: Cow<'static, str>,
360        /// The underlying IO error from the failed `CreateJobObjectW`, `OpenProcess`, or
361        /// `AssignProcessToJobObject` call.
362        #[source]
363        source: io::Error,
364    },
365}
366
367/// Errors that can occur when creating a stream consumer.
368#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
369#[non_exhaustive]
370pub enum StreamConsumerError {
371    /// A single-subscriber stream already has an active consumer.
372    #[error("Stream '{stream_name}' already has an active consumer")]
373    ActiveConsumer {
374        /// The name of the stream that rejected the consumer.
375        stream_name: &'static str,
376    },
377}
378
379impl StreamConsumerError {
380    /// The name of the stream that rejected the consumer.
381    #[must_use]
382    pub fn stream_name(&self) -> &'static str {
383        match self {
384            Self::ActiveConsumer { stream_name } => stream_name,
385        }
386    }
387}
388
389/// Error emitted when an output stream cannot be read to completion.
390#[derive(Debug, Clone, Error)]
391#[error("Could not read from stream '{stream_name}'")]
392pub struct StreamReadError {
393    stream_name: &'static str,
394    #[source]
395    source: Arc<io::Error>,
396}
397
398impl StreamReadError {
399    /// Creates a stream read error from the stream name and underlying IO error.
400    #[must_use]
401    pub fn new(stream_name: &'static str, source: io::Error) -> Self {
402        Self {
403            stream_name,
404            source: Arc::new(source),
405        }
406    }
407
408    /// The name of the stream that failed.
409    #[must_use]
410    pub fn stream_name(&self) -> &'static str {
411        self.stream_name
412    }
413
414    /// The [`io::ErrorKind`] of the underlying read failure.
415    #[must_use]
416    pub fn kind(&self) -> io::ErrorKind {
417        self.source.kind()
418    }
419
420    /// The underlying IO error.
421    #[must_use]
422    pub fn source_io_error(&self) -> &io::Error {
423        self.source.as_ref()
424    }
425}
426
427impl PartialEq for StreamReadError {
428    fn eq(&self, other: &Self) -> bool {
429        self.stream_name == other.stream_name && self.kind() == other.kind()
430    }
431}
432
433impl Eq for StreamReadError {}
434
435/// Result of waiting for an output line matching a predicate.
436///
437/// This enum is returned inside a `Result`; stream read failures are surfaced as
438/// [`StreamReadError`] rather than as a variant of this enum.
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440pub enum WaitForLineResult {
441    /// A matching line was observed before the stream ended or the timeout elapsed.
442    Matched,
443
444    /// The stream ended before any matching line was observed.
445    StreamClosed,
446
447    /// The timeout elapsed before a matching line was observed or the stream ended.
448    Timeout,
449}
450
451#[cfg(test)]
452pub(crate) mod tests {
453    use super::*;
454    use assertr::prelude::*;
455
456    pub(crate) fn assert_attempt_error(
457        attempt_error: &TerminationAttemptError,
458        expected_action: TerminationAction,
459        expected_kind: io::ErrorKind,
460        expected_message: &str,
461    ) {
462        assert_that!(attempt_error.action).is_equal_to(expected_action);
463
464        let io_error = attempt_error
465            .source
466            .downcast_ref::<io::Error>()
467            .expect("diagnostic should preserve the original io::Error");
468
469        assert_that!(io_error.kind()).is_equal_to(expected_kind);
470        assert_that!(io_error.to_string().as_str()).contains(expected_message);
471    }
472}