Skip to main content

tear_core/
pty.rs

1//! PTY ownership — one [`PtyHandle`] per running pane.
2//!
3//! Wraps `portable_pty` so the rest of `tear-core` doesn't import
4//! it directly. Keeps the dep boundary clear and makes future
5//! backend swaps (a custom platform-specific PTY layer) a single-
6//! crate change.
7
8use std::io::{Read, Write};
9use std::sync::Arc;
10use std::thread::JoinHandle;
11use std::time::{Duration, Instant};
12
13use parking_lot::Mutex;
14use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
15use tracing::warn;
16
17/// Hard upper bound on how long [`PtyHandle`]'s drop path waits for
18/// the killed child to die before handing the reap to a detached
19/// thread. `portable_pty`'s `kill()` is itself escalating on Unix
20/// (SIGHUP → ~250ms grace → SIGKILL), so the poll below almost always
21/// completes in well under a second; the deadline only exists so a
22/// pathological child (uninterruptible sleep on a dead fuse/network
23/// fd) can never block a teardown caller.
24const REAP_DEADLINE: Duration = Duration::from_secs(2);
25
26/// Poll interval for the bounded reap loop.
27const REAP_POLL: Duration = Duration::from_millis(10);
28
29/// Handle to one pane's PTY. The master side is held inside an
30/// `Arc<Mutex<...>>` so the reader thread and the writer thread (or
31/// `send_keys` caller) can share it. The child process handle is
32/// retained so `Drop` can explicitly `kill()` it — without this,
33/// dropping the master Arc doesn't kill the shell (the reader
34/// thread holds a cloned master fd that keeps the slave open),
35/// shells accumulate as orphans, and every subsequent fork copies
36/// a bloated address space.
37pub struct PtyHandle {
38    master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
39    writer: Arc<Mutex<Box<dyn Write + Send>>>,
40    /// Child handle retained for explicit kill on drop. Wrapped
41    /// in Mutex<Option<...>> so `kill()` can take ownership while
42    /// still leaving the slot in a valid (None) state after.
43    child: Arc<Mutex<Option<Box<dyn Child + Send + Sync>>>>,
44    /// Reader thread joins on `drop()`.
45    reader_join: Option<JoinHandle<()>>,
46    /// Total bytes consumed by the on-pane VT parser since spawn.
47    bytes_consumed: Arc<std::sync::atomic::AtomicU64>,
48}
49
50impl PtyHandle {
51    /// Spawn a child process attached to a freshly minted PTY pair.
52    /// The caller provides a sink for child bytes — typically a
53    /// `Box<dyn FnMut(&[u8]) + Send>` that feeds into a `vte` parser
54    /// or appends to a scrollback grid. The reader thread loops on
55    /// `master.try_clone_reader()` until EOF, calling the sink on
56    /// each read.
57    ///
58    /// `on_exit` is the typed end-of-stream notification: when the
59    /// reader loop ends (PTY EOF because the child exited, or a read
60    /// error), the reader thread reaps the child to capture its exit
61    /// code, then calls `on_exit(code)` exactly once. `code` is
62    /// `None` only when the child was already reaped elsewhere (the
63    /// explicit-kill path, where [`Drop`] took the child first) or
64    /// `wait()` failed. This is the single edge that lets the
65    /// multiplexer mark the pane exited and disconnect its byte-stream
66    /// subscribers; without it a reader thread that hit EOF would just
67    /// end silently and every subscriber would block forever.
68    pub fn spawn(
69        shell: &str,
70        args: &[String],
71        cwd: Option<&str>,
72        env: &[(String, String)],
73        size: PtySize,
74        mut on_bytes: Box<dyn FnMut(&[u8]) + Send>,
75        on_exit: Box<dyn FnOnce(Option<i32>) + Send>,
76    ) -> anyhow::Result<Self> {
77        let pty_system = NativePtySystem::default();
78        let pair = pty_system.openpty(size)?;
79        let mut cmd = CommandBuilder::new(shell);
80        for a in args {
81            cmd.arg(a);
82        }
83        if let Some(d) = cwd {
84            cmd.cwd(d);
85        }
86        for (k, v) in env {
87            cmd.env(k, v);
88        }
89        // PWD hygiene (cwd handshake, operator report 2026-06-12): a
90        // child shell trusts inherited `PWD` over `getcwd()`, so a
91        // stale parent `PWD` makes frost / prompt cwd wrong even though
92        // the real cwd is correct. Stamp `PWD` to the real cwd when one
93        // is set; strip any inherited `PWD` when none is, so a stale
94        // parent `PWD` can never leak. This is the lowest-level guard —
95        // it holds for EVERY caller (in-process, daemon, registry),
96        // independent of whatever the `env` vec carried.
97        match cwd {
98            Some(d) => cmd.env("PWD", d),
99            None => cmd.env_remove("PWD"),
100        }
101        // Retain the child handle so Drop can kill() it. Without
102        // this the shell becomes an orphan after PtyHandle drops:
103        // closing the master fd would normally SIGHUP the child,
104        // but the reader thread (below) holds a separate cloned
105        // master fd that keeps the slave open, so the shell never
106        // sees HUP. Accumulating orphan shells bloats the daemon
107        // and slows every fork.
108        let child = pair.slave.spawn_command(cmd)?;
109        let child = Arc::new(Mutex::new(Some(child)));
110        // Clone for the reader thread so it can reap the child + read
111        // its exit code the instant the PTY hits EOF (rather than
112        // leaving the zombie + the exit notification to `Drop`, which
113        // only fires on explicit kill).
114        let child_for_reader = Arc::clone(&child);
115        // Slave fd retained by the child; once it exits the master
116        // reader hits EOF.
117        drop(pair.slave);
118
119        let mut reader = pair.master.try_clone_reader()?;
120        let writer = pair.master.take_writer()?;
121        let master = Arc::new(Mutex::new(pair.master));
122        let writer = Arc::new(Mutex::new(writer));
123
124        let bytes_consumed = Arc::new(std::sync::atomic::AtomicU64::new(0));
125        let bytes_consumed_for_thread = Arc::clone(&bytes_consumed);
126        let reader_join = std::thread::Builder::new()
127            .name("tear-pty-reader".into())
128            .spawn(move || {
129                let mut buf = vec![0u8; 64 * 1024];
130                loop {
131                    match reader.read(&mut buf) {
132                        Ok(0) => break,
133                        Ok(n) => {
134                            bytes_consumed_for_thread
135                                .fetch_add(n as u64, std::sync::atomic::Ordering::Relaxed);
136                            on_bytes(&buf[..n]);
137                        }
138                        Err(e) => {
139                            warn!(error = %e, "tear pty reader error");
140                            break;
141                        }
142                    }
143                }
144                // EOF (or read error) — the byte stream is over. Reap
145                // the child to capture its exit code AND clear the
146                // zombie, then fire the typed exit notification so the
147                // multiplexer marks the pane exited + disconnects its
148                // subscribers. If the explicit-kill path (PtyHandle::
149                // Drop) already took the child, `take()` is None and
150                // `code` is None — the notification still fires so the
151                // disconnect is idempotent.
152                //
153                // `take()` and `wait()` are SEPARATE statements on
154                // purpose: chained, the temporary `MutexGuard` lives
155                // until the end of the full expression, holding the
156                // child slot's lock across a blocking `wait()`. The
157                // explicit-kill path locks the same slot, so a wait
158                // that outlives the child's fds (EOF without exit —
159                // daemonizing children) would hand `Drop` an unbounded
160                // lock to block on. Take under the lock, wait outside.
161                let taken = child_for_reader.lock().take();
162                let code = taken
163                    .and_then(|mut c| c.wait().ok())
164                    .map(|status| status.exit_code() as i32);
165                on_exit(code);
166            })?;
167
168        Ok(Self {
169            master,
170            writer,
171            child,
172            reader_join: Some(reader_join),
173            bytes_consumed,
174        })
175    }
176
177    /// Send bytes to the child's stdin.
178    pub fn write(&self, bytes: &[u8]) -> std::io::Result<()> {
179        let mut w = self.writer.lock();
180        w.write_all(bytes)
181    }
182
183    /// Resize the PTY winsize. Causes SIGWINCH delivery to the child.
184    pub fn resize(&self, size: PtySize) -> anyhow::Result<()> {
185        let m = self.master.lock();
186        m.resize(size)?;
187        Ok(())
188    }
189
190    /// Total bytes consumed by the pane's parser since spawn.
191    pub fn bytes_consumed(&self) -> u64 {
192        self.bytes_consumed
193            .load(std::sync::atomic::Ordering::Relaxed)
194    }
195}
196
197impl Drop for PtyHandle {
198    fn drop(&mut self) {
199        // Explicitly kill the child shell first. Dropping the
200        // master Arc alone is NOT enough — the reader thread
201        // holds a cloned master fd that keeps the slave alive,
202        // so the shell never sees SIGHUP. kill() sends SIGHUP,
203        // grace-polls ~250ms, then escalates to SIGKILL
204        // (portable_pty's behavior on Unix); the shell exits,
205        // the slave fd closes, and the reader's read() returns
206        // Ok(0). The reader thread then exits the loop and the
207        // JoinHandle drop below completes immediately.
208        //
209        // On the natural-exit path the reader thread has already
210        // `take()`n + `wait()`ed the child (see `spawn`), so this
211        // slot is `None` and we skip straight to detaching the
212        // reader join handle — no double-wait, no double-kill.
213        if let Some(child) = self.child.lock().take() {
214            reap_with_deadline(child);
215        }
216        if let Some(j) = self.reader_join.take() {
217            drop(j);
218        }
219    }
220}
221
222/// Kill `child` and reap it within [`REAP_DEADLINE`] — NEVER an
223/// unbounded `wait()`.
224///
225/// Drop runs on the teardown caller's own thread, and the caller may
226/// be a lock-heavy path (`InProcess::kill_*`); a blocking `wait()`
227/// here wedged mado's L1 teardown for 20+ minutes (2026-06-10) when
228/// the pane's reader thread was simultaneously blocked acquiring an
229/// `InProcess` lock the caller held — mutual wait. The fix is two
230/// halves: the kill paths drop handles outside every lock (see
231/// `InProcess::detach_panes`), and this reap is bounded regardless.
232///
233/// A child that survives even kill()'s SIGKILL escalation past the
234/// deadline (uninterruptible-sleep pathology) is handed to a detached
235/// reaper thread, so the zombie is still collected without the caller
236/// ever blocking on it.
237fn reap_with_deadline(mut child: Box<dyn Child + Send + Sync>) {
238    if let Err(e) = child.kill() {
239        warn!(error = %e, "tear pty child kill failed (already exited?)");
240    }
241    let deadline = Instant::now() + REAP_DEADLINE;
242    loop {
243        match child.try_wait() {
244            // Reaped — no zombie left in the daemon's pid table.
245            Ok(Some(_)) => return,
246            // Already reaped elsewhere (kill()'s internal grace loop
247            // try_waits, so a fast-dying child is often collected
248            // before we get here) — nothing left to do.
249            Err(_) => return,
250            Ok(None) => {
251                if Instant::now() >= deadline {
252                    break;
253                }
254                std::thread::sleep(REAP_POLL);
255            }
256        }
257    }
258    warn!("tear pty child survived kill past reap deadline — detaching reaper thread");
259    let _ = std::thread::Builder::new()
260        .name("tear-pty-reaper".into())
261        .spawn(move || {
262            let _ = child.wait();
263        });
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use std::sync::mpsc;
270
271    /// FIX 3 cwd handshake (operator report 2026-06-12): a spawn with a
272    /// cwd stamps `PWD=<cwd>` on the child, so a shell that trusts
273    /// inherited `PWD` over `getcwd()` reports the RIGHT directory.
274    /// PTY-gated (openpty); passes in isolation.
275    #[test]
276    fn spawn_with_cwd_sets_env_pwd_consistent() {
277        let dir = std::env::temp_dir();
278        let dir_str = dir.to_string_lossy().into_owned();
279        let (tx, rx) = mpsc::channel::<Vec<u8>>();
280        let _handle = PtyHandle::spawn(
281            "/bin/sh",
282            &["-c".into(), "printf 'PWDCHK[%s]\\n' \"$PWD\"".into()],
283            Some(&dir_str),
284            // Seed a STALE PWD in the env so the test proves the cwd
285            // stamp WINS (env applied first, then the cwd-stamp guard).
286            &[("PWD".into(), "/stale/parent".into())],
287            PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 },
288            Box::new(move |b| {
289                let _ = tx.send(b.to_vec());
290            }),
291            Box::new(|_| {}),
292        )
293        .expect("spawn /bin/sh");
294        let mut buf = Vec::new();
295        let deadline = Instant::now() + Duration::from_secs(2);
296        while Instant::now() < deadline {
297            if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(100)) {
298                buf.extend_from_slice(&chunk);
299                if std::str::from_utf8(&buf).map(|s| s.contains("PWDCHK[")).unwrap_or(false) {
300                    break;
301                }
302            }
303        }
304        let text = String::from_utf8_lossy(&buf);
305        assert!(
306            text.contains(&format!("PWDCHK[{}", dir_str.trim_end_matches('/'))),
307            "child $PWD must match the spawn cwd, not the stale parent PWD: {text:?}"
308        );
309        assert!(
310            !text.contains("/stale/parent"),
311            "the stale parent PWD leaked to the child: {text:?}"
312        );
313    }
314
315    /// FIX 3: a spawn with NO cwd strips any inherited `PWD`, so a
316    /// stale parent `PWD` can never leak — the child falls back to its
317    /// real `getcwd()`-derived directory. PTY-gated; passes in isolation.
318    #[test]
319    fn spawn_without_cwd_never_leaks_parent_pwd() {
320        let (tx, rx) = mpsc::channel::<Vec<u8>>();
321        let _handle = PtyHandle::spawn(
322            "/bin/sh",
323            &["-c".into(), "printf 'PWDCHK[%s]\\n' \"${PWD:-UNSET}\"".into()],
324            None,
325            &[("PWD".into(), "/stale/parent".into())],
326            PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 },
327            Box::new(move |b| {
328                let _ = tx.send(b.to_vec());
329            }),
330            Box::new(|_| {}),
331        )
332        .expect("spawn /bin/sh");
333        let mut buf = Vec::new();
334        let deadline = Instant::now() + Duration::from_secs(2);
335        while Instant::now() < deadline {
336            if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(100)) {
337                buf.extend_from_slice(&chunk);
338                if std::str::from_utf8(&buf).map(|s| s.contains("PWDCHK[")).unwrap_or(false) {
339                    break;
340                }
341            }
342        }
343        let text = String::from_utf8_lossy(&buf);
344        assert!(text.contains("PWDCHK["), "no PWDCHK output: {text:?}");
345        assert!(
346            !text.contains("/stale/parent"),
347            "no-cwd spawn must strip the inherited PWD; the stale parent leaked: {text:?}"
348        );
349    }
350
351    #[test]
352    fn drop_reaps_sighup_immune_child_within_deadline() {
353        // portable_pty's kill() leads with SIGHUP; a child that ignores
354        // it (anything nohup-wrapped) only dies on the SIGKILL
355        // escalation. The drop path must stay bounded for that class —
356        // the pre-fix unbounded `wait()` is exactly what a teardown
357        // caller would block on. nohup redirects stdout to ./nohup.out
358        // (stdout IS a tty here), so cwd points at a temp dir.
359        let dir = std::env::temp_dir();
360        let handle = PtyHandle::spawn(
361            "/usr/bin/nohup",
362            &["cat".into()],
363            dir.to_str(),
364            &[("PATH".into(), "/usr/bin:/bin".into())],
365            PtySize {
366                rows: 24,
367                cols: 80,
368                pixel_width: 0,
369                pixel_height: 0,
370            },
371            Box::new(|_| {}),
372            Box::new(|_| {}),
373        )
374        .expect("spawn nohup cat");
375        // Give nohup a beat to exec cat with SIGHUP ignored — killing
376        // during the exec window would test the wrong disposition.
377        std::thread::sleep(Duration::from_millis(200));
378        let started = Instant::now();
379        drop(handle);
380        let elapsed = started.elapsed();
381        assert!(
382            elapsed < Duration::from_secs(5),
383            "PtyHandle drop blocked {elapsed:?} on a SIGHUP-immune child — reap is unbounded again"
384        );
385    }
386}