conpty_oxide/tokio/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
5use std::fmt;
6use std::io;
7use std::os::windows::io::AsRawHandle;
8use std::pin::Pin;
9use std::ptr;
10#[cfg(test)]
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::Arc;
13use std::task::{Context, Poll};
14
15use ::tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
16use ::tokio::net::windows::named_pipe::NamedPipeServer;
17use windows_sys::Win32::System::IO::CancelIoEx;
18
19#[cfg(test)]
20use crate::backend::BackendKind;
21use crate::core::is_disconnect_error;
22use crate::core::pseudocon::ConsoleShared;
23use crate::core::session::Session as SessionCore;
24#[cfg(test)]
25use crate::error::Result;
26#[cfg(test)]
27use crate::size::Size;
28
29use super::builder::PtyBuilder;
30use crate::PtyController;
31
32/// An asynchronous pseudoconsole session: the console plus both ends of its
33/// I/O.
34///
35/// `Pty` implements [`AsyncRead`] (rendered console output) and [`AsyncWrite`]
36/// (console input). Because both use `Pin<&mut Self>`, reading and writing
37/// concurrently requires splitting the session first — see [`Pty::split`] for
38/// a borrowed split and [`Pty::into_split`] for an owned one.
39///
40/// # Teardown
41///
42/// Dropping a `Pty` retires the read end first, then the write end, then the
43/// pseudoconsole itself. Dropping never waits for legacy
44/// `ClosePseudoConsole`: a close that may block is handed to a detached
45/// worker, whatever order the halves of a split session are dropped in. If
46/// Windows cannot create that worker, teardown leaves the handle for process
47/// cleanup instead of risking a wedged destructor.
48///
49/// One async-specific caveat: dropping a session's pipes only *initiates*
50/// their OS-level close. An overlapped operation still in flight is
51/// cancelled, and the handle actually closes when the runtime's I/O driver
52/// retires the cancelled operation. The lifecycle machinery accounts for
53/// this — a `ClosePseudoConsole` that cannot be proven prompt runs on a
54/// detached thread, never on the dropping one — but it does mean that on a
55/// backend without `ReleasePseudoConsole` the console host may observe the
56/// session's end only after the I/O driver next runs. Prompt teardown at the
57/// OS level therefore additionally wants a live runtime; the drop itself
58/// never waits for one.
59///
60/// Because closing the input pipe is part of that teardown, dropping a `Pty`
61/// whose child is still running **terminates the child** — see the module
62/// documentation. Keep the session alive until
63/// [`crate::tokio::Child::wait`] returns.
64pub(crate) struct Pty {
65 pub(super) reader: ConoutReader,
66 pub(super) writer: ConinWriter,
67 pub(super) inner: Arc<SessionCore>,
68}
69
70/// Shows the session's identity — its size and backend — rather than raw
71/// handle values and the private lifecycle state, which are noise that varies
72/// between runs and would otherwise become de-facto public surface.
73/// [`crate::ConPtyBackend`]'s own `Debug` follows the same rule.
74impl fmt::Debug for Pty {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.debug_struct("Pty")
77 .field("size", &self.inner.size())
78 .field("backend_kind", self.inner.backend_kind())
79 .finish_non_exhaustive()
80 }
81}
82
83impl Pty {
84 /// Starts building a session.
85 #[must_use]
86 pub(crate) fn builder() -> PtyBuilder {
87 PtyBuilder::default()
88 }
89
90 /// Resizes the pseudoconsole.
91 ///
92 /// The child observes the new dimensions the way a real console resize is
93 /// reported: `GetConsoleScreenBufferInfo` returns the new size, and a
94 /// program in virtual-terminal mode sees the redraw.
95 ///
96 /// This is a plain synchronous method: the underlying
97 /// `ResizePseudoConsole` is a short signal write to the console host that
98 /// never blocks, so making it a future would buy nothing.
99 ///
100 /// # Errors
101 ///
102 /// [`crate::ErrorKind::Resize`] with the backend failure, or an
103 /// [`io::ErrorKind::NotConnected`] error once the session has been torn
104 /// down.
105 #[cfg(test)]
106 pub(crate) fn resize(&self, size: Size) -> Result<()> {
107 self.inner.resize(size)
108 }
109
110 /// Returns the size last accepted by [`Pty::resize`], or the size the
111 /// session was built with.
112 #[must_use]
113 #[cfg(test)]
114 pub(crate) fn size(&self) -> Size {
115 self.inner.size()
116 }
117
118 /// Clears the pseudoconsole's screen and scrollback.
119 ///
120 /// This is the "clear buffer" operation of a terminal emulator, performed
121 /// by the console host itself: everything it has rendered so far is
122 /// discarded, and the client keeps running untouched. It is a signal, not
123 /// output — nothing is written into the session's input pipe and the child
124 /// is not notified.
125 ///
126 /// The session's reader is unaffected: bytes already delivered stay
127 /// delivered. What changes is what the console host will re-render, so a
128 /// full-screen application repaints on its next update.
129 ///
130 /// # Availability
131 ///
132 /// `ClearPseudoConsole` is not part of the public Windows SDK and
133 /// `kernel32.dll` does not export it, so this fails with
134 /// [`crate::ErrorKind::UnsupportedFeature`] on the system backend. Bundling a
135 /// `conpty.dll` (see [`ConPtyBackend::from_dir`]) is what makes it
136 /// available; [`Pty::supports_clear`] answers in advance.
137 ///
138 /// # Errors
139 ///
140 /// - [`crate::ErrorKind::UnsupportedFeature`] if the backend has no clear
141 /// export.
142 /// - [`crate::ErrorKind::Clear`] with the backend failure, or an
143 /// [`io::ErrorKind::NotConnected`] error once the session has been torn
144 /// down.
145 ///
146 /// [`ConPtyBackend::from_dir`]: crate::ConPtyBackend::from_dir
147 #[cfg(test)]
148 pub(crate) fn clear(&self) -> Result<()> {
149 self.inner.clear()
150 }
151
152 /// Returns whether [`Pty::clear`] is available on this session's backend.
153 #[must_use]
154 #[cfg(test)]
155 pub(crate) fn supports_clear(&self) -> bool {
156 self.inner.supports_clear()
157 }
158
159 /// Returns whether this session's backend exports
160 /// `ReleasePseudoConsole`, which decides which of the two lifecycles from
161 /// the module documentation the session runs.
162 ///
163 /// With `true`, the session is released right after
164 /// [`crate::tokio::Command::spawn_in`] and end-of-file arrives naturally
165 /// once the console host exits. With `false`, end-of-file has to be forced
166 /// by the legacy watcher that [`PtyBuilder::eof_on_root_exit`] controls,
167 /// about a second after the root child exits.
168 ///
169 /// A session built without an explicit backend can only learn its
170 /// lifecycle here: which backend the default resolves to depends on the
171 /// operating system and on any bundle next to the executable.
172 #[must_use]
173 #[cfg(test)]
174 pub(crate) fn supports_release(&self) -> bool {
175 self.inner.supports_release()
176 }
177
178 /// Returns which `ConPTY` implementation backs this session.
179 #[must_use]
180 #[cfg(test)]
181 pub(crate) fn backend_kind(&self) -> &BackendKind {
182 self.inner.backend_kind()
183 }
184
185 /// Returns a cloneable control handle for this pseudoconsole.
186 #[must_use]
187 pub(crate) fn controller(&self) -> PtyController {
188 PtyController::new(Arc::clone(&self.inner))
189 }
190
191 /// Borrows the read and write halves separately.
192 ///
193 /// Useful to hand the two directions to different helpers within one
194 /// scope, for instance to `tokio::io::copy` in both directions under a
195 /// `tokio::try_join!`. The borrow covers the whole `Pty`, so
196 /// [`Pty::resize`] cannot be called while the halves are alive and the
197 /// halves cannot be moved into a task that outlives this scope — use
198 /// [`Pty::into_split`] when either is needed.
199 #[must_use]
200 #[cfg(test)]
201 pub(crate) fn split(&mut self) -> (ReadHalf<'_>, WriteHalf<'_>) {
202 let Self { reader, writer, .. } = self;
203 (ReadHalf { reader }, WriteHalf { writer })
204 }
205
206 /// Splits the session into independently owned read and write halves.
207 ///
208 /// This is the shape a real session usually wants: the
209 /// [`OwnedReadHalf`] moves into a dedicated reader task (which the
210 /// pseudoconsole effectively requires anyway, see the module docs), the
211 /// [`OwnedWriteHalf`] goes wherever input is produced. Obtain a
212 /// [`PtyController`] first with [`Pty::controller`] when control operations
213 /// must continue after this call. Both halves retain the session strongly.
214 #[must_use]
215 pub(crate) fn into_split(self) -> (OwnedReadHalf, OwnedWriteHalf) {
216 let Self {
217 reader,
218 writer,
219 inner,
220 } = self;
221 let read_session = Arc::clone(&inner);
222 (
223 OwnedReadHalf {
224 reader,
225 _session: read_session,
226 },
227 OwnedWriteHalf {
228 writer,
229 _session: inner,
230 },
231 )
232 }
233}
234
235/// Reads rendered console output. See [`OwnedReadHalf`] for the end-of-file
236/// contract.
237impl AsyncRead for Pty {
238 fn poll_read(
239 self: Pin<&mut Self>,
240 cx: &mut Context<'_>,
241 buf: &mut ReadBuf<'_>,
242 ) -> Poll<io::Result<()>> {
243 Pin::new(&mut self.get_mut().reader).poll_read(cx, buf)
244 }
245}
246
247/// Writes console input. See [`OwnedWriteHalf`] for what flushing and shutting
248/// down mean here.
249impl AsyncWrite for Pty {
250 fn poll_write(
251 self: Pin<&mut Self>,
252 cx: &mut Context<'_>,
253 buf: &[u8],
254 ) -> Poll<io::Result<usize>> {
255 Pin::new(&mut self.get_mut().writer).poll_write(cx, buf)
256 }
257
258 fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
259 // Every conin writer is unbuffered, so flush is deliberately a direct
260 // no-op. Delegating would have exactly the same behavior and state,
261 // but spelling the contract here avoids pretending otherwise.
262 Poll::Ready(Ok(()))
263 }
264
265 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
266 Pin::new(&mut self.get_mut().writer).poll_shutdown(cx)
267 }
268}
269
270/// Borrowed read half of an asynchronous [`Pty`], from [`Pty::split`].
271#[cfg(test)]
272pub(crate) struct ReadHalf<'a> {
273 reader: &'a mut ConoutReader,
274}
275
276/// Deliberately opaque: the interesting state lives in the [`Pty`] this
277/// borrows from.
278#[cfg(test)]
279impl fmt::Debug for ReadHalf<'_> {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 f.debug_struct("ReadHalf").finish_non_exhaustive()
282 }
283}
284
285#[cfg(test)]
286impl AsyncRead for ReadHalf<'_> {
287 fn poll_read(
288 self: Pin<&mut Self>,
289 cx: &mut Context<'_>,
290 buf: &mut ReadBuf<'_>,
291 ) -> Poll<io::Result<()>> {
292 Pin::new(&mut *self.get_mut().reader).poll_read(cx, buf)
293 }
294}
295
296/// Borrowed write half of an asynchronous [`Pty`], from [`Pty::split`].
297///
298/// Writing has the same semantics as [`OwnedWriteHalf`], including the fact
299/// that shutting it down ends the session. Dropping this borrow, on the other
300/// hand, closes nothing, because the pipe stays owned by the [`Pty`].
301#[cfg(test)]
302pub(crate) struct WriteHalf<'a> {
303 writer: &'a mut ConinWriter,
304}
305
306/// Deliberately opaque: the interesting state lives in the [`Pty`] this
307/// borrows from.
308#[cfg(test)]
309impl fmt::Debug for WriteHalf<'_> {
310 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311 f.debug_struct("WriteHalf").finish_non_exhaustive()
312 }
313}
314
315#[cfg(test)]
316impl AsyncWrite for WriteHalf<'_> {
317 fn poll_write(
318 self: Pin<&mut Self>,
319 cx: &mut Context<'_>,
320 buf: &[u8],
321 ) -> Poll<io::Result<usize>> {
322 Pin::new(&mut *self.get_mut().writer).poll_write(cx, buf)
323 }
324
325 fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
326 Poll::Ready(Ok(()))
327 }
328
329 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
330 Pin::new(&mut *self.get_mut().writer).poll_shutdown(cx)
331 }
332}
333
334/// Owned output half returned by [`Session::into_parts`](super::Session::into_parts).
335///
336/// # End-of-file
337///
338/// A read completes with zero bytes when the session is over. Errors that mean
339/// "the other end is gone" — `ERROR_BROKEN_PIPE`, `ERROR_HANDLE_EOF`,
340/// `ERROR_NO_DATA`, `ERROR_PIPE_NOT_CONNECTED` — are reported as that same
341/// end-of-file, not as errors, so `AsyncReadExt::read_to_end` finishes cleanly
342/// instead of failing on the last read.
343///
344/// Reaching end-of-file, and dropping this half, are both reported to the
345/// session's lifecycle machinery: they are the events that let
346/// `ClosePseudoConsole` run promptly instead of waiting for a reader that will
347/// never come back. The reporting happens in [`Drop`] and never blocks: a
348/// close runs inline only where it is proven prompt, and one that cannot be
349/// proven prompt — possible on a backend without `ReleasePseudoConsole`,
350/// because dropping an async pipe closes the OS handle only once the I/O
351/// driver retires its in-flight read — is handed to a detached thread
352/// instead.
353///
354/// The bytes are a UTF-8 virtual-terminal stream. They arrive in whatever
355/// chunks the console host produced, so a multi-byte character can straddle
356/// two reads — decode across reads (or buffer) rather than per read.
357pub struct OwnedReadHalf {
358 reader: ConoutReader,
359 /// Keeps the console alive even when every controller is dropped first.
360 _session: Arc<SessionCore>,
361}
362
363/// Deliberately opaque: what this half owns — a pipe handle and lifecycle
364/// bookkeeping — is exactly what `Debug` must not turn into public surface.
365impl fmt::Debug for OwnedReadHalf {
366 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367 f.debug_struct("OwnedReadHalf").finish_non_exhaustive()
368 }
369}
370
371impl AsyncRead for OwnedReadHalf {
372 fn poll_read(
373 self: Pin<&mut Self>,
374 cx: &mut Context<'_>,
375 buf: &mut ReadBuf<'_>,
376 ) -> Poll<io::Result<()>> {
377 Pin::new(&mut self.get_mut().reader).poll_read(cx, buf)
378 }
379}
380
381/// Owned input half returned by [`Session::into_parts`](super::Session::into_parts).
382///
383/// Bytes written here become console input for the child, exactly as if they
384/// had been typed: line-oriented programs expect `\r\n`, not `\n`.
385///
386/// `AsyncWriteExt::flush` is a no-op: writes go straight to the pipe, and
387/// there is no user-space buffer to push.
388///
389/// # Dropping — or shutting down — this half ends the session
390///
391/// Closing this half is not a polite "no more input" signal. It closes conin
392/// and requests pseudoconsole close, which sends a close event to every
393/// attached client. A child that is still running is therefore terminated
394/// with exit code `0xC000013A` (`STATUS_CONTROL_C_EXIT`) and loses any output
395/// it had not written yet. Hold on to this half until the child has exited —
396/// or close it deliberately to end a session that ignores everything else.
397///
398/// `AsyncWriteExt::shutdown` is that deliberate close: it never blocks, it
399/// cancels a write still in flight rather than flushing it, and it makes
400/// later writes fail with [`io::ErrorKind::BrokenPipe`]; shutting down again
401/// is a no-op. Dropping this half closes the pipe the same way. One caveat:
402/// with an overlapped write in flight, the OS handle closes only once the
403/// runtime's I/O driver has retired the cancelled operation, so the console
404/// host observes the close after the driver's next poll — one poll, not an
405/// unbounded wait for the host to drain the pipe.
406pub struct OwnedWriteHalf {
407 writer: ConinWriter,
408 /// Keeps the console alive even when every controller is dropped first.
409 _session: Arc<SessionCore>,
410}
411
412/// Deliberately opaque: nothing but the conin pipe handle lives here.
413impl fmt::Debug for OwnedWriteHalf {
414 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415 f.debug_struct("OwnedWriteHalf").finish_non_exhaustive()
416 }
417}
418
419impl AsyncWrite for OwnedWriteHalf {
420 fn poll_write(
421 self: Pin<&mut Self>,
422 cx: &mut Context<'_>,
423 buf: &[u8],
424 ) -> Poll<io::Result<usize>> {
425 Pin::new(&mut self.get_mut().writer).poll_write(cx, buf)
426 }
427
428 fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
429 Poll::Ready(Ok(()))
430 }
431
432 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
433 Pin::new(&mut self.get_mut().writer).poll_shutdown(cx)
434 }
435}
436
437/// The server end of the conout named pipe, plus the lifecycle notifications
438/// the pseudoconsole state machine needs from a reader.
439#[derive(Debug)]
440pub(super) struct ConoutReader {
441 /// `None` only between the start and the end of [`Drop`].
442 pipe: Option<NamedPipeServer>,
443 shared: Arc<ConsoleShared>,
444 /// Whether end-of-file has already been reported once; the notification
445 /// is idempotent, but repeating it on every subsequent poll would take the
446 /// state lock for nothing.
447 saw_eof: bool,
448}
449
450/// Runs one reader EOF notification and suppresses repeated observations.
451fn notify_eof_once(saw_eof: &mut bool, notify: impl FnOnce()) {
452 if !*saw_eof {
453 *saw_eof = true;
454 notify();
455 }
456}
457
458/// Maps only disconnect-class pipe failures to the EOF result.
459fn conout_error_as_eof(err: io::Error) -> io::Result<()> {
460 if is_disconnect_error(&err) {
461 Ok(())
462 } else {
463 Err(err)
464 }
465}
466
467impl ConoutReader {
468 pub(super) const fn new(pipe: NamedPipeServer, shared: Arc<ConsoleShared>) -> Self {
469 Self {
470 pipe: Some(pipe),
471 shared,
472 saw_eof: false,
473 }
474 }
475}
476
477impl AsyncRead for ConoutReader {
478 fn poll_read(
479 self: Pin<&mut Self>,
480 cx: &mut Context<'_>,
481 buf: &mut ReadBuf<'_>,
482 ) -> Poll<io::Result<()>> {
483 // An empty buffer must not be mistaken for end-of-file.
484 if buf.remaining() == 0 {
485 return Poll::Ready(Ok(()));
486 }
487 let this = self.get_mut();
488 let Some(pipe) = this.pipe.as_mut() else {
489 return Poll::Ready(Ok(()));
490 };
491
492 let before = buf.filled().len();
493 match Pin::new(pipe).poll_read(cx, buf) {
494 // Zero bytes into a non-empty buffer is end-of-file. Whether the
495 // console host's exit surfaces this way or as a disconnect error
496 // below depends on how far the broken pipe got through Tokio's
497 // and mio's layers, so both are handled identically.
498 Poll::Ready(Ok(())) => {
499 if buf.filled().len() == before {
500 let shared = &this.shared;
501 notify_eof_once(&mut this.saw_eof, || shared.notify_reader_eof());
502 }
503 Poll::Ready(Ok(()))
504 },
505 Poll::Ready(Err(err)) => match conout_error_as_eof(err) {
506 Ok(()) => {
507 let shared = &this.shared;
508 notify_eof_once(&mut this.saw_eof, || shared.notify_reader_eof());
509 Poll::Ready(Ok(()))
510 },
511 Err(err) => Poll::Ready(Err(err)),
512 },
513 Poll::Pending => Poll::Pending,
514 }
515 }
516}
517
518/// Retires the read end, then tells the lifecycle state machine about it.
519///
520/// Dropping the Tokio pipe only *initiates* the OS-level close: with a
521/// mio-scheduled overlapped read still in flight — the state of every session
522/// between registration and the I/O driver's next poll after conout activity
523/// — the drop cancels the read, and the `CloseHandle` runs when the driver
524/// retires the cancelled operation, not here. The notification therefore must
525/// not be taken as proof that the conout read end is gone, and the lifecycle
526/// machinery does not take it as one: every legacy final close is routed to a
527/// detached thread instead of running on this destructor's thread. Both steps
528/// below are synchronous and nonblocking, which is what lets them happen in a
529/// destructor at all.
530impl Drop for ConoutReader {
531 fn drop(&mut self) {
532 drop(self.pipe.take());
533 self.shared.notify_reader_closed();
534 }
535}
536
537/// The server end of the conin named pipe.
538///
539/// Dropping it — or shutting it down — closes the pipe and requests session
540/// close. Both operations go through [`Self::close_pipe`], which cancels an
541/// in-flight write first and notifies the lifecycle core exactly once.
542#[derive(Debug)]
543pub(super) struct ConinWriter {
544 /// `None` once the pipe has been closed by a shutdown.
545 pipe: Option<NamedPipeServer>,
546 session: Arc<SessionCore>,
547 /// Per-instance proof that Drop took the cancellation path.
548 #[cfg(test)]
549 close_observer: Option<Arc<AtomicBool>>,
550}
551
552impl ConinWriter {
553 pub(super) const fn new(pipe: NamedPipeServer, session: Arc<SessionCore>) -> Self {
554 Self {
555 pipe: Some(pipe),
556 session,
557 #[cfg(test)]
558 close_observer: None,
559 }
560 }
561
562 /// Borrows the pipe, or reports that a shutdown has already closed it.
563 fn pipe(&mut self) -> io::Result<&mut NamedPipeServer> {
564 self.pipe.as_mut().ok_or_else(|| {
565 io::Error::new(
566 io::ErrorKind::BrokenPipe,
567 "the pseudoconsole input pipe has been shut down",
568 )
569 })
570 }
571
572 /// Cancels any in-flight overlapped write, then closes the pipe.
573 ///
574 /// mio deliberately lets a pending write run to completion before the
575 /// handle is closed, which is right for data pipes and wrong for conin: a
576 /// conin write goes pending exactly when the console host has stopped
577 /// draining input, and the close *is* the "terminal is gone" signal the
578 /// caller is trying to send — flushing would defer that signal until the
579 /// wedged host reads again, potentially forever. `CancelIoEx` bounds the
580 /// deferral to one poll of the runtime's I/O driver instead: the write
581 /// completes as cancelled, the driver retires it, and the handle closes.
582 /// Idempotent, synchronous, and never blocking.
583 fn close_pipe(&mut self) {
584 let Some(pipe) = self.pipe.take() else {
585 return;
586 };
587 #[cfg(test)]
588 if let Some(observer) = &self.close_observer {
589 observer.store(true, Ordering::SeqCst);
590 }
591 // SAFETY: `pipe` still owns the handle, so it is live for the call; a
592 // null OVERLAPPED requests cancellation of every operation this
593 // process issued on it, which is the intent (conin carries no reads —
594 // mio's eager registration read fails synchronously on an outbound
595 // pipe). Failure needs no handling: `ERROR_NOT_FOUND` just means
596 // nothing was pending, and the drop below closes the handle — or
597 // schedules the close — either way.
598 unsafe { CancelIoEx(pipe.as_raw_handle(), ptr::null()) };
599 drop(pipe);
600 self.session.request_close_after_input();
601 }
602}
603
604/// Dropping the write half is documented to end the session, so the close
605/// must not linger behind an in-flight write either; see
606/// [`ConinWriter::close_pipe`].
607impl Drop for ConinWriter {
608 fn drop(&mut self) {
609 self.close_pipe();
610 }
611}
612
613impl AsyncWrite for ConinWriter {
614 fn poll_write(
615 self: Pin<&mut Self>,
616 cx: &mut Context<'_>,
617 buf: &[u8],
618 ) -> Poll<io::Result<usize>> {
619 let pipe = match self.get_mut().pipe() {
620 Ok(pipe) => pipe,
621 Err(err) => return Poll::Ready(Err(err)),
622 };
623 Pin::new(pipe).poll_write(cx, buf)
624 }
625
626 fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
627 // Writes reach the pipe without passing through any buffer of ours, so
628 // there is nothing to flush. Flushing the underlying handle would call
629 // `FlushFileBuffers`, which on a pipe blocks until the *reader* has
630 // consumed everything — a deadlock, not a flush.
631 Poll::Ready(Ok(()))
632 }
633
634 fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
635 // Closing the handle is the shutdown: a named pipe has no half-close,
636 // and this direction's end-of-file is precisely what tells the console
637 // host that the terminal is gone. Idempotent, and never blocking; a
638 // write still in flight is cancelled rather than flushed (see
639 // `close_pipe`).
640 self.get_mut().close_pipe();
641 Poll::Ready(Ok(()))
642 }
643}
644
645#[cfg(test)]
646mod behavior_tests {
647 use std::cell::Cell;
648 use std::io;
649 use std::sync::atomic::{AtomicBool, Ordering};
650 use std::sync::Arc;
651
652 use super::{conout_error_as_eof, notify_eof_once, Pty};
653
654 #[test]
655 fn eof_notification_runs_exactly_once() {
656 let mut saw_eof = false;
657 let notifications = Cell::new(0);
658 notify_eof_once(&mut saw_eof, || notifications.set(notifications.get() + 1));
659 notify_eof_once(&mut saw_eof, || notifications.set(notifications.get() + 1));
660 assert!(saw_eof);
661 assert_eq!(notifications.get(), 1);
662 }
663
664 #[test]
665 fn only_disconnect_errors_become_eof() {
666 assert!(conout_error_as_eof(io::Error::new(io::ErrorKind::BrokenPipe, "closed")).is_ok());
667 let err = conout_error_as_eof(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
668 .expect_err("an unrelated I/O failure must not become EOF");
669 assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
670 }
671
672 #[tokio::test]
673 async fn dropping_a_pty_runs_the_conin_cancellation_path() {
674 let mut pty = Pty::builder().build().expect("building must succeed");
675 let closed = Arc::new(AtomicBool::new(false));
676 pty.writer.close_observer = Some(Arc::clone(&closed));
677
678 drop(pty);
679
680 assert!(
681 closed.load(Ordering::SeqCst),
682 "ConinWriter::drop must cancel pending I/O before closing the pipe"
683 );
684 }
685}