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;