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}