Skip to main content

conpty_oxide/tokio/
session.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::future::Future;
7use std::io;
8use std::pin::Pin;
9use std::task::{Context, Poll};
10
11use ::tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
12
13use crate::error::Result;
14use crate::size::Size;
15use crate::status::ExitStatus;
16use crate::{PtyController, SessionOutput};
17
18use super::{Child, OwnedReadHalf, OwnedWriteHalf};
19
20/// A managed asynchronous pseudoconsole session.
21///
22/// [`Session::wait`] drains and discards output concurrently, while
23/// [`Session::collect_output`] retains it. Use [`Session::into_parts`] for
24/// interactive or externally coordinated I/O.
25///
26/// The managed child always has kill-on-drop and Job kill-on-close enabled.
27/// Dropping an unfinished `Session` therefore terminates the root process and
28/// every descendant. The same guarantee applies when a future that owns the
29/// session—most notably [`Session::wait`] or [`Session::collect_output`]—is cancelled.
30pub struct Session {
31    pub(super) child: Child,
32    pub(super) output: OwnedReadHalf,
33    pub(super) input: OwnedWriteHalf,
34    pub(super) controller: PtyController,
35}
36
37impl fmt::Debug for Session {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.debug_struct("Session")
40            .field("child", &self.child)
41            .field("controller", &self.controller)
42            .finish_non_exhaustive()
43    }
44}
45
46impl Session {
47    /// Returns the root process identifier.
48    #[must_use]
49    pub const fn id(&self) -> u32 {
50        self.child.id()
51    }
52
53    /// Returns the exit status when the root process has already exited.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error with [`crate::ErrorKind::Wait`] if Windows cannot
58    /// query the process.
59    pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
60        self.child.try_wait()
61    }
62
63    /// Terminates the root process and every descendant in its Job.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error with [`crate::ErrorKind::Kill`] if Windows cannot
68    /// terminate the Job.
69    pub fn kill(&mut self) -> Result<()> {
70        self.child.kill()
71    }
72
73    /// Resizes the pseudoconsole.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error with [`crate::ErrorKind::Resize`] if the backend
78    /// rejects the size, or an [`std::io::ErrorKind::NotConnected`] source
79    /// after teardown.
80    pub fn resize(&self, size: Size) -> Result<()> {
81        self.controller.resize(size)
82    }
83
84    /// Returns the last successfully applied terminal size.
85    #[must_use]
86    pub fn size(&self) -> Size {
87        self.controller.size()
88    }
89
90    /// Clears the pseudoconsole screen and scrollback.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error with [`crate::ErrorKind::UnsupportedFeature`] when the
95    /// backend has no clear operation, [`crate::ErrorKind::Clear`] on backend
96    /// failure, or an [`std::io::ErrorKind::NotConnected`] source after
97    /// teardown.
98    pub fn clear(&self) -> Result<()> {
99        self.controller.clear()
100    }
101
102    /// Returns whether this backend supports clearing the console.
103    #[must_use]
104    pub fn supports_clear(&self) -> bool {
105        self.controller.supports_clear()
106    }
107    /// Waits for the root process while draining and discarding VT output.
108    ///
109    /// Output and the root wait are polled concurrently. Once the root status
110    /// is saved, remaining descendants are terminated and the teardown tail is
111    /// drained to EOF without allocating an output-sized buffer.
112    ///
113    /// Terminal input remains open until the root exits. Input shutdown is
114    /// session teardown, not an ordinary stdin EOF signal.
115    ///
116    /// # Cancel safety
117    ///
118    /// The future owns the session. Cancelling it drops the managed child and
119    /// terminates the process tree.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if output cannot be drained, the root process status
124    /// cannot be obtained, or the remaining process tree cannot be
125    /// terminated.
126    pub async fn wait(self) -> Result<ExitStatus> {
127        Ok(self.complete(false).await?.status())
128    }
129
130    /// Waits for the root process while collecting the remaining VT output.
131    ///
132    /// Collection leaves terminal input open until the root exits, so the
133    /// caller must first arrange for the program to finish through its own
134    /// protocol. `ConPTY` has no ordinary stdin half-close: closing its input
135    /// is terminal teardown and could replace the real exit status.
136    ///
137    /// Output and the root wait are polled concurrently. Once the root status
138    /// is captured, any descendants still in the managed Job are terminated,
139    /// terminal input is retired, and the reader drains the teardown tail to
140    /// EOF. This gives released and legacy `ConPTY` backends the same finite,
141    /// root-bounded completion rule.
142    ///
143    /// Bytes already read from this `Session` are not included.
144    ///
145    /// Collection is unbounded and may allocate as much memory as the child
146    /// writes. Use [`Session::wait`] when output is unnecessary, or
147    /// [`Session::into_parts`] to process it as a stream.
148    ///
149    /// # Cancel safety
150    ///
151    /// The future owns the whole session. Cancelling it drops the managed
152    /// child first and terminates the process tree.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if output cannot be drained, the root process status
157    /// cannot be obtained, or the remaining process tree cannot be
158    /// terminated.
159    pub async fn collect_output(self) -> Result<SessionOutput> {
160        self.complete(true).await
161    }
162
163    async fn complete(mut self, collect: bool) -> Result<SessionOutput> {
164        let mut bytes = Vec::new();
165        let (status, output_finished) =
166            collect_until_root(&mut self.child, &mut self.output, &mut bytes, collect).await?;
167
168        // The root status is cached before this call, so terminating the Job
169        // now affects only descendants that outlived it.
170        let kill = self.child.kill();
171        let Self {
172            child,
173            mut output,
174            mut input,
175            controller,
176        } = self;
177        // Closing the last Job handle is the fallback if explicit termination
178        // failed. It must happen before input retirement and tail draining so
179        // a released console cannot remain held by a descendant.
180        drop(child);
181        let input_result = std::future::poll_fn(|cx| Pin::new(&mut input).poll_shutdown(cx)).await;
182        drop(input);
183        drop(controller);
184
185        let output_result = if output_finished {
186            Ok(())
187        } else {
188            drain_to_end(&mut output, &mut bytes, collect).await
189        };
190        output_result?;
191        input_result?;
192        kill?;
193        Ok(SessionOutput::new(status, bytes))
194    }
195
196    /// Decomposes this session for interactive or externally coordinated I/O.
197    ///
198    /// Splitting changes ownership only: root exit still terminates remaining
199    /// descendants and advances output to EOF. It does not detach the session.
200    #[must_use]
201    pub fn into_parts(self) -> SessionParts {
202        SessionParts {
203            child: self.child,
204            output: self.output,
205            input: self.input,
206            controller: self.controller,
207        }
208    }
209}
210
211enum CollectionEvent {
212    Root(Result<ExitStatus>),
213    Output(io::Result<usize>),
214}
215
216async fn collect_until_root(
217    child: &mut Child,
218    output: &mut OwnedReadHalf,
219    bytes: &mut Vec<u8>,
220    collect: bool,
221) -> Result<(ExitStatus, bool)> {
222    // Keep the returned public future small enough to live comfortably in an
223    // executor task. This fixed buffer is backpressure plumbing, not a
224    // collection limit.
225    let mut chunk = [0_u8; 4096];
226    let mut output_finished = false;
227    let mut wait = std::pin::pin!(child.wait());
228
229    let status = loop {
230        let event = std::future::poll_fn(|cx| {
231            if let Poll::Ready(status) = wait.as_mut().poll(cx) {
232                return Poll::Ready(CollectionEvent::Root(status));
233            }
234            if output_finished {
235                return Poll::Pending;
236            }
237
238            let mut read_buf = ReadBuf::new(&mut chunk);
239            match Pin::new(&mut *output).poll_read(cx, &mut read_buf) {
240                Poll::Ready(Ok(())) => {
241                    Poll::Ready(CollectionEvent::Output(Ok(read_buf.filled().len())))
242                },
243                Poll::Ready(Err(err)) => Poll::Ready(CollectionEvent::Output(Err(err))),
244                Poll::Pending => Poll::Pending,
245            }
246        })
247        .await;
248
249        match event {
250            CollectionEvent::Root(status) => break status?,
251            CollectionEvent::Output(Ok(0)) => output_finished = true,
252            CollectionEvent::Output(Ok(read)) => {
253                append_collected(bytes, &chunk[..read], collect);
254            },
255            CollectionEvent::Output(Err(err)) => return Err(err.into()),
256        }
257    };
258
259    Ok((status, output_finished))
260}
261
262async fn drain_to_end(
263    output: &mut OwnedReadHalf,
264    bytes: &mut Vec<u8>,
265    collect: bool,
266) -> io::Result<()> {
267    let mut chunk = [0_u8; 4096];
268    loop {
269        let read = std::future::poll_fn(|cx| {
270            let mut read_buf = ReadBuf::new(&mut chunk);
271            match Pin::new(&mut *output).poll_read(cx, &mut read_buf) {
272                Poll::Ready(Ok(())) => Poll::Ready(Ok(read_buf.filled().len())),
273                Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
274                Poll::Pending => Poll::Pending,
275            }
276        })
277        .await?;
278        if read == 0 {
279            return Ok(());
280        }
281        append_collected(bytes, &chunk[..read], collect);
282    }
283}
284
285fn append_collected(bytes: &mut Vec<u8>, chunk: &[u8], collect: bool) {
286    if collect {
287        bytes.extend_from_slice(chunk);
288    }
289}
290
291impl AsyncRead for Session {
292    fn poll_read(
293        self: Pin<&mut Self>,
294        cx: &mut Context<'_>,
295        buf: &mut ReadBuf<'_>,
296    ) -> Poll<io::Result<()>> {
297        Pin::new(&mut self.get_mut().output).poll_read(cx, buf)
298    }
299}
300
301impl AsyncWrite for Session {
302    fn poll_write(
303        self: Pin<&mut Self>,
304        cx: &mut Context<'_>,
305        buf: &[u8],
306    ) -> Poll<io::Result<usize>> {
307        Pin::new(&mut self.get_mut().input).poll_write(cx, buf)
308    }
309
310    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
311        Poll::Ready(Ok(()))
312    }
313
314    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
315        Pin::new(&mut self.get_mut().input).poll_shutdown(cx)
316    }
317}
318
319/// Independently owned parts of a managed asynchronous session.
320#[non_exhaustive]
321pub struct SessionParts {
322    /// Root process and kill-on-drop Job ownership.
323    pub child: Child,
324    /// Rendered virtual-terminal output.
325    pub output: OwnedReadHalf,
326    /// Console input.
327    pub input: OwnedWriteHalf,
328    /// Cloneable resize/clear/capability control.
329    pub controller: PtyController,
330}
331
332impl fmt::Debug for SessionParts {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        f.debug_struct("SessionParts")
335            .field("child", &self.child)
336            .field("controller", &self.controller)
337            .finish_non_exhaustive()
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::append_collected;
344
345    #[test]
346    fn discarded_chunks_do_not_grow_the_collection_buffer() {
347        let mut bytes = vec![1, 2, 3];
348        let initial_capacity = bytes.capacity();
349
350        append_collected(&mut bytes, &[4, 5, 6], false);
351        assert_eq!(bytes, [1, 2, 3]);
352        assert_eq!(bytes.capacity(), initial_capacity);
353
354        append_collected(&mut bytes, &[4, 5, 6], true);
355        assert_eq!(bytes, [1, 2, 3, 4, 5, 6]);
356    }
357}