Skip to main content

conpty_oxide/blocking/
pty.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//! Blocking pseudoconsole ownership and synchronous I/O.
6
7use std::fmt;
8use std::fs::File;
9use std::io::{self, Read, Write};
10use std::os::windows::io::OwnedHandle;
11use std::sync::Arc;
12
13use super::builder::PtyBuilder;
14#[cfg(test)]
15use crate::backend::BackendKind;
16use crate::core::is_disconnect_error;
17use crate::core::pseudocon::ConsoleShared;
18use crate::core::session::Session as SessionCore;
19#[cfg(test)]
20use crate::error::Result;
21#[cfg(test)]
22use crate::size::Size;
23use crate::PtyController;
24
25/// A pseudoconsole session: the console plus both ends of its I/O.
26///
27/// `Pty` implements [`Read`] (rendered console output) and [`Write`] (console
28/// input). Because both use `&mut self`, reading and writing concurrently
29/// requires splitting the session first — see [`Pty::split`] for a borrowed
30/// split and [`Pty::into_split`] for an owned one.
31///
32/// # Teardown
33///
34/// Dropping a `Pty` retires the read end first, then the write end, then the
35/// pseudoconsole itself. Dropping never waits for legacy
36/// `ClosePseudoConsole`: a close that may block is handed to a detached
37/// worker, whatever order the halves of a split session are dropped in. If
38/// Windows cannot create that worker, teardown leaves the handle for process
39/// cleanup instead of risking a wedged destructor.
40///
41/// Because closing the input pipe is part of that teardown, dropping a `Pty`
42/// whose child is still running **terminates the child** — see the module
43/// documentation. Keep the session alive until
44/// [`Child::wait`](super::Child::wait) returns.
45pub(crate) struct Pty {
46    pub(super) reader: ConoutReader,
47    pub(super) writer: ConinWriter,
48    pub(super) inner: Arc<SessionCore>,
49}
50
51/// Shows the session's identity — its size and backend — rather than raw
52/// handle values and the private lifecycle state, which are noise that varies
53/// between runs and would otherwise become de-facto public surface.
54/// [`ConPtyBackend`](crate::ConPtyBackend)'s own `Debug` follows the same rule.
55impl fmt::Debug for Pty {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        f.debug_struct("Pty")
58            .field("size", &self.inner.size())
59            .field("backend_kind", self.inner.backend_kind())
60            .finish_non_exhaustive()
61    }
62}
63
64impl Pty {
65    /// Starts building a session.
66    #[must_use]
67    pub(crate) fn builder() -> PtyBuilder {
68        PtyBuilder::default()
69    }
70
71    /// Resizes the pseudoconsole.
72    ///
73    /// The child observes the new dimensions the way a real console resize is
74    /// reported: `GetConsoleScreenBufferInfo` returns the new size, and a
75    /// program in virtual-terminal mode sees the redraw.
76    ///
77    /// # Errors
78    ///
79    /// [`crate::ErrorKind::Resize`] with the backend failure, or
80    /// an
81    /// [`io::ErrorKind::NotConnected`] error once the session has been torn
82    /// down.
83    #[cfg(test)]
84    pub(crate) fn resize(&self, size: Size) -> Result<()> {
85        self.inner.resize(size)
86    }
87
88    /// Returns the size last accepted by [`Pty::resize`], or the size the
89    /// session was built with.
90    #[must_use]
91    #[cfg(test)]
92    pub(crate) fn size(&self) -> Size {
93        self.inner.size()
94    }
95
96    /// Clears the pseudoconsole's screen and scrollback.
97    ///
98    /// This is the "clear buffer" operation of a terminal emulator, performed
99    /// by the console host itself: everything it has rendered so far is
100    /// discarded, and the client keeps running untouched. It is a signal, not
101    /// output — nothing is written into the session's input pipe and the child
102    /// is not notified.
103    ///
104    /// The session's reader is unaffected: bytes already delivered stay
105    /// delivered. What changes is what the console host will re-render, so a
106    /// full-screen application repaints on its next update.
107    ///
108    /// # Availability
109    ///
110    /// `ClearPseudoConsole` is not part of the public Windows SDK and
111    /// `kernel32.dll` does not export it, so this fails with
112    /// [`crate::ErrorKind::UnsupportedFeature`] on the
113    /// system backend. Bundling a `conpty.dll` (see
114    /// [`ConPtyBackend::from_dir`](crate::ConPtyBackend::from_dir)) is what
115    /// makes it available; [`Pty::supports_clear`] answers in advance.
116    ///
117    /// # Errors
118    ///
119    /// - [`crate::ErrorKind::UnsupportedFeature`] if the
120    ///   backend has no clear export.
121    /// - [`crate::ErrorKind::Clear`] with the backend failure, or
122    ///   an
123    ///   [`io::ErrorKind::NotConnected`] error once the session has been torn
124    ///   down.
125    ///
126    /// [`ConPtyBackend::from_dir`]: crate::ConPtyBackend::from_dir
127    #[cfg(test)]
128    pub(crate) fn clear(&self) -> Result<()> {
129        self.inner.clear()
130    }
131
132    /// Returns whether [`Pty::clear`] is available on this session's backend.
133    #[must_use]
134    #[cfg(test)]
135    pub(crate) fn supports_clear(&self) -> bool {
136        self.inner.supports_clear()
137    }
138
139    /// Returns whether this session's backend exports
140    /// `ReleasePseudoConsole`, which decides which of the two lifecycles from
141    /// the module documentation the session runs.
142    ///
143    /// With `true`, the session is released right after
144    /// [`Command::spawn_in`](super::Command::spawn_in) and end-of-file arrives
145    /// naturally once the console host exits. With `false`, end-of-file has to
146    /// be forced by the legacy watcher that [`PtyBuilder::eof_on_root_exit`]
147    /// controls, about a second after the root child exits.
148    ///
149    /// A session built without an explicit backend can only learn its
150    /// lifecycle here: which backend the default resolves to depends on the
151    /// operating system and on any bundle next to the executable.
152    #[must_use]
153    #[cfg(test)]
154    pub(crate) fn supports_release(&self) -> bool {
155        self.inner.supports_release()
156    }
157
158    /// Returns which `ConPTY` implementation backs this session.
159    #[must_use]
160    #[cfg(test)]
161    pub(crate) fn backend_kind(&self) -> &BackendKind {
162        self.inner.backend_kind()
163    }
164
165    /// Returns a cloneable control handle for this pseudoconsole.
166    #[must_use]
167    pub(crate) fn controller(&self) -> PtyController {
168        PtyController::new(Arc::clone(&self.inner))
169    }
170
171    /// Borrows the read and write halves separately.
172    ///
173    /// Useful to hand the two directions to different helpers within one
174    /// scope. The borrow covers the whole `Pty`, so [`Pty::resize`] cannot be
175    /// called while the halves are alive and the halves cannot be moved to
176    /// another thread that outlives this one — use [`Pty::into_split`] when
177    /// either is needed.
178    #[must_use]
179    #[cfg(test)]
180    pub(crate) fn split(&mut self) -> (ReadHalf<'_>, WriteHalf<'_>) {
181        let Self { reader, writer, .. } = self;
182        (ReadHalf { reader }, WriteHalf { writer })
183    }
184
185    /// Splits the session into independently owned read and write halves.
186    ///
187    /// This is the shape a real session usually wants: the
188    /// [`OwnedReadHalf`] moves to a dedicated reader thread (which the
189    /// pseudoconsole requires anyway, see the module docs), the
190    /// [`OwnedWriteHalf`] goes wherever input is produced. Obtain a
191    /// [`PtyController`] first with [`Pty::controller`] when control operations
192    /// must continue after this call. Both halves retain the session strongly.
193    #[must_use]
194    pub(crate) fn into_split(self) -> (OwnedReadHalf, OwnedWriteHalf) {
195        let Self {
196            reader,
197            writer,
198            inner,
199        } = self;
200        let read_session = Arc::clone(&inner);
201        (
202            OwnedReadHalf {
203                reader,
204                _session: read_session,
205            },
206            OwnedWriteHalf {
207                writer,
208                _session: inner,
209            },
210        )
211    }
212}
213
214/// Reads rendered console output. See [`OwnedReadHalf`] for the end-of-file
215/// contract.
216impl Read for Pty {
217    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
218        self.reader.read(buf)
219    }
220}
221
222/// Writes console input. [`flush`](Write::flush) is a no-op.
223impl Write for Pty {
224    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
225        self.writer.write(buf)
226    }
227
228    fn flush(&mut self) -> io::Result<()> {
229        Ok(())
230    }
231}
232
233/// Borrowed read half of a [`Pty`], from [`Pty::split`].
234#[cfg(test)]
235pub(crate) struct ReadHalf<'a> {
236    reader: &'a mut ConoutReader,
237}
238
239/// Deliberately opaque: the interesting state lives in the [`Pty`] this
240/// borrows from.
241#[cfg(test)]
242impl fmt::Debug for ReadHalf<'_> {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        f.debug_struct("ReadHalf").finish_non_exhaustive()
245    }
246}
247
248#[cfg(test)]
249impl Read for ReadHalf<'_> {
250    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
251        self.reader.read(buf)
252    }
253}
254
255/// Borrowed write half of a [`Pty`], from [`Pty::split`].
256///
257/// Writing has the same semantics as [`OwnedWriteHalf`]; dropping this borrow
258/// does not close anything, because the pipe stays owned by the [`Pty`].
259#[cfg(test)]
260pub(crate) struct WriteHalf<'a> {
261    writer: &'a mut ConinWriter,
262}
263
264/// Deliberately opaque: the interesting state lives in the [`Pty`] this
265/// borrows from.
266#[cfg(test)]
267impl fmt::Debug for WriteHalf<'_> {
268    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269        f.debug_struct("WriteHalf").finish_non_exhaustive()
270    }
271}
272
273#[cfg(test)]
274impl Write for WriteHalf<'_> {
275    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
276        self.writer.write(buf)
277    }
278
279    fn flush(&mut self) -> io::Result<()> {
280        Ok(())
281    }
282}
283
284/// Owned output half returned by [`Session::into_parts`](super::Session::into_parts).
285///
286/// # End-of-file
287///
288/// [`read`](Read::read) returns `Ok(0)` when the session is over. Errors that
289/// mean "the other end is gone" — `ERROR_BROKEN_PIPE`, `ERROR_HANDLE_EOF`,
290/// `ERROR_NO_DATA`, `ERROR_PIPE_NOT_CONNECTED` — are reported as that same
291/// end-of-file, not as errors, so `read_to_end` finishes cleanly instead of
292/// failing on the last read.
293///
294/// Reaching end-of-file, and dropping this half, are both reported to the
295/// session's lifecycle machinery: they are the events that let
296/// `ClosePseudoConsole` run promptly instead of waiting for a reader that will
297/// never come back.
298///
299/// The bytes are a UTF-8 virtual-terminal stream. They arrive in whatever
300/// chunks the console host produced, so a multi-byte character can straddle
301/// two reads — decode across reads (or buffer) rather than per read.
302pub struct OwnedReadHalf {
303    reader: ConoutReader,
304    /// Keeps the console alive even when every controller is dropped first.
305    _session: Arc<SessionCore>,
306}
307
308/// Deliberately opaque: what this half owns — a pipe handle and lifecycle
309/// bookkeeping — is exactly what `Debug` must not turn into public surface.
310impl fmt::Debug for OwnedReadHalf {
311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312        f.debug_struct("OwnedReadHalf").finish_non_exhaustive()
313    }
314}
315
316impl Read for OwnedReadHalf {
317    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
318        self.reader.read(buf)
319    }
320}
321
322/// Owned input half returned by [`Session::into_parts`](super::Session::into_parts).
323///
324/// Bytes written here become console input for the child, exactly as if they
325/// had been typed: line-oriented programs expect `\r\n`, not `\n`.
326///
327/// [`flush`](Write::flush) is a no-op: writes go straight to the pipe, and
328/// there is no user-space buffer to push.
329///
330/// # Dropping this half ends the session
331///
332/// Closing this half is not a polite "no more input" signal. It closes conin
333/// and requests pseudoconsole close, which sends a close event to every
334/// attached client. A child that is still running is therefore terminated
335/// with exit code `0xC000013A` (`STATUS_CONTROL_C_EXIT`) and loses any output
336/// it had not written yet. Hold on to this half until the child has exited —
337/// or drop it deliberately to end a session that ignores everything else.
338pub struct OwnedWriteHalf {
339    writer: ConinWriter,
340    /// Keeps the console alive even when every controller is dropped first.
341    _session: Arc<SessionCore>,
342}
343
344/// Deliberately opaque: nothing but the conin pipe handle lives here.
345impl fmt::Debug for OwnedWriteHalf {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        f.debug_struct("OwnedWriteHalf").finish_non_exhaustive()
348    }
349}
350
351impl Write for OwnedWriteHalf {
352    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
353        self.writer.write(buf)
354    }
355
356    fn flush(&mut self) -> io::Result<()> {
357        Ok(())
358    }
359}
360
361/// The read end of the conout pipe, plus the lifecycle notifications the
362/// pseudoconsole state machine needs from a reader.
363#[derive(Debug)]
364pub(super) struct ConoutReader {
365    /// `None` only between the start and the end of [`Drop`].
366    file: Option<File>,
367    shared: Arc<ConsoleShared>,
368    /// Whether end-of-file has already been reported once; the notification
369    /// is idempotent, but repeating it on every subsequent read would take the
370    /// state lock for nothing.
371    saw_eof: bool,
372}
373
374/// Runs one reader EOF notification and suppresses repeated observations.
375fn notify_eof_once(saw_eof: &mut bool, notify: impl FnOnce()) {
376    if !*saw_eof {
377        *saw_eof = true;
378        notify();
379    }
380}
381
382/// Maps only disconnect-class pipe failures to the EOF result.
383fn conout_error_as_eof(err: io::Error) -> io::Result<()> {
384    if is_disconnect_error(&err) {
385        Ok(())
386    } else {
387        Err(err)
388    }
389}
390
391impl ConoutReader {
392    pub(super) fn new(handle: OwnedHandle, shared: Arc<ConsoleShared>) -> Self {
393        Self {
394            file: Some(File::from(handle)),
395            shared,
396            saw_eof: false,
397        }
398    }
399
400    /// Reports end-of-file to the lifecycle state machine, once.
401    ///
402    /// This may run `ClosePseudoConsole` inline on the calling (reader)
403    /// thread. That is the one case where closing from the reader is correct:
404    /// end-of-file proves the console host is already gone, so the close has
405    /// nothing left to wait for.
406    fn on_eof(&mut self) {
407        let shared = &self.shared;
408        notify_eof_once(&mut self.saw_eof, || shared.notify_reader_eof());
409    }
410}
411
412impl Read for ConoutReader {
413    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
414        // An empty buffer must not be mistaken for end-of-file.
415        if buf.is_empty() {
416            return Ok(0);
417        }
418        let Some(file) = self.file.as_mut() else {
419            return Ok(0);
420        };
421
422        match file.read(buf) {
423            Ok(0) => {
424                self.on_eof();
425                Ok(0)
426            },
427            Ok(read) => Ok(read),
428            Err(err) => match conout_error_as_eof(err) {
429                Ok(()) => {
430                    self.on_eof();
431                    Ok(0)
432                },
433                Err(err) => Err(err),
434            },
435        }
436    }
437}
438
439/// Closes the read end, then tells the lifecycle state machine about it.
440///
441/// The order matters: with the handle already closed, a `ClosePseudoConsole`
442/// triggered by the notification cannot block waiting for this reader — the
443/// console host's writes fail instead. This is the documented "close the
444/// output pipe first" shutdown.
445impl Drop for ConoutReader {
446    fn drop(&mut self) {
447        drop(self.file.take());
448        self.shared.notify_reader_closed();
449    }
450}
451
452/// The write end of the conin pipe and its session-close notification.
453///
454/// Dropping closes the pipe first, then asks the lifecycle core to close a
455/// spawned pseudoconsole. The explicit close request is required on legacy
456/// Windows, where conin end-of-file alone does not reliably retire clients.
457#[derive(Debug)]
458pub(super) struct ConinWriter {
459    file: Option<File>,
460    session: Arc<SessionCore>,
461}
462
463impl ConinWriter {
464    pub(super) fn new(handle: OwnedHandle, session: Arc<SessionCore>) -> Self {
465        Self {
466            file: Some(File::from(handle)),
467            session,
468        }
469    }
470}
471
472impl Write for ConinWriter {
473    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
474        self.file
475            .as_mut()
476            .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "input is closed"))?
477            .write(buf)
478    }
479
480    fn flush(&mut self) -> io::Result<()> {
481        // Writes reach the pipe synchronously and this type adds no buffering,
482        // so there is nothing to flush. Flushing the underlying handle would
483        // call `FlushFileBuffers`, which on a pipe blocks until the *reader*
484        // has consumed everything — a deadlock, not a flush.
485        Ok(())
486    }
487}
488
489impl Drop for ConinWriter {
490    fn drop(&mut self) {
491        // Close conin before requesting HPCON close. The detached legacy
492        // closer may start immediately, and must observe the terminal input
493        // as already retired.
494        drop(self.file.take());
495        self.session.request_close_after_input();
496    }
497}
498
499#[cfg(test)]
500mod behavior_tests {
501    use std::cell::Cell;
502    use std::io;
503    use std::sync::Arc;
504
505    use super::{conout_error_as_eof, notify_eof_once, Pty};
506    use crate::backend::ConPtyBackend;
507    use crate::blocking::Command;
508
509    #[test]
510    fn writer_drop_requests_close_while_the_controller_keeps_the_session_alive() {
511        let backend = ConPtyBackend::system()
512            .expect("ConPTY must be available")
513            .without_release();
514        let pty = Pty::builder()
515            .backend(backend)
516            .eof_on_root_exit(false)
517            .build()
518            .expect("building a forced-legacy pty must succeed");
519        let controller = pty.controller();
520        let shared = Arc::clone(&pty.reader.shared);
521        let child = Command::new("cmd.exe")
522            .args(["/c", "pause"])
523            .kill_on_drop(true)
524            .spawn_in(&pty)
525            .expect("spawning must succeed");
526        let (reader, writer) = pty.into_split();
527
528        drop(reader);
529        assert!(
530            !shared.is_closed(),
531            "reader retirement alone must not request pseudoconsole close"
532        );
533        drop(writer);
534        assert!(
535            shared.is_closed(),
536            "writer drop must claim pseudoconsole close while the controller keeps it alive"
537        );
538
539        drop(child);
540        drop(controller);
541    }
542
543    #[test]
544    fn eof_notification_runs_exactly_once() {
545        let mut saw_eof = false;
546        let notifications = Cell::new(0);
547        notify_eof_once(&mut saw_eof, || notifications.set(notifications.get() + 1));
548        notify_eof_once(&mut saw_eof, || notifications.set(notifications.get() + 1));
549        assert!(saw_eof);
550        assert_eq!(notifications.get(), 1);
551    }
552
553    #[test]
554    fn only_disconnect_errors_become_eof() {
555        assert!(conout_error_as_eof(io::Error::new(io::ErrorKind::BrokenPipe, "closed")).is_ok());
556        let err = conout_error_as_eof(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
557            .expect_err("an unrelated I/O failure must not become EOF");
558        assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
559    }
560}