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    /// # 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}