Skip to main content

fno_agents/
pty.rs

1//! PTY spawn + bounded-ring output drainer (design module `pty.rs`).
2//!
3//! Per Wave 0's Outcome B, this is **worker-side**: a worker process owns the
4//! PTY master for an agent's whole lifetime so the child survives daemon
5//! restarts. [`PtySession`] spawns a child on a fresh PTY, owns the master, and
6//! runs a drainer thread that copies master output into a [`BoundedRing`].
7//!
8//! LD31: the drainer is always running with a bounded ring (1MB default,
9//! `config.pty.output_ring_bytes`). On overflow it drops the OLDEST bytes and
10//! accounts them in [`BoundedRing::dropped_bytes`]; the daemon (Wave 3) turns
11//! that counter into rate-limited `pty_output_dropped` events. Dropping oldest
12//! (not blocking) is what prevents the child from deadlocking on a full kernel
13//! pipe buffer.
14//!
15//! Domain Pitfall (deferred to Wave 3): the production daemon decouples the
16//! drainer (PTY read -> ring, never touches disk) from a separate timeline
17//! writer task (ring -> timeline.jsonl). Wave 1 ships the drainer + ring; the
18//! timeline writer is a Wave 3 tokio task that consumes [`PtySession::snapshot`]
19//! / a future incremental cursor.
20
21use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
22use std::collections::VecDeque;
23use std::io::{Read, Write};
24use std::sync::{Arc, Mutex};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant};
27
28/// How long `Drop` waits for the drainer to terminate before detaching it.
29const DRAINER_JOIN_TIMEOUT: Duration = Duration::from_secs(2);
30
31/// LD31 default ring capacity.
32pub const DEFAULT_OUTPUT_RING_BYTES: usize = 1024 * 1024;
33
34#[derive(Debug, thiserror::Error)]
35pub enum PtyError {
36    #[error("failed to open pty: {0}")]
37    OpenPty(String),
38    #[error("failed to spawn child: {0}")]
39    Spawn(String),
40    #[error("failed to obtain pty writer: {0}")]
41    Writer(String),
42    #[error("failed to obtain pty reader: {0}")]
43    Reader(String),
44    #[error("pty write failed: {0}")]
45    Write(std::io::Error),
46    #[error("pty resize failed: {0}")]
47    Resize(String),
48    #[error("child wait failed: {0}")]
49    Wait(String),
50    #[error("child kill failed: {0}")]
51    Kill(String),
52}
53
54/// A fixed-capacity byte ring. On overflow the oldest bytes are dropped (never
55/// blocks the writer) and counted so the consumer can surface backpressure.
56#[derive(Debug)]
57pub struct BoundedRing {
58    buf: VecDeque<u8>,
59    capacity: usize,
60    dropped: u64,
61}
62
63impl BoundedRing {
64    pub fn new(capacity: usize) -> Self {
65        BoundedRing {
66            buf: VecDeque::new(),
67            capacity: capacity.max(1),
68            dropped: 0,
69        }
70    }
71
72    /// Append `data`, dropping the oldest bytes if it would exceed capacity.
73    pub fn extend(&mut self, data: &[u8]) {
74        if data.is_empty() {
75            return;
76        }
77        if data.len() >= self.capacity {
78            // The incoming chunk alone fills the ring: everything currently
79            // buffered plus the chunk's leading excess is dropped.
80            let keep_from = data.len() - self.capacity;
81            self.dropped += self.buf.len() as u64 + keep_from as u64;
82            self.buf.clear();
83            self.buf.extend(&data[keep_from..]);
84            return;
85        }
86        let overflow = (self.buf.len() + data.len()).saturating_sub(self.capacity);
87        for _ in 0..overflow {
88            self.buf.pop_front();
89        }
90        self.dropped += overflow as u64;
91        self.buf.extend(data);
92    }
93
94    /// Copy the current contents out (oldest-to-newest).
95    pub fn snapshot(&self) -> Vec<u8> {
96        self.buf.iter().copied().collect()
97    }
98
99    pub fn len(&self) -> usize {
100        self.buf.len()
101    }
102
103    pub fn is_empty(&self) -> bool {
104        self.buf.is_empty()
105    }
106
107    pub fn capacity(&self) -> usize {
108        self.capacity
109    }
110
111    /// Total bytes dropped due to overflow over this ring's lifetime.
112    pub fn dropped_bytes(&self) -> u64 {
113        self.dropped
114    }
115
116    /// Total bytes ever written to the ring (dropped + currently buffered).
117    /// This is a monotonic cursor: a reader that remembers the `next` value
118    /// from [`BoundedRing::read_since`] can ask for everything appended since,
119    /// without the fragile two-snapshot diff a sliding window would otherwise
120    /// require. Drive output streaming (Wave 4) rides on this.
121    pub fn total_written(&self) -> u64 {
122        self.dropped + self.buf.len() as u64
123    }
124
125    /// Return everything appended after the absolute byte offset `cursor`.
126    ///
127    /// `next` is the offset to pass on the following call. `gap` is true when
128    /// `cursor` pointed at bytes the ring has since dropped (overflow), so the
129    /// reader knows its stream skipped ahead and can surface a drop notice
130    /// rather than silently splicing non-contiguous output. A `cursor` past the
131    /// current tail (a reader ahead of the writer, or a stale-but-future value)
132    /// yields no bytes and `next == cursor.max(total)`.
133    pub fn read_since(&self, cursor: u64) -> ReadSince {
134        let total = self.total_written();
135        if cursor >= total {
136            // Reader is at or ahead of the tail: nothing new. Never rewind the
137            // caller's cursor below where they already were.
138            return ReadSince {
139                bytes: Vec::new(),
140                next: cursor.max(total),
141                gap: false,
142            };
143        }
144        // cursor < total, so there is something to return.
145        let (start, gap) = if cursor < self.dropped {
146            // The bytes between `cursor` and `dropped` are gone; hand back the
147            // whole live window and flag the discontinuity.
148            (0usize, true)
149        } else {
150            ((cursor - self.dropped) as usize, false)
151        };
152        let bytes: Vec<u8> = self.buf.iter().skip(start).copied().collect();
153        ReadSince {
154            bytes,
155            next: total,
156            gap,
157        }
158    }
159}
160
161/// Result of [`BoundedRing::read_since`]: the new bytes, the cursor to use next,
162/// and whether older bytes were dropped before this read (a stream gap).
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ReadSince {
165    pub bytes: Vec<u8>,
166    pub next: u64,
167    pub gap: bool,
168}
169
170/// Why the drainer thread stopped reading the PTY. The daemon (Wave 3) reads
171/// this via [`PtySession::drain_outcome`] to distinguish a clean child exit
172/// from a PTY fault and surface a `pty_drainer_errored` event. Without it, a
173/// quietly-faulted PTY is indistinguishable from a healthy quiet agent.
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub enum DrainOutcome {
176    /// Drainer still running.
177    #[default]
178    Running,
179    /// Child closed the slave cleanly (read returned 0 bytes / EOF).
180    Eof,
181    /// A read error terminated the drainer. `kind`/`message` are preserved so
182    /// the daemon can attribute the fault rather than guessing.
183    Errored { kind: String, message: String },
184}
185
186/// A live PTY-managed child plus its output drainer. The owner (a worker
187/// process under Outcome B) holds this for the child's lifetime.
188pub struct PtySession {
189    // Keep the master alive for the session: holding it keeps the fd open and
190    // backs `resize`. Boxed trait object as returned by portable-pty.
191    master: Box<dyn MasterPty + Send>,
192    writer: Mutex<Box<dyn Write + Send>>,
193    child: Mutex<Box<dyn portable_pty::Child + Send + Sync>>,
194    child_pid: Option<u32>,
195    ring: Arc<Mutex<BoundedRing>>,
196    drain_outcome: Arc<Mutex<DrainOutcome>>,
197    drainer: Option<JoinHandle<()>>,
198}
199
200impl PtySession {
201    /// Spawn `cmd` on a fresh PTY of `rows`x`cols` with an output ring of
202    /// `ring_bytes`, and start the drainer thread.
203    pub fn spawn(
204        cmd: CommandBuilder,
205        rows: u16,
206        cols: u16,
207        ring_bytes: usize,
208    ) -> Result<PtySession, PtyError> {
209        let pty_system = native_pty_system();
210        let pair = pty_system
211            .openpty(PtySize {
212                rows,
213                cols,
214                pixel_width: 0,
215                pixel_height: 0,
216            })
217            .map_err(|e| PtyError::OpenPty(e.to_string()))?;
218
219        let child = pair
220            .slave
221            .spawn_command(cmd)
222            .map_err(|e| PtyError::Spawn(e.to_string()))?;
223        let child_pid = child.process_id();
224
225        // Standard pattern: drop the slave so only the child holds it. The
226        // master (held below) is the supervised-output side.
227        drop(pair.slave);
228
229        let writer = pair
230            .master
231            .take_writer()
232            .map_err(|e| PtyError::Writer(e.to_string()))?;
233        let reader = pair
234            .master
235            .try_clone_reader()
236            .map_err(|e| PtyError::Reader(e.to_string()))?;
237
238        let ring = Arc::new(Mutex::new(BoundedRing::new(ring_bytes)));
239        let drain_outcome = Arc::new(Mutex::new(DrainOutcome::Running));
240        let drainer = spawn_drainer(reader, Arc::clone(&ring), Arc::clone(&drain_outcome))?;
241
242        Ok(PtySession {
243            master: pair.master,
244            writer: Mutex::new(writer),
245            child: Mutex::new(child),
246            child_pid,
247            ring,
248            drain_outcome,
249            drainer: Some(drainer),
250        })
251    }
252
253    /// Why the drainer stopped (or [`DrainOutcome::Running`] if still draining).
254    /// The daemon turns an `Errored` outcome into a `pty_drainer_errored` event.
255    pub fn drain_outcome(&self) -> DrainOutcome {
256        match self.drain_outcome.lock() {
257            Ok(o) => o.clone(),
258            Err(poisoned) => poisoned.into_inner().clone(),
259        }
260    }
261
262    /// PID of the spawned child, if the platform reported one.
263    pub fn child_pid(&self) -> Option<u32> {
264        self.child_pid
265    }
266
267    /// Write bytes to the child's stdin (PTY master). The caller is responsible
268    /// for any envelope wrapping (non-Claude providers).
269    pub fn write_input(&self, bytes: &[u8]) -> Result<(), PtyError> {
270        // Mutex poisoning would mean a prior writer panicked mid-write; treat
271        // as a write failure rather than propagating a panic.
272        let mut w = self
273            .writer
274            .lock()
275            .map_err(|_| PtyError::Write(std::io::Error::other("writer mutex poisoned")))?;
276        w.write_all(bytes).map_err(PtyError::Write)?;
277        w.flush().map_err(PtyError::Write)?;
278        Ok(())
279    }
280
281    /// Resize the PTY (drive resize handshake, terminal change).
282    pub fn resize(&self, rows: u16, cols: u16) -> Result<(), PtyError> {
283        self.master
284            .resize(PtySize {
285                rows,
286                cols,
287                pixel_width: 0,
288                pixel_height: 0,
289            })
290            .map_err(|e| PtyError::Resize(e.to_string()))
291    }
292
293    /// Snapshot the current ring contents (oldest-to-newest).
294    pub fn snapshot(&self) -> Vec<u8> {
295        match self.ring.lock() {
296            Ok(r) => r.snapshot(),
297            Err(poisoned) => {
298                // A prior holder panicked mid-mutation. Recover (availability
299                // over propagation) but do not let the panic vanish silently.
300                tracing::warn!("pty ring mutex poisoned; recovering for snapshot");
301                poisoned.into_inner().snapshot()
302            }
303        }
304    }
305
306    /// Bytes dropped from the ring due to overflow.
307    pub fn dropped_bytes(&self) -> u64 {
308        match self.ring.lock() {
309            Ok(r) => r.dropped_bytes(),
310            Err(poisoned) => {
311                tracing::warn!("pty ring mutex poisoned; recovering for dropped_bytes");
312                poisoned.into_inner().dropped_bytes()
313            }
314        }
315    }
316
317    /// Incrementally read PTY output appended after `cursor` (drive streaming).
318    /// See [`BoundedRing::read_since`]. A poisoned ring recovers in place rather
319    /// than propagating a panic onto the drive output pump.
320    pub fn read_since(&self, cursor: u64) -> ReadSince {
321        match self.ring.lock() {
322            Ok(r) => r.read_since(cursor),
323            Err(poisoned) => {
324                tracing::warn!("pty ring mutex poisoned; recovering for read_since");
325                poisoned.into_inner().read_since(cursor)
326            }
327        }
328    }
329
330    /// True if the child has not yet exited.
331    ///
332    /// On a poisoned child mutex this returns `true` (assume alive), NOT
333    /// `false`: the primary consumer is `Drop`, which kills the child only when
334    /// this reports alive. Reporting `false` on poison would skip the kill and
335    /// leak a still-running child. Erring toward "alive" makes `Drop` attempt
336    /// the (idempotent) kill instead.
337    pub fn is_child_alive(&self) -> bool {
338        let mut child = match self.child.lock() {
339            Ok(c) => c,
340            Err(_) => {
341                tracing::warn!("pty child mutex poisoned; assuming alive so Drop still kills");
342                return true;
343            }
344        };
345        // Return false ONLY when certain the child has exited (`Ok(Some(_))`).
346        // A `try_wait` error (e.g. ECHILD if reaped elsewhere) errs toward
347        // "alive" so `Drop`'s idempotent kill still fires rather than leaking a
348        // lingering process.
349        !matches!(child.try_wait(), Ok(Some(_)))
350    }
351
352    /// Block until the child exits, returning its exit code (if known).
353    pub fn wait(&self) -> Result<u32, PtyError> {
354        let mut child = self
355            .child
356            .lock()
357            .map_err(|_| PtyError::Wait("child mutex poisoned".into()))?;
358        let status = child.wait().map_err(|e| PtyError::Wait(e.to_string()))?;
359        Ok(status.exit_code())
360    }
361
362    /// Kill the child (SIGKILL-equivalent via portable-pty).
363    pub fn kill(&self) -> Result<(), PtyError> {
364        let mut child = self
365            .child
366            .lock()
367            .map_err(|_| PtyError::Kill("child mutex poisoned".into()))?;
368        child.kill().map_err(|e| PtyError::Kill(e.to_string()))
369    }
370
371    /// Join the drainer thread with a bounded wait. If it does not terminate
372    /// within `timeout` (e.g. the child is wedged in uninterruptible sleep so
373    /// the blocking read never sees EOF), DETACH it rather than blocking the
374    /// caller forever: drop the handle, log a warning. A leaked-but-logged
375    /// thread beats a `Drop` that hangs the whole worker.
376    fn join_drainer(&mut self, timeout: Duration) {
377        if let Some(handle) = self.drainer.take() {
378            let deadline = Instant::now() + timeout;
379            while !handle.is_finished() {
380                if Instant::now() >= deadline {
381                    tracing::warn!(
382                        "pty drainer did not terminate within {:?}; detaching (child may be in uninterruptible sleep)",
383                        timeout
384                    );
385                    return; // detach: handle dropped without join
386                }
387                std::thread::sleep(Duration::from_millis(20));
388            }
389            let _ = handle.join();
390        }
391    }
392}
393
394impl Drop for PtySession {
395    fn drop(&mut self) {
396        // Best-effort: if the child is still running, kill it so the drainer's
397        // blocking read sees EOF and the thread can exit. Then join with a
398        // bounded wait, detaching the drainer if the child is wedged in
399        // uninterruptible sleep so `Drop` cannot hang the worker indefinitely.
400        if self.is_child_alive() {
401            let _ = self.kill();
402        }
403        self.join_drainer(DRAINER_JOIN_TIMEOUT);
404    }
405}
406
407/// Spawn the drainer thread: read the PTY master to EOF, copying into the ring.
408/// Records why it stopped into `drain_outcome` so a read fault is distinguishable
409/// from a clean child exit (instead of both collapsing to a silent `break`).
410fn spawn_drainer(
411    mut reader: Box<dyn Read + Send>,
412    ring: Arc<Mutex<BoundedRing>>,
413    drain_outcome: Arc<Mutex<DrainOutcome>>,
414) -> Result<JoinHandle<()>, PtyError> {
415    std::thread::Builder::new()
416        .name("fno-agents-pty-drainer".into())
417        .spawn(move || {
418            let mut buf = [0u8; 8192];
419            let outcome = loop {
420                match reader.read(&mut buf) {
421                    Ok(0) => break DrainOutcome::Eof, // child closed the slave
422                    Ok(n) => {
423                        // Lock briefly to append; never hold across a read.
424                        match ring.lock() {
425                            Ok(mut r) => r.extend(&buf[..n]),
426                            Err(poisoned) => {
427                                tracing::warn!("pty ring mutex poisoned in drainer; recovering");
428                                poisoned.into_inner().extend(&buf[..n]);
429                            }
430                        }
431                    }
432                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
433                    Err(e) => {
434                        // On Linux a PTY master read returns EIO when the slave
435                        // closes, which is the NORMAL child-exit termination
436                        // (macOS returns Ok(0)). Treat EIO as a clean EOF so a
437                        // healthy Linux exit is not misclassified as a PTY fault
438                        // (which would wrongly drive pty_drainer_errored in the
439                        // Wave 3 daemon). Any OTHER error is a real fault whose
440                        // cause we preserve rather than discard.
441                        if e.raw_os_error() == Some(libc::EIO) {
442                            tracing::debug!("pty drainer read returned EIO (slave closed); treating as clean EOF");
443                            break DrainOutcome::Eof;
444                        }
445                        tracing::warn!(error = %e, kind = ?e.kind(), "pty drainer read faulted");
446                        break DrainOutcome::Errored {
447                            kind: format!("{:?}", e.kind()),
448                            message: e.to_string(),
449                        };
450                    }
451                }
452            };
453            match drain_outcome.lock() {
454                Ok(mut o) => *o = outcome,
455                Err(poisoned) => *poisoned.into_inner() = outcome,
456            }
457        })
458        // Thread creation only fails under OS resource exhaustion (EAGAIN/
459        // ENOMEM). Return a typed error rather than panicking: this is library
460        // code on a long-lived supervision path, so an avoidable panic would be
461        // an availability failure for the worker/daemon.
462        .map_err(|e| PtyError::Spawn(format!("drainer thread spawn failed: {e}")))
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn ring_keeps_newest_on_overflow_and_counts_drops() {
471        let mut ring = BoundedRing::new(4);
472        ring.extend(b"abc");
473        assert_eq!(ring.snapshot(), b"abc");
474        assert_eq!(ring.dropped_bytes(), 0);
475        ring.extend(b"de"); // "abcde" -> drop "a", keep "bcde"
476        assert_eq!(ring.snapshot(), b"bcde");
477        assert_eq!(ring.dropped_bytes(), 1);
478    }
479
480    #[test]
481    fn ring_chunk_larger_than_capacity_keeps_tail() {
482        let mut ring = BoundedRing::new(3);
483        ring.extend(b"xy");
484        ring.extend(b"123456"); // bigger than capacity; keep last 3 = "456"
485        assert_eq!(ring.snapshot(), b"456");
486        // dropped = the 2 buffered ("xy") + 3 leading of the chunk ("123")
487        assert_eq!(ring.dropped_bytes(), 5);
488    }
489
490    #[test]
491    fn ring_zero_capacity_clamps_to_one() {
492        let mut ring = BoundedRing::new(0);
493        assert_eq!(ring.capacity(), 1);
494        ring.extend(b"ab");
495        assert_eq!(ring.snapshot(), b"b");
496    }
497
498    #[test]
499    fn read_since_streams_contiguous_appends() {
500        let mut ring = BoundedRing::new(64);
501        // Fresh reader starts at cursor 0.
502        let r0 = ring.read_since(0);
503        assert_eq!(r0.bytes, b"");
504        assert_eq!(r0.next, 0);
505        assert!(!r0.gap);
506
507        ring.extend(b"hello");
508        let r1 = ring.read_since(r0.next);
509        assert_eq!(r1.bytes, b"hello");
510        assert_eq!(r1.next, 5);
511        assert!(!r1.gap);
512
513        ring.extend(b" world");
514        let r2 = ring.read_since(r1.next);
515        assert_eq!(r2.bytes, b" world");
516        assert_eq!(r2.next, 11);
517        assert!(!r2.gap);
518
519        // Re-reading at the tail yields nothing and does not rewind.
520        let r3 = ring.read_since(r2.next);
521        assert_eq!(r3.bytes, b"");
522        assert_eq!(r3.next, 11);
523        assert!(!r3.gap);
524    }
525
526    #[test]
527    fn read_since_flags_gap_when_cursor_bytes_were_dropped() {
528        let mut ring = BoundedRing::new(4);
529        ring.extend(b"abcd"); // total=4, dropped=0
530        let r1 = ring.read_since(0);
531        assert_eq!(r1.bytes, b"abcd");
532        assert_eq!(r1.next, 4);
533        assert!(!r1.gap);
534
535        // Overflow: "ef" pushes out "ab". total=6, dropped=2, buf="cdef".
536        ring.extend(b"ef");
537        // A reader stuck at cursor 0 lost bytes 0..2 ("ab"): gap, gets the live
538        // window, advances to the tail.
539        let stale = ring.read_since(0);
540        assert!(stale.gap);
541        assert_eq!(stale.bytes, b"cdef");
542        assert_eq!(stale.next, 6);
543
544        // A reader caught up at cursor 4 only missed nothing it had seen; it
545        // gets "ef" with no gap (4 >= dropped=2).
546        let fresh = ring.read_since(4);
547        assert!(!fresh.gap);
548        assert_eq!(fresh.bytes, b"ef");
549        assert_eq!(fresh.next, 6);
550    }
551
552    #[test]
553    fn read_since_future_cursor_is_noop() {
554        let mut ring = BoundedRing::new(16);
555        ring.extend(b"abc"); // total=3
556        let r = ring.read_since(99);
557        assert_eq!(r.bytes, b"");
558        assert_eq!(r.next, 99);
559        assert!(!r.gap);
560    }
561}