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 /// # Examples
160 ///
161 /// ```no_run
162 /// use conpty_oxide::blocking::Command;
163 ///
164 /// # fn main() -> conpty_oxide::Result<()> {
165 /// let output = Command::new("cmd.exe")
166 /// .args(["/d", "/c", "echo", "hello"])
167 /// .spawn()?
168 /// .collect_output()?;
169 /// assert!(output.status().success());
170 /// print!("{}", String::from_utf8_lossy(output.as_bytes()));
171 /// # Ok(())
172 /// # }
173 /// ```
174 ///
175 /// # Errors
176 ///
177 /// Returns an error if the reader thread cannot be created, output cannot
178 /// be drained, the root process status cannot be obtained, or the
179 /// remaining process tree cannot be terminated.
180 pub fn collect_output(self) -> Result<SessionOutput> {
181 self.complete(true)
182 }
183
184 fn complete(self, collect: bool) -> Result<SessionOutput> {
185 let (completed_tx, completed_rx) = mpsc::sync_channel(1);
186 let mut output = self.output;
187 let reader = thread::Builder::new()
188 .name("conpty-oxide-output".into())
189 .spawn(move || {
190 let result = if collect {
191 let mut bytes = Vec::new();
192 output.read_to_end(&mut bytes).map(|_| bytes)
193 } else {
194 io::copy(&mut output, &mut io::sink()).map(|_| Vec::new())
195 };
196 match completed_tx.send(()) {
197 Ok(()) | Err(_) => {},
198 }
199 result
200 })?;
201
202 BlockingCollector {
203 child: Some(self.child),
204 input: Some(self.input),
205 controller: Some(self.controller),
206 reader: Some(reader),
207 completed: completed_rx,
208 }
209 .finish()
210 }
211
212 /// Decomposes this session for interactive or externally coordinated I/O.
213 ///
214 /// Splitting changes ownership only: root exit still terminates remaining
215 /// descendants and advances output to EOF. It does not detach the session.
216 #[must_use]
217 pub fn into_parts(self) -> SessionParts {
218 SessionParts {
219 child: self.child,
220 output: self.output,
221 input: self.input,
222 controller: self.controller,
223 }
224 }
225}
226
227/// Owns an in-progress blocking collection in teardown-safe field order.
228///
229/// If collection unwinds, the child and its kill-on-close Job are dropped
230/// before terminal input. That preserves the root status whenever possible
231/// and prevents the reader worker from being left behind with a live tree.
232struct BlockingCollector {
233 child: Option<Child>,
234 input: Option<OwnedWriteHalf>,
235 controller: Option<PtyController>,
236 reader: Option<JoinHandle<io::Result<Vec<u8>>>>,
237 completed: Receiver<()>,
238}
239
240impl BlockingCollector {
241 fn finish(mut self) -> Result<SessionOutput> {
242 let mut output = None;
243
244 let status = loop {
245 let child = self
246 .child
247 .as_mut()
248 .ok_or_else(|| io::Error::other("the collection child was already retired"))?;
249 match child.try_wait() {
250 Ok(Some(status)) => break Some(Ok(status)),
251 Ok(None) => {},
252 Err(err) => break Some(Err(err)),
253 }
254
255 match self.completed.recv_timeout(Duration::from_millis(10)) {
256 Ok(()) | Err(RecvTimeoutError::Disconnected) => {
257 output = Some(self.join_reader());
258 break match output.as_ref() {
259 Some(Ok(_bytes)) => Some(
260 self.child
261 .as_mut()
262 .ok_or_else(|| {
263 io::Error::other("the collection child was already retired")
264 })?
265 .wait(),
266 ),
267 Some(Err(_reader_error)) => None,
268 None => {
269 return Err(io::Error::other(
270 "the output reader completed without a result",
271 )
272 .into());
273 },
274 };
275 },
276 Err(RecvTimeoutError::Timeout) => {},
277 }
278 };
279
280 // The root status is cached before this call on the success path, so
281 // terminating the Job now affects only descendants that outlived it.
282 let kill = self
283 .child
284 .as_mut()
285 .ok_or_else(|| io::Error::other("the collection child was already retired"))?
286 .kill();
287 // Drop the Job before retiring input or joining the reader. If the
288 // explicit termination failed, kill-on-close gets one last chance to
289 // remove a descendant that would otherwise keep released ConPTY open.
290 drop(self.child.take());
291 drop(self.input.take());
292 drop(self.controller.take());
293
294 let bytes = output.unwrap_or_else(|| self.join_reader())?;
295 let status = status
296 .ok_or_else(|| io::Error::other("output collection ended without a root status"))??;
297 kill?;
298 Ok(SessionOutput::new(status, bytes))
299 }
300
301 fn join_reader(&mut self) -> io::Result<Vec<u8>> {
302 self.reader
303 .take()
304 .ok_or_else(|| io::Error::other("the output reader was already joined"))?
305 .join()
306 .map_err(|_panic_payload| io::Error::other("the output reader thread panicked"))?
307 }
308}
309
310impl Read for Session {
311 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
312 self.output.read(buf)
313 }
314}
315
316impl Write for Session {
317 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
318 self.input.write(buf)
319 }
320
321 fn flush(&mut self) -> io::Result<()> {
322 Ok(())
323 }
324}
325
326/// Independently owned parts of a managed blocking session.
327#[non_exhaustive]
328pub struct SessionParts {
329 /// Root process and kill-on-drop Job ownership.
330 pub child: Child,
331 /// Rendered virtual-terminal output.
332 pub output: OwnedReadHalf,
333 /// Console input.
334 pub input: OwnedWriteHalf,
335 /// Cloneable resize/clear/capability control.
336 pub controller: PtyController,
337}
338
339impl fmt::Debug for SessionParts {
340 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341 f.debug_struct("SessionParts")
342 .field("child", &self.child)
343 .field("controller", &self.controller)
344 .finish_non_exhaustive()
345 }
346}