Skip to main content

conpty_oxide/
backend.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//! Dynamic loading of the `ConPTY` entry points.
6//!
7//! The pseudoconsole API (`CreatePseudoConsole` and friends) is not linked
8//! statically. It is resolved at run time with `GetProcAddress`, for two
9//! reasons:
10//!
11//! 1. **Graceful degradation.** Linking `CreatePseudoConsole` statically makes
12//!    the executable fail to start on Windows versions older than 10 1809
13//!    (build 17763) with an unhelpful loader error. Resolving it dynamically
14//!    turns that into [`crate::BackendErrorKind::Unsupported`].
15//! 2. **Capability detection.** `ReleasePseudoConsole` only exists on Windows
16//!    11 24H2 (build 26100) and later, and `ClearPseudoConsole` exists only in
17//!    the standalone `conpty.dll`. Whether they are available decides which
18//!    shutdown strategy the crate uses and which operations it can offer, and
19//!    the presence of the export is the check microsoft/terminal recommends —
20//!    *not* an OS build-number comparison, which misfires under compatibility
21//!    shims and on backported builds (microsoft/terminal#19112).
22//!
23//! The same loader serves a bundled `conpty.dll`, which is why symbol lookup
24//! goes through [`exports::resolve_export`]: that DLL exports each entry point twice,
25//! once under its canonical `Conpty`-prefixed name and once under the bare
26//! system name.
27//!
28//! # Loading a bundled `conpty.dll`
29//!
30//! [`ConPtyBackend::from_dir`] takes a directory and does four things before
31//! any code from it runs:
32//!
33//! 1. It checks that `conpty.dll` is there at all.
34//! 2. It locates the `OpenConsole.exe` the DLL will launch — next to the DLL,
35//!    or in the architecture subdirectory the DLL itself searches.
36//! 3. It compares the two files' `ProductVersion` resources. The DLL and the
37//!    console host speak a private, versioned protocol, and a bad `ConPTY`
38//!    bundle takes the client process down with a `FailFast` rather than an
39//!    error (wezterm#7774), so it is far better to refuse the bundle than to
40//!    crash later.
41//! 4. It loads the DLL by absolute path with `LoadLibraryExW` and a search
42//!    policy that never consults `PATH`, the current directory, or the
43//!    registry, so a stray `conpty.dll` cannot be planted into the process.
44//!
45//! [`ConPtyBackend::auto`] applies that to the executable's own directory and
46//! falls back to the operating system's `ConPTY`, which is what an
47//! application that merely *may* ship a bundle wants, and returns
48//! [`crate::BackendErrorKind::Unsupported`] when neither implementation is
49//! usable.
50
51mod bundle;
52mod exports;
53
54use std::fmt;
55#[cfg(any(feature = "blocking", feature = "tokio", test))]
56use std::io;
57use std::iter;
58#[cfg(any(feature = "blocking", feature = "tokio", test))]
59use std::os::windows::io::{AsRawHandle, BorrowedHandle};
60use std::path::{Path, PathBuf};
61use std::sync::{Arc, Mutex, OnceLock};
62
63#[cfg(any(feature = "blocking", feature = "tokio", test))]
64use windows_sys::core::HRESULT;
65#[cfg(any(feature = "blocking", feature = "tokio", test))]
66use windows_sys::Win32::System::Console::COORD;
67/// Official Windows SDK pseudoconsole handle and cursor-inheritance flag.
68#[cfg(any(feature = "blocking", feature = "tokio", test))]
69pub(super) use windows_sys::Win32::System::Console::{HPCON, PSEUDOCONSOLE_INHERIT_CURSOR};
70use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
71
72#[cfg(test)]
73use bundle::{
74    absolute_dir, find_console_host, machine_arch_subdir, native_arch_subdir, parse_version,
75    read_product_version, selected_native_machine, translation_count, trim_resource_string,
76    versions_are_compatible, OPEN_CONSOLE_EXE, UNKNOWN_VERSION,
77};
78use bundle::{exe_dir, log_rejected, validate, CONPTY_DLL};
79use exports::{load_module, ConptyApi, ModuleGuard};
80#[cfg(test)]
81use exports::{resolve_export, restricted_search_flags, wide_path, CREATE_PSEUDO_CONSOLE};
82
83use crate::error::BackendError;
84#[cfg(any(feature = "blocking", feature = "tokio", test))]
85use crate::size::Size;
86
87/// Which `ConPTY` implementation a [`ConPtyBackend`] is bound to.
88///
89/// Marked `#[non_exhaustive]`, like the crate's error enums: new kinds may be
90/// added in later releases, so matches on it need a wildcard arm.
91#[derive(Debug, Clone, PartialEq, Eq, Hash)]
92#[non_exhaustive]
93pub(crate) enum BackendKind {
94    /// The `ConPTY` API built into the operating system (`kernel32.dll`).
95    System,
96
97    /// A standalone `conpty.dll` loaded from the given path.
98    ///
99    /// A bundled DLL ships a newer console host than the operating system
100    /// provides, which is how an application can get `ReleasePseudoConsole`
101    /// semantics on a Windows version that does not have them natively.
102    External {
103        /// Path of the loaded `conpty.dll`.
104        dll: PathBuf,
105    },
106}
107
108/// The shared, immutable state behind a [`ConPtyBackend`].
109#[derive(Debug)]
110struct BackendInner {
111    kind: BackendKind,
112
113    /// The resolved entry points.
114    ///
115    /// A public backend is always usable: construction fails before a
116    /// `BackendInner` exists when the required `ConPTY` exports are absent.
117    api: ConptyApi,
118
119    /// Pins the module `api` was resolved from.
120    ///
121    /// [`None`] for the system backend: `kernel32.dll` is mapped into every
122    /// Win32 process for its entire lifetime, so there is nothing to pin and no
123    /// reference to release.
124    module_pin: Option<Arc<ModuleGuard>>,
125}
126
127/// A loaded `ConPTY` implementation.
128///
129/// Cloning is cheap: clones share one [`Arc`], so resolving the entry points
130/// happens once per backend rather than once per pseudoconsole.
131///
132/// # Thread safety
133///
134/// `ConPtyBackend` is `Send + Sync`, and that is sound:
135///
136/// - `BackendInner` holds a private backend-kind value — a unit variant or a
137///   [`PathBuf`] — a table of bare `extern "system"` function pointers, and a
138///   module guard. Function pointers are `Send + Sync`: they are immutable code
139///   addresses, not resources. The guard states its own argument.
140/// - The backend owns no `HPCON`, no OS handle, and no interior mutability,
141///   so a shared `&ConPtyBackend` exposes nothing mutable.
142/// - The module the addresses point into stays loaded for the lifetime of the
143///   backend: the system backend targets `kernel32.dll`, which is mapped into
144///   every Win32 process and never unloaded, and an external backend owns a
145///   `LoadLibraryExW` reference that is released only when the last clone is
146///   dropped.
147/// - `Send` is not merely convenient but required. `ClosePseudoConsole` must
148///   not be called from the thread reading the conout pipe, so the shutdown
149///   path necessarily runs on a different thread from the reader and both need
150///   the backend.
151pub struct ConPtyBackend {
152    inner: Arc<BackendInner>,
153}
154
155/// A fallible initializer that caches only its first successful result.
156///
157/// [`OnceLock::get_or_init`] cannot represent retryable failure. Pairing the
158/// value cell with a short initialization mutex keeps the detector outside the
159/// permanent state while ensuring concurrent first callers do not load the
160/// same DLL more than once. A poisoned mutex is still usable here: neither a
161/// detector error nor a panic can partially initialize `value`.
162#[derive(Debug)]
163struct SuccessfulCache<T> {
164    value: OnceLock<T>,
165    initialization: Mutex<()>,
166}
167
168impl<T> SuccessfulCache<T> {
169    const fn new() -> Self {
170        Self {
171            value: OnceLock::new(),
172            initialization: Mutex::new(()),
173        }
174    }
175}
176
177impl<T: Clone> SuccessfulCache<T> {
178    fn get_or_try_init<E>(&self, detect: impl FnOnce() -> Result<T, E>) -> Result<T, E> {
179        if let Some(value) = self.value.get() {
180            return Ok(value.clone());
181        }
182
183        let _initialization = self
184            .initialization
185            .lock()
186            .unwrap_or_else(std::sync::PoisonError::into_inner);
187        if let Some(value) = self.value.get() {
188            return Ok(value.clone());
189        }
190
191        let detected = detect()?;
192        if self.value.set(detected.clone()).is_ok() {
193            return Ok(detected);
194        }
195        Ok(self.value.get().map_or(detected, Clone::clone))
196    }
197}
198
199/// Cached successful result of [`ConPtyBackend::auto`].
200///
201/// Caching matters for more than speed: it keeps a bundled `conpty.dll` loaded
202/// once per process instead of once per session.
203static AUTO_DEFAULT: SuccessfulCache<ConPtyBackend> = SuccessfulCache::new();
204
205impl ConPtyBackend {
206    /// Loads the `ConPTY` API built into the operating system.
207    ///
208    /// Resolves the entry points from the already-mapped `kernel32.dll`; no
209    /// library is loaded and no reference count is taken, because
210    /// `kernel32.dll` is present in every Win32 process for its entire
211    /// lifetime.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`crate::BackendErrorKind::Unsupported`] when
216    /// `CreatePseudoConsole`,
217    /// `ResizePseudoConsole`, or `ClosePseudoConsole` is missing, i.e. on
218    /// Windows versions older than 10 1809 (build 17763).
219    pub fn system() -> Result<Self, BackendError> {
220        // `windows-sys` has no `w!` macro, so the module name is widened at
221        // run time. This happens once per backend, not per pseudoconsole.
222        let module_name: Vec<u16> = "kernel32.dll".encode_utf16().chain(iter::once(0)).collect();
223
224        // SAFETY: `module_name` is a NUL-terminated UTF-16 string that
225        // outlives the call.
226        let module = unsafe { GetModuleHandleW(module_name.as_ptr()) };
227        if module.is_null() {
228            // Unreachable in practice: kernel32.dll is mapped into every
229            // Win32 process. Report it as "no ConPTY here" rather than
230            // panicking on a hostile or exotic environment.
231            return Err(BackendError::unsupported());
232        }
233
234        // SAFETY: `module` is a live handle to kernel32.dll, which stays
235        // loaded for the lifetime of the process, and its ConPTY exports have
236        // the signatures documented on Microsoft Learn.
237        let api = match unsafe { ConptyApi::from_module(module) } {
238            Ok(api) => api,
239            Err(symbol) => {
240                log_missing_system_export(symbol);
241                return Err(BackendError::unsupported());
242            },
243        };
244
245        Ok(Self {
246            inner: Arc::new(BackendInner {
247                kind: BackendKind::System,
248                api,
249                // kernel32.dll needs no pin; see `BackendInner::module_pin`.
250                module_pin: None,
251            }),
252        })
253    }
254
255    /// Loads a bundled `conpty.dll` from `dir`, validating the bundle first.
256    ///
257    /// A bundle is `conpty.dll` plus the `OpenConsole.exe` it launches, as
258    /// shipped by the `Microsoft.Windows.Console.ConPTY` NuGet package. Both
259    /// must come from the same package: the DLL and the console host share a
260    /// private protocol with no compatibility promise across releases, and a
261    /// bad `ConPTY` bundle crashes the client process rather than degrading —
262    /// wezterm#7774 is PowerShell dying with a `0x8013_1623` `FailFast` until
263    /// the bundle was replaced. This constructor therefore refuses a pair it
264    /// cannot prove consistent; public callers cannot bypass this validation.
265    ///
266    /// Note the check's limit: it proves the pair *matches*, not that it is
267    /// current. wezterm#7774's actual configuration was a matched but outdated
268    /// pair, which this validation accepts; keeping the bundled version up to
269    /// date remains the application's responsibility.
270    ///
271    /// The console host is looked for exactly where `conpty.dll` itself will
272    /// look: next to the DLL first, then in the single subdirectory named
273    /// after the machine's *native* architecture (`x64`, `arm64`, or `x86`).
274    /// A host anywhere else — a cross-architecture subdirectory, say — does
275    /// not count, because the DLL never searches there and would silently run
276    /// every session against the operating system's inbox `conhost.exe`
277    /// instead of the file this constructor validated. Placing
278    /// `OpenConsole.exe` next to the DLL, as the repository's
279    /// `just fetch-conpty` tooling does, is the recommended layout.
280    ///
281    /// A relative `dir` is resolved against the current working directory once,
282    /// here. The DLL is then loaded by absolute path with a search policy that
283    /// excludes `PATH`, the current directory, the application directory, and
284    /// the registry, so nothing but `dir` and `System32` can satisfy the load.
285    /// A *drive-relative* `dir` (`C:dir`) is rejected as
286    /// [`crate::BackendErrorKind::DllNotFound`]: it names a path relative to that drive's
287    /// own current directory, which cannot be resolved once and pinned.
288    ///
289    /// # Examples
290    ///
291    /// ```no_run
292    /// use conpty_oxide::ConPtyBackend;
293    ///
294    /// # fn main() -> Result<(), conpty_oxide::BackendError> {
295    /// let backend = ConPtyBackend::from_dir("vendor/conpty")?;
296    /// println!("validated bundle: {backend:?}");
297    /// # Ok(())
298    /// # }
299    /// ```
300    ///
301    /// # Errors
302    ///
303    /// - [`crate::BackendErrorKind::DllNotFound`] if `dir/conpty.dll` is missing or
304    ///   cannot be loaded (the source carries the OS error, e.g.
305    ///   `ERROR_BAD_EXE_FORMAT` for a file that is not a DLL at all).
306    /// - [`crate::BackendErrorKind::OpenConsoleMissing`] if no `OpenConsole.exe`
307    ///   accompanies the DLL.
308    /// - [`crate::BackendErrorKind::VersionMismatch`] if the two files report different
309    ///   `ProductVersion` resources, or if either version cannot be read.
310    /// - [`crate::BackendErrorKind::MissingExport`] if the DLL lacks
311    ///   `CreatePseudoConsole`, `ResizePseudoConsole`, or
312    ///   `ClosePseudoConsole`.
313    pub fn from_dir(dir: impl AsRef<Path>) -> Result<Self, BackendError> {
314        Self::load_from_dir(dir.as_ref(), true)
315    }
316
317    /// Loads a bundled `conpty.dll` from `dir` **without** checking that it
318    /// matches its `OpenConsole.exe`.
319    ///
320    /// Every other check [`Self::from_dir`] performs still runs; only the
321    /// version comparison is skipped.
322    ///
323    /// # Why this is dangerous
324    ///
325    /// `conpty.dll` and `OpenConsole.exe` communicate over a private, versioned
326    /// protocol and are shipped as a pair for that reason. Running a DLL
327    /// against a console host from a different release is not a graceful
328    /// degradation: the failure mode of a bad `ConPTY` bundle is a hard crash of
329    /// the *client* process — in wezterm#7774, PowerShell dies with a
330    /// `0x8013_1623` `FailFast` — at an arbitrary later point, far from this
331    /// call.
332    ///
333    /// Use this only when the version resources are unreadable for a reason you
334    /// control, for example a locally rebuilt `OpenConsole.exe` that carries no
335    /// version stamp, and you can guarantee the pair by other means. Prefer
336    /// [`Self::from_dir`] everywhere else.
337    ///
338    /// # Errors
339    ///
340    /// The same as [`Self::from_dir`], minus
341    /// [`crate::BackendErrorKind::VersionMismatch`].
342    #[cfg(test)]
343    pub(crate) fn from_dir_unchecked(dir: impl AsRef<Path>) -> Result<Self, BackendError> {
344        Self::load_from_dir(dir.as_ref(), false)
345    }
346
347    /// Shared implementation of [`Self::from_dir`] and
348    /// [`Self::from_dir_unchecked`].
349    fn load_from_dir(dir: &Path, verify_pair: bool) -> Result<Self, BackendError> {
350        // Discovery and validation complete before executable code is mapped.
351        let bundle = validate(dir, verify_pair)?;
352        let dir = bundle.dir;
353        let dll = bundle.dll;
354
355        let module =
356            load_module(&dll).map_err(|source| BackendError::dll_not_found(dir.clone(), source))?;
357
358        // SAFETY: the module stays pinned in the same `BackendInner` as the
359        // resolved table, and standalone ConPTY exports use the SDK signatures.
360        let api = unsafe { ConptyApi::from_module(module.module) }
361            .map_err(|symbol| BackendError::missing_export(dll.clone(), symbol))?;
362
363        Ok(Self {
364            inner: Arc::new(BackendInner {
365                kind: BackendKind::External { dll },
366                api,
367                module_pin: Some(Arc::new(module)),
368            }),
369        })
370    }
371
372    /// Returns the best backend available to this process.
373    ///
374    /// The search order is:
375    ///
376    /// 1. A bundle next to the current executable. If `conpty.dll` sits in the
377    ///    executable's directory it is loaded with [`Self::from_dir`], with all
378    ///    of its validation.
379    /// 2. The operating system's `ConPTY` ([`Self::system`]).
380    ///
381    /// A bundle that fails to load is not an error: the process still has the
382    /// system implementation, and falling back to it is what an application
383    /// that merely *may* ship a bundle wants. The rejection is recorded with
384    /// `tracing::warn!` when the `tracing` feature is enabled, so a bundle that
385    /// is silently ignored — a version-mismatched pair, say — is still
386    /// diagnosable.
387    ///
388    /// # Errors
389    ///
390    /// Returns [`crate::BackendErrorKind::Unsupported`] when neither a valid bundle nor
391    /// the system `ConPTY` implementation is available.
392    pub fn auto() -> Result<Self, BackendError> {
393        AUTO_DEFAULT.get_or_try_init(Self::detect_auto)
394    }
395
396    /// Performs one uncached automatic-detection attempt.
397    fn detect_auto() -> Result<Self, BackendError> {
398        if let Some(dir) = exe_dir() {
399            // Only attempt the load when a bundle is actually present:
400            // otherwise every ordinary program would log a warning about a
401            // `conpty.dll` it never intended to ship.
402            if dir.join(CONPTY_DLL).is_file() {
403                match Self::from_dir(&dir) {
404                    Ok(backend) => return Ok(backend),
405                    Err(err) => log_rejected(&dir, &err),
406                }
407            }
408        }
409
410        Self::system()
411    }
412
413    /// Returns which `ConPTY` implementation this backend is bound to.
414    #[must_use]
415    pub(crate) fn kind(&self) -> &BackendKind {
416        &self.inner.kind
417    }
418
419    /// Returns whether this backend exports `ReleasePseudoConsole`.
420    ///
421    /// When `true`, the crate can relinquish the `HPCON` right after spawning
422    /// and let the pseudoconsole exit on its own once every client has
423    /// disconnected; conout then reaches end-of-file naturally. When `false`,
424    /// end-of-file has to be forced by closing the pseudoconsole after the
425    /// child exits, because the console host outlives the child.
426    ///
427    /// The value depends on the operating system (`ReleasePseudoConsole`
428    /// requires Windows 11 24H2 / Server 2025, build 26100) or on the version
429    /// of a bundled `conpty.dll`.
430    #[must_use]
431    pub(crate) fn supports_release(&self) -> bool {
432        self.inner.api.release.is_some()
433    }
434
435    /// Returns whether this backend can clear the pseudoconsole's buffer.
436    ///
437    /// `ClearPseudoConsole` is not part of the public Windows SDK and
438    /// `kernel32.dll` does not export it, so this is `false` on the system
439    /// backend and `true` only for a bundled `conpty.dll` that exports
440    /// `ConptyClearPseudoConsole`.
441    ///
442    /// It is also `false` on 32-bit x86 regardless of the DLL: the export
443    /// changed arity between releases (microsoft/terminal#18976) and `__stdcall`
444    /// makes an arity mismatch corrupt the stack, so the call is not offered
445    /// where it cannot be made safely.
446    #[must_use]
447    pub fn supports_clear(&self) -> bool {
448        self.inner.api.clear.is_some()
449    }
450
451    /// Returns a clone of this backend with the `ReleasePseudoConsole` export
452    /// removed.
453    ///
454    /// Sessions on the returned backend behave exactly as on a Windows version
455    /// that predates the export (everything before Windows 11 24H2):
456    /// [`Self::supports_release`] answers `false`, releasing after spawn is
457    /// impossible, and end-of-file has to be forced by the legacy watcher.
458    ///
459    /// This works on every backend, including an external one: the stripped
460    /// clone shares the original's module pin, so the addresses it copies stay
461    /// valid for as long as it does.
462    ///
463    /// This crate-private test hook lets the unit suite exercise the legacy
464    /// shutdown path deterministically on machines whose operating system
465    /// exports `ReleasePseudoConsole`, where ordinary sessions otherwise run
466    /// only in released mode.
467    #[must_use]
468    #[cfg(test)]
469    pub(super) fn without_release(&self) -> Self {
470        Self {
471            inner: Arc::new(BackendInner {
472                kind: self.inner.kind.clone(),
473                api: self.inner.api.without_release(),
474                // Share the pin rather than re-loading: the copied addresses
475                // point into the very module the original keeps mapped.
476                module_pin: self.inner.module_pin.clone(),
477            }),
478        }
479    }
480
481    /// Replaces only the close export so lifecycle tests can observe a
482    /// detached FFI call without passing a fabricated handle to Windows.
483    #[cfg(test)]
484    pub(super) fn with_test_close(&self, close: unsafe extern "system" fn(HPCON)) -> Self {
485        Self {
486            inner: Arc::new(BackendInner {
487                kind: self.inner.kind.clone(),
488                api: self.inner.api.with_close(close),
489                module_pin: self.inner.module_pin.clone(),
490            }),
491        }
492    }
493
494    /// Returns the backend to use when the caller did not name one.
495    ///
496    /// Only successful automatic detection is cached; failures remain
497    /// retryable.
498    #[cfg(any(feature = "blocking", feature = "tokio", test))]
499    pub(super) fn resolve_default() -> Result<Self, BackendError> {
500        Self::auto()
501    }
502
503    /// Calls `CreatePseudoConsole`.
504    ///
505    /// `input_read` is the read end of the conin pipe and `output_write` the
506    /// write end of the conout pipe; both must be synchronous handles, which
507    /// anonymous pipes always are. `ConPTY` duplicates them, so the caller
508    /// should close its own copies as soon as the child has been spawned —
509    /// until then the extra references keep conout from ever reaching
510    /// end-of-file.
511    ///
512    /// The returned `HPCON` is *not* owned by any RAII type here; the caller
513    /// must eventually pass it to [`Self::close`].
514    ///
515    /// # Errors
516    ///
517    /// Returns the failing `HRESULT` mapped to an [`io::Error`]. Construction
518    /// has already proved that the backend provides this required export.
519    #[cfg(any(feature = "blocking", feature = "tokio", test))]
520    pub(super) fn create(
521        &self,
522        size: Size,
523        input_read: BorrowedHandle<'_>,
524        output_write: BorrowedHandle<'_>,
525        flags: u32,
526    ) -> io::Result<HPCON> {
527        let api = &self.inner.api;
528        let (cols, rows) = size.to_i16_pair();
529        let size = COORD { X: cols, Y: rows };
530        let mut hpc: HPCON = 0;
531
532        // SAFETY: `api.create` was resolved from a module this backend keeps
533        // mapped. Both handles are borrowed for the duration of the call, and
534        // `hpc` is a valid out-parameter.
535        let hr = unsafe {
536            (api.create)(
537                size,
538                input_read.as_raw_handle(),
539                output_write.as_raw_handle(),
540                flags,
541                &mut hpc,
542            )
543        };
544        hresult_ok(hr)?;
545
546        Ok(hpc)
547    }
548
549    /// Calls `ResizePseudoConsole`.
550    ///
551    /// # Errors
552    ///
553    /// Returns the failing `HRESULT` mapped to an [`io::Error`].
554    ///
555    /// # Safety
556    ///
557    /// `hpc` must be a live handle from [`Self::create`] on *this* backend
558    /// that has not yet been passed to [`Self::close`].
559    #[cfg(any(feature = "blocking", feature = "tokio", test))]
560    pub(super) unsafe fn resize(&self, hpc: HPCON, size: Size) -> io::Result<()> {
561        let api = &self.inner.api;
562        let (cols, rows) = size.to_i16_pair();
563        let size = COORD { X: cols, Y: rows };
564
565        // SAFETY: `hpc` is live per this function's contract, and the
566        // function pointer was resolved from a module this backend keeps
567        // mapped.
568        let hr = unsafe { (api.resize)(hpc, size) };
569        hresult_ok(hr)
570    }
571
572    /// Calls `ClosePseudoConsole`, releasing the session's resources.
573    ///
574    /// This returns no status because `ClosePseudoConsole` returns `void`.
575    ///
576    /// # Safety
577    ///
578    /// `hpc` must be a live handle from [`Self::create`] on *this* backend
579    /// that has not been closed before; the handle is invalid afterwards.
580    ///
581    /// Beyond memory safety, two liveness rules from the `ConPTY` documentation
582    /// apply, and violating them hangs the process rather than corrupting it:
583    ///
584    /// - Before Windows 11 24H2 (build 26100), this call waits until every
585    ///   client has disconnected. The caller must therefore have closed its
586    ///   conout read end first, or keep another thread draining it.
587    /// - It must never be called from the thread that reads conout, because
588    ///   that thread is exactly the one that would have to make progress for
589    ///   the call to return.
590    #[cfg(any(feature = "blocking", feature = "tokio", test))]
591    pub(super) unsafe fn close(&self, hpc: HPCON) {
592        let api = &self.inner.api;
593
594        // SAFETY: `hpc` is live and unclosed per this function's contract.
595        unsafe { (api.close)(hpc) }
596    }
597
598    /// Calls `ReleasePseudoConsole`, or returns [`None`] if the backend does
599    /// not export it (see [`Self::supports_release`]).
600    ///
601    /// Releasing hands ownership of the session to the pseudoconsole itself:
602    /// once every client has disconnected, the console host exits on its own
603    /// and conout fails with `ERROR_BROKEN_PIPE`, which the reader maps to
604    /// end-of-file. That breaks the ownership cycle in which the application
605    /// waits for the session to end while the session waits for the
606    /// application to close it.
607    ///
608    /// Releasing does **not** free the `HPCON`: [`Self::close`] must still be
609    /// called afterwards to reclaim it.
610    ///
611    /// # Errors
612    ///
613    /// Returns `Some(Err(..))` with the failing `HRESULT` mapped to an
614    /// [`io::Error`]. Microsoft documents `E_INVALIDARG` as the only expected
615    /// failure.
616    ///
617    /// # Safety
618    ///
619    /// `hpc` must be a live handle from [`Self::create`] on *this* backend
620    /// that has not yet been passed to [`Self::close`].
621    #[must_use]
622    #[cfg(any(feature = "blocking", feature = "tokio", test))]
623    pub(super) unsafe fn release(&self, hpc: HPCON) -> Option<io::Result<()>> {
624        let release = self.inner.api.release?;
625
626        // SAFETY: `hpc` is live per this function's contract, and the
627        // function pointer was resolved from a module this backend keeps
628        // mapped.
629        let hr = unsafe { release(hpc) };
630        Some(hresult_ok(hr))
631    }
632
633    /// Calls `ClearPseudoConsole`, or returns [`None`] if the backend does not
634    /// export it (see [`Self::supports_clear`]).
635    ///
636    /// Clearing discards the console host's scrollback and its visible screen,
637    /// as the "clear buffer" action of a terminal emulator does. It is a signal
638    /// to the host, not a write into conout, so it stays valid after a release
639    /// and needs no cooperation from the reader.
640    ///
641    /// `keepCursorRow` is passed as `FALSE`, which is deliberate: it is the
642    /// behaviour every version of the export has — the parameter was added in
643    /// microsoft/terminal#18976 to *opt out* of clearing the cursor's row — so
644    /// this operation means the same thing whichever `conpty.dll` is loaded.
645    ///
646    /// # Errors
647    ///
648    /// Returns `Some(Err(..))` with the failing `HRESULT` mapped to an
649    /// [`io::Error`]; `E_INVALIDARG` for a null handle, otherwise the failure
650    /// of the write to the signal pipe.
651    ///
652    /// # Safety
653    ///
654    /// `hpc` must be a live handle from [`Self::create`] on *this* backend
655    /// that has not yet been passed to [`Self::close`].
656    #[must_use]
657    #[cfg(any(feature = "blocking", feature = "tokio", test))]
658    pub(super) unsafe fn clear(&self, hpc: HPCON) -> Option<io::Result<()>> {
659        let clear = self.inner.api.clear?;
660
661        // SAFETY: `hpc` is live per this function's contract, and the
662        // function pointer was resolved from a module this backend keeps
663        // mapped. The two-argument call shape is sound on every target that
664        // resolves the export at all; see `backend::exports`.
665        let hr = unsafe { clear(hpc, 0) };
666        Some(hresult_ok(hr))
667    }
668}
669
670impl Clone for ConPtyBackend {
671    fn clone(&self) -> Self {
672        Self {
673            inner: Arc::clone(&self.inner),
674        }
675    }
676}
677
678/// Prints the backend's identity rather than raw function addresses, which
679/// are noise and vary between runs.
680impl fmt::Debug for ConPtyBackend {
681    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
682        f.debug_struct("ConPtyBackend")
683            .field("kind", &self.kind())
684            .field("supports_release", &self.supports_release())
685            .field("supports_clear", &self.supports_clear())
686            .field("module_pinned", &self.inner.module_pin.is_some())
687            .finish()
688    }
689}
690
691/// Turns an `HRESULT` into a [`io::Result`], failing on `FAILED(hr)`.
692#[cfg(any(feature = "blocking", feature = "tokio", test))]
693fn hresult_ok(hr: HRESULT) -> io::Result<()> {
694    if hr >= 0 {
695        Ok(())
696    } else {
697        Err(hresult_to_io_error(hr))
698    }
699}
700
701/// Converts a failed `HRESULT` into an [`io::Error`].
702///
703/// `HRESULT_FROM_WIN32` wraps a plain Win32 error code as `0x8007_xxxx`.
704/// Unwrapping that back to the bare code is what lets [`io::Error`] classify
705/// it (`ERROR_ACCESS_DENIED` becomes [`io::ErrorKind::PermissionDenied`]
706/// rather than an unrecognised code) and format a readable message. Any other
707/// facility is passed through unchanged, which at least keeps the exact
708/// `HRESULT` visible in the error's `Display`.
709#[cfg(any(feature = "blocking", feature = "tokio", test))]
710fn hresult_to_io_error(hr: HRESULT) -> io::Error {
711    /// Mask selecting the severity and facility bits of an `HRESULT`.
712    const FACILITY_MASK: u32 = 0xFFFF_0000;
713    /// Severity `FAILED` plus `FACILITY_WIN32`, i.e. an `HRESULT_FROM_WIN32`.
714    const FAILED_FACILITY_WIN32: u32 = 0x8007_0000;
715
716    let bits = u32::from_ne_bytes(hr.to_ne_bytes());
717    if bits & FACILITY_MASK == FAILED_FACILITY_WIN32 {
718        let code = i32::try_from(bits & 0xFFFF).unwrap_or(i32::MAX);
719        io::Error::from_raw_os_error(code)
720    } else {
721        io::Error::from_raw_os_error(hr)
722    }
723}
724
725#[cfg(feature = "tracing")]
726fn log_missing_system_export(symbol: &'static str) {
727    tracing::warn!(
728        symbol,
729        "the system ConPTY backend is missing a required export"
730    );
731}
732
733#[cfg(not(feature = "tracing"))]
734const fn log_missing_system_export(_symbol: &'static str) {}
735
736#[cfg(test)]
737#[path = "backend_tests.rs"]
738mod tests;