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}