Skip to main content

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}