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 `scripts/fetch-conpty.ps1` does,
279 /// 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 /// # Errors
290 ///
291 /// - [`crate::BackendErrorKind::DllNotFound`] if `dir/conpty.dll` is missing or
292 /// cannot be loaded (the source carries the OS error, e.g.
293 /// `ERROR_BAD_EXE_FORMAT` for a file that is not a DLL at all).
294 /// - [`crate::BackendErrorKind::OpenConsoleMissing`] if no `OpenConsole.exe`
295 /// accompanies the DLL.
296 /// - [`crate::BackendErrorKind::VersionMismatch`] if the two files report different
297 /// `ProductVersion` resources, or if either version cannot be read.
298 /// - [`crate::BackendErrorKind::MissingExport`] if the DLL lacks
299 /// `CreatePseudoConsole`, `ResizePseudoConsole`, or
300 /// `ClosePseudoConsole`.
301 pub fn from_dir(dir: impl AsRef<Path>) -> Result<Self, BackendError> {
302 Self::load_from_dir(dir.as_ref(), true)
303 }
304
305 /// Loads a bundled `conpty.dll` from `dir` **without** checking that it
306 /// matches its `OpenConsole.exe`.
307 ///
308 /// Every other check [`Self::from_dir`] performs still runs; only the
309 /// version comparison is skipped.
310 ///
311 /// # Why this is dangerous
312 ///
313 /// `conpty.dll` and `OpenConsole.exe` communicate over a private, versioned
314 /// protocol and are shipped as a pair for that reason. Running a DLL
315 /// against a console host from a different release is not a graceful
316 /// degradation: the failure mode of a bad `ConPTY` bundle is a hard crash of
317 /// the *client* process — in wezterm#7774, PowerShell dies with a
318 /// `0x8013_1623` `FailFast` — at an arbitrary later point, far from this
319 /// call.
320 ///
321 /// Use this only when the version resources are unreadable for a reason you
322 /// control, for example a locally rebuilt `OpenConsole.exe` that carries no
323 /// version stamp, and you can guarantee the pair by other means. Prefer
324 /// [`Self::from_dir`] everywhere else.
325 ///
326 /// # Errors
327 ///
328 /// The same as [`Self::from_dir`], minus
329 /// [`crate::BackendErrorKind::VersionMismatch`].
330 #[cfg(test)]
331 pub(crate) fn from_dir_unchecked(dir: impl AsRef<Path>) -> Result<Self, BackendError> {
332 Self::load_from_dir(dir.as_ref(), false)
333 }
334
335 /// Shared implementation of [`Self::from_dir`] and
336 /// [`Self::from_dir_unchecked`].
337 fn load_from_dir(dir: &Path, verify_pair: bool) -> Result<Self, BackendError> {
338 // Discovery and validation complete before executable code is mapped.
339 let bundle = validate(dir, verify_pair)?;
340 let dir = bundle.dir;
341 let dll = bundle.dll;
342
343 let module =
344 load_module(&dll).map_err(|source| BackendError::dll_not_found(dir.clone(), source))?;
345
346 // SAFETY: the module stays pinned in the same `BackendInner` as the
347 // resolved table, and standalone ConPTY exports use the SDK signatures.
348 let api = unsafe { ConptyApi::from_module(module.module) }
349 .map_err(|symbol| BackendError::missing_export(dll.clone(), symbol))?;
350
351 Ok(Self {
352 inner: Arc::new(BackendInner {
353 kind: BackendKind::External { dll },
354 api,
355 module_pin: Some(Arc::new(module)),
356 }),
357 })
358 }
359
360 /// Returns the best backend available to this process.
361 ///
362 /// The search order is:
363 ///
364 /// 1. A bundle next to the current executable. If `conpty.dll` sits in the
365 /// executable's directory it is loaded with [`Self::from_dir`], with all
366 /// of its validation.
367 /// 2. The operating system's `ConPTY` ([`Self::system`]).
368 ///
369 /// A bundle that fails to load is not an error: the process still has the
370 /// system implementation, and falling back to it is what an application
371 /// that merely *may* ship a bundle wants. The rejection is recorded with
372 /// `tracing::warn!` when the `tracing` feature is enabled, so a bundle that
373 /// is silently ignored — a version-mismatched pair, say — is still
374 /// diagnosable.
375 ///
376 /// # Errors
377 ///
378 /// Returns [`crate::BackendErrorKind::Unsupported`] when neither a valid bundle nor
379 /// the system `ConPTY` implementation is available.
380 pub fn auto() -> Result<Self, BackendError> {
381 AUTO_DEFAULT.get_or_try_init(Self::detect_auto)
382 }
383
384 /// Performs one uncached automatic-detection attempt.
385 fn detect_auto() -> Result<Self, BackendError> {
386 if let Some(dir) = exe_dir() {
387 // Only attempt the load when a bundle is actually present:
388 // otherwise every ordinary program would log a warning about a
389 // `conpty.dll` it never intended to ship.
390 if dir.join(CONPTY_DLL).is_file() {
391 match Self::from_dir(&dir) {
392 Ok(backend) => return Ok(backend),
393 Err(err) => log_rejected(&dir, &err),
394 }
395 }
396 }
397
398 Self::system()
399 }
400
401 /// Returns which `ConPTY` implementation this backend is bound to.
402 #[must_use]
403 pub(crate) fn kind(&self) -> &BackendKind {
404 &self.inner.kind
405 }
406
407 /// Returns whether this backend exports `ReleasePseudoConsole`.
408 ///
409 /// When `true`, the crate can relinquish the `HPCON` right after spawning
410 /// and let the pseudoconsole exit on its own once every client has
411 /// disconnected; conout then reaches end-of-file naturally. When `false`,
412 /// end-of-file has to be forced by closing the pseudoconsole after the
413 /// child exits, because the console host outlives the child.
414 ///
415 /// The value depends on the operating system (`ReleasePseudoConsole`
416 /// requires Windows 11 24H2 / Server 2025, build 26100) or on the version
417 /// of a bundled `conpty.dll`.
418 #[must_use]
419 pub(crate) fn supports_release(&self) -> bool {
420 self.inner.api.release.is_some()
421 }
422
423 /// Returns whether this backend can clear the pseudoconsole's buffer.
424 ///
425 /// `ClearPseudoConsole` is not part of the public Windows SDK and
426 /// `kernel32.dll` does not export it, so this is `false` on the system
427 /// backend and `true` only for a bundled `conpty.dll` that exports
428 /// `ConptyClearPseudoConsole`.
429 ///
430 /// It is also `false` on 32-bit x86 regardless of the DLL: the export
431 /// changed arity between releases (microsoft/terminal#18976) and `__stdcall`
432 /// makes an arity mismatch corrupt the stack, so the call is not offered
433 /// where it cannot be made safely.
434 #[must_use]
435 pub fn supports_clear(&self) -> bool {
436 self.inner.api.clear.is_some()
437 }
438
439 /// Returns a clone of this backend with the `ReleasePseudoConsole` export
440 /// removed.
441 ///
442 /// Sessions on the returned backend behave exactly as on a Windows version
443 /// that predates the export (everything before Windows 11 24H2):
444 /// [`Self::supports_release`] answers `false`, releasing after spawn is
445 /// impossible, and end-of-file has to be forced by the legacy watcher.
446 ///
447 /// This works on every backend, including an external one: the stripped
448 /// clone shares the original's module pin, so the addresses it copies stay
449 /// valid for as long as it does.
450 ///
451 /// This crate-private test hook lets the unit suite exercise the legacy
452 /// shutdown path deterministically on machines whose operating system
453 /// exports `ReleasePseudoConsole`, where ordinary sessions otherwise run
454 /// only in released mode.
455 #[must_use]
456 #[cfg(test)]
457 pub(super) fn without_release(&self) -> Self {
458 Self {
459 inner: Arc::new(BackendInner {
460 kind: self.inner.kind.clone(),
461 api: self.inner.api.without_release(),
462 // Share the pin rather than re-loading: the copied addresses
463 // point into the very module the original keeps mapped.
464 module_pin: self.inner.module_pin.clone(),
465 }),
466 }
467 }
468
469 /// Replaces only the close export so lifecycle tests can observe a
470 /// detached FFI call without passing a fabricated handle to Windows.
471 #[cfg(test)]
472 pub(super) fn with_test_close(&self, close: unsafe extern "system" fn(HPCON)) -> Self {
473 Self {
474 inner: Arc::new(BackendInner {
475 kind: self.inner.kind.clone(),
476 api: self.inner.api.with_close(close),
477 module_pin: self.inner.module_pin.clone(),
478 }),
479 }
480 }
481
482 /// Returns the backend to use when the caller did not name one.
483 ///
484 /// Only successful automatic detection is cached; failures remain
485 /// retryable.
486 #[cfg(any(feature = "blocking", feature = "tokio", test))]
487 pub(super) fn resolve_default() -> Result<Self, BackendError> {
488 Self::auto()
489 }
490
491 /// Calls `CreatePseudoConsole`.
492 ///
493 /// `input_read` is the read end of the conin pipe and `output_write` the
494 /// write end of the conout pipe; both must be synchronous handles, which
495 /// anonymous pipes always are. `ConPTY` duplicates them, so the caller
496 /// should close its own copies as soon as the child has been spawned —
497 /// until then the extra references keep conout from ever reaching
498 /// end-of-file.
499 ///
500 /// The returned `HPCON` is *not* owned by any RAII type here; the caller
501 /// must eventually pass it to [`Self::close`].
502 ///
503 /// # Errors
504 ///
505 /// Returns the failing `HRESULT` mapped to an [`io::Error`]. Construction
506 /// has already proved that the backend provides this required export.
507 #[cfg(any(feature = "blocking", feature = "tokio", test))]
508 pub(super) fn create(
509 &self,
510 size: Size,
511 input_read: BorrowedHandle<'_>,
512 output_write: BorrowedHandle<'_>,
513 flags: u32,
514 ) -> io::Result<HPCON> {
515 let api = &self.inner.api;
516 let (cols, rows) = size.to_i16_pair();
517 let size = COORD { X: cols, Y: rows };
518 let mut hpc: HPCON = 0;
519
520 // SAFETY: `api.create` was resolved from a module this backend keeps
521 // mapped. Both handles are borrowed for the duration of the call, and
522 // `hpc` is a valid out-parameter.
523 let hr = unsafe {
524 (api.create)(
525 size,
526 input_read.as_raw_handle(),
527 output_write.as_raw_handle(),
528 flags,
529 &mut hpc,
530 )
531 };
532 hresult_ok(hr)?;
533
534 Ok(hpc)
535 }
536
537 /// Calls `ResizePseudoConsole`.
538 ///
539 /// # Errors
540 ///
541 /// Returns the failing `HRESULT` mapped to an [`io::Error`].
542 ///
543 /// # Safety
544 ///
545 /// `hpc` must be a live handle from [`Self::create`] on *this* backend
546 /// that has not yet been passed to [`Self::close`].
547 #[cfg(any(feature = "blocking", feature = "tokio", test))]
548 pub(super) unsafe fn resize(&self, hpc: HPCON, size: Size) -> io::Result<()> {
549 let api = &self.inner.api;
550 let (cols, rows) = size.to_i16_pair();
551 let size = COORD { X: cols, Y: rows };
552
553 // SAFETY: `hpc` is live per this function's contract, and the
554 // function pointer was resolved from a module this backend keeps
555 // mapped.
556 let hr = unsafe { (api.resize)(hpc, size) };
557 hresult_ok(hr)
558 }
559
560 /// Calls `ClosePseudoConsole`, releasing the session's resources.
561 ///
562 /// This returns no status because `ClosePseudoConsole` returns `void`.
563 ///
564 /// # Safety
565 ///
566 /// `hpc` must be a live handle from [`Self::create`] on *this* backend
567 /// that has not been closed before; the handle is invalid afterwards.
568 ///
569 /// Beyond memory safety, two liveness rules from the `ConPTY` documentation
570 /// apply, and violating them hangs the process rather than corrupting it:
571 ///
572 /// - Before Windows 11 24H2 (build 26100), this call waits until every
573 /// client has disconnected. The caller must therefore have closed its
574 /// conout read end first, or keep another thread draining it.
575 /// - It must never be called from the thread that reads conout, because
576 /// that thread is exactly the one that would have to make progress for
577 /// the call to return.
578 #[cfg(any(feature = "blocking", feature = "tokio", test))]
579 pub(super) unsafe fn close(&self, hpc: HPCON) {
580 let api = &self.inner.api;
581
582 // SAFETY: `hpc` is live and unclosed per this function's contract.
583 unsafe { (api.close)(hpc) }
584 }
585
586 /// Calls `ReleasePseudoConsole`, or returns [`None`] if the backend does
587 /// not export it (see [`Self::supports_release`]).
588 ///
589 /// Releasing hands ownership of the session to the pseudoconsole itself:
590 /// once every client has disconnected, the console host exits on its own
591 /// and conout fails with `ERROR_BROKEN_PIPE`, which the reader maps to
592 /// end-of-file. That breaks the ownership cycle in which the application
593 /// waits for the session to end while the session waits for the
594 /// application to close it.
595 ///
596 /// Releasing does **not** free the `HPCON`: [`Self::close`] must still be
597 /// called afterwards to reclaim it.
598 ///
599 /// # Errors
600 ///
601 /// Returns `Some(Err(..))` with the failing `HRESULT` mapped to an
602 /// [`io::Error`]. Microsoft documents `E_INVALIDARG` as the only expected
603 /// failure.
604 ///
605 /// # Safety
606 ///
607 /// `hpc` must be a live handle from [`Self::create`] on *this* backend
608 /// that has not yet been passed to [`Self::close`].
609 #[must_use]
610 #[cfg(any(feature = "blocking", feature = "tokio", test))]
611 pub(super) unsafe fn release(&self, hpc: HPCON) -> Option<io::Result<()>> {
612 let release = self.inner.api.release?;
613
614 // SAFETY: `hpc` is live per this function's contract, and the
615 // function pointer was resolved from a module this backend keeps
616 // mapped.
617 let hr = unsafe { release(hpc) };
618 Some(hresult_ok(hr))
619 }
620
621 /// Calls `ClearPseudoConsole`, or returns [`None`] if the backend does not
622 /// export it (see [`Self::supports_clear`]).
623 ///
624 /// Clearing discards the console host's scrollback and its visible screen,
625 /// as the "clear buffer" action of a terminal emulator does. It is a signal
626 /// to the host, not a write into conout, so it stays valid after a release
627 /// and needs no cooperation from the reader.
628 ///
629 /// `keepCursorRow` is passed as `FALSE`, which is deliberate: it is the
630 /// behaviour every version of the export has — the parameter was added in
631 /// microsoft/terminal#18976 to *opt out* of clearing the cursor's row — so
632 /// this operation means the same thing whichever `conpty.dll` is loaded.
633 ///
634 /// # Errors
635 ///
636 /// Returns `Some(Err(..))` with the failing `HRESULT` mapped to an
637 /// [`io::Error`]; `E_INVALIDARG` for a null handle, otherwise the failure
638 /// of the write to the signal pipe.
639 ///
640 /// # Safety
641 ///
642 /// `hpc` must be a live handle from [`Self::create`] on *this* backend
643 /// that has not yet been passed to [`Self::close`].
644 #[must_use]
645 #[cfg(any(feature = "blocking", feature = "tokio", test))]
646 pub(super) unsafe fn clear(&self, hpc: HPCON) -> Option<io::Result<()>> {
647 let clear = self.inner.api.clear?;
648
649 // SAFETY: `hpc` is live per this function's contract, and the
650 // function pointer was resolved from a module this backend keeps
651 // mapped. The two-argument call shape is sound on every target that
652 // resolves the export at all; see `backend::exports`.
653 let hr = unsafe { clear(hpc, 0) };
654 Some(hresult_ok(hr))
655 }
656}
657
658impl Clone for ConPtyBackend {
659 fn clone(&self) -> Self {
660 Self {
661 inner: Arc::clone(&self.inner),
662 }
663 }
664}
665
666/// Prints the backend's identity rather than raw function addresses, which
667/// are noise and vary between runs.
668impl fmt::Debug for ConPtyBackend {
669 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
670 f.debug_struct("ConPtyBackend")
671 .field("kind", &self.kind())
672 .field("supports_release", &self.supports_release())
673 .field("supports_clear", &self.supports_clear())
674 .field("module_pinned", &self.inner.module_pin.is_some())
675 .finish()
676 }
677}
678
679/// Turns an `HRESULT` into a [`io::Result`], failing on `FAILED(hr)`.
680#[cfg(any(feature = "blocking", feature = "tokio", test))]
681fn hresult_ok(hr: HRESULT) -> io::Result<()> {
682 if hr >= 0 {
683 Ok(())
684 } else {
685 Err(hresult_to_io_error(hr))
686 }
687}
688
689/// Converts a failed `HRESULT` into an [`io::Error`].
690///
691/// `HRESULT_FROM_WIN32` wraps a plain Win32 error code as `0x8007_xxxx`.
692/// Unwrapping that back to the bare code is what lets [`io::Error`] classify
693/// it (`ERROR_ACCESS_DENIED` becomes [`io::ErrorKind::PermissionDenied`]
694/// rather than an unrecognised code) and format a readable message. Any other
695/// facility is passed through unchanged, which at least keeps the exact
696/// `HRESULT` visible in the error's `Display`.
697#[cfg(any(feature = "blocking", feature = "tokio", test))]
698fn hresult_to_io_error(hr: HRESULT) -> io::Error {
699 /// Mask selecting the severity and facility bits of an `HRESULT`.
700 const FACILITY_MASK: u32 = 0xFFFF_0000;
701 /// Severity `FAILED` plus `FACILITY_WIN32`, i.e. an `HRESULT_FROM_WIN32`.
702 const FAILED_FACILITY_WIN32: u32 = 0x8007_0000;
703
704 let bits = u32::from_ne_bytes(hr.to_ne_bytes());
705 if bits & FACILITY_MASK == FAILED_FACILITY_WIN32 {
706 let code = i32::try_from(bits & 0xFFFF).unwrap_or(i32::MAX);
707 io::Error::from_raw_os_error(code)
708 } else {
709 io::Error::from_raw_os_error(hr)
710 }
711}
712
713#[cfg(feature = "tracing")]
714fn log_missing_system_export(symbol: &'static str) {
715 tracing::warn!(
716 symbol,
717 "the system ConPTY backend is missing a required export"
718 );
719}
720
721#[cfg(not(feature = "tracing"))]
722const fn log_missing_system_export(_symbol: &'static str) {}
723
724#[cfg(test)]
725#[path = "backend_tests.rs"]
726mod tests;