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    #[must_use]
101    pub const fn kind(&self) -> ErrorKind {
102        match self.repr {
103            ErrorRepr::Backend(_) => ErrorKind::Backend,
104            #[cfg(any(feature = "blocking", feature = "tokio", test))]
105            ErrorRepr::CreateConsole(_) => ErrorKind::CreateConsole,
106            #[cfg(any(feature = "blocking", feature = "tokio", test))]
107            ErrorRepr::Spawn { .. } => ErrorKind::Spawn,
108            #[cfg(any(feature = "blocking", feature = "tokio", test))]
109            ErrorRepr::Resize(_) => ErrorKind::Resize,
110            #[cfg(any(feature = "blocking", feature = "tokio", test))]
111            ErrorRepr::Clear(_) => ErrorKind::Clear,
112            #[cfg(any(feature = "blocking", feature = "tokio", test))]
113            ErrorRepr::UnsupportedFeature { .. } => ErrorKind::UnsupportedFeature,
114            ErrorRepr::InvalidSize { .. } => ErrorKind::InvalidSize,
115            #[cfg(any(feature = "blocking", feature = "tokio", test))]
116            ErrorRepr::Wait(_) => ErrorKind::Wait,
117            #[cfg(any(feature = "blocking", feature = "tokio", test))]
118            ErrorRepr::Kill(_) => ErrorKind::Kill,
119            ErrorRepr::Io(_) => ErrorKind::Io,
120        }
121    }
122
123    /// Returns the directly held I/O error, when this failure has one.
124    ///
125    /// This does not walk the source chain. In particular, a backend failure
126    /// returns `None`; call [`Error::backend_error`] and then
127    /// [`BackendError::io_error`] for that case.
128    #[must_use]
129    pub const fn io_error(&self) -> Option<&io::Error> {
130        match &self.repr {
131            #[cfg(any(feature = "blocking", feature = "tokio", test))]
132            ErrorRepr::CreateConsole(source)
133            | ErrorRepr::Resize(source)
134            | ErrorRepr::Clear(source)
135            | ErrorRepr::Wait(source)
136            | ErrorRepr::Kill(source)
137            | ErrorRepr::Spawn { source, .. } => Some(source),
138            ErrorRepr::Io(source) => Some(source),
139            #[cfg(any(feature = "blocking", feature = "tokio", test))]
140            ErrorRepr::UnsupportedFeature { .. } => None,
141            ErrorRepr::Backend(_) | ErrorRepr::InvalidSize { .. } => None,
142        }
143    }
144
145    /// Returns the backend failure when initialization or validation failed.
146    #[must_use]
147    pub const fn backend_error(&self) -> Option<&BackendError> {
148        match &self.repr {
149            ErrorRepr::Backend(source) => Some(source),
150            #[cfg(any(feature = "blocking", feature = "tokio", test))]
151            ErrorRepr::CreateConsole(_)
152            | ErrorRepr::Spawn { .. }
153            | ErrorRepr::Resize(_)
154            | ErrorRepr::Clear(_)
155            | ErrorRepr::UnsupportedFeature { .. }
156            | ErrorRepr::Wait(_)
157            | ErrorRepr::Kill(_) => None,
158            ErrorRepr::InvalidSize { .. } | ErrorRepr::Io(_) => None,
159        }
160    }
161
162    #[cfg(any(feature = "blocking", feature = "tokio", test))]
163    pub(crate) const fn create_console(source: io::Error) -> Self {
164        Self {
165            repr: ErrorRepr::CreateConsole(source),
166        }
167    }
168
169    #[cfg(any(feature = "blocking", feature = "tokio", test))]
170    pub(crate) const fn spawn(program: OsString, source: io::Error) -> Self {
171        Self {
172            repr: ErrorRepr::Spawn { program, source },
173        }
174    }
175
176    #[cfg(any(feature = "blocking", feature = "tokio", test))]
177    pub(crate) const fn resize(source: io::Error) -> Self {
178        Self {
179            repr: ErrorRepr::Resize(source),
180        }
181    }
182
183    #[cfg(any(feature = "blocking", feature = "tokio", test))]
184    pub(crate) const fn clear(source: io::Error) -> Self {
185        Self {
186            repr: ErrorRepr::Clear(source),
187        }
188    }
189
190    #[cfg(any(feature = "blocking", feature = "tokio", test))]
191    pub(crate) const fn unsupported_feature(feature: &'static str) -> Self {
192        Self {
193            repr: ErrorRepr::UnsupportedFeature { feature },
194        }
195    }
196
197    pub(crate) const fn invalid_size(rows: u16, cols: u16) -> Self {
198        Self {
199            repr: ErrorRepr::InvalidSize { rows, cols },
200        }
201    }
202
203    #[cfg(any(feature = "blocking", feature = "tokio", test))]
204    pub(crate) const fn wait(source: io::Error) -> Self {
205        Self {
206            repr: ErrorRepr::Wait(source),
207        }
208    }
209
210    #[cfg(any(feature = "blocking", feature = "tokio", test))]
211    pub(crate) const fn kill(source: io::Error) -> Self {
212        Self {
213            repr: ErrorRepr::Kill(source),
214        }
215    }
216}
217
218impl fmt::Display for Error {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        self.repr.fmt(f)
221    }
222}
223
224impl fmt::Debug for Error {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        f.debug_struct("Error")
227            .field("kind", &self.kind())
228            .field("context", &self.repr)
229            .finish()
230    }
231}
232
233impl std::error::Error for Error {
234    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
235        self.repr.source()
236    }
237}
238
239impl From<BackendError> for Error {
240    fn from(source: BackendError) -> Self {
241        Self {
242            repr: ErrorRepr::Backend(source),
243        }
244    }
245}
246
247impl From<io::Error> for Error {
248    fn from(source: io::Error) -> Self {
249        Self {
250            repr: ErrorRepr::Io(source),
251        }
252    }
253}
254
255/// The failure class reported by a [`BackendError`].
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
257#[non_exhaustive]
258pub enum BackendErrorKind {
259    /// A requested `conpty.dll` could not be found or loaded.
260    DllNotFound,
261    /// A loaded DLL did not export a required function.
262    MissingExport,
263    /// A bundled DLL had no accompanying `OpenConsole.exe`.
264    OpenConsoleMissing,
265    /// The DLL and console host were not a validated matching pair.
266    VersionMismatch,
267    /// The system does not provide the required `ConPTY` API.
268    Unsupported,
269}
270
271/// A failure while locating, loading, or validating a `ConPTY` backend.
272///
273/// The representation is private so paths, symbol names, and version strings
274/// can evolve without becoming `SemVer` commitments.
275pub struct BackendError {
276    repr: BackendErrorRepr,
277}
278
279#[derive(Debug, thiserror::Error)]
280enum BackendErrorRepr {
281    #[error("conpty.dll not found in `{}`", .dir.display())]
282    DllNotFound { dir: PathBuf, source: io::Error },
283    #[error("`{}` is missing required export `{symbol}`", .dll.display())]
284    MissingExport { dll: PathBuf, symbol: &'static str },
285    #[error("OpenConsole.exe not found next to `{}`", .dll.display())]
286    OpenConsoleMissing { dll: PathBuf },
287    #[error(
288        "version mismatch: `{}` reports {dll_version} \
289         but its OpenConsole.exe reports {exe_version}",
290        .dll.display()
291    )]
292    VersionMismatch {
293        dll: PathBuf,
294        dll_version: String,
295        exe_version: String,
296    },
297    #[error(
298        "ConPTY is not available on this version of Windows; \
299         Windows 10 1809 (build 17763) or later is required"
300    )]
301    Unsupported,
302}
303
304impl BackendError {
305    /// Returns the stable classification of this backend failure.
306    #[must_use]
307    pub const fn kind(&self) -> BackendErrorKind {
308        match self.repr {
309            BackendErrorRepr::DllNotFound { .. } => BackendErrorKind::DllNotFound,
310            BackendErrorRepr::MissingExport { .. } => BackendErrorKind::MissingExport,
311            BackendErrorRepr::OpenConsoleMissing { .. } => BackendErrorKind::OpenConsoleMissing,
312            BackendErrorRepr::VersionMismatch { .. } => BackendErrorKind::VersionMismatch,
313            BackendErrorRepr::Unsupported => BackendErrorKind::Unsupported,
314        }
315    }
316
317    /// Returns the directly held I/O error, when this failure has one.
318    #[must_use]
319    pub const fn io_error(&self) -> Option<&io::Error> {
320        match &self.repr {
321            BackendErrorRepr::DllNotFound { source, .. } => Some(source),
322            BackendErrorRepr::MissingExport { .. }
323            | BackendErrorRepr::OpenConsoleMissing { .. }
324            | BackendErrorRepr::VersionMismatch { .. }
325            | BackendErrorRepr::Unsupported => None,
326        }
327    }
328
329    pub(crate) const fn dll_not_found(dir: PathBuf, source: io::Error) -> Self {
330        Self {
331            repr: BackendErrorRepr::DllNotFound { dir, source },
332        }
333    }
334
335    pub(crate) const fn missing_export(dll: PathBuf, symbol: &'static str) -> Self {
336        Self {
337            repr: BackendErrorRepr::MissingExport { dll, symbol },
338        }
339    }
340
341    pub(crate) const fn open_console_missing(dll: PathBuf) -> Self {
342        Self {
343            repr: BackendErrorRepr::OpenConsoleMissing { dll },
344        }
345    }
346
347    pub(crate) const fn version_mismatch(
348        dll: PathBuf,
349        dll_version: String,
350        exe_version: String,
351    ) -> Self {
352        Self {
353            repr: BackendErrorRepr::VersionMismatch {
354                dll,
355                dll_version,
356                exe_version,
357            },
358        }
359    }
360
361    pub(crate) const fn unsupported() -> Self {
362        Self {
363            repr: BackendErrorRepr::Unsupported,
364        }
365    }
366}
367
368impl fmt::Display for BackendError {
369    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370        self.repr.fmt(f)
371    }
372}
373
374impl fmt::Debug for BackendError {
375    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376        f.debug_struct("BackendError")
377            .field("kind", &self.kind())
378            .field("context", &self.repr)
379            .finish()
380    }
381}
382
383impl std::error::Error for BackendError {
384    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
385        self.repr.source()
386    }
387}
388
389#[cfg(test)]
390#[path = "error_tests.rs"]
391mod tests;