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}