conpty_oxide/blocking/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
5//! Managed blocking sessions and root-bounded output collection.
6
7use std::fmt;
8use std::io::{self, Read, Write};
9use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
10use std::thread::{self, JoinHandle};
11use std::time::Duration;
12
13use super::command::Child;
14use super::pty::{OwnedReadHalf, OwnedWriteHalf};
15use crate::error::Result;
16use crate::size::Size;
17use crate::status::ExitStatus;
18use crate::{PtyController, SessionOutput};
19
20/// A managed blocking pseudoconsole session.
21///
22/// Reading and writing delegate to the session's output and input streams.
23/// [`Session::wait`] drains and discards output concurrently, while
24/// [`Session::collect_output`] retains it. Use [`Session::into_parts`] for
25/// interactive or externally coordinated I/O.
26///
27/// Dropping an unfinished managed session closes its kill-on-close Job and
28/// terminates the root process together with every descendant.
29pub struct Session {
30 pub(super) child: Child,
31 pub(super) output: OwnedReadHalf,
32 pub(super) input: OwnedWriteHalf,
33 pub(super) controller: PtyController,
34}
35
36impl fmt::Debug for Session {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.debug_struct("Session")
39 .field("child", &self.child)
40 .field("controller", &self.controller)
41 .finish_non_exhaustive()
42 }
43}
44
45impl Session {
46 pub(super) const fn new(
47 child: Child,
48 output: OwnedReadHalf,
49 input: OwnedWriteHalf,
50 controller: PtyController,
51 ) -> Self {
52 Self {
53 child,
54 output,
55 input,
56 controller,
57 }
58 }
59
60 /// Returns the root process identifier.
61 #[must_use]
62 pub const fn id(&self) -> u32 {
63 self.child.id()
64 }
65
66 /// Returns the exit status when the root process has already exited.
67 ///
68 /// # Errors
69 ///
70 /// Returns an error with [`crate::ErrorKind::Wait`] if Windows cannot
71 /// query the process.
72 pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
73 self.child.try_wait()
74 }
75
76 /// Terminates the root process and every descendant in its Job.
77 ///
78 /// # Errors
79 ///
80 /// Returns an error with [`crate::ErrorKind::Kill`] if Windows cannot
81 /// terminate the Job.
82 pub fn kill(&mut self) -> Result<()> {
83 self.child.kill()
84 }
85
86 /// Resizes the pseudoconsole.
87 ///
88 /// # Errors
89 ///
90 /// Returns an error with [`crate::ErrorKind::Resize`] if the backend
91 /// rejects the size, or an [`std::io::ErrorKind::NotConnected`] source
92 /// after teardown.
93 pub fn resize(&self, size: Size) -> Result<()> {
94 self.controller.resize(size)
95 }
96
97 /// Returns the last successfully applied terminal size.
98 #[must_use]
99 pub fn size(&self) -> Size {
100 self.controller.size()
101 }
102
103 /// Clears the pseudoconsole screen and scrollback.
104 ///
105 /// # Errors
106 ///
107 /// Returns an error with [`crate::ErrorKind::UnsupportedFeature`] when the
108 /// backend has no clear operation, [`crate::ErrorKind::Clear`] on backend
109 /// failure, or an [`std::io::ErrorKind::NotConnected`] source after
110 /// teardown.
111 pub fn clear(&self) -> Result<()> {
112 self.controller.clear()
113 }
114
115 /// Returns whether this backend supports clearing the console.
116 #[must_use]
117 pub fn supports_clear(&self) -> bool {
118 self.controller.supports_clear()
119 }
120
121 /// Waits for the root process while draining and discarding VT output.
122 ///
123 /// Output is drained on a dedicated thread, so a child that writes more
124 /// than the pipe capacity cannot deadlock. Once the root status is saved,
125 /// remaining descendants are terminated and the teardown tail is drained
126 /// to EOF without allocating an output-sized buffer.
127 ///
128 /// Terminal input remains open until the root exits. Closing input is
129 /// session teardown, not an ordinary stdin EOF signal.
130 ///
131 /// # Errors
132 ///
133 /// Returns an error if the reader thread cannot be created, output cannot
134 /// be drained, the root process status cannot be obtained, or the
135 /// remaining process tree cannot be terminated.
136 pub fn wait(self) -> Result<ExitStatus> {
137 Ok(self.complete(false)?.status())
138 }
139
140 /// Waits for the root process while collecting the remaining VT output.
141 ///
142 /// Collection leaves terminal input open until the root exits, so the
143 /// caller must first arrange for the program to finish through its own
144 /// protocol. `ConPTY` has no ordinary stdin half-close: closing its input
145 /// is terminal teardown and could replace the real exit status.
146 ///
147 /// Output is drained on a dedicated thread while the root runs. Once its
148 /// status is captured, any descendants still in the managed Job are
149 /// terminated, terminal input is retired, and the reader drains the
150 /// teardown tail to EOF. This gives released and legacy `ConPTY` backends
151 /// the same finite, root-bounded completion rule.
152 ///
153 /// Bytes already read from this `Session` are not included.
154 ///
155 /// Collection is unbounded and may allocate as much memory as the child
156 /// writes. Use [`Session::wait`] when output is unnecessary, or
157 /// [`Session::into_parts`] to process it as a stream.
158 ///
159 /// # Errors
160 ///
161 /// Returns an error if the reader thread cannot be created, output cannot
162 /// be drained, the root process status cannot be obtained, or the
163 /// remaining process tree cannot be terminated.
164 pub fn collect_output(self) -> Result<SessionOutput> {
165 self.complete(true)
166 }
167
168 fn complete(self, collect: bool) -> Result<SessionOutput> {
169 let (completed_tx, completed_rx) = mpsc::sync_channel(1);
170 let mut output = self.output;
171 let reader = thread::Builder::new()
172 .name("conpty-oxide-output".into())
173 .spawn(move || {
174 let result = if collect {
175 let mut bytes = Vec::new();
176 output.read_to_end(&mut bytes).map(|_| bytes)
177 } else {
178 io::copy(&mut output, &mut io::sink()).map(|_| Vec::new())
179 };
180 match completed_tx.send(()) {
181 Ok(()) | Err(_) => {},
182 }
183 result
184 })?;
185
186 BlockingCollector {
187 child: Some(self.child),
188 input: Some(self.input),
189 controller: Some(self.controller),
190 reader: Some(reader),
191 completed: completed_rx,
192 }
193 .finish()
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
211/// Owns an in-progress blocking collection in teardown-safe field order.
212///
213/// If collection unwinds, the child and its kill-on-close Job are dropped
214/// before terminal input. That preserves the root status whenever possible
215/// and prevents the reader worker from being left behind with a live tree.
216struct BlockingCollector {
217 child: Option<Child>,
218 input: Option<OwnedWriteHalf>,
219 controller: Option<PtyController>,
220 reader: Option<JoinHandle<io::Result<Vec<u8>>>>,
221 completed: Receiver<()>,
222}
223
224impl BlockingCollector {
225 fn finish(mut self) -> Result<SessionOutput> {
226 let mut output = None;
227
228 let status = loop {
229 let child = self
230 .child
231 .as_mut()
232 .ok_or_else(|| io::Error::other("the collection child was already retired"))?;
233 match child.try_wait() {
234 Ok(Some(status)) => break Some(Ok(status)),
235 Ok(None) => {},
236 Err(err) => break Some(Err(err)),
237 }
238
239 match self.completed.recv_timeout(Duration::from_millis(10)) {
240 Ok(()) | Err(RecvTimeoutError::Disconnected) => {
241 output = Some(self.join_reader());
242 break match output.as_ref() {
243 Some(Ok(_bytes)) => Some(
244 self.child
245 .as_mut()
246 .ok_or_else(|| {
247 io::Error::other("the collection child was already retired")
248 })?
249 .wait(),
250 ),
251 Some(Err(_reader_error)) => None,
252 None => {
253 return Err(io::Error::other(
254 "the output reader completed without a result",
255 )
256 .into());
257 },
258 };
259 },
260 Err(RecvTimeoutError::Timeout) => {},
261 }
262 };
263
264 // The root status is cached before this call on the success path, so
265 // terminating the Job now affects only descendants that outlived it.
266 let kill = self
267 .child
268 .as_mut()
269 .ok_or_else(|| io::Error::other("the collection child was already retired"))?
270 .kill();
271 // Drop the Job before retiring input or joining the reader. If the
272 // explicit termination failed, kill-on-close gets one last chance to
273 // remove a descendant that would otherwise keep released ConPTY open.
274 drop(self.child.take());
275 drop(self.input.take());
276 drop(self.controller.take());
277
278 let bytes = output.unwrap_or_else(|| self.join_reader())?;
279 let status = status
280 .ok_or_else(|| io::Error::other("output collection ended without a root status"))??;
281 kill?;
282 Ok(SessionOutput::new(status, bytes))
283 }
284
285 fn join_reader(&mut self) -> io::Result<Vec<u8>> {
286 self.reader
287 .take()
288 .ok_or_else(|| io::Error::other("the output reader was already joined"))?
289 .join()
290 .map_err(|_panic_payload| io::Error::other("the output reader thread panicked"))?
291 }
292}
293
294impl Read for Session {
295 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
296 self.output.read(buf)
297 }
298}
299
300impl Write for Session {
301 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
302 self.input.write(buf)
303 }
304
305 fn flush(&mut self) -> io::Result<()> {
306 Ok(())
307 }
308}
309
310/// Independently owned parts of a managed blocking session.
311#[non_exhaustive]
312pub struct SessionParts {
313 /// Root process and kill-on-drop Job ownership.
314 pub child: Child,
315 /// Rendered virtual-terminal output.
316 pub output: OwnedReadHalf,
317 /// Console input.
318 pub input: OwnedWriteHalf,
319 /// Cloneable resize/clear/capability control.
320 pub controller: PtyController,
321}
322
323impl fmt::Debug for SessionParts {
324 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325 f.debug_struct("SessionParts")
326 .field("child", &self.child)
327 .field("controller", &self.controller)
328 .finish_non_exhaustive()
329 }
330}