Skip to main content

rich_record/
session.rs

1//! A shell on a PTY, followed by a VT emulator, with a recording.
2
3use std::collections::VecDeque;
4use std::io::{Read, Write};
5use std::path::Path;
6use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
7use std::thread;
8use std::time::{Duration, Instant};
9
10use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
11
12use crate::screen::{Snapshot, Theme};
13use crate::terminal::Terminal;
14
15/// Frames are kept at most this often, per second: the video's frame rate.
16/// Output that arrives faster is gathered into one event and one frame.
17pub const FRAME_RATE: f64 = 12.0;
18/// Frames are kept for this much recorded time. A longer recording still
19/// has its screenshots and cast, but no video.
20pub const MAX_VIDEO: Duration = Duration::from_secs(300);
21/// Output gathered within one frame beyond this many bytes is recorded as a
22/// repaint of the screen instead, so a program that floods the terminal
23/// (`seq 1 1000000000`) costs a bounded amount of memory per frame.
24const MAX_BATCH: usize = 32 * 1024;
25/// The screens kept for [`Session::seen`], in bytes: past it the oldest go.
26const MAX_SEEN: usize = 16 * 1024 * 1024;
27
28/// What happened, and when, in the visible parts of a session.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum Event {
31    /// Bytes the program wrote.
32    Output(String),
33    /// Bytes typed into the terminal.
34    Input(String),
35    /// The terminal was resized.
36    Resize { columns: u16, rows: u16 },
37}
38
39/// The recorded timeline: seconds from the start, with hidden stretches cut.
40#[derive(Clone, Debug, Default)]
41pub struct Timeline {
42    pub events: Vec<(f64, Event)>,
43    /// The screen after each change.
44    pub frames: Vec<(f64, Snapshot)>,
45    /// Keys pressed, for the key overlay.
46    pub keys: Vec<(f64, String)>,
47    /// Whether frames after [`MAX_VIDEO`] were dropped: the timeline is too
48    /// long for video.
49    pub truncated: bool,
50}
51
52struct State {
53    terminal: Terminal,
54    theme: Theme,
55    hidden: bool,
56    hidden_since: Instant,
57    hidden_total: Duration,
58    start: Instant,
59    utf8: Vec<u8>,
60    /// Every screen shown since the last [`Session::mark`], so a `Wait`
61    /// sees text that scrolled away between polls; at most [`MAX_SEEN`]
62    /// bytes of them.
63    seen: VecDeque<String>,
64    seen_bytes: usize,
65    /// Output decoded since the last frame, and when it last arrived.
66    batch: String,
67    /// Whether `batch` passed [`MAX_BATCH`] and was dropped for a repaint.
68    overflow: bool,
69    pending: Option<f64>,
70    /// When the last frame was kept.
71    last_frame: Option<f64>,
72    timeline: Timeline,
73    alive: bool,
74    /// Why the emulator failed, if it did.
75    error: Option<String>,
76}
77
78impl State {
79    /// A hidden session's state, on a `columns` x `rows` screen.
80    fn new(rows: u16, columns: u16, theme: Theme) -> State {
81        let now = Instant::now();
82        State {
83            terminal: Terminal::new(rows, columns),
84            theme,
85            hidden: true,
86            hidden_since: now,
87            hidden_total: Duration::ZERO,
88            start: now,
89            utf8: Vec::new(),
90            seen: VecDeque::new(),
91            seen_bytes: 0,
92            batch: String::new(),
93            overflow: false,
94            pending: None,
95            last_frame: None,
96            timeline: Timeline::default(),
97            alive: true,
98            error: None,
99        }
100    }
101
102    /// Resize the screen, and record it. Output still queued was drawn at
103    /// the old size, so it is recorded, with its frame, first.
104    fn resize(&mut self, columns: u16, rows: u16) -> std::io::Result<()> {
105        if !self.hidden {
106            self.flush();
107        }
108        if let Err(error) = self.terminal.set_size(rows, columns) {
109            self.error = Some(error.to_string());
110            return Err(error);
111        }
112        if !self.hidden {
113            let t = self.now();
114            self.timeline
115                .events
116                .push((t, Event::Resize { columns, rows }));
117            self.frame(t);
118        }
119        Ok(())
120    }
121
122    fn now(&self) -> f64 {
123        (self.start.elapsed() - self.hidden_total).as_secs_f64()
124    }
125
126    fn frame(&mut self, t: f64) {
127        self.last_frame = Some(t);
128        if t > MAX_VIDEO.as_secs_f64() {
129            self.timeline.truncated = true;
130            return;
131        }
132        let snapshot = self.terminal.snapshot(&self.theme);
133        if self
134            .timeline
135            .frames
136            .last()
137            .is_some_and(|(_, last)| *last == snapshot)
138        {
139            return;
140        }
141        self.timeline.frames.push((t, snapshot));
142    }
143
144    /// Remember a screen for [`Session::seen`].
145    fn see(&mut self) {
146        let screen = self.terminal.contents();
147        if self.seen.back() == Some(&screen) {
148            return;
149        }
150        self.seen_bytes += screen.len();
151        self.seen.push_back(screen);
152        while self.seen_bytes > MAX_SEEN && self.seen.len() > 1 {
153            let old = self.seen.pop_front().expect("more than one screen");
154            self.seen_bytes -= old.len();
155        }
156    }
157
158    /// Queue output that arrived at `t`; it is recorded, with a frame, once
159    /// a frame interval has passed since the last one, or before the next
160    /// input, resize or hide.
161    fn output(&mut self, t: f64, bytes: &[u8]) {
162        self.utf8.extend_from_slice(bytes);
163        let text = self.decode();
164        if !self.overflow {
165            self.batch.push_str(&text);
166            if self.batch.len() > MAX_BATCH {
167                self.overflow = true;
168                self.batch = String::new();
169            }
170        }
171        self.pending = Some(t);
172        if self
173            .last_frame
174            .is_none_or(|last| t - last >= 1.0 / FRAME_RATE)
175        {
176            self.flush();
177        }
178    }
179
180    /// Record the queued output and its frame.
181    fn flush(&mut self) {
182        let Some(t) = self.pending.take() else {
183            return;
184        };
185        if self.overflow {
186            // The screen after the flood, drawn from scratch: what a player
187            // shows at this frame, without every line that scrolled past.
188            self.overflow = false;
189            let snapshot = self.terminal.snapshot(&self.theme);
190            let repaint = crate::render::cast::repaint(&snapshot, &self.theme);
191            self.timeline.events.push((t, Event::Output(repaint)));
192        } else if !self.batch.is_empty() {
193            let text = std::mem::take(&mut self.batch);
194            self.timeline.events.push((t, Event::Output(text)));
195        }
196        self.frame(t);
197    }
198
199    /// Decode as much of `utf8` as forms whole characters.
200    fn decode(&mut self) -> String {
201        let valid = match std::str::from_utf8(&self.utf8) {
202            Ok(_) => self.utf8.len(),
203            // An incomplete sequence at the end waits for the next read.
204            Err(e) if e.error_len().is_none() => e.valid_up_to(),
205            Err(_) => self.utf8.len(),
206        };
207        let text = String::from_utf8_lossy(&self.utf8[..valid]).into_owned();
208        self.utf8.drain(..valid);
209        text
210    }
211}
212
213/// A running shell session.
214pub struct Session {
215    state: Arc<Mutex<State>>,
216    master: Box<dyn MasterPty + Send>,
217    writer: Box<dyn Write + Send>,
218    child: Box<dyn Child + Send + Sync>,
219    /// Whether the child has been killed and waited for.
220    reaped: bool,
221}
222
223/// Lock the state, even after a panic elsewhere left the lock poisoned.
224fn lock(state: &Mutex<State>) -> MutexGuard<'_, State> {
225    state.lock().unwrap_or_else(PoisonError::into_inner)
226}
227
228fn check_size(columns: u16, rows: u16) -> std::io::Result<()> {
229    if crate::tape::size_allowed(columns, rows) {
230        return Ok(());
231    }
232    Err(std::io::Error::new(
233        std::io::ErrorKind::InvalidInput,
234        format!(
235            "a terminal of {columns}x{rows} is outside {}x{} to {}x{}",
236            crate::tape::MIN_COLUMNS,
237            crate::tape::MIN_ROWS,
238            crate::tape::MAX_COLUMNS,
239            crate::tape::MAX_ROWS
240        ),
241    ))
242}
243
244impl Session {
245    /// Start `command` (an interactive shell) in `workspace`, with exactly
246    /// the environment `env`.
247    pub fn start(
248        command: &[String],
249        workspace: &Path,
250        columns: u16,
251        rows: u16,
252        env: &[(String, String)],
253        theme: Theme,
254    ) -> std::io::Result<Session> {
255        check_size(columns, rows)?;
256        let pty = native_pty_system()
257            .openpty(PtySize {
258                rows,
259                cols: columns,
260                pixel_width: 0,
261                pixel_height: 0,
262            })
263            .map_err(std::io::Error::other)?;
264        let (program, args) = command.split_first().expect("a shell command");
265        let mut command = CommandBuilder::new(program);
266        command.args(args);
267        command.env_clear();
268        for (key, value) in env {
269            command.env(key, value);
270        }
271        command.cwd(workspace);
272        let child = pty
273            .slave
274            .spawn_command(command)
275            .map_err(std::io::Error::other)?;
276        drop(pty.slave);
277        let mut reader = pty
278            .master
279            .try_clone_reader()
280            .map_err(std::io::Error::other)?;
281        let writer = pty.master.take_writer().map_err(std::io::Error::other)?;
282        let state = Arc::new(Mutex::new(State::new(rows, columns, theme)));
283        let shared = Arc::clone(&state);
284        thread::spawn(move || {
285            let mut buffer = [0u8; 65536];
286            loop {
287                let read = match reader.read(&mut buffer) {
288                    Ok(0) | Err(_) => break,
289                    Ok(read) => read,
290                };
291                let mut state = lock(&shared);
292                if let Err(error) = state.terminal.process(&buffer[..read]) {
293                    // The session fails; the tape runner reports it.
294                    state.error = Some(error.to_string());
295                    break;
296                }
297                state.see();
298                if !state.hidden {
299                    let t = state.now();
300                    state.output(t, &buffer[..read]);
301                }
302            }
303            lock(&shared).alive = false;
304        });
305        Ok(Session {
306            state,
307            master: pty.master,
308            writer,
309            child,
310            reaped: false,
311        })
312    }
313
314    fn lock(&self) -> MutexGuard<'_, State> {
315        lock(&self.state)
316    }
317
318    /// Why the session failed (the terminal emulator panicked), if it did.
319    pub fn error(&self) -> Option<String> {
320        self.lock().error.clone()
321    }
322
323    /// Type `data`; `label` is shown by the key overlay.
324    pub fn send(&mut self, data: &str, label: Option<String>) -> std::io::Result<()> {
325        {
326            let mut state = self.lock();
327            if !state.hidden {
328                state.flush();
329                let t = state.now();
330                state
331                    .timeline
332                    .events
333                    .push((t, Event::Input(data.to_string())));
334                if let Some(label) = label {
335                    state.timeline.keys.push((t, label));
336                }
337            }
338        }
339        self.writer.write_all(data.as_bytes())?;
340        self.writer.flush()
341    }
342
343    pub fn resize(&mut self, columns: u16, rows: u16) -> std::io::Result<()> {
344        check_size(columns, rows)?;
345        self.master
346            .resize(PtySize {
347                rows,
348                cols: columns,
349                pixel_width: 0,
350                pixel_height: 0,
351            })
352            .map_err(std::io::Error::other)?;
353        self.lock().resize(columns, rows)
354    }
355
356    /// Forget the screens seen so far: the next [`Session::seen`] starts
357    /// from the current screen.
358    pub fn mark(&self) {
359        let mut state = self.lock();
360        let screen = state.terminal.contents();
361        state.seen_bytes = screen.len();
362        state.seen = VecDeque::from([screen]);
363    }
364
365    /// Whether `test` holds for the current screen or any screen shown since
366    /// the last [`Session::mark`].
367    pub fn seen(&self, test: impl Fn(&str) -> bool) -> bool {
368        let state = self.lock();
369        test(&state.terminal.contents()) || state.seen.iter().any(|screen| test(screen))
370    }
371
372    /// The screen's text, rows joined by line breaks.
373    pub fn contents(&self) -> String {
374        self.lock().terminal.contents()
375    }
376
377    pub fn alive(&self) -> bool {
378        self.lock().alive
379    }
380
381    pub fn snapshot(&self) -> Snapshot {
382        let state = self.lock();
383        state.terminal.snapshot(&state.theme)
384    }
385
386    pub fn hide(&self) {
387        let mut state = self.lock();
388        if !state.hidden {
389            state.flush();
390            state.hidden = true;
391            state.hidden_since = Instant::now();
392        }
393    }
394
395    /// Resume recording. The cast gets a repaint of the current screen, so
396    /// a player shows what the hidden steps left behind.
397    pub fn show(&self) {
398        let mut state = self.lock();
399        if state.hidden {
400            let hidden = state.hidden_since.elapsed();
401            state.hidden_total += hidden;
402            state.hidden = false;
403            state.utf8.clear();
404            state.batch.clear();
405            state.overflow = false;
406            let t = state.now();
407            let snapshot = state.terminal.snapshot(&state.theme);
408            let repaint = crate::render::cast::repaint(&snapshot, &state.theme);
409            state.timeline.events.push((t, Event::Output(repaint)));
410            state.frame(t);
411        }
412    }
413
414    /// Kill the shell, if it is still running, and reap it.
415    fn stop(&mut self) {
416        if !self.reaped {
417            self.reaped = true;
418            let _ = self.child.kill();
419            let _ = self.child.wait();
420        }
421    }
422
423    /// Stop the shell and return what was recorded.
424    pub fn finish(mut self) -> Timeline {
425        self.hide();
426        self.stop();
427        std::mem::take(&mut self.lock().timeline)
428    }
429}
430
431impl Drop for Session {
432    /// A session dropped on an error path still stops and reaps its shell.
433    fn drop(&mut self) {
434        self.stop();
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn row_text(snapshot: &Snapshot, row: usize) -> String {
443        snapshot.rows[row]
444            .iter()
445            .map(|cell| cell.text.as_str())
446            .collect()
447    }
448
449    #[test]
450    fn output_queued_before_a_resize_is_recorded_at_the_old_size() {
451        let mut state = State::new(4, 20, Theme::default());
452        state.hidden = false;
453        let t = state.now();
454        state.frame(t);
455        // Within a frame interval of the last frame: queued, not recorded.
456        state.terminal.process(b"hello").unwrap();
457        let t = state.now();
458        state.output(t, b"hello");
459        assert!(state.pending.is_some());
460        state.resize(30, 6).unwrap();
461
462        let events = &state.timeline.events;
463        let output = events
464            .iter()
465            .position(|(_, event)| *event == Event::Output("hello".into()))
466            .expect("the output is recorded");
467        let resize = events
468            .iter()
469            .position(|(_, event)| matches!(event, Event::Resize { .. }))
470            .expect("the resize is recorded");
471        assert!(output < resize, "{events:?}");
472        assert!(events[output].0 <= events[resize].0, "{events:?}");
473
474        // The output's frame is the old 20x4 screen; the resize's, 30x6.
475        let frames = &state.timeline.frames;
476        let (_, before) = &frames[frames.len() - 2];
477        assert_eq!((before.rows.len(), before.rows[0].len()), (4, 20));
478        assert!(row_text(before, 0).starts_with("hello"));
479        let (_, after) = frames.last().unwrap();
480        assert_eq!((after.rows.len(), after.rows[0].len()), (6, 30));
481    }
482}