Skip to main content

conpty_oxide/
error.rs

1// SPDX-FileCopyrightText: 2026 conpty-oxide contributors <https://github.com/P4suta/conpty-oxide/graphs/contributors>
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Stable error classifications with opaque diagnostic context.
6
7#[cfg(any(feature = "blocking", feature = "tokio", test))]
8use std::ffi::OsString;
9use std::fmt;
10use std::io;
11use std::path::PathBuf;
12
13/// Convenience alias for results produced by this crate.
14pub type Result<T> = std::result::Result<T, Error>;
15
16/// The operation phase in which an [`Error`] occurred.
17///
18/// The classification is the stable part of the error contract. Diagnostic
19/// context remains available through [`Display`](fmt::Display),
20/// [`Debug`](fmt::Debug), and the [`source`](std::error::Error::source) chain
21/// without making that context part of the crate's `SemVer` surface.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[non_exhaustive]
24pub enum ErrorKind {
25    /// Loading or validating the `ConPTY` backend failed.
26    Backend,
27    /// Creating the pseudoconsole or its pipes failed.
28    CreateConsole,
29    /// Spawning the root process failed.
30    Spawn,
31    /// Resizing the pseudoconsole failed.
32    Resize,
33    /// Clearing the pseudoconsole failed.
34    Clear,
35    /// The selected backend does not provide a requested capability.
36    UnsupportedFeature,
37    /// A requested terminal size was invalid.
38    InvalidSize,
39    /// Waiting for the root process failed.
40    Wait,
41    /// Terminating the process tree failed.
42    Kill,
43    /// Reading from or writing to a pseudoconsole pipe failed.
44    Io,
45}
46
47/// A failure produced by `conpty-oxide`.
48///
49/// The representation is intentionally private. Use [`Error::kind`] for
50/// control flow, [`Error::io_error`] for a directly held OS error, and
51/// [`Error::backend_error`] for backend initialization details.
52pub struct Error {
53    repr: ErrorRepr,
54}
55
56#[derive(Debug, thiserror::Error)]
57enum ErrorRepr {
58    #[error("failed to initialize the ConPTY backend")]
59    Backend(#[source] BackendError),
60    #[cfg(any(feature = "blocking", feature = "tokio", test))]
61    #[error("failed to create pseudoconsole")]
62    CreateConsole(#[source] io::Error),
63    #[cfg(any(feature = "blocking", feature = "tokio", test))]
64    #[error("failed to spawn `{}`", .program.to_string_lossy())]
65    Spawn {
66        program: OsString,
67        source: io::Error,
68    },
69    #[cfg(any(feature = "blocking", feature = "tokio", test))]
70    #[error("failed to resize pseudoconsole")]
71    Resize(#[source] io::Error),
72    #[cfg(any(feature = "blocking", feature = "tokio", test))]
73    #[error("failed to clear pseudoconsole")]
74    Clear(#[source] io::Error),
75    #[cfg(any(feature = "blocking", feature = "tokio", test))]
76    #[error("the ConPTY backend does not support {feature}")]
77    UnsupportedFeature { feature: &'static str },
78    #[error(
79        "invalid pseudoconsole size: {rows} rows x {cols} cols \
80         (each dimension must be 1..={max})",
81        max = crate::Size::MAX_DIMENSION
82    )]
83    InvalidSize { rows: u16, cols: u16 },
84    #[cfg(any(feature = "blocking", feature = "tokio", test))]
85    #[error("failed to wait for child process")]
86    Wait(#[source] io::Error),
87    #[cfg(any(feature = "blocking", feature = "tokio", test))]
88    #[error("failed to kill child process")]
89    Kill(#[source] io::Error),
90    #[error("{0}")]
91    Io(
92        #[from]
93        #[source]
94        io::Error,
95    ),
96}
97
98impl Error {
99    /// Returns the stable classification of this failure.
100    ///
101    #[cfg_attr(
102        feature = "blocking",
103        doc = "# Examples",
104        doc = "",
105        doc = "```no_run",
106        doc = "use conpty_oxide::blocking::Command;",
107        doc = "",
108        doc = "if let Err(error) = Command::new(\"does-not-exist.exe\").spawn() {",
109        doc = "    eprintln!(\"{:?}: {error}\", error.kind());",
110        doc = "}",
111        doc = "```"
112    )]
113    #[must_use]
114    pub const fn kind(&self) -> ErrorKind {
115        match self.repr {
116            ErrorRepr::Backend(_) => ErrorKind::Backend,
117            #[cfg(any(feature = "blocking", feature = "tokio", test))]
118            ErrorRepr::CreateConsole(_) => ErrorKind::CreateConsole,
119            #[cfg(any(feature = "blocking", feature = "tokio", test))]
120            ErrorRepr::Spawn { .. } => ErrorKind::Spawn,
121            #[cfg(any(feature = "blocking", feature = "tokio", test))]
122            ErrorRepr::Resize(_) => ErrorKind::Resize,
123            #[cfg(any(feature = "blocking", feature = "tokio", test))]
124            ErrorRepr::Clear(_) => ErrorKind::Clear,
125            #[cfg(any(feature = "blocking", feature = "tokio", test))]
126            ErrorRepr::UnsupportedFeature { .. } => ErrorKind::UnsupportedFeature,
127            ErrorRepr::InvalidSize { .. } => ErrorKind::InvalidSize,
128            #[cfg(any(feature = "blocking", feature = "tokio", test))]
129            ErrorRepr::Wait(_) => ErrorKind::Wait,
130            #[cfg(any(feature = "blocking", feature = "tokio", test))]
131            ErrorRepr::Kill(_) => ErrorKind::Kill,
132            ErrorRepr::Io(_) => ErrorKind::Io,
133        }
134    }
135
136    /// Returns the directly held I/O error, when this failure has one.
137    ///
138    /// This does not walk the source chain. In particular, a backend failure
139    /// returns `None`; call [`Error::backend_error`] and then
140    /// [`BackendError::io_error`] for that case.
141    #[must_use]
142    pub const fn io_error(&self) -> Option<&io::Error> {
143        match &self.repr {
144            #[cfg(any(feature = "blocking", feature = "tokio", test))]
145            ErrorRepr::CreateConsole(source)
146            | ErrorRepr::Resize(source)
147            | ErrorRepr::Clear(source)
148            | ErrorRepr::Wait(source)
149            | ErrorRepr::Kill(source)
150            | ErrorRepr::Spawn { source, .. } => Some(source),
151            ErrorRepr::Io(source) => Some(source),
152            #[cfg(any(feature = "blocking", feature = "tokio", test))]
153            ErrorRepr::UnsupportedFeature { .. } => None,
154            ErrorRepr::Backend(_) | ErrorRepr::InvalidSize { .. } => None,
155        }
156    }
157
158    /// Returns the backend failure when initialization or validation failed.
159    #[must_use]
160    pub const fn backend_error(&self) -> Option<&BackendError> {
161        match &self.repr {
162            ErrorRepr::Backend(source) => Some(source),
163            #[cfg(any(feature = "blocking", feature = "tokio", test))]
164            ErrorRepr::CreateConsole(_)
165            | ErrorRepr::Spawn { .. }
166            | ErrorRepr::Resize(_)
167            | ErrorRepr::Clear(_)
168            | ErrorRepr::UnsupportedFeature { .. }
169            | ErrorRepr::Wait(_)
170            | ErrorRepr::Kill(_) => None,
171            ErrorRepr::InvalidSize { .. } | ErrorRepr::Io(_) => None,
172        }
173    }
174
175    #[cfg(any(feature = "blocking", feature = "tokio", test))]
176    pub(crate) const fn create_console(source: io::Error) -> Self {
177        Self {
178            repr: ErrorRepr::CreateConsole(source),
179        }
180    }
181
182    #[cfg(any(feature = "blocking", feature = "tokio", test))]
183    pub(crate) const fn spawn(program: OsString, source: io::Error) -> Self {
184        Self {
185            repr: ErrorRepr::Spawn { program, source },
186        }
187    }
188
189    #[cfg(any(feature = "blocking", feature = "tokio", test))]
190    pub(crate) const fn resize(source: io::Error) -> Self {
191        Self {
192            repr: ErrorRepr::Resize(source),
193        }
194    }
195
196    #[cfg(any(feature = "blocking", feature = "tokio", test))]
197    pub(crate) const fn clear(source: io::Error) -> Self {
198        Self {
199            repr: ErrorRepr::Clear(source),
200        }
201    }
202
203    #[cfg(any(feature = "blocking", feature = "tokio", test))]
204    pub(crate) const fn unsupported_feature(feature: &'static str) -> Self {
205        Self {
206            repr: ErrorRepr::UnsupportedFeature { feature },
207        }
208    }
209
210    pub(crate) const fn invalid_size(rows: u16, cols: u16) -> Self {
211        Self {
212            repr: ErrorRepr::InvalidSize { rows, cols },
213        }
214    }
215
216    #[cfg(any(feature = "blocking", feature = "tokio", test))]
217    pub(crate) const fn wait(source: io::Error) -> Self {
218        Self {
219            repr: ErrorRepr::Wait(source),
220        }
221    }
222
223    #[cfg(any(feature = "blocking", feature = "tokio", test))]
224    pub(crate) const fn kill(source: io::Error) -> Self {
225        Self {
226            repr: ErrorRepr::Kill(source),
227        }
228    }
229}
230
231impl fmt::Display for Error {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        self.repr.fmt(f)
234    }
235}
236
237impl fmt::Debug for Error {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        f.debug_struct("Error")
240            .field("kind", &self.kind())
241            .field("context", &self.repr)
242            .finish()
243    }
244}
245
246impl std::error::Error for Error {
247    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
248        self.repr.source()
249    }
250}
251
252impl From<BackendError> for Error {
253    fn from(source: BackendError) -> Self {
254        Self {
255            repr: ErrorRepr::Backend(source),
256        }
257    }
258}
259
260impl From<io::Error> for Error {
261    fn from(source: io::Error) -> Self {
262        Self {
263            repr: ErrorRepr::Io(source),
264        }
265    }
266}
267
268/// The failure class reported by a [`BackendError`].
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
270#[non_exhaustive]
271pub enum BackendErrorKind {
272    /// A requested `conpty.dll` could not be found or loaded.
273    DllNotFound,
274    /// A loaded DLL did not export a required function.
275    MissingExport,
276    /// A bundled DLL had no accompanying `OpenConsole.exe`.
277    OpenConsoleMissing,
278    /// The DLL and console host were not a validated matching pair.
279    VersionMismatch,
280    /// The system does not provide the required `ConPTY` API.
281    Unsupported,
282}
283
284/// A failure while locating, loading, or validating a `ConPTY` backend.
285///
286/// The representation is private so paths, symbol names, and version strings
287/// can evolve without becoming `SemVer` commitments.
288pub struct BackendError {
289    repr: BackendErrorRepr,
290}
291
292#[derive(Debug, thiserror::Error)]
293enum BackendErrorRepr {
294    #[error("conpty.dll not found in `{}`", .dir.display())]
295    DllNotFound { dir: PathBuf, source: io::Error },
296    #[error("`{}` is missing required export `{symbol}`", .dll.display())]
297    MissingExport { dll: PathBuf, symbol: &'static str },
298    #[error("OpenConsole.exe not found next to `{}`", .dll.display())]
299    OpenConsoleMissing { dll: PathBuf },
300    #[error(
301        "version mismatch: `{}` reports {dll_version} \
302         but its OpenConsole.exe reports {exe_version}",
303        .dll.display()
304    )]
305    VersionMismatch {
306        dll: PathBuf,
307        dll_version: String,
308        exe_version: String,
309    },
310    #[error(
311        "ConPTY is not available on this version of Windows; \
312         Windows 10 1809 (build 17763) or later is required"
313    )]
314    Unsupported,
315}
316
317impl BackendError {
318    /// Returns the stable classification of this backend failure.
319    #[must_use]
320    pub const fn kind(&self) -> BackendErrorKind {
321        match self.repr {
322            BackendErrorRepr::DllNotFound { .. } => BackendErrorKind::DllNotFound,
323            BackendErrorRepr::MissingExport { .. } => BackendErrorKind::MissingExport,
324            BackendErrorRepr::OpenConsoleMissing { .. } => BackendErrorKind::OpenConsoleMissing,
325            BackendErrorRepr::VersionMismatch { .. } => BackendErrorKind::VersionMismatch,
326            BackendErrorRepr::Unsupported => BackendErrorKind::Unsupported,
327        }
328    }
329
330    /// Returns the directly held I/O error, when this failure has one.
331    #[must_use]
332    pub const fn io_error(&self) -> Option<&io::Error> {
333        match &self.repr {
334            BackendErrorRepr::DllNotFound { source, .. } => Some(source),
335            BackendErrorRepr::MissingExport { .. }
336            | BackendErrorRepr::OpenConsoleMissing { .. }
337            | BackendErrorRepr::VersionMismatch { .. }
338            | BackendErrorRepr::Unsupported => None,
339        }
340    }
341
342    pub(crate) const fn dll_not_found(dir: PathBuf, source: io::Error) -> Self {
343        Self {
344            repr: BackendErrorRepr::DllNotFound { dir, source },
345        }
346    }
347
348    pub(crate) const fn missing_export(dll: PathBuf, symbol: &'static str) -> Self {
349        Self {
350            repr: BackendErrorRepr::MissingExport { dll, symbol },
351        }
352    }
353
354    pub(crate) const fn open_console_missing(dll: PathBuf) -> Self {
355        Self {
356            repr: BackendErrorRepr::OpenConsoleMissing { dll },
357        }
358    }
359
360    pub(crate) const fn version_mismatch(
361        dll: PathBuf,
362        dll_version: String,
363        exe_version: String,
364    ) -> Self {
365        Self {
366            repr: BackendErrorRepr::VersionMismatch {
367                dll,
368                dll_version,
369                exe_version,
370            },
371        }
372    }
373
374    pub(crate) const fn unsupported() -> Self {
375        Self {
376            repr: BackendErrorRepr::Unsupported,
377        }
378    }
379}
380
381impl fmt::Display for BackendError {
382    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383        self.repr.fmt(f)
384    }
385}
386
387impl fmt::Debug for BackendError {
388    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
389        f.debug_struct("BackendError")
390            .field("kind", &self.kind())
391            .field("context", &self.repr)
392            .finish()
393    }
394}
395
396impl std::error::Error for BackendError {
397    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
398        self.repr.source()
399    }
400}
401
402#[cfg(test)]
403#[path = "error_tests.rs"]
404mod tests;