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::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9
10use crate::error::VictauriError;
11use crate::event::{AppEvent, IpcCall};
12
13const DEFAULT_MAX_CHECKPOINTS: usize = 1000;
14const DEFAULT_MAX_EVENTS: usize = 50_000;
15
16/// A snapshot of application state taken at a specific point during recording.
17#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
18#[non_exhaustive]
19pub struct StateCheckpoint {
20    /// Unique identifier for this checkpoint.
21    pub id: String,
22    /// Optional human-readable label for the checkpoint.
23    pub label: Option<String>,
24    /// When the checkpoint was created.
25    pub timestamp: DateTime<Utc>,
26    /// Serialized application state at the checkpoint.
27    pub state: serde_json::Value,
28    /// Index into the event stream at the time of this checkpoint.
29    pub event_index: usize,
30}
31
32/// A complete recorded session with events and state checkpoints. Serializable for export/import.
33#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
34#[non_exhaustive]
35pub struct RecordedSession {
36    /// Unique session identifier (UUID).
37    pub id: String,
38    /// When the recording session began.
39    pub started_at: DateTime<Utc>,
40    /// All events captured during the session, in order.
41    pub events: Vec<RecordedEvent>,
42    /// State checkpoints created during the session.
43    pub checkpoints: Vec<StateCheckpoint>,
44}
45
46/// A single event captured during a recording session, with its sequence index.
47#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct RecordedEvent {
50    /// Monotonically increasing sequence number within the recording session.
51    pub index: usize,
52    /// When the event occurred.
53    pub timestamp: DateTime<Utc>,
54    /// The captured application event.
55    pub event: AppEvent,
56}
57
58impl RecordedSession {
59    /// Creates a recorded session from its parts (e.g. when importing a
60    /// session built outside a live [`EventRecorder`]).
61    #[must_use]
62    pub fn new(
63        id: impl Into<String>,
64        started_at: DateTime<Utc>,
65        events: Vec<RecordedEvent>,
66        checkpoints: Vec<StateCheckpoint>,
67    ) -> Self {
68        Self {
69            id: id.into(),
70            started_at,
71            events,
72            checkpoints,
73        }
74    }
75}
76
77impl RecordedEvent {
78    /// Creates a recorded event with the given sequence index.
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use victauri_core::{AppEvent, RecordedEvent, RecordedSession};
84    ///
85    /// let now = chrono::Utc::now();
86    /// let ev = RecordedEvent::new(0, now, AppEvent::window_event("main", "focus", now));
87    /// let session = RecordedSession::new("s1", now, vec![ev], vec![]);
88    /// assert_eq!(session.events.len(), 1);
89    /// ```
90    #[must_use]
91    pub fn new(index: usize, timestamp: DateTime<Utc>, event: AppEvent) -> Self {
92        Self {
93            index,
94            timestamp,
95            event,
96        }
97    }
98}
99
100/// Thread-safe session recorder for time-travel debugging. Records events and
101/// state checkpoints during a recording session. Only one session can be active at a time.
102#[derive(Debug, Clone)]
103pub struct EventRecorder {
104    recording: Arc<Mutex<Option<ActiveRecording>>>,
105    last_session: Arc<Mutex<Option<RecordedSession>>>,
106    max_events: usize,
107    /// Source of recording generations: every `start`/`import` takes a fresh one, so a
108    /// caller holding an old generation can never touch a later recording — even one that
109    /// reuses the same (caller-chosen) session id.
110    generations: Arc<AtomicU64>,
111}
112
113#[derive(Debug, Clone)]
114struct ActiveRecording {
115    session_id: String,
116    generation: u64,
117    started_at: DateTime<Utc>,
118    events: VecDeque<RecordedEvent>,
119    checkpoints: VecDeque<StateCheckpoint>,
120    event_counter: usize,
121    max_events: usize,
122    max_checkpoints: usize,
123}
124
125impl EventRecorder {
126    /// Creates a new recorder with the given maximum event capacity.
127    ///
128    /// ```
129    /// use victauri_core::EventRecorder;
130    ///
131    /// let recorder = EventRecorder::new(1000);
132    /// assert!(!recorder.is_recording());
133    /// assert_eq!(recorder.event_count(), 0);
134    /// ```
135    #[must_use]
136    pub fn new(max_events: usize) -> Self {
137        Self {
138            recording: Arc::new(Mutex::new(None)),
139            last_session: Arc::new(Mutex::new(None)),
140            max_events,
141            generations: Arc::new(AtomicU64::new(0)),
142        }
143    }
144
145    fn next_generation(&self) -> u64 {
146        self.generations.fetch_add(1, Ordering::Relaxed) + 1
147    }
148
149    /// Starts a new recording session; returns `Err` if one is already active.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`VictauriError::RecordingAlreadyActive`] if a session is already in progress.
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use victauri_core::EventRecorder;
159    ///
160    /// let recorder = EventRecorder::new(1000);
161    /// recorder.start("session-1".to_string()).unwrap();
162    /// assert!(recorder.is_recording());
163    /// ```
164    pub fn start(&self, session_id: String) -> crate::error::Result<()> {
165        self.start_session(session_id).map(|_| ())
166    }
167
168    /// [`start`](Self::start), returning the new recording's generation — the token for
169    /// [`record_event_if`](Self::record_event_if) and [`stop_if_generation`](Self::stop_if_generation).
170    ///
171    /// # Errors
172    ///
173    /// Returns [`VictauriError::RecordingAlreadyActive`] if a session is already in progress.
174    pub fn start_session(&self, session_id: String) -> crate::error::Result<u64> {
175        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
176        if rec.is_some() {
177            return Err(VictauriError::RecordingAlreadyActive);
178        }
179        let generation = self.next_generation();
180        *rec = Some(ActiveRecording {
181            session_id,
182            generation,
183            started_at: Utc::now(),
184            events: VecDeque::new(),
185            checkpoints: VecDeque::new(),
186            event_counter: 0,
187            max_events: self.max_events,
188            max_checkpoints: DEFAULT_MAX_CHECKPOINTS,
189        });
190        Ok(generation)
191    }
192
193    /// Stops the active recording and returns the completed session, or None if not recording.
194    ///
195    /// # Examples
196    ///
197    /// ```
198    /// use victauri_core::EventRecorder;
199    ///
200    /// let recorder = EventRecorder::new(1000);
201    /// recorder.start("session-1".to_string()).unwrap();
202    /// let session = recorder.stop().expect("should return session");
203    /// assert_eq!(session.id, "session-1");
204    /// assert!(!recorder.is_recording());
205    /// ```
206    #[must_use]
207    pub fn stop(&self) -> Option<RecordedSession> {
208        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
209        self.finish(&mut rec)
210    }
211
212    /// Take the active recording out of `rec` (whose lock the caller holds) and keep it as the
213    /// last session.
214    fn finish(&self, rec: &mut Option<ActiveRecording>) -> Option<RecordedSession> {
215        rec.take().map(|r| {
216            let session = RecordedSession {
217                id: r.session_id,
218                started_at: r.started_at,
219                events: r.events.into_iter().collect(),
220                checkpoints: r.checkpoints.into_iter().collect(),
221            };
222            *crate::acquire_lock(&self.last_session, "EventRecorder::last_session") =
223                Some(session.clone());
224            session
225        })
226    }
227
228    /// Returns true if a recording session is currently active.
229    #[must_use]
230    pub fn is_recording(&self) -> bool {
231        crate::acquire_lock(&self.recording, "EventRecorder").is_some()
232    }
233
234    /// Generation of the active recording, or `None` if not recording.
235    #[must_use]
236    pub fn generation(&self) -> Option<u64> {
237        crate::acquire_lock(&self.recording, "EventRecorder")
238            .as_ref()
239            .map(|r| r.generation)
240    }
241
242    /// Appends an event to the active recording, evicting the oldest if at capacity.
243    pub fn record_event(&self, event: AppEvent) {
244        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
245        if let Some(ref mut active) = *rec {
246            Self::append(active, event);
247        }
248    }
249
250    /// [`record_event`](Self::record_event), but only into the recording of `generation`.
251    /// A reader that captured the generation before a slow read (the webview drain) must not
252    /// append what it read into a recording started after it — those events predate it.
253    /// Returns whether the event was recorded.
254    #[must_use]
255    pub fn record_event_if(&self, generation: u64, event: AppEvent) -> bool {
256        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
257        match rec.as_mut() {
258            Some(active) if active.generation == generation => {
259                Self::append(active, event);
260                true
261            }
262            _ => false,
263        }
264    }
265
266    fn append(active: &mut ActiveRecording, event: AppEvent) {
267        let timestamp = event.timestamp();
268        let index = active.event_counter;
269        // Saturating: an imported session can seed event_counter at usize::MAX
270        // (its index is attacker-controlled); a bare `+= 1` would then panic in
271        // debug / wrap in release on the next auto-captured event (audit #18).
272        active.event_counter = active.event_counter.saturating_add(1);
273
274        if active.events.len() >= active.max_events {
275            active.events.pop_front();
276        }
277
278        active.events.push_back(RecordedEvent {
279            index,
280            timestamp,
281            event,
282        });
283    }
284
285    /// Creates a named state checkpoint at the current event index; returns `Err` if not recording.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`VictauriError::NoActiveRecording`] if no session is in progress.
290    pub fn checkpoint(
291        &self,
292        id: String,
293        label: Option<String>,
294        state: serde_json::Value,
295    ) -> crate::error::Result<()> {
296        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
297        if let Some(ref mut active) = *rec {
298            let event_index = active.event_counter;
299            if active.checkpoints.len() >= active.max_checkpoints {
300                active.checkpoints.pop_front();
301            }
302            active.checkpoints.push_back(StateCheckpoint {
303                id,
304                label,
305                timestamp: Utc::now(),
306                state,
307                event_index,
308            });
309            Ok(())
310        } else {
311            Err(VictauriError::NoActiveRecording)
312        }
313    }
314
315    /// Session id of the active recording, or `None` if not recording.
316    #[must_use]
317    pub fn active_session_id(&self) -> Option<String> {
318        crate::acquire_lock(&self.recording, "EventRecorder")
319            .as_ref()
320            .map(|r| r.session_id.clone())
321    }
322
323    /// Stop the active recording only if it is the session `session_id` (a caller that started
324    /// a recording must not stop a different one someone else started after it ended).
325    ///
326    /// The id check and the stop happen under one lock: checked and stopped separately, a
327    /// stop + start by someone else in between made this stop the OTHER recording. Session ids
328    /// are caller-chosen and may repeat, so an owner that must never stop a later recording
329    /// with the same id should hold its generation and use
330    /// [`stop_if_generation`](Self::stop_if_generation).
331    #[must_use]
332    pub fn stop_if_session(&self, session_id: &str) -> Option<RecordedSession> {
333        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
334        if rec.as_ref().is_some_and(|r| r.session_id == session_id) {
335            self.finish(&mut rec)
336        } else {
337            None
338        }
339    }
340
341    /// Stop the active recording only if it is the one of `generation` (atomically).
342    #[must_use]
343    pub fn stop_if_generation(&self, generation: u64) -> Option<RecordedSession> {
344        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
345        if rec.as_ref().is_some_and(|r| r.generation == generation) {
346            self.finish(&mut rec)
347        } else {
348            None
349        }
350    }
351
352    /// When the active recording started, or `None` if not recording.
353    #[must_use]
354    pub fn started_at(&self) -> Option<DateTime<Utc>> {
355        crate::acquire_lock(&self.recording, "EventRecorder")
356            .as_ref()
357            .map(|r| r.started_at)
358    }
359
360    /// Timestamp of the newest event in the active recording (or its start time if it has no
361    /// events yet); `None` if not recording. A caller pulling more events can read strictly
362    /// after this to avoid re-recording what is already there.
363    #[must_use]
364    pub fn latest_event_timestamp(&self) -> Option<DateTime<Utc>> {
365        crate::acquire_lock(&self.recording, "EventRecorder")
366            .as_ref()
367            .map(|r| {
368                r.events
369                    .iter()
370                    .map(|e| e.timestamp)
371                    .max()
372                    .unwrap_or(r.started_at)
373            })
374    }
375
376    /// Returns the number of events recorded so far, or 0 if not recording.
377    #[must_use]
378    pub fn event_count(&self) -> usize {
379        crate::acquire_lock(&self.recording, "EventRecorder")
380            .as_ref()
381            .map_or(0, |r| r.events.len())
382    }
383
384    /// Returns the number of checkpoints created so far, or 0 if not recording.
385    #[must_use]
386    pub fn checkpoint_count(&self) -> usize {
387        crate::acquire_lock(&self.recording, "EventRecorder")
388            .as_ref()
389            .map_or(0, |r| r.checkpoints.len())
390    }
391
392    /// Returns all events with an index >= the given value.
393    /// Falls back to the last stopped session if no active recording.
394    #[must_use]
395    pub fn events_since(&self, index: usize) -> Vec<RecordedEvent> {
396        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
397        if let Some(active) = rec.as_ref() {
398            return active
399                .events
400                .iter()
401                .filter(|e| e.index >= index)
402                .cloned()
403                .collect();
404        }
405        drop(rec);
406        let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
407        last.as_ref().map_or_else(Vec::new, |session| {
408            session
409                .events
410                .iter()
411                .filter(|e| e.index >= index)
412                .cloned()
413                .collect()
414        })
415    }
416
417    /// Returns events whose timestamps fall within the given inclusive range.
418    /// Falls back to the last stopped session if no active recording.
419    #[must_use]
420    pub fn events_between(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> Vec<RecordedEvent> {
421        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
422        if let Some(active) = rec.as_ref() {
423            return active
424                .events
425                .iter()
426                .filter(|e| e.timestamp >= from && e.timestamp <= to)
427                .cloned()
428                .collect();
429        }
430        drop(rec);
431        let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
432        last.as_ref().map_or_else(Vec::new, |session| {
433            session
434                .events
435                .iter()
436                .filter(|e| e.timestamp >= from && e.timestamp <= to)
437                .cloned()
438                .collect()
439        })
440    }
441
442    /// Returns all checkpoints from the active recording session.
443    /// Falls back to the last stopped session if no active recording.
444    #[must_use]
445    pub fn get_checkpoints(&self) -> Vec<StateCheckpoint> {
446        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
447        if let Some(active) = rec.as_ref() {
448            return active.checkpoints.iter().cloned().collect();
449        }
450        drop(rec);
451        let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
452        last.as_ref()
453            .map_or_else(Vec::new, |session| session.checkpoints.to_vec())
454    }
455
456    /// Returns events recorded between two named checkpoints.
457    /// Falls back to the last stopped session if no active recording.
458    ///
459    /// # Errors
460    ///
461    /// - [`VictauriError::NoActiveRecording`] if no session is active and no last session exists.
462    /// - [`VictauriError::CheckpointNotFound`] if either checkpoint ID does not exist.
463    pub fn events_between_checkpoints(
464        &self,
465        from_checkpoint_id: &str,
466        to_checkpoint_id: &str,
467    ) -> crate::error::Result<Vec<RecordedEvent>> {
468        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
469        let source_checkpoints;
470        let source_events;
471        if let Some(active) = rec.as_ref() {
472            source_checkpoints = active.checkpoints.iter().cloned().collect::<Vec<_>>();
473            source_events = active.events.iter().cloned().collect::<Vec<_>>();
474        } else {
475            drop(rec);
476            let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
477            let session = last.as_ref().ok_or(VictauriError::NoActiveRecording)?;
478            source_checkpoints = session.checkpoints.clone();
479            source_events = session.events.clone();
480        }
481
482        let from_idx = source_checkpoints
483            .iter()
484            .find(|c| c.id == from_checkpoint_id)
485            .ok_or_else(|| VictauriError::CheckpointNotFound {
486                id: from_checkpoint_id.to_string(),
487            })?
488            .event_index;
489        let to_idx = source_checkpoints
490            .iter()
491            .find(|c| c.id == to_checkpoint_id)
492            .ok_or_else(|| VictauriError::CheckpointNotFound {
493                id: to_checkpoint_id.to_string(),
494            })?
495            .event_index;
496
497        let (start, end) = if from_idx <= to_idx {
498            (from_idx, to_idx)
499        } else {
500            (to_idx, from_idx)
501        };
502
503        Ok(source_events
504            .iter()
505            .filter(|e| e.index >= start && e.index < end)
506            .cloned()
507            .collect())
508    }
509
510    /// Snapshot the current recording as a session WITHOUT stopping it.
511    /// Falls back to the last stopped session if no active recording.
512    #[must_use]
513    pub fn export(&self) -> Option<RecordedSession> {
514        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
515        if let Some(r) = rec.as_ref() {
516            return Some(RecordedSession {
517                id: r.session_id.clone(),
518                started_at: r.started_at,
519                events: r.events.iter().cloned().collect(),
520                checkpoints: r.checkpoints.iter().cloned().collect(),
521            });
522        }
523        drop(rec);
524        crate::acquire_lock(&self.last_session, "EventRecorder::last_session").clone()
525    }
526
527    /// Import a previously exported session, replacing any active recording.
528    ///
529    /// The imported session is fully caller-controlled, so its collections are
530    /// clamped to the configured caps (keeping the most recent entries) before
531    /// becoming active — an oversized session cannot inflate resident memory past
532    /// `max_events`/`max_checkpoints` (audit #19), and a crafted event index of
533    /// `usize::MAX` cannot overflow the counter (audit #18).
534    pub fn import(&self, session: RecordedSession) {
535        let active = self.imported(session);
536        *crate::acquire_lock(&self.recording, "EventRecorder") = Some(active);
537    }
538
539    /// [`import`](Self::import), but only if no recording is active — checked and replaced under
540    /// one lock, so a recording started concurrently is never silently discarded. Returns the
541    /// imported recording's generation.
542    ///
543    /// # Errors
544    ///
545    /// Returns [`VictauriError::RecordingAlreadyActive`] if a session is in progress.
546    pub fn import_if_idle(&self, session: RecordedSession) -> crate::error::Result<u64> {
547        let active = self.imported(session);
548        let generation = active.generation;
549        let mut rec = crate::acquire_lock(&self.recording, "EventRecorder");
550        if rec.is_some() {
551            return Err(VictauriError::RecordingAlreadyActive);
552        }
553        *rec = Some(active);
554        Ok(generation)
555    }
556
557    fn imported(&self, session: RecordedSession) -> ActiveRecording {
558        let max_events = self.max_events;
559        let max_checkpoints = DEFAULT_MAX_CHECKPOINTS;
560
561        let mut events: std::collections::VecDeque<RecordedEvent> =
562            session.events.into_iter().collect();
563        while events.len() > max_events {
564            events.pop_front();
565        }
566        let mut checkpoints: std::collections::VecDeque<StateCheckpoint> =
567            session.checkpoints.into_iter().collect();
568        while checkpoints.len() > max_checkpoints {
569            checkpoints.pop_front();
570        }
571
572        let event_counter = events.back().map_or(0, |e| e.index.saturating_add(1));
573
574        ActiveRecording {
575            session_id: session.id,
576            generation: self.next_generation(),
577            started_at: session.started_at,
578            events,
579            checkpoints,
580            event_counter,
581            max_events,
582            max_checkpoints,
583        }
584    }
585
586    /// Extracts IPC calls in order from the active recording or last stopped session for replay.
587    #[must_use]
588    pub fn ipc_replay_sequence(&self) -> Vec<IpcCall> {
589        let rec = crate::acquire_lock(&self.recording, "EventRecorder");
590        if let Some(active) = rec.as_ref() {
591            return active
592                .events
593                .iter()
594                .filter_map(|re| match &re.event {
595                    AppEvent::Ipc(call) => Some(call.clone()),
596                    _ => None,
597                })
598                .collect();
599        }
600        drop(rec);
601        let last = crate::acquire_lock(&self.last_session, "EventRecorder::last_session");
602        last.as_ref().map_or_else(Vec::new, |session| {
603            session
604                .events
605                .iter()
606                .filter_map(|re| match &re.event {
607                    AppEvent::Ipc(call) => Some(call.clone()),
608                    _ => None,
609                })
610                .collect()
611        })
612    }
613}
614
615impl Default for EventRecorder {
616    fn default() -> Self {
617        Self::new(DEFAULT_MAX_EVENTS)
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    fn console(msg: &str) -> AppEvent {
626        AppEvent::console("log".to_string(), msg.to_string(), Utc::now())
627    }
628
629    // C15a: the id check and the stop must be one step. With the check and the stop under
630    // separate locks, a stop+start by someone else in between made `stop_if_session("x")`
631    // stop (and return) the OTHER recording.
632    #[test]
633    fn stop_if_session_never_stops_a_different_session() {
634        let rec = EventRecorder::new(100);
635        let racer = rec.clone();
636        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
637        let stop2 = Arc::clone(&stop);
638        let t = std::thread::spawn(move || {
639            while !stop2.load(std::sync::atomic::Ordering::Relaxed) {
640                let _ = racer.stop();
641                let _ = racer.start("other".to_string());
642            }
643        });
644        let mut wrong = 0;
645        for _ in 0..200_000 {
646            let _ = rec.start("mine".to_string());
647            if let Some(s) = rec.stop_if_session("mine")
648                && s.id != "mine"
649            {
650                wrong += 1;
651            }
652        }
653        stop.store(true, std::sync::atomic::Ordering::Relaxed);
654        t.join().unwrap();
655        assert_eq!(wrong, 0, "stop_if_session stopped someone else's recording");
656    }
657
658    // C15a (ABA): session ids are caller-chosen and may repeat, so ownership is by generation.
659    #[test]
660    fn stale_generation_cannot_stop_a_later_recording_with_the_same_id() {
661        let rec = EventRecorder::new(100);
662        let old = rec.start_session("s".to_string()).unwrap();
663        let _ = rec.stop();
664        let new = rec.start_session("s".to_string()).unwrap();
665        assert_ne!(old, new);
666        assert!(rec.stop_if_generation(old).is_none());
667        assert!(rec.is_recording());
668        assert_eq!(rec.stop_if_generation(new).unwrap().id, "s");
669    }
670
671    // C6: a reader holding an old generation must not append into a newer recording.
672    #[test]
673    fn record_event_if_rejects_a_stale_generation() {
674        let rec = EventRecorder::new(100);
675        let a = rec.start_session("A".to_string()).unwrap();
676        assert!(rec.record_event_if(a, console("during A")));
677        let _ = rec.stop();
678        let b = rec.start_session("B".to_string()).unwrap();
679        assert!(!rec.record_event_if(a, console("late A read")));
680        assert!(rec.record_event_if(b, console("during B")));
681        assert_eq!(rec.event_count(), 1);
682        assert_eq!(rec.generation(), Some(b));
683    }
684
685    // C15b: import must not replace a recording that became active after an is_recording check.
686    #[test]
687    fn import_if_idle_refuses_while_recording() {
688        let rec = EventRecorder::new(100);
689        rec.start("live".to_string()).unwrap();
690        let session = RecordedSession::new("imported", Utc::now(), vec![], vec![]);
691        assert!(rec.import_if_idle(session.clone()).is_err());
692        assert_eq!(rec.active_session_id().as_deref(), Some("live"));
693        let _ = rec.stop();
694        let g = rec.import_if_idle(session).unwrap();
695        assert_eq!(rec.generation(), Some(g));
696        assert_eq!(rec.active_session_id().as_deref(), Some("imported"));
697    }
698}