Skip to main content

tear_core/
recording.rs

1//! Per-pane recording — captures every PTY byte with a relative
2//! timestamp, exposes a ring-buffered snapshot, and exports as
3//! asciinema v2 .cast (JSON-lines) so any external player handles
4//! playback. Daemon-native: no `asciinema rec` wrapper needed.
5//!
6//! Storage shape
7//! -------------
8//! Each pane gets a `PaneRecording` instance keyed off the pane
9//! id. Recording starts as Disabled; `enable()` flips it on and
10//! marks the start instant. From then, every chunk fed via
11//! [`Self::push`] is appended as `(millis_since_start, Vec<u8>)`.
12//!
13//! Size cap
14//! --------
15//! A long-lived pane could pile up arbitrarily many bytes. Each
16//! recording has a configurable max event count (default 50_000 —
17//! enough for ~1h of typical interactive output). When the cap is
18//! exceeded, the oldest events are dropped (ring-buffer semantics)
19//! so the recording always reflects the most recent N events.
20//!
21//! Export format
22//! -------------
23//! `to_cast_json()` emits the asciinema v2 header line + one
24//! data line per event. Every byte chunk lands as `[t_s, "o",
25//! "<utf-8 string>"]` — the v2 wire shape. Non-UTF-8 bytes are
26//! losslessly preserved by the JSON string encoding (asciinema
27//! players handle the raw bytes the same as terminal display
28//! does).
29
30use std::sync::Mutex;
31use std::time::Instant;
32
33use serde::{Deserialize, Serialize};
34
35/// Wall-clock now, unix epoch ms. Saturates to 0 before the epoch rather
36/// than panicking — a clock that far wrong is not this module's problem to
37/// escalate.
38fn now_unix_ms() -> u64 {
39    use std::time::{SystemTime, UNIX_EPOCH};
40    SystemTime::now()
41        .duration_since(UNIX_EPOCH)
42        .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
43        .unwrap_or(0)
44}
45
46/// One captured PTY chunk, relative to the recording's start.
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct PaneEvent {
49    /// Milliseconds since `enable()` was called.
50    pub ts_ms: u64,
51    /// Raw bytes pushed to the PTY's output side. The vte parser
52    /// has already fed these into the grid; the recording stores
53    /// a verbatim copy so replays match what a live viewer saw.
54    pub bytes: Vec<u8>,
55}
56
57/// State of a per-pane recording. Cheap when disabled — the
58/// `on_bytes` hook hits a single boolean check before deciding
59/// whether to deep-copy the chunk.
60pub struct PaneRecording {
61    enabled: Mutex<RecordingState>,
62}
63
64struct RecordingState {
65    /// `None` when disabled.
66    started_at: Option<Instant>,
67    /// Wall-clock start, unix epoch ms. `None` when disabled.
68    ///
69    /// `started_at` is an [`Instant`] — monotonic, with NO epoch — so it
70    /// can measure elapsed time and can never answer "when did this
71    /// begin?". This field is that answer, and it exists for two reasons:
72    ///
73    /// 1. The asciinema v2 header's `timestamp` is the RECORDING START.
74    ///    Without an anchor `to_cast_json` had to reach for
75    ///    `SystemTime::now()`, which stamps the EXPORT time — so a cast
76    ///    exported a day later claimed to have been recorded a day later.
77    /// 2. It is the join key to anything stamped in epoch time (a
78    ///    `Block.started_at_unix_ms`, say). `read_around` takes ms since
79    ///    `enable()`, so converting requires this anchor; passing an
80    ///    epoch timestamp straight in is off by ~1.7e12 and silently
81    ///    returns the buffer tail rather than erroring.
82    started_at_unix_ms: Option<u64>,
83    /// Captured events. Ring-buffered against `max_events`.
84    events: std::collections::VecDeque<PaneEvent>,
85    /// Max retained events. Default 50_000.
86    max_events: usize,
87    /// Recorded cols × rows at start — written into the asciinema
88    /// cast header on export.
89    cols: u16,
90    rows: u16,
91}
92
93impl Default for PaneRecording {
94    fn default() -> Self {
95        Self::new(50_000)
96    }
97}
98
99impl PaneRecording {
100    #[must_use]
101    pub fn new(max_events: usize) -> Self {
102        Self {
103            enabled: Mutex::new(RecordingState {
104                started_at: None,
105                started_at_unix_ms: None,
106                events: std::collections::VecDeque::new(),
107                max_events,
108                cols: 80,
109                rows: 24,
110            }),
111        }
112    }
113
114    /// Begin (or restart) recording. Clears prior events; stamps
115    /// the start instant; remembers the pane dimensions for the
116    /// cast header.
117    pub fn enable(&self, cols: u16, rows: u16) {
118        let mut g = self.enabled.lock().expect("recording state poisoned");
119        g.started_at = Some(Instant::now());
120        g.started_at_unix_ms = Some(now_unix_ms());
121        g.events.clear();
122        g.cols = cols;
123        g.rows = rows;
124    }
125
126    /// Stop recording. Retains captured events so an operator can
127    /// `export` them after stopping; a subsequent `enable()`
128    /// clears + restarts.
129    pub fn disable(&self) {
130        let mut g = self.enabled.lock().expect("recording state poisoned");
131        g.started_at = None;
132        g.started_at_unix_ms = None;
133    }
134
135    /// Wall-clock start of the current recording, unix epoch ms.
136    ///
137    /// The join key for anything stamped in epoch time. [`Self::read_around`]
138    /// takes ms since `enable()`, so a caller holding an epoch timestamp
139    /// must convert through this anchor:
140    ///
141    /// ```text
142    /// rel_ms = epoch_ms.saturating_sub(anchor)
143    /// ```
144    ///
145    /// Passing an epoch timestamp straight to `read_around` is off by
146    /// ~1.7e12 ms and silently returns the buffer tail — it does not error,
147    /// which is why this accessor exists rather than leaving callers to
148    /// guess.
149    #[must_use]
150    pub fn epoch_anchor(&self) -> Option<u64> {
151        self.enabled
152            .lock()
153            .expect("recording state poisoned")
154            .started_at_unix_ms
155    }
156
157    /// Backdate the epoch anchor. **Tests only.**
158    ///
159    /// Exists because the header-timestamp invariant cannot be tested
160    /// without it: `enable()` and export happen in the same wall-clock
161    /// second, so a correct implementation and one that stamps
162    /// `SystemTime::now()` produce identical output. Backdating is what
163    /// makes the two distinguishable without a `sleep`.
164    #[cfg(test)]
165    pub(crate) fn set_epoch_anchor_for_test(&self, unix_ms: u64) {
166        self.enabled
167            .lock()
168            .expect("recording state poisoned")
169            .started_at_unix_ms = Some(unix_ms);
170    }
171
172    /// Whether recording is currently capturing new events.
173    #[must_use]
174    pub fn is_enabled(&self) -> bool {
175        self.enabled
176            .lock()
177            .expect("recording state poisoned")
178            .started_at
179            .is_some()
180    }
181
182    /// Number of currently-buffered events.
183    #[must_use]
184    pub fn event_count(&self) -> usize {
185        self.enabled
186            .lock()
187            .expect("recording state poisoned")
188            .events
189            .len()
190    }
191
192    /// Append a PTY chunk. Cheap no-op when recording is disabled.
193    /// Ring-evicts the oldest event if the cap is hit.
194    pub fn push(&self, bytes: &[u8]) {
195        let mut g = self.enabled.lock().expect("recording state poisoned");
196        let Some(start) = g.started_at else {
197            return;
198        };
199        let ts_ms = start.elapsed().as_millis() as u64;
200        let cap = g.max_events;
201        if g.events.len() == cap {
202            g.events.pop_front();
203        }
204        g.events.push_back(PaneEvent {
205            ts_ms,
206            bytes: bytes.to_vec(),
207        });
208    }
209
210    /// Export as asciinema v2 .cast (JSON-lines). The first line
211    /// is the header object; every subsequent line is
212    /// `[t_seconds, "o", "<utf-8 chunk>"]`. Returns the full
213    /// string ready to write to disk or pipe to `asciinema play`.
214    pub fn to_cast_json(&self) -> String {
215        use std::time::{SystemTime, UNIX_EPOCH};
216        let g = self.enabled.lock().expect("recording state poisoned");
217        let header = serde_json::json!({
218            "version": 2,
219            "width": g.cols,
220            "height": g.rows,
221            // The RECORDING START, not the export time. See
222            // `RecordingState::started_at_unix_ms`. Falls back to now()
223            // only when nothing was ever recorded, where there is no
224            // start to report and the header value is meaningless anyway.
225            // The RECORDING START, not the export time. See
226            // `RecordingState::started_at_unix_ms`. Falls back to now()
227            // only when nothing was ever recorded, where there is no
228            // start to report and the header value is meaningless anyway.
229            "timestamp": g.started_at_unix_ms.map_or_else(
230                || SystemTime::now()
231                    .duration_since(UNIX_EPOCH)
232                    .map(|d| d.as_secs())
233                    .unwrap_or(0),
234                |ms| ms / 1000,
235            ),
236            "env": {
237                "TERM": "xterm-256color",
238                "SHELL": std::env::var("SHELL").unwrap_or_default(),
239            },
240        });
241        let mut out = header.to_string();
242        out.push('\n');
243        for ev in &g.events {
244            let secs = (ev.ts_ms as f64) / 1000.0;
245            let chunk = String::from_utf8_lossy(&ev.bytes).into_owned();
246            // asciinema v2 row: [<float-seconds>, "o", "<chunk>"]
247            let row = serde_json::Value::Array(vec![
248                serde_json::json!(secs),
249                serde_json::json!("o"),
250                serde_json::json!(chunk),
251            ]);
252            out.push_str(&row.to_string());
253            out.push('\n');
254        }
255        out
256    }
257
258    /// Read the events at (or just before) a given `ts_ms` —
259    /// returns up to `limit` events nearest to the cursor for the
260    /// time-travel scrubber. Lets a replay renderer seek without
261    /// re-reading the full recording.
262    pub fn read_around(&self, ts_ms: u64, limit: usize) -> Vec<PaneEvent> {
263        let g = self.enabled.lock().expect("recording state poisoned");
264        // Binary-search-ish — events are sorted by ts_ms since
265        // they're appended in order. Simple linear scan is fine
266        // for the typical recording size (<10k events).
267        let cursor = g
268            .events
269            .iter()
270            .position(|e| e.ts_ms >= ts_ms)
271            .unwrap_or(g.events.len());
272        let start = cursor.saturating_sub(limit / 2);
273        let end = (start + limit).min(g.events.len());
274        g.events.range(start..end).cloned().collect()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    /// ★ RED AGAINST THE CODE AS IT SHIPPED. The asciinema v2 header's
283    /// `timestamp` is the RECORDING START; `to_cast_json` stamped
284    /// `SystemTime::now()`, so a cast exported an hour later claimed to
285    /// have been recorded an hour later. `started_at` is an `Instant` —
286    /// monotonic, no epoch — so it structurally could not answer the
287    /// question, which is why the fix is a new field rather than a
288    /// different expression.
289    #[test]
290    fn the_cast_header_timestamp_is_the_recording_start_not_the_export_time() {
291        let r = PaneRecording::default();
292        r.enable(80, 24);
293        r.push(b"x");
294        let anchor = r.epoch_anchor().expect("enabled recording has an anchor");
295
296        // ★ The discrimination this test exists for. A first draft simply
297        // exported and compared against the anchor — and PASSED with the
298        // bug restored, because enable() and export land in the same
299        // wall-clock second, so `now()` and the anchor are equal. A test
300        // that cannot tell the two implementations apart proves nothing.
301        //
302        // So: backdate the anchor by an hour, standing in for "this cast
303        // was exported an hour after it was recorded" without sleeping.
304        // Only an implementation that READS the anchor can report it.
305        let backdated = anchor - 3_600_000;
306        r.set_epoch_anchor_for_test(backdated);
307
308        let json = r.to_cast_json();
309        let header: serde_json::Value =
310            serde_json::from_str(json.lines().next().unwrap()).unwrap();
311        let ts = header["timestamp"].as_u64().unwrap();
312
313        assert_eq!(
314            ts,
315            backdated / 1000,
316            "header timestamp must be the RECORDING START ({}), not the \
317             export time ({}) — a cast that misreports when it was \
318             recorded is worse than one carrying no timestamp at all",
319            backdated / 1000,
320            anchor / 1000
321        );
322    }
323
324    /// The anchor is the join key to epoch-stamped data, and it must be
325    /// absent when there is nothing to anchor.
326    #[test]
327    fn the_epoch_anchor_appears_on_enable_and_clears_on_disable() {
328        let r = PaneRecording::default();
329        assert!(r.epoch_anchor().is_none(), "nothing recorded, nothing to anchor");
330        r.enable(80, 24);
331        let a = r.epoch_anchor().expect("enabled");
332        assert!(a > 1_700_000_000_000, "anchor must be epoch MS, got {a}");
333        r.disable();
334        assert!(
335            r.epoch_anchor().is_none(),
336            "a stopped recording must not keep advertising a live anchor"
337        );
338    }
339
340    #[test]
341    fn disabled_recording_drops_pushes() {
342        let r = PaneRecording::default();
343        r.push(b"hello");
344        assert_eq!(r.event_count(), 0);
345    }
346
347    #[test]
348    fn enable_then_push_captures_events() {
349        let r = PaneRecording::default();
350        r.enable(80, 24);
351        r.push(b"hello");
352        r.push(b" world");
353        assert_eq!(r.event_count(), 2);
354    }
355
356    #[test]
357    fn ring_buffer_caps_at_max() {
358        let r = PaneRecording::new(3);
359        r.enable(80, 24);
360        r.push(b"a");
361        r.push(b"b");
362        r.push(b"c");
363        r.push(b"d");
364        r.push(b"e");
365        assert_eq!(r.event_count(), 3);
366    }
367
368    #[test]
369    fn cast_export_has_header_plus_one_line_per_event() {
370        let r = PaneRecording::default();
371        r.enable(120, 40);
372        r.push(b"$ ls\n");
373        r.push(b"file1 file2\n");
374        let cast = r.to_cast_json();
375        let lines: Vec<&str> = cast.lines().collect();
376        assert_eq!(lines.len(), 3, "expected header + 2 events, got {cast}");
377        let header: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
378        assert_eq!(header["version"], 2);
379        assert_eq!(header["width"], 120);
380        assert_eq!(header["height"], 40);
381        let ev: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
382        let arr = ev.as_array().unwrap();
383        assert_eq!(arr[1], "o");
384        assert_eq!(arr[2].as_str().unwrap(), "$ ls\n");
385    }
386
387    #[test]
388    fn disable_then_enable_clears_old_events() {
389        let r = PaneRecording::default();
390        r.enable(80, 24);
391        r.push(b"x");
392        assert_eq!(r.event_count(), 1);
393        r.disable();
394        r.enable(80, 24);
395        assert_eq!(r.event_count(), 0);
396    }
397
398    #[test]
399    fn read_around_returns_events_near_cursor() {
400        let r = PaneRecording::default();
401        r.enable(80, 24);
402        // Manually craft 5 events at fixed timestamps for a
403        // deterministic test (push uses Instant::now under the
404        // hood which we'd race on).
405        {
406            let mut g = r.enabled.lock().unwrap();
407            for i in 0..5u64 {
408                g.events.push_back(PaneEvent {
409                    ts_ms: i * 1000,
410                    bytes: vec![b'a' + i as u8],
411                });
412            }
413        }
414        let around = r.read_around(2500, 4);
415        // Cursor lands at index 3 (ts=3000 >= 2500). With limit=4
416        // start = 3 - 4/2 = 1; end = 1 + 4 = 5. So we get events at
417        // ts=1000, 2000, 3000, 4000.
418        assert_eq!(around.len(), 4);
419        assert_eq!(around[0].ts_ms, 1000);
420        assert_eq!(around[3].ts_ms, 4000);
421    }
422}