Skip to main content

tui_lipan/widgets/terminal/
pty.rs

1#![allow(unsafe_code)]
2
3use std::io::{Read, Write};
4use std::sync::{
5    Arc, Condvar, Mutex,
6    atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering},
7};
8use std::thread::JoinHandle;
9use std::time::Duration;
10
11#[cfg(unix)]
12use std::fs::File;
13#[cfg(unix)]
14use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
15
16use portable_pty::{CommandBuilder, PtySize, native_pty_system};
17
18use super::events::{TerminalKeyModes, key_event_to_bytes};
19use super::screen::TerminalCellSize;
20use crate::core::event::KeyEvent;
21
22/// PTY launch error.
23#[derive(thiserror::Error, Debug)]
24pub enum TerminalPtyError {
25    /// Could not initialize PTY pair.
26    #[error("pty initialization failed: {0}")]
27    Setup(String),
28    /// Could not clone master reader.
29    #[error("failed to clone pty reader: {0}")]
30    Reader(String),
31    /// Could not acquire writer.
32    #[error("failed to acquire pty writer: {0}")]
33    Writer(String),
34    /// Child spawn failed.
35    #[error("failed to spawn pty command: {0}")]
36    Spawn(String),
37}
38
39/// Resolve the generic fallback shell for [`TerminalPtyConfig::default`].
40///
41/// Unix: `$SHELL`, falling back to `/bin/sh` when unset/empty. Windows has no `$SHELL`
42/// equivalent and `/bin/sh` does not exist, so the fallback there is `%COMSPEC%` (normally
43/// `cmd.exe`), falling back to a bare `cmd.exe` lookup via `PATH` when even that is unset.
44///
45/// This is a last-resort generic default for library consumers that never configure a command;
46/// app-level shell resolution (respecting user config, `pwsh.exe`/`powershell.exe` preference,
47/// etc.) belongs to the host application, not this widget.
48fn default_shell_command() -> String {
49    #[cfg(windows)]
50    {
51        std::env::var("COMSPEC")
52            .ok()
53            .filter(|value| !value.trim().is_empty())
54            .unwrap_or_else(|| "cmd.exe".to_string())
55    }
56    #[cfg(not(windows))]
57    {
58        std::env::var("SHELL")
59            .ok()
60            .filter(|value| !value.trim().is_empty())
61            .unwrap_or_else(|| "/bin/sh".to_string())
62    }
63}
64
65#[cfg(windows)]
66fn prime_conpty_cursor(writer: &mut dyn Write) -> std::io::Result<()> {
67    // portable-pty 0.9 enables PSEUDOCONSOLE_INHERIT_CURSOR. Satisfy its initial DSR before
68    // CreateProcessW, because another ConPTY request can otherwise wait for the cursor reply.
69    writer.write_all(b"\x1b[1;1R")?;
70    writer.flush()
71}
72
73#[cfg(not(windows))]
74fn prime_conpty_cursor(_writer: &mut dyn Write) -> std::io::Result<()> {
75    Ok(())
76}
77
78/// PTY spawn options.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct TerminalPtyConfig {
81    pub(crate) command: Arc<str>,
82    pub(crate) args: Vec<Arc<str>>,
83    pub(crate) cols: u16,
84    pub(crate) rows: u16,
85    pub(crate) cwd: Option<Arc<str>>,
86    pub(crate) term: Arc<str>,
87    pub(crate) env: Vec<(Arc<str>, Arc<str>)>,
88    pub(crate) cell: TerminalCellSize,
89}
90
91impl Default for TerminalPtyConfig {
92    fn default() -> Self {
93        let shell = default_shell_command();
94
95        Self {
96            command: shell.into(),
97            args: Vec::new(),
98            cols: 120,
99            rows: 32,
100            cwd: None,
101            term: Arc::from("xterm-256color"),
102            env: vec![(Arc::from("COLORTERM"), Arc::from("truecolor"))],
103            cell: TerminalCellSize::default(),
104        }
105    }
106}
107
108impl TerminalPtyConfig {
109    /// Report `cell` as the host's cell size in the PTY's `TIOCGWINSZ` pixel fields.
110    ///
111    /// A program that draws pictures reads those fields to learn how many pixels a cell is worth.
112    /// Pass [`host_cell_size`](crate::host_cell_size), and give the same value to
113    /// [`TerminalScreen::set_cell_size`](super::TerminalScreen::set_cell_size) so both ends agree.
114    pub fn cell_size(mut self, cell: TerminalCellSize) -> Self {
115        self.cell = cell;
116        self
117    }
118
119    /// Create config with an explicit executable.
120    pub fn new(command: impl Into<Arc<str>>) -> Self {
121        Self {
122            command: command.into(),
123            ..Self::default()
124        }
125    }
126
127    /// Add one CLI argument.
128    pub fn arg(mut self, arg: impl Into<Arc<str>>) -> Self {
129        self.args.push(arg.into());
130        self
131    }
132
133    /// Set CLI arguments.
134    pub fn args<I>(mut self, args: I) -> Self
135    where
136        I: IntoIterator<Item = Arc<str>>,
137    {
138        self.args = args.into_iter().collect();
139        self
140    }
141
142    /// Set initial PTY size (columns x rows).
143    pub fn size(mut self, cols: u16, rows: u16) -> Self {
144        self.cols = cols.max(1);
145        self.rows = rows.max(1);
146        self
147    }
148
149    /// Set child process working directory.
150    pub fn cwd(mut self, cwd: impl Into<Arc<str>>) -> Self {
151        self.cwd = Some(cwd.into());
152        self
153    }
154
155    /// Set `TERM` passed to the child process.
156    pub fn term(mut self, term: impl Into<Arc<str>>) -> Self {
157        self.term = term.into();
158        self
159    }
160
161    /// Add one environment variable.
162    pub fn env(mut self, key: impl Into<Arc<str>>, value: impl Into<Arc<str>>) -> Self {
163        self.env.push((key.into(), value.into()));
164        self
165    }
166}
167
168/// Handle to a running PTY process.
169///
170/// Cloning shares the same underlying child process; see [`Drop`](#impl-Drop-for-TerminalPty) for
171/// why dropping one clone must not affect the others.
172pub struct TerminalPty {
173    inner: Arc<TerminalPtyInner>,
174}
175
176impl Clone for TerminalPty {
177    fn clone(&self) -> Self {
178        // Track *logical* handles separately from `Arc::strong_count`: the reader thread and the
179        // exit-wait thread each hold their own internal clone of `inner` for as long as they run,
180        // so `Arc::strong_count` alone can never reach 1 while the PTY is still connected and
181        // would make `Drop` unable to ever kill a live child. `handle_count` counts only
182        // `TerminalPty` values a caller can see (this one, `TerminalPtyHandoff`'s keepalive,
183        // etc.), independent of that internal bookkeeping.
184        self.inner.handle_count.fetch_add(1, Ordering::AcqRel);
185        Self {
186            inner: self.inner.clone(),
187        }
188    }
189}
190
191/// How long the exit-wait thread lets the reader thread finish draining before it delivers
192/// [`TerminalPtyEvent::Exited`] itself.
193///
194/// Only a backstop: on every platform the reader delivers the exit itself as soon as the stream is
195/// drained, so this elapses only when the reader can neither reach end-of-stream nor observe an
196/// idle master - a child that exited while a grandchild still holds the PTY open on a platform
197/// without readiness polling. Generous on purpose, because expiring early is what truncates output.
198const EXIT_DRAIN_GRACE: Duration = Duration::from_secs(2);
199
200/// Handshake that keeps [`TerminalPtyEvent::Exited`] behind the output the child already wrote.
201///
202/// `child.wait()` returns the moment the child dies, which is typically *before* the reader thread
203/// has been scheduled to pick up the bytes still sitting in the master's buffer. Consumers
204/// reasonably treat `Exited` as "this PTY is finished" and drop the handle, and dropping kills the
205/// reader - so an unordered exit event silently truncates the output of any command that writes and
206/// exits immediately. Whichever thread can prove the stream is drained emits the event; this pairs
207/// them so it is emitted exactly once.
208#[derive(Default)]
209struct ExitSync {
210    state: Mutex<ExitState>,
211    signal: Condvar,
212}
213
214#[derive(Default)]
215struct ExitState {
216    /// Set once `child.wait()` has returned.
217    code: Option<i32>,
218    /// Set once `Exited` has been handed to the callback, so only one thread ever emits it.
219    emitted: bool,
220    /// Set when the PTY is being torn down (`kill`/`handoff`). Both waits below give up on it,
221    /// because a deactivated reader will never reach the end of the stream to report a drain.
222    stopped: bool,
223}
224
225impl ExitSync {
226    /// Publish the child's status and wake whoever is waiting on it.
227    fn publish(&self, code: i32) {
228        if let Ok(mut state) = self.state.lock() {
229            state.code = Some(code);
230        }
231        self.signal.notify_all();
232    }
233
234    /// Release both waits: the PTY is being torn down, so no drain is coming.
235    fn stop(&self) {
236        if let Ok(mut state) = self.state.lock() {
237            state.stopped = true;
238        }
239        self.signal.notify_all();
240    }
241
242    /// Take ownership of emitting `Exited`, if the status is known and nobody has emitted it yet.
243    ///
244    /// Returns the code to emit; the caller must emit it *outside* the lock, because the event
245    /// callback can block on a full consumer queue.
246    fn claim(&self) -> Option<i32> {
247        let mut state = self.state.lock().ok()?;
248        let code = state.code?;
249        if state.emitted {
250            return None;
251        }
252        state.emitted = true;
253        Some(code)
254    }
255
256    /// Reader side: the stream is drained, so wait (briefly) for the status and claim it.
257    fn claim_when_known(&self, timeout: Duration) -> Option<i32> {
258        self.claim_when(timeout, |state| state.code.is_none())
259    }
260
261    /// Wait side: give the reader a chance to claim the exit once it has drained, then step in.
262    fn claim_after_drain(&self, timeout: Duration) -> Option<i32> {
263        self.claim_when(timeout, |state| !state.emitted)
264    }
265
266    fn claim_when(
267        &self,
268        timeout: Duration,
269        mut keep_waiting: impl FnMut(&mut ExitState) -> bool,
270    ) -> Option<i32> {
271        let state = self.state.lock().ok()?;
272        let (state, _) = self
273            .signal
274            .wait_timeout_while(state, timeout, |state| {
275                !state.stopped && keep_waiting(state)
276            })
277            .ok()?;
278        drop(state);
279        self.claim()
280    }
281}
282
283struct TerminalPtyInner {
284    backend: Mutex<TerminalPtyBackend>,
285    writer: Mutex<Option<Box<dyn Write + Send>>>,
286    killer: Mutex<Option<Box<dyn portable_pty::ChildKiller + Send + Sync>>>,
287    reader_thread: Mutex<Option<JoinHandle<()>>>,
288    /// Orders `Exited` behind the child's final output; see [`ExitSync`].
289    exit: ExitSync,
290    active: AtomicBool,
291    kill_on_drop: AtomicBool,
292    /// Number of live `TerminalPty` handles sharing this child (see [`Clone`] above).
293    handle_count: AtomicUsize,
294    /// OS process id of the spawned child, captured at spawn time (`None` if unavailable).
295    pid: Option<u32>,
296    /// Cell size last reported to the child, so a plain `resize` keeps it.
297    cell: AtomicCellSize,
298}
299
300/// The cell size a PTY last reported, kept lock-free because a resize can come from any thread.
301///
302/// Packed as `width << 16 | height`; both axes are `u16` and neither is ever zero.
303struct AtomicCellSize(AtomicU32);
304
305impl AtomicCellSize {
306    fn new(cell: TerminalCellSize) -> Self {
307        let slot = Self(AtomicU32::new(0));
308        slot.store(cell);
309        slot
310    }
311
312    fn load(&self) -> TerminalCellSize {
313        let packed = self.0.load(Ordering::Acquire);
314        TerminalCellSize::new((packed >> 16) as u16, packed as u16)
315    }
316
317    fn store(&self, cell: TerminalCellSize) {
318        let packed = (u32::from(cell.width) << 16) | u32::from(cell.height);
319        self.0.store(packed, Ordering::Release);
320    }
321}
322
323/// A PTY window size that carries the pixel dimensions a graphics-drawing child needs.
324fn pty_size(cols: u16, rows: u16, cell: TerminalCellSize) -> PtySize {
325    let cols = cols.max(1);
326    let rows = rows.max(1);
327    PtySize {
328        rows,
329        cols,
330        pixel_width: cols.saturating_mul(cell.width),
331        pixel_height: rows.saturating_mul(cell.height),
332    }
333}
334
335enum TerminalPtyBackend {
336    Portable(Box<dyn portable_pty::MasterPty + Send>),
337}
338
339#[cfg(unix)]
340/// A live PTY master fd prepared for transfer to another process.
341pub struct TerminalPtyHandoff {
342    /// Raw master PTY fd kept open by this token until it is dropped.
343    pub master_fd: RawFd,
344    /// Child process id, if the platform reported one at spawn time.
345    pub pid: Option<u32>,
346    _keepalive: TerminalPty,
347}
348
349impl TerminalPty {
350    /// Spawn a PTY process and stream events through `on_event`.
351    pub fn spawn(
352        config: TerminalPtyConfig,
353        on_event: impl Fn(TerminalPtyEvent) + Send + Sync + 'static,
354    ) -> Result<Self, TerminalPtyError> {
355        let pty_system = native_pty_system();
356        let pair = pty_system
357            .openpty(pty_size(config.cols, config.rows, config.cell))
358            .map_err(|err| TerminalPtyError::Setup(err.to_string()))?;
359
360        #[cfg(unix)]
361        let reader = File::from(
362            unix_dup_master_fd(&*pair.master)
363                .map_err(|err| TerminalPtyError::Reader(err.to_string()))?,
364        );
365        #[cfg(not(unix))]
366        let reader = pair
367            .master
368            .try_clone_reader()
369            .map_err(|err| TerminalPtyError::Reader(err.to_string()))?;
370
371        #[cfg(unix)]
372        let mut writer = Box::new(File::from(
373            unix_dup_master_fd(&*pair.master)
374                .map_err(|err| TerminalPtyError::Writer(err.to_string()))?,
375        )) as Box<dyn Write + Send>;
376        #[cfg(not(unix))]
377        let mut writer = pair
378            .master
379            .take_writer()
380            .map_err(|err| TerminalPtyError::Writer(err.to_string()))?;
381
382        prime_conpty_cursor(&mut *writer)
383            .map_err(|err| TerminalPtyError::Writer(err.to_string()))?;
384
385        let mut builder = CommandBuilder::new(config.command.as_ref());
386        for arg in config.args {
387            builder.arg(arg.as_ref());
388        }
389        builder.env("TERM", config.term.as_ref());
390        if let Some(cwd) = config.cwd {
391            builder.cwd(cwd.as_ref());
392        }
393        for (key, value) in config.env {
394            builder.env(key.as_ref(), value.as_ref());
395        }
396
397        let mut child = pair
398            .slave
399            .spawn_command(builder)
400            .map_err(|err| TerminalPtyError::Spawn(err.to_string()))?;
401
402        let pid = child.process_id();
403        let inner = Arc::new(TerminalPtyInner {
404            backend: Mutex::new(TerminalPtyBackend::Portable(pair.master)),
405            writer: Mutex::new(Some(writer)),
406            killer: Mutex::new(Some(child.clone_killer())),
407            reader_thread: Mutex::new(None),
408            exit: ExitSync::default(),
409            active: AtomicBool::new(true),
410            kill_on_drop: AtomicBool::new(true),
411            handle_count: AtomicUsize::new(1),
412            pid,
413            cell: AtomicCellSize::new(config.cell),
414        });
415
416        let on_event = Arc::new(on_event);
417
418        {
419            let on_event = on_event.clone();
420            let inner = inner.clone();
421            let thread_inner = inner.clone();
422            let reader_thread = std::thread::spawn(move || {
423                let mut reader = reader;
424                let mut buffer = [0u8; 8192];
425                // Whether the loop ended because the stream itself ended, as opposed to the PTY
426                // being deactivated under it. Only the former proves there is nothing left to read.
427                let mut stream_ended = false;
428                loop {
429                    if !thread_inner.active.load(Ordering::Acquire) {
430                        break;
431                    }
432                    #[cfg(unix)]
433                    match unix_wait_readable(reader.as_raw_fd(), &thread_inner.active) {
434                        PtyReadiness::Readable => {}
435                        PtyReadiness::Idle => {
436                            // The master has nothing pending. If the child is already gone, every
437                            // byte it wrote has been delivered, so its status can be released now.
438                            // Keep reading afterwards: a grandchild may still hold the PTY open.
439                            if let Some(code) = thread_inner.exit.claim() {
440                                on_event(TerminalPtyEvent::Exited(code));
441                            }
442                            continue;
443                        }
444                        PtyReadiness::Stop => break,
445                    }
446                    match reader.read(&mut buffer) {
447                        Ok(0) => {
448                            stream_ended = true;
449                            break;
450                        }
451                        Ok(read) => {
452                            if !thread_inner.active.load(Ordering::Acquire) {
453                                break;
454                            }
455                            on_event(TerminalPtyEvent::Output(Arc::<[u8]>::from(
456                                buffer[..read].to_vec(),
457                            )));
458                        }
459                        Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
460                        Err(err) => {
461                            stream_ended = true;
462                            // On Linux a PTY master read returns EIO once the slave side has been
463                            // fully closed (the child exited); that is the normal end-of-stream
464                            // signal for a master, not a fault. Treat it like EOF - the exit code
465                            // is delivered below, not as an error.
466                            #[cfg(unix)]
467                            if err.raw_os_error() == Some(libc::EIO) {
468                                break;
469                            }
470                            on_event(TerminalPtyEvent::Error(err.to_string().into()));
471                            break;
472                        }
473                    }
474                }
475                // End of stream: no further output can arrive on this PTY, so deliver the exit as
476                // soon as the status is known rather than making the consumer wait out the exit
477                // thread's grace. This is the ordering guarantee - `Exited` is emitted from the
478                // same thread as, and after, the child's last `Output`.
479                if stream_ended
480                    && let Some(code) = thread_inner.exit.claim_when_known(EXIT_DRAIN_GRACE)
481                {
482                    on_event(TerminalPtyEvent::Exited(code));
483                }
484            });
485            if let Ok(mut slot) = inner.reader_thread.lock() {
486                *slot = Some(reader_thread);
487            }
488        }
489
490        {
491            let on_event = on_event.clone();
492            let thread_inner = inner.clone();
493            std::thread::spawn(move || {
494                let exit_code = child
495                    .wait()
496                    .ok()
497                    .map(|status| status.exit_code() as i32)
498                    .unwrap_or(-1);
499                // Publish rather than emit: `wait` returns the instant the child dies, which is
500                // usually before the reader has picked up the bytes it left behind. The reader
501                // emits the exit once it has drained them; this thread only steps in when the
502                // reader cannot get there (see `EXIT_DRAIN_GRACE`) or when the PTY was killed.
503                thread_inner.exit.publish(exit_code);
504                if let Some(code) = thread_inner.exit.claim_after_drain(EXIT_DRAIN_GRACE) {
505                    on_event(TerminalPtyEvent::Exited(code));
506                }
507            });
508        }
509
510        Ok(Self { inner })
511    }
512
513    #[cfg(unix)]
514    /// Prepare this PTY for transfer to another process.
515    pub fn handoff(&self) -> std::io::Result<TerminalPtyHandoff> {
516        self.inner.active.store(false, Ordering::Release);
517        // The reader is about to stop without reaching end of stream, so nothing will ever report
518        // a drain; release the exit waits so joining it below cannot stall on the drain grace.
519        self.inner.exit.stop();
520        self.inner.kill_on_drop.store(false, Ordering::Release);
521        if let Some(handle) = self
522            .inner
523            .reader_thread
524            .lock()
525            .map_err(|_| std::io::Error::other("pty reader thread lock poisoned"))?
526            .take()
527        {
528            let _ = handle.join();
529        }
530        let mut writer = self
531            .inner
532            .writer
533            .lock()
534            .map_err(|_| std::io::Error::other("pty writer lock poisoned"))?;
535        writer.take();
536        drop(writer);
537
538        let backend = self
539            .inner
540            .backend
541            .lock()
542            .map_err(|_| std::io::Error::other("pty master lock poisoned"))?;
543        let fd = match &*backend {
544            TerminalPtyBackend::Portable(master) => master
545                .as_raw_fd()
546                .ok_or_else(|| std::io::Error::other("pty master fd unavailable"))?,
547        };
548        Ok(TerminalPtyHandoff {
549            master_fd: fd,
550            pid: self.inner.pid,
551            _keepalive: self.clone(),
552        })
553    }
554
555    /// OS process id of the spawned child process, if the platform reports one.
556    pub fn pid(&self) -> Option<u32> {
557        self.inner.pid
558    }
559
560    #[cfg(unix)]
561    /// Foreground process-group id currently attached to this PTY (`tcgetpgrp(3)`).
562    ///
563    /// This is the building block a Linux/macOS foreground-executable fallback needs (e.g. to
564    /// resolve which process a shell handed the terminal to) without exposing the underlying
565    /// master file descriptor to callers. Returns `None` once the PTY has been killed or handed
566    /// off, or if the ioctl fails (e.g. no foreground group is currently set).
567    pub fn foreground_process_group_id(&self) -> Option<i32> {
568        if !self.inner.active.load(Ordering::Acquire) {
569            return None;
570        }
571        let backend = self.inner.backend.lock().ok()?;
572        let fd = match &*backend {
573            TerminalPtyBackend::Portable(master) => master.as_raw_fd()?,
574        };
575        let pgid = unsafe { libc::tcgetpgrp(fd) };
576        (pgid >= 0).then_some(pgid)
577    }
578
579    /// Send raw bytes to child stdin.
580    pub fn write(&self, bytes: &[u8]) -> std::io::Result<()> {
581        if !self.inner.active.load(Ordering::Acquire) {
582            return Err(std::io::Error::other("pty has been handed off"));
583        }
584        let mut writer = self
585            .inner
586            .writer
587            .lock()
588            .map_err(|_| std::io::Error::other("pty writer lock poisoned"))?;
589        let writer = writer
590            .as_mut()
591            .ok_or_else(|| std::io::Error::other("pty writer unavailable"))?;
592        writer.write_all(bytes)?;
593        writer.flush()
594    }
595
596    /// Encode key and send it to child stdin.
597    ///
598    /// Pass the modes the child has enabled, from `TerminalScreen::key_modes()`. Returns `false`
599    /// when the key has no terminal encoding and nothing was written.
600    pub fn send_key(&self, key: KeyEvent, modes: TerminalKeyModes) -> std::io::Result<bool> {
601        let Some(bytes) = key_event_to_bytes(key, modes) else {
602            return Ok(false);
603        };
604        self.write(&bytes)?;
605        Ok(true)
606    }
607
608    /// Resize PTY dimensions, keeping the cell size the child was last told.
609    ///
610    /// Equivalent to [`resize_with_cell_size`](Self::resize_with_cell_size) with the size from
611    /// [`TerminalPtyConfig::cell_size`].
612    pub fn resize(&self, cols: u16, rows: u16) -> std::io::Result<()> {
613        self.resize_with_cell_size(cols, rows, self.inner.cell.load())
614    }
615
616    /// Resize PTY dimensions and report the host's cell size in pixels.
617    ///
618    /// The pixel fields of `TIOCGWINSZ` are how a program that draws pictures learns how big a
619    /// cell is; a terminal that leaves them zero forces it to guess or to fall back to `CSI 14 t`.
620    /// Pass the same size the screen was given through
621    /// [`TerminalScreen::set_cell_size`](super::TerminalScreen::set_cell_size).
622    pub fn resize_with_cell_size(
623        &self,
624        cols: u16,
625        rows: u16,
626        cell: TerminalCellSize,
627    ) -> std::io::Result<()> {
628        if !self.inner.active.load(Ordering::Acquire) {
629            return Err(std::io::Error::other("pty has been handed off"));
630        }
631        let backend = self
632            .inner
633            .backend
634            .lock()
635            .map_err(|_| std::io::Error::other("pty master lock poisoned"))?;
636        match &*backend {
637            TerminalPtyBackend::Portable(master) => {
638                self.inner.cell.store(cell);
639                master
640                    .resize(pty_size(cols, rows, cell))
641                    .map_err(|err| std::io::Error::other(err.to_string()))
642            }
643        }
644    }
645
646    /// Request graceful process termination.
647    pub fn kill(&self) -> std::io::Result<()> {
648        if !self.inner.kill_on_drop.load(Ordering::Acquire) {
649            return Ok(());
650        }
651        self.inner.active.store(false, Ordering::Release);
652        // An explicit kill stops the reader mid-stream, so the exit must not wait for a drain that
653        // will never be reported - it is delivered as soon as the child is reaped.
654        self.inner.exit.stop();
655        let mut killer = self
656            .inner
657            .killer
658            .lock()
659            .map_err(|_| std::io::Error::other("pty killer lock poisoned"))?;
660        if let Some(killer) = killer.as_mut() {
661            return killer
662                .kill()
663                .map_err(|err| std::io::Error::other(err.to_string()));
664        }
665        Ok(())
666    }
667}
668
669#[cfg(unix)]
670fn unix_dup_master_fd(master: &dyn portable_pty::MasterPty) -> std::io::Result<OwnedFd> {
671    let fd = master
672        .as_raw_fd()
673        .ok_or_else(|| std::io::Error::other("pty master fd unavailable"))?;
674    unix_dup_raw_fd(fd)
675}
676
677#[cfg(unix)]
678fn unix_dup_raw_fd(fd: RawFd) -> std::io::Result<OwnedFd> {
679    let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
680    if dup < 0 {
681        return Err(std::io::Error::last_os_error());
682    }
683    Ok(unsafe { OwnedFd::from_raw_fd(dup) })
684}
685
686/// What [`unix_wait_readable`] observed about the master.
687#[cfg(unix)]
688enum PtyReadiness {
689    /// Data (or a hangup) is pending; read it.
690    Readable,
691    /// Nothing is pending right now - which, once the child has exited, means fully drained.
692    Idle,
693    /// The PTY was deactivated, or the master can no longer be polled.
694    Stop,
695}
696
697#[cfg(unix)]
698fn unix_wait_readable(fd: RawFd, active: &AtomicBool) -> PtyReadiness {
699    if !active.load(Ordering::Acquire) {
700        return PtyReadiness::Stop;
701    }
702    let mut pollfd = libc::pollfd {
703        fd,
704        events: libc::POLLIN,
705        revents: 0,
706    };
707    let rc = unsafe { libc::poll(&mut pollfd, 1, 100) };
708    if rc > 0 {
709        if pollfd.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 {
710            return PtyReadiness::Readable;
711        }
712        // `POLLNVAL` means the master is no longer a valid descriptor: it would be signalled again
713        // immediately, so reporting it as idle would spin.
714        if pollfd.revents & libc::POLLNVAL != 0 {
715            return PtyReadiness::Stop;
716        }
717        return PtyReadiness::Idle;
718    }
719    if rc == 0 {
720        return PtyReadiness::Idle;
721    }
722    if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
723        return PtyReadiness::Idle;
724    }
725    PtyReadiness::Stop
726}
727
728/// PTY runtime event.
729///
730/// `Exited` is delivered after every `Output` the child produced, so a consumer can treat it as
731/// "this PTY is finished" and drop the handle without losing bytes still in flight. An explicit
732/// [`TerminalPty::kill`] is the exception: it stops the reader deliberately, so anything buffered
733/// at that moment is discarded and the exit is reported as soon as the child is reaped.
734#[derive(Clone, Debug, PartialEq, Eq)]
735pub enum TerminalPtyEvent {
736    /// Raw bytes emitted by PTY stdout/stderr stream.
737    Output(Arc<[u8]>),
738    /// Child process exited with status code (or -1 when unavailable).
739    Exited(i32),
740    /// Runtime error message.
741    Error(Arc<str>),
742}
743
744impl Drop for TerminalPty {
745    fn drop(&mut self) {
746        // Only kill the child when *this* drop removes the last outstanding logical handle
747        // (`handle_count`, not `Arc::strong_count` - see the `Clone` impl above for why).
748        if self.inner.handle_count.fetch_sub(1, Ordering::AcqRel) == 1 {
749            // Ignore errors - the child may have already exited.
750            let _ = self.kill();
751        }
752    }
753}
754
755#[cfg(all(test, unix))]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn dropping_a_clone_does_not_kill_the_shared_pty() {
761        let pty = TerminalPty::spawn(
762            TerminalPtyConfig::new("/bin/sh").arg("-c").arg("sleep 5"),
763            |_event| {},
764        )
765        .expect("spawn");
766
767        let clone = pty.clone();
768        assert_eq!(pty.inner.handle_count.load(Ordering::Acquire), 2);
769        drop(clone);
770        assert_eq!(pty.inner.handle_count.load(Ordering::Acquire), 1);
771
772        // Before the fix, dropping any clone unconditionally killed the child; this must not
773        // happen while another handle (`pty`) is still alive.
774        assert!(
775            pty.write(b"").is_ok(),
776            "pty should still be alive after dropping a clone"
777        );
778
779        drop(pty);
780    }
781
782    /// A consumer that treats `Exited` as "this PTY is done" and drops the handle - the natural
783    /// way to use this API, and what `hyprmux` does - must still have been given everything the
784    /// child wrote. Dropping kills the reader, so an exit event emitted ahead of the reader used
785    /// to discard whatever was still sitting in the master's buffer: a command that wrote and
786    /// exited immediately could lose its output entirely.
787    #[test]
788    fn a_fast_command_s_output_arrives_before_its_exit() {
789        // The race needs the child to write and exit in one breath, so retry: a single run that
790        // happens to schedule the reader first would pass either way.
791        for attempt in 0..40 {
792            let events = Arc::new(Mutex::new(Vec::new()));
793            let sink = events.clone();
794            let mut pty = Some(
795                TerminalPty::spawn(
796                    TerminalPtyConfig::new("/bin/sh")
797                        .arg("-c")
798                        .arg("printf 'fast output\\n'; exit 3"),
799                    move |event| sink.lock().expect("events").push(event),
800                )
801                .expect("spawn"),
802            );
803
804            let deadline = std::time::Instant::now() + Duration::from_secs(10);
805            while std::time::Instant::now() < deadline {
806                let exited = events
807                    .lock()
808                    .expect("events")
809                    .iter()
810                    .any(|event| matches!(event, TerminalPtyEvent::Exited(_)));
811                if exited {
812                    break;
813                }
814                std::thread::sleep(Duration::from_millis(5));
815            }
816            // Exactly what a consumer does on exit, and exactly what used to truncate the output.
817            drop(pty.take());
818
819            let events = events.lock().expect("events");
820            let text: String = events
821                .iter()
822                .filter_map(|event| match event {
823                    TerminalPtyEvent::Output(bytes) => Some(String::from_utf8_lossy(bytes)),
824                    _ => None,
825                })
826                .collect();
827            assert!(
828                text.contains("fast output"),
829                "attempt {attempt}: the command's output was lost; events: {events:?}"
830            );
831            assert!(
832                matches!(events.last(), Some(TerminalPtyEvent::Exited(3))),
833                "attempt {attempt}: the exit must come last and carry the real status; \
834                 events: {events:?}"
835            );
836        }
837    }
838
839    /// The exit event must still be delivered promptly when the reader cannot observe the end of
840    /// the stream, rather than waiting out the drain grace or being lost.
841    #[test]
842    fn killing_a_pty_reports_the_exit_without_waiting_for_a_drain() {
843        let events = Arc::new(Mutex::new(Vec::new()));
844        let sink = events.clone();
845        let pty = TerminalPty::spawn(
846            TerminalPtyConfig::new("/bin/sh").arg("-c").arg("sleep 30"),
847            move |event| sink.lock().expect("events").push(event),
848        )
849        .expect("spawn");
850
851        let started = std::time::Instant::now();
852        pty.kill().expect("kill");
853        let deadline = started + EXIT_DRAIN_GRACE;
854        while std::time::Instant::now() < deadline {
855            if events
856                .lock()
857                .expect("events")
858                .iter()
859                .any(|event| matches!(event, TerminalPtyEvent::Exited(_)))
860            {
861                return;
862            }
863            std::thread::sleep(Duration::from_millis(5));
864        }
865        panic!("a killed pty must report its exit without waiting out the drain grace");
866    }
867
868    #[test]
869    fn foreground_process_group_id_reports_a_value_while_alive() {
870        let pty = TerminalPty::spawn(
871            TerminalPtyConfig::new("/bin/sh").arg("-c").arg("sleep 5"),
872            |_event| {},
873        )
874        .expect("spawn");
875
876        // The freshly spawned shell is its own foreground process group.
877        assert!(pty.foreground_process_group_id().is_some());
878
879        drop(pty);
880    }
881}