Skip to main content

retroglyph_recorder/
pty.rs

1//! [`capture_pty`]: a real-pseudo-terminal capture source, behind the `pty` feature.
2//!
3//! Promotes the technique `examples/tests/support::capture_pty_until` already used (test-only,
4//! synchronizing on a ready marker) into a public, reusable capture source that feeds the same
5//! [`write_cast`](crate::write_cast) path [`FrameRecorder`](crate::FrameRecorder) does. This is
6//! the mechanism retroglyph#461's "supersede the `vhs` tape plan" bet rests on: a real
7//! `portable-pty` pseudo-terminal plus a real `vt100` VT-parser aims for the fidelity `vhs` gets
8//! from a real terminal emulator, without one in this crate's own dependency tree.
9//!
10//! [`PtySession`] is the spawn/write/read/lifecycle plumbing this shares with
11//! `examples/tests/support`'s marker/settle-driven test harness (retroglyph#1320): opening the
12//! PTY, spawning the child, and draining its output on a background thread are identical in both
13//! places, but what each side does with that output differs -- `capture_pty` below polls on a
14//! fixed interval and diffs settled frames, while the test harness waits for a ready-marker
15//! substring, writes input, then waits for a caller's settle callback before quitting, with much
16//! richer panic diagnostics. `PtySession` only owns the part that's genuinely identical; neither
17//! loop shape is imposed on the other.
18//!
19//! # Fidelity vs. the `TestHarness`-driven source
20//!
21//! Frames here come from diffing successive [`vt100::Screen`] polls of a real child process's PTY
22//! output, not from `Output::draw_layers`'s own diff stream -- so timing reflects real scheduling
23//! (the child's own draw cadence, kernel PTY buffering, this capture's poll interval) rather than
24//! `FrameRecorder`'s frame-exact clock, and a poll interval coarser than the source's redraw rate
25//! can miss or coalesce short-lived intermediate frames a `TestHarness`-driven capture would
26//! record individually. Both still converge on identical [`write_cast`](crate::write_cast) output
27//! for a *settled* scripted session (see this crate's `pty_matches_frame_recorder` test): the
28//! difference is in what "a frame" means for a live, real-terminal source, not in the exported
29//! format.
30
31use crate::CapturedFrame;
32use crate::owned_cell::OwnedCell;
33use retroglyph_core::color::{Color, Style};
34use retroglyph_core::grid::{Pos, Size};
35use retroglyph_core::tile::Tile;
36use std::io::{Read, Write};
37use std::path::Path;
38use std::sync::{Arc, Mutex};
39use std::time::{Duration, Instant};
40
41/// How often the child's accumulated PTY output is re-parsed and diffed against the previous
42/// poll's screen. Fast enough that a docs-length capture (seconds, not minutes) doesn't visibly
43/// coarsen the recorded pacing; see this module's docs for what a coarser interval costs.
44const POLL_INTERVAL: Duration = Duration::from_millis(16);
45
46/// A live, real-pseudo-terminal-backed child process.
47///
48/// The spawn/write/read/lifecycle plumbing [`capture_pty`] and `examples/tests/support`'s
49/// marker/settle-driven test harness both need, factored out so each keeps its own distinct
50/// polling loop on top of it. See this module's docs for why the two loops aren't shared too.
51pub struct PtySession {
52    child: Box<dyn portable_pty::Child + Send + Sync>,
53    // Kept alive for the session's duration even though nothing reads from it directly once
54    // `reader`/`writer` are split off it below: dropping a `portable_pty` master closes the PTY
55    // out from under a still-running child.
56    _master: Box<dyn portable_pty::MasterPty + Send>,
57    writer: Option<Box<dyn Write + Send>>,
58    output: Arc<Mutex<Vec<u8>>>,
59    reader_done: Arc<Mutex<bool>>,
60    reader_handle: Option<std::thread::JoinHandle<()>>,
61}
62
63impl PtySession {
64    /// Opens a `cols`x`rows` pseudo-terminal, spawns `bin` (with `args` and `env`, plus
65    /// `TERM=xterm-256color`) on its slave side, and starts draining the master side's output on
66    /// a background thread into [`output`](Self::output).
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the PTY can't be opened, `bin` can't be spawned, or the reader/writer
71    /// handles can't be obtained from the master side.
72    ///
73    /// # Panics
74    ///
75    /// Never panics itself; the spawned background reader thread panics only if `output`'s lock
76    /// is poisoned, which requires another panic while holding it first.
77    pub fn spawn(
78        bin: &Path,
79        args: &[&str],
80        env: &[(&str, &str)],
81        cols: u16,
82        rows: u16,
83    ) -> std::io::Result<Self> {
84        let pty = portable_pty::native_pty_system();
85        let pair = pty
86            .openpty(portable_pty::PtySize {
87                rows,
88                cols,
89                pixel_width: 0,
90                pixel_height: 0,
91            })
92            .map_err(|error| std::io::Error::other(error.to_string()))?;
93
94        let mut cmd = portable_pty::CommandBuilder::new(bin);
95        cmd.env("TERM", "xterm-256color");
96        for arg in args {
97            cmd.arg(arg);
98        }
99        for (key, value) in env {
100            cmd.env(key, value);
101        }
102        let child = pair
103            .slave
104            .spawn_command(cmd)
105            .map_err(|error| std::io::Error::other(error.to_string()))?;
106        drop(pair.slave);
107
108        let mut reader = pair
109            .master
110            .try_clone_reader()
111            .map_err(|error| std::io::Error::other(error.to_string()))?;
112        let writer = pair
113            .master
114            .take_writer()
115            .map_err(|error| std::io::Error::other(error.to_string()))?;
116
117        let output = Arc::new(Mutex::new(Vec::new()));
118        let output_clone = Arc::clone(&output);
119        let reader_done = Arc::new(Mutex::new(false));
120        let reader_done_clone = Arc::clone(&reader_done);
121        let reader_handle = std::thread::spawn(move || {
122            let mut buf = [0u8; 4096];
123            loop {
124                match reader.read(&mut buf) {
125                    Ok(0) | Err(_) => break,
126                    Ok(n) => output_clone.lock().unwrap().extend_from_slice(&buf[..n]),
127                }
128            }
129            *reader_done_clone.lock().unwrap() = true;
130            drop(reader);
131        });
132
133        Ok(Self {
134            child,
135            _master: pair.master,
136            writer: Some(writer),
137            output,
138            reader_done,
139            reader_handle: Some(reader_handle),
140        })
141    }
142
143    /// The shared output buffer, appended to by the background reader thread as bytes arrive.
144    /// Lock it directly (rather than calling this repeatedly and cloning the whole capture) to
145    /// scan or parse only the bytes that arrived since a caller's own last poll.
146    #[must_use]
147    pub fn output(&self) -> Arc<Mutex<Vec<u8>>> {
148        Arc::clone(&self.output)
149    }
150
151    /// Set once the background reader thread's `read` returns EOF or an error: the child has
152    /// closed the PTY and no more output will ever arrive. Useful as a non-blocking check before
153    /// committing to a wait with its own timeout/diagnostics, which [`finish`](Self::finish)
154    /// doesn't provide (it blocks on the reader thread unconditionally).
155    #[must_use]
156    pub fn reader_done(&self) -> Arc<Mutex<bool>> {
157        Arc::clone(&self.reader_done)
158    }
159
160    /// Writes `bytes` to the child's PTY input.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the write half was already closed via
165    /// [`close_writer`](Self::close_writer), or if the underlying write fails (e.g. the child has
166    /// exited and closed its end of the PTY).
167    pub fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
168        self.writer.as_mut().map_or_else(
169            || Err(std::io::Error::other("PTY writer already closed")),
170            |writer| writer.write_all(bytes),
171        )
172    }
173
174    /// Closes the write half of the PTY, so the child sees EOF the next time it reads its stdin.
175    /// Idempotent. [`finish`](Self::finish) always does this first; call it directly only to
176    /// close the write half earlier (e.g. before a child that reacts to EOF on its own).
177    pub fn close_writer(&mut self) {
178        self.writer = None;
179    }
180
181    /// Polls whether the child has exited, without blocking.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the underlying platform wait call fails.
186    pub fn try_wait(&mut self) -> std::io::Result<Option<portable_pty::ExitStatus>> {
187        self.child.try_wait()
188    }
189
190    /// Sends the child a kill signal. Best-effort: a child that has already exited is not an
191    /// error here.
192    pub fn kill(&mut self) {
193        let _ = self.child.kill();
194    }
195
196    /// Blocks until the child exits. Best-effort: an already-reaped child is not an error here.
197    pub fn wait(&mut self) {
198        let _ = self.child.wait();
199    }
200
201    /// Closes the write half (if still open) and blocks until the background reader thread has
202    /// fully drained the output and returned, then hands back everything captured.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the reader thread itself panicked.
207    ///
208    /// # Panics
209    ///
210    /// Panics if `reader_handle` is already `None`, which can't happen through the public API --
211    /// [`spawn`](Self::spawn) always sets it, and `finish` is the only place that takes it.
212    pub fn finish(mut self) -> std::io::Result<Vec<u8>> {
213        self.close_writer();
214        self.reader_handle
215            .take()
216            .expect("PtySession::spawn always sets reader_handle")
217            .join()
218            .map_err(|_| std::io::Error::other("PTY reader thread panicked"))?;
219        Ok(std::mem::take(
220            &mut *self
221                .output
222                .lock()
223                .unwrap_or_else(std::sync::PoisonError::into_inner),
224        ))
225    }
226}
227
228/// Runs `bin` (with `args`) under a real pseudo-terminal sized `cols`x`rows`, and captures its
229/// settled frames until it exits or `timeout` elapses.
230///
231/// Polls the accumulated PTY output through a [`vt100::Parser`] every `POLL_INTERVAL`, diffing
232/// each poll's [`vt100::Screen`] cell-by-cell against the previous one; a poll with no visible
233/// change produces no [`CapturedFrame`], the same convention
234/// [`FrameRecorder`](crate::FrameRecorder) uses for an empty `draw_layers` call.
235///
236/// # Errors
237///
238/// Returns an error if the PTY can't be opened, `bin` can't be spawned, or the reader thread
239/// panics.
240///
241/// # Panics
242///
243/// Panics if the internal output buffer's lock is poisoned, which only happens if the reader
244/// thread itself panics while holding it.
245pub fn capture_pty(
246    bin: &Path,
247    args: &[&str],
248    cols: u16,
249    rows: u16,
250    timeout: Duration,
251) -> std::io::Result<(Size, Vec<CapturedFrame>)> {
252    let mut session = PtySession::spawn(bin, args, &[], cols, rows)?;
253
254    let started = Instant::now();
255    let mut parser = vt100::Parser::new(rows, cols, 0);
256    let mut parsed = 0usize;
257    let mut previous: Option<Vec<Vec<(String, Style)>>> = None;
258    let mut frames = Vec::new();
259
260    let output = session.output();
261    loop {
262        std::thread::sleep(POLL_INTERVAL);
263        {
264            let raw = output.lock().unwrap();
265            parser.process(&raw[parsed..]);
266            parsed = raw.len();
267        }
268
269        let screen = parser.screen();
270        let current = snapshot(screen, cols, rows);
271        if let Some(cells) = diff(previous.as_deref(), &current) {
272            frames.push(CapturedFrame {
273                at: started.elapsed(),
274                cells,
275            });
276        }
277        previous = Some(current);
278
279        if matches!(session.try_wait(), Ok(Some(_))) || started.elapsed() > timeout {
280            break;
281        }
282    }
283
284    session.kill();
285    session.wait();
286    // The child (and any process still holding the slave open) has now actually gone away, so
287    // `finish`'s own writer close unblocks the reader thread's `read` with EOF.
288    session.finish()?;
289
290    Ok((Size::new(cols, rows), frames))
291}
292
293/// One `(glyph+grapheme, style)` pair per visible cell, row-major -- a plain, comparable snapshot
294/// of a [`vt100::Screen`], independent of `vt100`'s own cell representation.
295fn snapshot(screen: &vt100::Screen, cols: u16, rows: u16) -> Vec<Vec<(String, Style)>> {
296    (0..rows)
297        .map(|row| {
298            (0..cols)
299                .map(|col| {
300                    let Some(cell) = screen.cell(row, col) else {
301                        return (String::new(), Style::default());
302                    };
303                    let style = Style::new()
304                        .fg(convert_color(cell.fgcolor()))
305                        .bg(convert_color(cell.bgcolor()));
306                    (cell.contents().to_owned(), style)
307                })
308                .collect()
309        })
310        .collect()
311}
312
313/// Every cell that differs between `previous` and `current`, as [`OwnedCell`]s -- `None` if
314/// nothing changed (mirrors [`FrameRecorder`](crate::FrameRecorder)'s "no frame for an empty
315/// `draw_layers`" convention).
316fn diff(
317    previous: Option<&[Vec<(String, Style)>]>,
318    current: &[Vec<(String, Style)>],
319) -> Option<Vec<OwnedCell>> {
320    let mut cells = Vec::new();
321    for (y, row) in current.iter().enumerate() {
322        for (x, (contents, style)) in row.iter().enumerate() {
323            let prev_cell = previous.and_then(|p| p.get(y)).and_then(|r| r.get(x));
324            let changed = prev_cell.is_none_or(|(prev_contents, prev_style)| {
325                prev_contents != contents || prev_style != style
326            });
327            if !changed {
328                continue;
329            }
330            let glyph = contents.chars().next().unwrap_or(' ');
331            let grapheme = if contents.chars().count() > 1 {
332                Some(contents.clone())
333            } else {
334                None
335            };
336            #[allow(clippy::cast_possible_truncation)]
337            let pos = Pos::new(x as u16, y as u16);
338            cells.push(OwnedCell {
339                layer: 0,
340                pos,
341                tile: Tile::new(glyph, *style),
342                grapheme,
343                tint: retroglyph_core::color::Tint::None,
344            });
345        }
346    }
347    if cells.is_empty() { None } else { Some(cells) }
348}
349
350/// Maps a `vt100` color to this workspace's own [`Color`]. `vt100::Color::Idx` collapses to
351/// [`Color::Indexed`] uniformly (rather than distinguishing the first 16 indices as
352/// [`Color::Ansi`]): the SGR this crate's own [`push_sgr`](retroglyph_core::color::sgr::push_sgr)
353/// emits for `Indexed` (`38;5;n`/`48;5;n`) renders identically to the 16-color form for those
354/// indices in every terminal that matters here, so the distinction isn't worth carrying through.
355const fn convert_color(color: vt100::Color) -> Color {
356    match color {
357        vt100::Color::Default => Color::Default,
358        vt100::Color::Idx(index) => Color::Indexed(index),
359        vt100::Color::Rgb(r, g, b) => Color::rgb(r, g, b),
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn snapshot_and_diff_report_no_change_between_identical_snapshots() {
369        let a = vec![vec![("x".to_owned(), Style::default())]];
370        let b = a.clone();
371        assert!(diff(Some(&a), &b).is_none());
372    }
373
374    #[test]
375    fn diff_reports_a_changed_cell() {
376        let a = vec![vec![("x".to_owned(), Style::default())]];
377        let b = vec![vec![("y".to_owned(), Style::default())]];
378        let cells = diff(Some(&a), &b).expect("expected a diff");
379        assert_eq!(cells.len(), 1);
380        assert_eq!(cells[0].tile.glyph(), 'y');
381    }
382
383    #[test]
384    fn first_diff_treats_every_cell_as_changed() {
385        let current = vec![vec![
386            ("x".to_owned(), Style::default()),
387            ("y".to_owned(), Style::default()),
388        ]];
389        let cells = diff(None, &current).expect("expected a diff");
390        assert_eq!(cells.len(), 2);
391    }
392}