Skip to main content

victauri_core/
recording.rs

1//! Time-travel recording: captures event streams and state checkpoints
2//! for replay and debugging.
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::VecDeque;
7use std::sync::{Arc, Mutex};
8
9use crate::error::VictauriError;
10use crate::event::{AppEvent, IpcCall};
11
12const DEFAULT_MAX_CHECKPOINTS: usize = 1000;
13const DEFAULT_MAX_EVENTS: usize = 50_000;
14
15/// A snapshot of application state taken at a specific point during recording.
16#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
17pub struct StateCheckpoint {
18    /// Unique identifier for this checkpoint.
19    pub id: String,
20    /// Optional human-readable label for the checkpoint.
21    pub label: Option<String>,
22    /// When the checkpoint was created.
23    pub timestamp: DateTime<Utc>,
24    /// Serialized application state at the checkpoint.
25    pub state: serde_json::Value,
26    /// Index into the event stream at the time of this checkpoint.
27    pub event_index: usize,
28}
29
30/// A complete recorded session with events and state checkpoints. Serializable for export/import.
31#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
32pub struct RecordedSession {
33    /// Unique session identifier (UUID).
34    pub id: String,
35    /// When the recording session began.
36    pub started_at: DateTime<Utc>,
37    /// All events captured during the session, in order.
38    pub events: Vec<RecordedEvent>,
39    /// State checkpoints created during the session.
40    pub checkpoints: Vec<StateCheckpoint>,
41}
42
43/// A single event captured during a recording session, with its sequence index.
44#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
45pub struct RecordedEvent {
46    /// Monotonically increasing sequence number within the recording session.
47    pub index: usize,
48    /// When the event occurred.
49    pub timestamp: DateTime<Utc>,
50    /// The captured application event.
51    pub event: AppEvent,
52}
53
54/// Thread-safe session recorder for time-travel debugging. Records events and
55/// state checkpoints during a recording session. Only one session can be active at a time.
56#[derive(Debug, Clone)]
57pub struct EventRecorder {
58    recording: Arc<Mutex<Option<ActiveRecording>>>,
59    last_session: Arc<Mutex<Option<RecordedSession>>>,
60    max_events: usize,
61}
62
63#[derive(Debug, Clone)]
64struct ActiveRecording {
65    session_id: String,
66    started_at: DateTime<Utc>,
67    events: VecDeque<RecordedEvent>,
68    checkpoints: VecDeque<StateCheckpoint>,
69    event_counter: usize,
70    max_events: usize,
71    max_checkpoints: usize,
72}
73
74impl EventRecorder {
75    /// Creates a new recorder with the given maximum event capacity.
76    ///
77    /// ```
78    /// use victauri_core::EventRecorder;
79    ///
80    /// let recorder = EventRecorder::new(1000);
81    /// assert!(!recorder.is_recording());
82    /// assert_eq!(recorder.event_count(), 0);
83    /// ```
84    #[must_use]
85    pub fn new(max_events: usize) -> Self {
86        Self {
87            recording: Arc::new(Mutex::new(None)),
88            last_session: Arc::new(Mutex::new(None)),
89            max_events,
90        }
91    }
92
93    /// Starts a new recording session; returns `Err` if one is already active.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`VictauriError::RecordingAlreadyActive`] if a session is already in progress.
98    ///
99    /// # Examples
100    ///
101    /// ```
102    /// use victauri_core::EventRecorder;
103    ///
104    /// let recorder = EventRecorder::new(1000);
105    /// recorder.start("session-1".to_string()).unwrap();
106    /// assert!(recorder.is_recording());
107    /// ```
108    pub fn start(&self, session_id: String) -> crate::error::Result<()> {
109        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
110        if rec.is_some() {
111            return Err(VictauriError::RecordingAlreadyActive);
112        }
113        *rec = Some(ActiveRecording {
114            session_id,
115            started_at: Utc::now(),
116            events: VecDeque::new(),
117            checkpoints: VecDeque::new(),
118            event_counter: 0,
119            max_events: self.max_events,
120            max_checkpoints: DEFAULT_MAX_CHECKPOINTS,
121        });
122        Ok(())
123    }
124
125    /// Stops the active recording and returns the completed session, or None if not recording.
126    ///
127    /// # Examples
128    ///
129    /// ```
130    /// use victauri_core::EventRecorder;
131    ///
132    /// let recorder = EventRecorder::new(1000);
133    /// recorder.start("session-1".to_string()).unwrap();
134    /// let session = recorder.stop().expect("should return session");
135    /// assert_eq!(session.id, "session-1");
136    /// assert!(!recorder.is_recording());
137    /// ```
138    #[must_use]
139    pub fn stop(&self) -> Option<RecordedSession> {
140        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
141        rec.take().map(|r| {
142            let session = RecordedSession {
143                id: r.session_id,
144                started_at: r.started_at,
145                events: r.events.into_iter().collect(),
146                checkpoints: r.checkpoints.into_iter().collect(),
147            };
148            *crate::acquire_lock(&self.last_session, "EventRecorder::last_session") =
149                Some(session.clone());
150            session
151        })
152    }
153
154    /// Returns true if a recording session is currently active.
155    #[must_use]
156    pub fn is_recording(&self) -> bool {
157        crate::acquire_lock(&self.recording, "EventRecorder").is_some()
158    }
159
160    /// Appends an event to the active recording, evicting the oldest if at capacity.
161    pub fn record_event(&self, event: AppEvent) {
162        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
163        if let Some(ref mut active) = *rec {
164            let timestamp = event.timestamp();
165            let index = active.event_counter;
166            // Saturating: an imported session can seed event_counter at usize::MAX
167            // (its index is attacker-controlled); a bare `+= 1` would then panic in
168            // debug / wrap in release on the next auto-captured event (audit #18).
169            active.event_counter = active.event_counter.saturating_add(1);
170
171            if active.events.len() >= active.max_events {
172                active.events.pop_front();
173            }
174
175            active.events.push_back(RecordedEvent {
176                index,
177                timestamp,
178                event,
179            });
180        }
181    }
182
183    /// Creates a named state checkpoint at the current event index; returns `Err` if not recording.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`VictauriError::NoActiveRecording`] if no session is in progress.
188    pub fn checkpoint(
189        &self,
190        id: String,
191        label: Option<String>,
192        state: serde_json::Value,
193    ) -> crate::error::Result<()> {
194        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
195        if let Some(ref mut active) = *rec {
196            let event_index = active.event_counter;
197            if active.checkpoints.len() >= active.max_checkpoints {
198                active.checkpoints.pop_front();
199            }
200            active.checkpoints.push_back(StateCheckpoint {
201                id,
202                label,
203                timestamp: Utc::now(),
204                state,
205                event_index,
206            });
207            Ok(())
208        } else {
209            Err(VictauriError::NoActiveRecording)
210        }
211    }
212
213    /// Returns the number of events recorded so far, or 0 if not recording.
214    #[must_use]
215    pub fn event_count(&self) -> usize {
216        crate::acquire_lock(&self.recording, "EventRecorder")
217            .as_ref()
218            .map_or(0, |r| r.events.len())
219    }
220
221    /// Returns the number of checkpoints created so far, or 0 if not recording.
222    #[must_use]
223    pub fn checkpoint_count(&self) -> usize {
224        crate::acquire_lock(&self.recording, "EventRecorder")
225            .as_ref()
226            .map_or(0, |r| r.checkpoints.len())
227    }
228
229    /// Returns all events with an index >= the given value.
230    /// Falls back to the last stopped session if no active recording.
231    #[must_use]
232    pub fn events_since(&self, index: usize) -> Vec<RecordedEvent> {
233        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
234        if let Some(active) = rec.as_ref() {
235            return active
236                .events
237                .iter()
238                .filter(|e| e.index >= index)
239                .cloned()
240                .collect();
241        }
242        drop(rec);
243        let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
244        last.as_ref().map_or_else(Vec::new, |session| {
245            session
246                .events
247                .iter()
248                .filter(|e| e.index >= index)
249                .cloned()
250                .collect()
251        })
252    }
253
254    /// Returns events whose timestamps fall within the given inclusive range.
255    /// Falls back to the last stopped session if no active recording.
256    #[must_use]
257    pub fn events_between(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> Vec<RecordedEvent> {
258        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
259        if let Some(active) = rec.as_ref() {
260            return active
261                .events
262                .iter()
263                .filter(|e| e.timestamp >= from && e.timestamp <= to)
264                .cloned()
265                .collect();
266        }
267        drop(rec);
268        let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
269        last.as_ref().map_or_else(Vec::new, |session| {
270            session
271                .events
272                .iter()
273                .filter(|e| e.timestamp >= from && e.timestamp <= to)
274                .cloned()
275                .collect()
276        })
277    }
278
279    /// Returns all checkpoints from the active recording session.
280    /// Falls back to the last stopped session if no active recording.
281    #[must_use]
282    pub fn get_checkpoints(&self) -> Vec<StateCheckpoint> {
283        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
284        if let Some(active) = rec.as_ref() {
285            return active.checkpoints.iter().cloned().collect();
286        }
287        drop(rec);
288        let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
289        last.as_ref()
290            .map_or_else(Vec::new, |session| session.checkpoints.to_vec())
291    }
292
293    /// Returns events recorded between two named checkpoints.
294    /// Falls back to the last stopped session if no active recording.
295    ///
296    /// # Errors
297    ///
298    /// - [`VictauriError::NoActiveRecording`] if no session is active and no last session exists.
299    /// - [`VictauriError::CheckpointNotFound`] if either checkpoint ID does not exist.
300    pub fn events_between_checkpoints(
301        &self,
302        from_checkpoint_id: &str,
303        to_checkpoint_id: &str,
304    ) -> crate::error::Result<Vec<RecordedEvent>> {
305        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
306        let source_checkpoints;
307        let source_events;
308        if let Some(active) = rec.as_ref() {
309            source_checkpoints = active.checkpoints.iter().cloned().collect::<Vec<_>>();
310            source_events = active.events.iter().cloned().collect::<Vec<_>>();
311        } else {
312            drop(rec);
313            let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
314            let session = last.as_ref().ok_or(VictauriError::NoActiveRecording)?;
315            source_checkpoints = session.checkpoints.clone();
316            source_events = session.events.clone();
317        }
318
319        let from_idx = source_checkpoints
320            .iter()
321            .find(|c| c.id == from_checkpoint_id)
322            .ok_or_else(|| VictauriError::CheckpointNotFound {
323                id: from_checkpoint_id.to_string(),
324            })?
325            .event_index;
326        let to_idx = source_checkpoints
327            .iter()
328            .find(|c| c.id == to_checkpoint_id)
329            .ok_or_else(|| VictauriError::CheckpointNotFound {
330                id: to_checkpoint_id.to_string(),
331            })?
332            .event_index;
333
334        let (start, end) = if from_idx <= to_idx {
335            (from_idx, to_idx)
336        } else {
337            (to_idx, from_idx)
338        };
339
340        Ok(source_events
341            .iter()
342            .filter(|e| e.index >= start && e.index < end)
343            .cloned()
344            .collect())
345    }
346
347    /// Snapshot the current recording as a session WITHOUT stopping it.
348    /// Falls back to the last stopped session if no active recording.
349    #[must_use]
350    pub fn export(&self) -> Option<RecordedSession> {
351        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
352        if let Some(r) = rec.as_ref() {
353            return Some(RecordedSession {
354                id: r.session_id.clone(),
355                started_at: r.started_at,
356                events: r.events.iter().cloned().collect(),
357                checkpoints: r.checkpoints.iter().cloned().collect(),
358            });
359        }
360        drop(rec);
361        crate::acquire_lock(&self.last_session, "EventRecorder::last_session").clone()
362    }
363
364    /// Import a previously exported session, replacing any active recording.
365    ///
366    /// The imported session is fully caller-controlled, so its collections are
367    /// clamped to the configured caps (keeping the most recent entries) before
368    /// becoming active — an oversized session cannot inflate resident memory past
369    /// `max_events`/`max_checkpoints` (audit #19), and a crafted event index of
370    /// `usize::MAX` cannot overflow the counter (audit #18).
371    pub fn import(&self, session: RecordedSession) {
372        let max_events = self.max_events;
373        let max_checkpoints = DEFAULT_MAX_CHECKPOINTS;
374
375        let mut events: std::collections::VecDeque<RecordedEvent> =
376            session.events.into_iter().collect();
377        while events.len() > max_events {
378            events.pop_front();
379        }
380        let mut checkpoints: std::collections::VecDeque<StateCheckpoint> =
381            session.checkpoints.into_iter().collect();
382        while checkpoints.len() > max_checkpoints {
383            checkpoints.pop_front();
384        }
385
386        let event_counter = events.back().map_or(0, |e| e.index.saturating_add(1));
387
388        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
389        *rec = Some(ActiveRecording {
390            session_id: session.id,
391            started_at: session.started_at,
392            events,
393            checkpoints,
394            event_counter,
395            max_events,
396            max_checkpoints,
397        });
398    }
399
400    /// Extracts IPC calls in order from the active recording or last stopped session for replay.
401    #[must_use]
402    pub fn ipc_replay_sequence(&self) -> Vec<IpcCall> {
403        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
404        if let Some(active) = rec.as_ref() {
405            return active
406                .events
407                .iter()
408                .filter_map(|re| match &re.event {
409                    AppEvent::Ipc(call) => Some(call.clone()),
410                    _ => None,
411                })
412                .collect();
413        }
414        drop(rec);
415        let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
416        last.as_ref().map_or_else(Vec::new, |session| {
417            session
418                .events
419                .iter()
420                .filter_map(|re| match &re.event {
421                    AppEvent::Ipc(call) => Some(call.clone()),
422                    _ => None,
423                })
424                .collect()
425        })
426    }
427}
428
429impl Default for EventRecorder {
430    fn default() -> Self {
431        Self::new(DEFAULT_MAX_EVENTS)
432    }
433}