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}