Skip to main content

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}