Skip to main content

zerodds_recorder/
session.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Live recording session — high-level API for a running capture.
5//!
6//! Wraps a [`RecordWriter`] behind a topic-indexing layer:
7//! the consumer calls `record_sample(topic, type, payload)` and the
8//! indexer takes care of:
9//!
10//! * Initial header written on the first sample (lazy).
11//! * Topic/participant entries are added on demand.
12//!   Since the format is header-once, a new header is written
13//!   when the first topics are still unknown — i.e. the caller
14//!   should register the set up front via [`SessionOptions`].
15//! * Atomic counters (frames, bytes) for the dashboard.
16//!
17//! Wiring to the DcpsRuntime (a hook on the built-in topics
18//! `DCPSPublication`/`DCPSSubscription`) is the job of the dcps crate
19//! and of `tools/recorder-bridge` — `RecordingSession` provides the
20//! thread-safe write ingress here.
21
22use alloc::string::String;
23use alloc::vec::Vec;
24use core::fmt;
25use core::sync::atomic::{AtomicU64, Ordering};
26use std::sync::Mutex;
27
28use crate::format::{Frame, Header, ParticipantEntry, SampleKind, TopicEntry};
29use crate::writer::{RecordWriter, WriteError};
30
31/// Convenient topic key: tuple of topic name and type name.
32#[derive(Clone, Debug, Hash, PartialEq, Eq)]
33pub struct TopicKey {
34    /// DDS topic name (e.g. with an `rt/` prefix).
35    pub topic: String,
36    /// Type name (e.g. `"std_msgs::msg::String"`).
37    pub type_name: String,
38}
39
40/// Setup options for a session.
41#[derive(Clone, Debug)]
42pub struct SessionOptions {
43    /// UNIX epoch anchor in nanoseconds — frame timestamps are
44    /// deltas relative to it.
45    pub time_base_unix_ns: i64,
46    /// Pre-known participants (GUID + name).
47    pub participants: Vec<ParticipantEntry>,
48    /// Pre-known topics. If a topic arrives that is not
49    /// in here, the session ignores the sample (the counter
50    /// `samples_dropped_unknown_topic` is incremented).
51    pub topics: Vec<TopicKey>,
52}
53
54impl SessionOptions {
55    /// Constructor with time_base_unix_ns and empty lists.
56    #[must_use]
57    pub fn new(time_base_unix_ns: i64) -> Self {
58        Self {
59            time_base_unix_ns,
60            participants: Vec::new(),
61            topics: Vec::new(),
62        }
63    }
64
65    /// Adds a participant (builder form).
66    #[must_use]
67    pub fn with_participant(mut self, p: ParticipantEntry) -> Self {
68        self.participants.push(p);
69        self
70    }
71
72    /// Adds a topic (builder form).
73    #[must_use]
74    pub fn with_topic(mut self, t: TopicKey) -> Self {
75        self.topics.push(t);
76        self
77    }
78}
79
80/// Session error.
81#[derive(Debug)]
82pub enum SessionError {
83    /// Underlying writer error.
84    Writer(WriteError),
85    /// Session mutex poisoned.
86    Poisoned,
87}
88
89impl fmt::Display for SessionError {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            Self::Writer(e) => write!(f, "writer: {e}"),
93            Self::Poisoned => write!(f, "session mutex poisoned"),
94        }
95    }
96}
97
98impl std::error::Error for SessionError {}
99
100impl From<WriteError> for SessionError {
101    fn from(e: WriteError) -> Self {
102        Self::Writer(e)
103    }
104}
105
106/// Live recording session.
107///
108/// Thread-safe: multiple threads can call
109/// `record_sample` concurrently.
110pub struct RecordingSession<W: std::io::Write + Send> {
111    inner: Mutex<Inner<W>>,
112    samples_total: AtomicU64,
113    samples_dropped: AtomicU64,
114    bytes_total: AtomicU64,
115}
116
117struct Inner<W: std::io::Write> {
118    writer: RecordWriter<W>,
119    /// (topic, type) → topic_idx in the header.
120    topic_index: Vec<(TopicKey, u32)>,
121    participant_index: Vec<([u8; 16], u32)>,
122    time_base_unix_ns: i64,
123    header_written: bool,
124    /// Pre-allocated header data — flushed on the first sample.
125    pending_header: Header,
126}
127
128impl<W: std::io::Write + Send> RecordingSession<W> {
129    /// Creates a new session over `sink`. The header is written on
130    /// the first `record_sample`.
131    pub fn new(sink: W, opts: SessionOptions) -> Self {
132        let mut topic_index = Vec::with_capacity(opts.topics.len());
133        for (i, t) in opts.topics.iter().enumerate() {
134            topic_index.push((t.clone(), i as u32));
135        }
136        let participant_index = opts
137            .participants
138            .iter()
139            .enumerate()
140            .map(|(i, p)| (p.guid, i as u32))
141            .collect();
142        let header = Header {
143            time_base_unix_ns: opts.time_base_unix_ns,
144            participants: opts.participants,
145            topics: opts
146                .topics
147                .into_iter()
148                .map(|t| TopicEntry {
149                    name: t.topic,
150                    type_name: t.type_name,
151                })
152                .collect(),
153        };
154        Self {
155            inner: Mutex::new(Inner {
156                writer: RecordWriter::new(sink),
157                topic_index,
158                participant_index,
159                time_base_unix_ns: opts.time_base_unix_ns,
160                header_written: false,
161                pending_header: header,
162            }),
163            samples_total: AtomicU64::new(0),
164            samples_dropped: AtomicU64::new(0),
165            bytes_total: AtomicU64::new(0),
166        }
167    }
168
169    /// Writes a sample. `now_unix_ns` must be the current
170    /// wall-clock time in nanoseconds since the epoch.
171    ///
172    /// # Errors
173    /// See [`SessionError`].
174    pub fn record_sample(
175        &self,
176        now_unix_ns: i64,
177        participant_guid: [u8; 16],
178        topic: &TopicKey,
179        sample_kind: SampleKind,
180        payload: Vec<u8>,
181    ) -> Result<(), SessionError> {
182        let mut g = self.inner.lock().map_err(|_| SessionError::Poisoned)?;
183        if !g.header_written {
184            let header = g.pending_header.clone();
185            g.writer.write_header(&header)?;
186            g.header_written = true;
187        }
188        let Some(topic_idx) = g
189            .topic_index
190            .iter()
191            .find(|(k, _)| k == topic)
192            .map(|(_, i)| *i)
193        else {
194            self.samples_dropped.fetch_add(1, Ordering::Relaxed);
195            return Ok(());
196        };
197        let participant_idx = g
198            .participant_index
199            .iter()
200            .find(|(g_guid, _)| g_guid == &participant_guid)
201            .map(|(_, i)| *i)
202            .unwrap_or(0);
203        let frame = Frame {
204            timestamp_delta_ns: now_unix_ns - g.time_base_unix_ns,
205            participant_idx,
206            topic_idx,
207            sample_kind,
208            payload,
209        };
210        g.writer.write_frame(&frame)?;
211        self.samples_total.fetch_add(1, Ordering::Relaxed);
212        self.bytes_total
213            .fetch_add(g.writer.bytes_written(), Ordering::Relaxed);
214        Ok(())
215    }
216
217    /// Returns the current counters (snapshot).
218    #[must_use]
219    pub fn stats(&self) -> SessionStats {
220        SessionStats {
221            samples_total: self.samples_total.load(Ordering::Relaxed),
222            samples_dropped: self.samples_dropped.load(Ordering::Relaxed),
223            bytes_total: self.bytes_total.load(Ordering::Relaxed),
224        }
225    }
226}
227
228/// Counter snapshot.
229#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
230pub struct SessionStats {
231    /// Number of successfully written samples.
232    pub samples_total: u64,
233    /// Number of dropped samples (topic not in the header).
234    pub samples_dropped: u64,
235    /// Total file bytes (incl. header).
236    pub bytes_total: u64,
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used)] // tests may use unwrap.
241mod tests {
242    use super::*;
243
244    fn p(name: &str, guid_byte: u8) -> ParticipantEntry {
245        ParticipantEntry {
246            guid: [guid_byte; 16],
247            name: name.into(),
248        }
249    }
250    fn t(topic: &str, ty: &str) -> TopicKey {
251        TopicKey {
252            topic: topic.into(),
253            type_name: ty.into(),
254        }
255    }
256
257    #[test]
258    fn session_writes_header_lazy_on_first_sample() {
259        let opts = SessionOptions::new(1_700_000_000_000_000_000)
260            .with_participant(p("talker", 1))
261            .with_topic(t("/x", "T"));
262        let s: RecordingSession<Vec<u8>> = RecordingSession::new(Vec::new(), opts);
263        assert_eq!(s.stats().samples_total, 0);
264        s.record_sample(
265            1_700_000_000_000_001_000,
266            [1u8; 16],
267            &t("/x", "T"),
268            SampleKind::Alive,
269            vec![1, 2, 3],
270        )
271        .unwrap();
272        assert_eq!(s.stats().samples_total, 1);
273    }
274
275    #[test]
276    fn session_drops_unknown_topic() {
277        let opts = SessionOptions::new(0)
278            .with_participant(p("p", 1))
279            .with_topic(t("/known", "T"));
280        let s: RecordingSession<Vec<u8>> = RecordingSession::new(Vec::new(), opts);
281        s.record_sample(1, [1u8; 16], &t("/unknown", "U"), SampleKind::Alive, vec![])
282            .unwrap();
283        assert_eq!(s.stats().samples_total, 0);
284        assert_eq!(s.stats().samples_dropped, 1);
285    }
286
287    #[test]
288    fn session_thread_safe_record() {
289        use std::sync::Arc;
290        use std::thread;
291        let opts = SessionOptions::new(0)
292            .with_participant(p("p0", 1))
293            .with_participant(p("p1", 2))
294            .with_topic(t("/a", "T"))
295            .with_topic(t("/b", "T"));
296        let s: Arc<RecordingSession<Vec<u8>>> = Arc::new(RecordingSession::new(Vec::new(), opts));
297        let mut handles = Vec::new();
298        for thread_id in 0..4 {
299            let s = Arc::clone(&s);
300            handles.push(thread::spawn(move || {
301                for i in 0..100 {
302                    let topic = if i % 2 == 0 {
303                        t("/a", "T")
304                    } else {
305                        t("/b", "T")
306                    };
307                    let guid_byte = if thread_id < 2 { 1 } else { 2 };
308                    s.record_sample(
309                        i as i64,
310                        [guid_byte; 16],
311                        &topic,
312                        SampleKind::Alive,
313                        vec![i as u8],
314                    )
315                    .unwrap();
316                }
317            }));
318        }
319        for h in handles {
320            h.join().unwrap();
321        }
322        assert_eq!(s.stats().samples_total, 400);
323        assert_eq!(s.stats().samples_dropped, 0);
324    }
325
326    #[test]
327    fn session_unknown_participant_falls_back_to_idx_zero() {
328        let opts = SessionOptions::new(0)
329            .with_participant(p("p", 1))
330            .with_topic(t("/a", "T"));
331        let s: RecordingSession<Vec<u8>> = RecordingSession::new(Vec::new(), opts);
332        // GUID not in the list → fallback idx=0.
333        s.record_sample(
334            1,
335            [99u8; 16], // unknown
336            &t("/a", "T"),
337            SampleKind::Alive,
338            vec![],
339        )
340        .unwrap();
341        assert_eq!(s.stats().samples_total, 1);
342    }
343
344    /// Full record → read-back round-trip with the REAL (typed) topic and all
345    /// three sample kinds — the recorder side of the type-following capture path
346    /// (`zerodds-record`): the header carries the writer's true type (not
347    /// RawBytes) and every frame's kind / topic / payload / timestamp-delta
348    /// survives a parse.
349    #[test]
350    fn session_roundtrip_typed_topic_all_kinds() {
351        use crate::reader::RecordReader;
352        use std::io::Write;
353        use std::sync::{Arc, Mutex};
354
355        #[derive(Clone)]
356        struct SharedSink(Arc<Mutex<Vec<u8>>>);
357        impl Write for SharedSink {
358            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
359                self.0.lock().unwrap().extend_from_slice(b);
360                Ok(b.len())
361            }
362            fn flush(&mut self) -> std::io::Result<()> {
363                Ok(())
364            }
365        }
366
367        let buf = Arc::new(Mutex::new(Vec::new()));
368        let opts = SessionOptions::new(1_000)
369            .with_participant(p("rec", 7))
370            .with_topic(t("Track", "cuas::Track"));
371        let s = RecordingSession::new(SharedSink(Arc::clone(&buf)), opts);
372        let guid = [7u8; 16];
373        let key = t("Track", "cuas::Track");
374        s.record_sample(
375            1_500,
376            guid,
377            &key,
378            SampleKind::Alive,
379            vec![0xde, 0xad, 0xbe, 0xef],
380        )
381        .unwrap();
382        s.record_sample(1_600, guid, &key, SampleKind::NotAliveDisposed, vec![])
383            .unwrap();
384        s.record_sample(1_700, guid, &key, SampleKind::NotAliveUnregistered, vec![])
385            .unwrap();
386        assert_eq!(s.stats().samples_total, 3);
387
388        let bytes = buf.lock().unwrap().clone();
389        let mut rdr = RecordReader::new(&bytes);
390        let header = rdr.parse_header().unwrap();
391        assert_eq!(header.time_base_unix_ns, 1_000);
392        assert_eq!(header.topics.len(), 1);
393        assert_eq!(header.topics[0].name, "Track");
394        // The REAL writer type is recorded, not the generic RawBytes.
395        assert_eq!(header.topics[0].type_name, "cuas::Track");
396
397        let f1 = rdr.next_frame().unwrap().unwrap();
398        assert_eq!(f1.sample_kind, SampleKind::Alive);
399        assert_eq!(f1.topic_idx, 0);
400        assert_eq!(f1.payload, vec![0xde, 0xad, 0xbe, 0xef]);
401        assert_eq!(f1.timestamp_delta_ns, 500); // 1500 - 1000
402
403        let f2 = rdr.next_frame().unwrap().unwrap();
404        assert_eq!(f2.sample_kind, SampleKind::NotAliveDisposed);
405        assert!(f2.payload.is_empty());
406
407        let f3 = rdr.next_frame().unwrap().unwrap();
408        assert_eq!(f3.sample_kind, SampleKind::NotAliveUnregistered);
409
410        assert!(rdr.next_frame().unwrap().is_none());
411    }
412}