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}