Skip to main content

codex_utils_pty/
process.rs

1use core::fmt;
2use std::io;
3#[cfg(unix)]
4use std::os::fd::RawFd;
5use std::process::ExitStatus;
6use std::sync::Arc;
7use std::sync::Mutex as StdMutex;
8use std::sync::atomic::AtomicBool;
9
10use anyhow::anyhow;
11use portable_pty::MasterPty;
12use portable_pty::PtySize;
13use portable_pty::SlavePty;
14use tokio::sync::broadcast;
15use tokio::sync::mpsc;
16use tokio::sync::oneshot;
17use tokio::sync::watch;
18use tokio::task::AbortHandle;
19use tokio::task::JoinHandle;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum ProcessSignal {
23    Interrupt,
24}
25
26pub(crate) fn unsupported_signal(signal: ProcessSignal) -> io::Error {
27    match signal {
28        ProcessSignal::Interrupt => io::Error::new(
29            io::ErrorKind::Unsupported,
30            "process interrupt is not supported by this process backend",
31        ),
32    }
33}
34
35pub(crate) fn exit_code_from_status(status: ExitStatus) -> i32 {
36    if let Some(code) = status.code() {
37        return code;
38    }
39
40    #[cfg(unix)]
41    {
42        use std::os::unix::process::ExitStatusExt;
43        if let Some(signal) = status.signal() {
44            return 128 + signal;
45        }
46    }
47
48    -1
49}
50
51pub(crate) trait ChildTerminator: Send + Sync {
52    fn signal(&mut self, signal: ProcessSignal) -> io::Result<()>;
53
54    fn kill(&mut self) -> io::Result<()>;
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct TerminalSize {
59    pub rows: u16,
60    pub cols: u16,
61}
62
63impl Default for TerminalSize {
64    fn default() -> Self {
65        Self { rows: 24, cols: 80 }
66    }
67}
68
69impl From<TerminalSize> for PtySize {
70    fn from(value: TerminalSize) -> Self {
71        Self {
72            rows: value.rows,
73            cols: value.cols,
74            pixel_width: 0,
75            pixel_height: 0,
76        }
77    }
78}
79
80#[cfg(unix)]
81pub(crate) trait PtyHandleKeepAlive: Send {}
82
83#[cfg(unix)]
84impl<T: Send + ?Sized> PtyHandleKeepAlive for T {}
85
86pub(crate) enum PtyMasterHandle {
87    Resizable(Box<dyn MasterPty + Send>),
88    #[cfg(unix)]
89    Opaque {
90        raw_fd: RawFd,
91        _handle: Box<dyn PtyHandleKeepAlive>,
92    },
93}
94
95pub struct PtyHandles {
96    pub _slave: Option<Box<dyn SlavePty + Send>>,
97    pub(crate) _master: PtyMasterHandle,
98}
99
100impl fmt::Debug for PtyHandles {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.debug_struct("PtyHandles").finish()
103    }
104}
105
106/// Callback used by driver-backed sessions to resize a PTY-like backend when
107/// there is no local `PtyHandles` instance to resize directly.
108type ResizeFn = Box<dyn FnMut(TerminalSize) -> anyhow::Result<()> + Send>;
109
110/// Handle for driving an interactive process (PTY or pipe).
111pub struct ProcessHandle {
112    writer_tx: StdMutex<Option<mpsc::Sender<Vec<u8>>>>,
113    killer: StdMutex<Option<Box<dyn ChildTerminator>>>,
114    reader_handle: StdMutex<Option<JoinHandle<()>>>,
115    reader_abort_handles: StdMutex<Vec<AbortHandle>>,
116    writer_handle: StdMutex<Option<JoinHandle<()>>>,
117    wait_handle: StdMutex<Option<JoinHandle<()>>>,
118    exit_status: Arc<AtomicBool>,
119    exit_code: Arc<StdMutex<Option<i32>>>,
120    // PtyHandles must be preserved because the process will receive Control+C if the
121    // slave is closed
122    _pty_handles: StdMutex<Option<PtyHandles>>,
123    // Optional resize hook for driver-backed sessions that proxy PTY control to
124    // another backend instead of owning local PTY handles.
125    resizer: StdMutex<Option<ResizeFn>>,
126}
127
128impl fmt::Debug for ProcessHandle {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.debug_struct("ProcessHandle").finish()
131    }
132}
133
134impl ProcessHandle {
135    #[allow(clippy::too_many_arguments)]
136    pub(crate) fn new(
137        writer_tx: mpsc::Sender<Vec<u8>>,
138        killer: Box<dyn ChildTerminator>,
139        reader_handle: JoinHandle<()>,
140        reader_abort_handles: Vec<AbortHandle>,
141        writer_handle: JoinHandle<()>,
142        wait_handle: JoinHandle<()>,
143        exit_status: Arc<AtomicBool>,
144        exit_code: Arc<StdMutex<Option<i32>>>,
145        pty_handles: Option<PtyHandles>,
146        resizer: Option<ResizeFn>,
147    ) -> Self {
148        Self {
149            writer_tx: StdMutex::new(Some(writer_tx)),
150            killer: StdMutex::new(Some(killer)),
151            reader_handle: StdMutex::new(Some(reader_handle)),
152            reader_abort_handles: StdMutex::new(reader_abort_handles),
153            writer_handle: StdMutex::new(Some(writer_handle)),
154            wait_handle: StdMutex::new(Some(wait_handle)),
155            exit_status,
156            exit_code,
157            _pty_handles: StdMutex::new(pty_handles),
158            resizer: StdMutex::new(resizer),
159        }
160    }
161
162    /// Returns a channel sender for writing raw bytes to the child stdin.
163    pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
164        if let Ok(writer_tx) = self.writer_tx.lock()
165            && let Some(writer_tx) = writer_tx.as_ref()
166        {
167            return writer_tx.clone();
168        }
169
170        let (writer_tx, writer_rx) = mpsc::channel(1);
171        drop(writer_rx);
172        writer_tx
173    }
174
175    /// True if the child process has exited.
176    pub fn has_exited(&self) -> bool {
177        self.exit_status.load(std::sync::atomic::Ordering::SeqCst)
178    }
179
180    /// Returns the exit code if known.
181    pub fn exit_code(&self) -> Option<i32> {
182        self.exit_code.lock().ok().and_then(|guard| *guard)
183    }
184
185    /// Resize the PTY in character cells.
186    pub fn resize(&self, size: TerminalSize) -> anyhow::Result<()> {
187        {
188            let handles = self
189                ._pty_handles
190                .lock()
191                .map_err(|_| anyhow!("failed to lock PTY handles"))?;
192            if let Some(handles) = handles.as_ref() {
193                return match &handles._master {
194                    PtyMasterHandle::Resizable(master) => master.resize(size.into()),
195                    #[cfg(unix)]
196                    PtyMasterHandle::Opaque { raw_fd, .. } => resize_raw_pty(*raw_fd, size),
197                };
198            }
199        }
200
201        let mut resizer = self
202            .resizer
203            .lock()
204            .map_err(|_| anyhow!("failed to lock PTY resizer"))?;
205        if let Some(resizer) = resizer.as_mut() {
206            resizer(size)
207        } else {
208            Err(anyhow!("process is not attached to a PTY"))
209        }
210    }
211
212    /// Close the child's stdin channel.
213    pub fn close_stdin(&self) {
214        if let Ok(mut writer_tx) = self.writer_tx.lock() {
215            writer_tx.take();
216        }
217    }
218
219    /// Attempts to kill the child while leaving the reader/writer tasks alive
220    /// so callers can still drain output until EOF.
221    pub fn request_terminate(&self) {
222        if let Ok(mut killer_opt) = self.killer.lock()
223            && let Some(mut killer) = killer_opt.take()
224        {
225            let _ = killer.kill();
226        }
227    }
228
229    pub fn signal(&self, signal: ProcessSignal) -> io::Result<()> {
230        let Ok(mut killer_opt) = self.killer.lock() else {
231            return Ok(());
232        };
233        let Some(killer) = killer_opt.as_mut() else {
234            return Ok(());
235        };
236
237        killer.signal(signal)
238    }
239
240    /// Attempts to kill the child and abort helper tasks.
241    pub fn terminate(&self) {
242        self.request_terminate();
243
244        if let Ok(mut h) = self.reader_handle.lock()
245            && let Some(handle) = h.take()
246        {
247            handle.abort();
248        }
249        if let Ok(mut handles) = self.reader_abort_handles.lock() {
250            for handle in handles.drain(..) {
251                handle.abort();
252            }
253        }
254        if let Ok(mut h) = self.writer_handle.lock()
255            && let Some(handle) = h.take()
256        {
257            handle.abort();
258        }
259        if let Ok(mut h) = self.wait_handle.lock()
260            && let Some(handle) = h.take()
261        {
262            handle.abort();
263        }
264    }
265}
266
267impl Drop for ProcessHandle {
268    fn drop(&mut self) {
269        self.terminate();
270    }
271}
272
273/// Adapts a closure into a `ChildTerminator` implementation.
274struct ClosureTerminator {
275    inner: Option<Box<dyn FnMut() + Send + Sync>>,
276}
277
278impl ChildTerminator for ClosureTerminator {
279    fn signal(&mut self, signal: ProcessSignal) -> io::Result<()> {
280        Err(unsupported_signal(signal))
281    }
282
283    fn kill(&mut self) -> io::Result<()> {
284        if let Some(inner) = self.inner.as_mut() {
285            (inner)();
286        }
287        Ok(())
288    }
289}
290
291#[cfg(unix)]
292fn resize_raw_pty(raw_fd: RawFd, size: TerminalSize) -> anyhow::Result<()> {
293    let mut winsize = libc::winsize {
294        ws_row: size.rows,
295        ws_col: size.cols,
296        ws_xpixel: 0,
297        ws_ypixel: 0,
298    };
299    let result = unsafe { libc::ioctl(raw_fd, libc::TIOCSWINSZ, &mut winsize) };
300    if result == -1 {
301        return Err(std::io::Error::last_os_error().into());
302    }
303    Ok(())
304}
305
306/// Combine split stdout/stderr receivers into a single broadcast receiver.
307pub fn combine_output_receivers(
308    mut stdout_rx: mpsc::Receiver<Vec<u8>>,
309    mut stderr_rx: mpsc::Receiver<Vec<u8>>,
310) -> broadcast::Receiver<Vec<u8>> {
311    let (combined_tx, combined_rx) = broadcast::channel(256);
312    tokio::spawn(async move {
313        let mut stdout_open = true;
314        let mut stderr_open = true;
315
316        loop {
317            tokio::select! {
318                stdout = stdout_rx.recv(), if stdout_open => match stdout {
319                    Some(chunk) => {
320                        let _ = combined_tx.send(chunk);
321                    }
322                    None => {
323                        stdout_open = false;
324                    }
325                },
326                stderr = stderr_rx.recv(), if stderr_open => match stderr {
327                    Some(chunk) => {
328                        let _ = combined_tx.send(chunk);
329                    }
330                    None => {
331                        stderr_open = false;
332                    }
333                },
334                else => break,
335            }
336        }
337    });
338    combined_rx
339}
340
341/// Return value from PTY or pipe spawn helpers.
342#[derive(Debug)]
343pub struct SpawnedProcess {
344    pub session: ProcessHandle,
345    pub stdout_rx: mpsc::Receiver<Vec<u8>>,
346    pub stderr_rx: mpsc::Receiver<Vec<u8>>,
347    pub exit_rx: oneshot::Receiver<i32>,
348}
349
350/// Driver-backed process handles for non-standard spawn backends.
351pub struct ProcessDriver {
352    pub writer_tx: mpsc::Sender<Vec<u8>>,
353    pub stdout_rx: broadcast::Receiver<Vec<u8>>,
354    pub stderr_rx: Option<broadcast::Receiver<Vec<u8>>>,
355    pub exit_rx: oneshot::Receiver<i32>,
356    pub terminator: Option<Box<dyn FnMut() + Send + Sync>>,
357    pub writer_handle: Option<JoinHandle<()>>,
358    pub resizer: Option<ResizeFn>,
359}
360
361/// Build a `SpawnedProcess` from a driver that supplies stdin/output/exit channels.
362pub fn spawn_from_driver(driver: ProcessDriver) -> SpawnedProcess {
363    let ProcessDriver {
364        writer_tx,
365        stdout_rx: stdout_driver_rx,
366        stderr_rx: mut stderr_driver_rx,
367        exit_rx,
368        terminator,
369        writer_handle,
370        resizer,
371    } = driver;
372
373    let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>(256);
374    let (stderr_tx, stderr_rx) = mpsc::channel::<Vec<u8>>(256);
375    let (exit_seen_tx, exit_seen_rx) = watch::channel(false);
376    let spawn_stream_reader =
377        |mut output_rx: broadcast::Receiver<Vec<u8>>,
378         output_tx: mpsc::Sender<Vec<u8>>,
379         mut exit_seen_rx: watch::Receiver<bool>| {
380            tokio::spawn(async move {
381                loop {
382                    let recv_result = if *exit_seen_rx.borrow() {
383                        // Once exit has been observed, we no longer want a timer here. Some
384                        // backends publish the exit code before their final stdout/stderr bytes
385                        // have been forwarded through the broadcast channel, so a fixed grace
386                        // period can still drop the tail of the stream under load.
387                        //
388                        // Instead, keep waiting until the driver closes the broadcast sender.
389                        // That makes the shutdown contract explicit: the backend is responsible
390                        // for dropping its sender when it has truly finished forwarding output.
391                        output_rx.recv().await
392                    } else {
393                        tokio::select! {
394                            _ = exit_seen_rx.changed() => {
395                                continue;
396                            }
397                            result = output_rx.recv() => result,
398                        }
399                    };
400                    match recv_result {
401                        Ok(chunk) => {
402                            if output_tx.send(chunk).await.is_err() {
403                                break;
404                            }
405                        }
406                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
407                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
408                    }
409                }
410            })
411        };
412    let reader_handle = spawn_stream_reader(stdout_driver_rx, stdout_tx, exit_seen_rx.clone());
413    let stderr_reader_handle = stderr_driver_rx
414        .take()
415        .map(|rx| spawn_stream_reader(rx, stderr_tx, exit_seen_rx));
416
417    let writer_handle = writer_handle.unwrap_or_else(|| tokio::spawn(async {}));
418
419    let (exit_tx, exit_rx_out) = oneshot::channel::<i32>();
420    let exit_status = Arc::new(AtomicBool::new(false));
421    let wait_exit_status = Arc::clone(&exit_status);
422    let exit_code = Arc::new(StdMutex::new(None));
423    let wait_exit_code = Arc::clone(&exit_code);
424    let wait_handle = tokio::spawn(async move {
425        let code = exit_rx.await.unwrap_or(-1);
426        wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
427        if let Ok(mut guard) = wait_exit_code.lock() {
428            *guard = Some(code);
429        }
430        let _ = exit_seen_tx.send(true);
431        let _ = exit_tx.send(code);
432    });
433
434    let handle = ProcessHandle::new(
435        writer_tx,
436        Box::new(ClosureTerminator { inner: terminator }),
437        reader_handle,
438        stderr_reader_handle
439            .map(|handle| handle.abort_handle())
440            .into_iter()
441            .collect(),
442        writer_handle,
443        wait_handle,
444        exit_status,
445        exit_code,
446        /*pty_handles*/ None,
447        resizer,
448    );
449
450    SpawnedProcess {
451        session: handle,
452        stdout_rx,
453        stderr_rx,
454        exit_rx: exit_rx_out,
455    }
456}