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(), ¤t) {
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, ¤t).expect("expected a diff");
390 assert_eq!(cells.len(), 2);
391 }
392}