1use 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#[derive(Clone, Debug, Hash, PartialEq, Eq)]
33pub struct TopicKey {
34 pub topic: String,
36 pub type_name: String,
38}
39
40#[derive(Clone, Debug)]
42pub struct SessionOptions {
43 pub time_base_unix_ns: i64,
46 pub participants: Vec<ParticipantEntry>,
48 pub topics: Vec<TopicKey>,
52}
53
54impl SessionOptions {
55 #[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 #[must_use]
67 pub fn with_participant(mut self, p: ParticipantEntry) -> Self {
68 self.participants.push(p);
69 self
70 }
71
72 #[must_use]
74 pub fn with_topic(mut self, t: TopicKey) -> Self {
75 self.topics.push(t);
76 self
77 }
78}
79
80#[derive(Debug)]
82pub enum SessionError {
83 Writer(WriteError),
85 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
106pub 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_index: Vec<(TopicKey, u32)>,
121 participant_index: Vec<([u8; 16], u32)>,
122 time_base_unix_ns: i64,
123 header_written: bool,
124 pending_header: Header,
126}
127
128impl<W: std::io::Write + Send> RecordingSession<W> {
129 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 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 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
230pub struct SessionStats {
231 pub samples_total: u64,
233 pub samples_dropped: u64,
235 pub bytes_total: u64,
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used)] mod 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 s.record_sample(
334 1,
335 [99u8; 16], &t("/a", "T"),
337 SampleKind::Alive,
338 vec![],
339 )
340 .unwrap();
341 assert_eq!(s.stats().samples_total, 1);
342 }
343
344 #[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 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); 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}