ktstr/monitor/timeline.rs
1//! Failure-dump timeline support.
2//!
3//! Two related primitives live here:
4//!
5//! 1. **Sched-event timeline** ([`TimelineEvent`], [`parse_timeline_buf`],
6//! [`TimelineCapture`]) — host-side consumer for the
7//! `timeline_events` BPF ringbuf populated by `tp_btf/sched_switch`,
8//! `sched_migrate_task`, and `sched_wakeup` handlers (see
9//! `src/bpf/probe.bpf.c::ktstr_tl_*`). The freeze coordinator
10//! drains the ringbuf at failure time, parses the records into
11//! [`TimelineEvent`] values, and stitches them into the failure
12//! dump.
13//!
14//! 2. **Incremental snapshot ring** ([`SnapshotRing`],
15//! [`IncrementalCapture`]) — periodic VM-freeze capture of raw
16//! BPF state bytes for deferred render at trigger time. Cadence,
17//! ring depth, and rendering policy are tuned per consumer; the
18//! [`DEFAULT_SNAPSHOT_RING_DEPTH`] constant pins the storage
19//! budget at 60 entries.
20//!
21//! Both surfaces are defined and re-exported from the crate root
22//! (see `crate::prelude`) but not yet wired into the dump pipeline:
23//! [`super::dump::DumpContext`] has no field for either capture, and
24//! every item here carries `#[allow(dead_code)]`. Consuming them is
25//! a follow-up.
26//!
27//! # Layout pinning
28//!
29//! [`TimelineEvent`] mirrors the on-the-wire `struct timeline_event`
30//! defined in `src/bpf/intf.h`. Field order, sizes, and the type
31//! constants must stay in lockstep — a unit test
32//! (`tests::timeline_event_layout_pinned`) verifies the 40-byte
33//! footprint and field offsets against the BPF-side layout.
34
35use serde::{Deserialize, Serialize};
36
37/// Type-byte values from `src/bpf/intf.h::TL_EVT_*`. Pinned here as
38/// the userspace-facing identifier for each variant; the parser
39/// uses these to discriminate the [`TimelineEvent`] variant.
40pub mod tl_evt {
41 /// `tp_btf/sched_switch` record. `prev_pid`/`next_pid`/`a` (prev_state)/`b` (preempt).
42 pub const SWITCH: u32 = 1;
43 /// `tp_btf/sched_migrate_task` record. `prev_pid`/`a` (dest_cpu)/`b` (orig_cpu).
44 pub const MIGRATE: u32 = 2;
45 /// `tp_btf/sched_wakeup` record. `prev_pid`/`a` (target_cpu).
46 pub const WAKEUP: u32 = 3;
47 /// `fentry/fexit` rt_mutex_setprio. PI boost record.
48 pub const PI_BOOST: u32 = 4;
49 /// `tp_btf/lock:contention_begin` record.
50 pub const LOCK_CONTEND: u32 = 5;
51}
52
53/// Wire-format mirror of `struct timeline_event` from
54/// `src/bpf/intf.h`.
55///
56/// Layout pinning: 40 bytes total (4 type + 4 cpu + 8 ts +
57/// 4 prev_pid + 4 next_pid + 8 a + 8 b). Order matches the BPF
58/// emit sites in `probe.bpf.c::ktstr_tl_switch/migrate/wakeup`.
59#[repr(C)]
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct TimelineEventRaw {
62 pub type_: u32,
63 pub cpu: u32,
64 pub ts: u64,
65 pub prev_pid: u32,
66 pub next_pid: u32,
67 pub a: u64,
68 pub b: u64,
69}
70
71/// Parsed timeline event with variant-aware field naming.
72///
73/// `non_exhaustive` so future BPF event types added in `intf.h`
74/// (per the TL_EVT_PI_BOOST / TL_EVT_LOCK_CONTEND sites) can land
75/// without breaking existing on-disk dumps.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[non_exhaustive]
78#[serde(tag = "kind")]
79#[allow(dead_code)] // re-exported for consumers; not yet wired into
80// the dump pipeline.
81pub enum TimelineEvent {
82 /// `tp_btf/sched_switch`. The kernel switched from `prev_pid`
83 /// to `next_pid` on `cpu` at `ts` (boot-time ns).
84 Switch {
85 ts: u64,
86 cpu: u32,
87 prev_pid: u32,
88 next_pid: u32,
89 /// Raw `prev_state` bitfield (TASK_RUNNING / TASK_INTERRUPTIBLE
90 /// / etc., from `include/linux/sched.h`).
91 prev_state: u64,
92 /// True when the switch was a preemption (vs voluntary
93 /// dequeue).
94 preempt: bool,
95 },
96 /// `tp_btf/sched_migrate_task`. Task `pid` migrated from
97 /// `orig_cpu` to `dest_cpu`.
98 Migrate {
99 ts: u64,
100 cpu: u32,
101 pid: u32,
102 orig_cpu: u32,
103 dest_cpu: u32,
104 },
105 /// `tp_btf/sched_wakeup`. Task `pid` woken up; scheduler
106 /// chose `target_cpu` for placement.
107 Wakeup {
108 ts: u64,
109 cpu: u32,
110 pid: u32,
111 target_cpu: u32,
112 },
113 /// PI boost. Probe-context tid `prober_tid`; boosted task
114 /// `pid`. `old_prio`/`new_prio` are the boosted task's effective
115 /// kernel priority (signed `int`) before and after the boost.
116 /// Scheduling-class transitions are counted separately via the
117 /// `pi_class_changes` counter, not carried in this record. Field
118 /// layout per `src/bpf/intf.h::TL_EVT_PI_BOOST`.
119 PiBoost {
120 ts: u64,
121 cpu: u32,
122 prober_tid: u32,
123 pid: u32,
124 old_prio: i32,
125 new_prio: i32,
126 },
127 /// Lock contention begin. `tid` is the waiter; `lock_kva` is
128 /// the lock's kernel virtual address; `flags` carries the
129 /// LCB_* class bits (F_SPIN, F_READ, F_WRITE, F_RT — see
130 /// `include/trace/events/lock.h`).
131 LockContend {
132 ts: u64,
133 cpu: u32,
134 tid: u32,
135 lock_kva: u64,
136 flags: u32,
137 },
138 /// Unrecognized type byte. Library doesn't drop unknown
139 /// records — surfacing them as `Unknown` lets the failure
140 /// dump preserve forward-compat data the consumer can opt
141 /// into rendering later.
142 Unknown {
143 ts: u64,
144 cpu: u32,
145 type_: u32,
146 prev_pid: u32,
147 next_pid: u32,
148 a: u64,
149 b: u64,
150 },
151}
152
153/// Parse a single 40-byte ringbuf record.
154///
155/// Returns `None` when the input is shorter than the on-the-wire
156/// size — the caller has truncated buffer / partial read and should
157/// stop draining at this slot.
158#[allow(dead_code)]
159pub fn parse_timeline_record(bytes: &[u8]) -> Option<TimelineEvent> {
160 if bytes.len() < std::mem::size_of::<TimelineEventRaw>() {
161 return None;
162 }
163 // SAFETY: TimelineEventRaw is repr(C) plain-data, all fields
164 // are integer types so any byte pattern is a valid value.
165 // The size check above guarantees we have enough bytes.
166 let raw = unsafe { std::ptr::read_unaligned(bytes.as_ptr() as *const TimelineEventRaw) };
167 Some(decode_raw(&raw))
168}
169
170/// Parse a contiguous buffer of timeline records into a vec of
171/// [`TimelineEvent`] values, in encounter order.
172///
173/// `bytes` is the concatenation of N timeline_event records.
174/// Trailing bytes that don't form a full record are silently
175/// dropped (a torn final record at ringbuf wrap is the typical
176/// case; the consumer's next drain picks up the remainder).
177#[allow(dead_code)]
178pub fn parse_timeline_buf(bytes: &[u8]) -> Vec<TimelineEvent> {
179 let stride = std::mem::size_of::<TimelineEventRaw>();
180 let mut out = Vec::with_capacity(bytes.len() / stride);
181 let mut off = 0;
182 while off + stride <= bytes.len() {
183 if let Some(ev) = parse_timeline_record(&bytes[off..off + stride]) {
184 out.push(ev);
185 }
186 off += stride;
187 }
188 out
189}
190
191fn decode_raw(raw: &TimelineEventRaw) -> TimelineEvent {
192 match raw.type_ {
193 tl_evt::SWITCH => TimelineEvent::Switch {
194 ts: raw.ts,
195 cpu: raw.cpu,
196 prev_pid: raw.prev_pid,
197 next_pid: raw.next_pid,
198 prev_state: raw.a,
199 preempt: raw.b != 0,
200 },
201 tl_evt::MIGRATE => TimelineEvent::Migrate {
202 ts: raw.ts,
203 cpu: raw.cpu,
204 pid: raw.prev_pid,
205 orig_cpu: raw.b as u32,
206 dest_cpu: raw.a as u32,
207 },
208 tl_evt::WAKEUP => TimelineEvent::Wakeup {
209 ts: raw.ts,
210 cpu: raw.cpu,
211 pid: raw.prev_pid,
212 target_cpu: raw.a as u32,
213 },
214 tl_evt::PI_BOOST => {
215 // The producer widens the signed kernel prio to u64 via
216 // (u64)(s64)prio; truncating back to i32 recovers the
217 // original signed value. No class id is packed in the high
218 // bits — class transitions surface via the pi_class_changes
219 // counter, not this record.
220 let old_prio = raw.a as i32;
221 let new_prio = raw.b as i32;
222 TimelineEvent::PiBoost {
223 ts: raw.ts,
224 cpu: raw.cpu,
225 prober_tid: raw.prev_pid,
226 pid: raw.next_pid,
227 old_prio,
228 new_prio,
229 }
230 }
231 tl_evt::LOCK_CONTEND => TimelineEvent::LockContend {
232 ts: raw.ts,
233 cpu: raw.cpu,
234 tid: raw.prev_pid,
235 lock_kva: raw.a,
236 flags: raw.b as u32,
237 },
238 _ => TimelineEvent::Unknown {
239 ts: raw.ts,
240 cpu: raw.cpu,
241 type_: raw.type_,
242 prev_pid: raw.prev_pid,
243 next_pid: raw.next_pid,
244 a: raw.a,
245 b: raw.b,
246 },
247 }
248}
249
250/// Capture handle for the freeze coordinator's drain of the
251/// `timeline_events` BPF ringbuf.
252///
253/// At dump time the coordinator constructs this with the drained
254/// raw bytes (concatenated 40-byte records, in ringbuf order) plus
255/// the BSS-side drop count. The dump consumer parses the buffer
256/// into [`TimelineEvent`] values. This capture is not yet consumed
257/// by the dump pipeline; the BSS-side drop count is surfaced today
258/// via [`super::dump::ProbeBssCounters::timeline_drops`] (reached
259/// through `super::dump::FailureDumpReport::probe_counters`).
260#[derive(Debug, Clone, Default)]
261#[allow(dead_code)]
262pub struct TimelineCapture<'a> {
263 /// Raw concatenated record bytes drained from the
264 /// `timeline_events` ringbuf. Length must be a multiple of
265 /// `size_of::<TimelineEventRaw>()` (40); trailing partial
266 /// records are silently dropped at parse time.
267 pub records: &'a [u8],
268 /// `KTSTR_PCPU_TIMELINE_DROPS` per-CPU slot (summed across
269 /// CPUs) at drain time. Non-zero indicates the BPF producer
270 /// hit a full ringbuf and dropped the newest event(s) on
271 /// submit.
272 pub drops: u64,
273}
274
275// ---------------------------------------------------------------
276// Incremental capture: periodic VM-freeze ring of raw bytes.
277// ---------------------------------------------------------------
278
279/// Default snapshot-ring depth: 60 entries at 1 Hz steady-state
280/// covers 60 seconds of pre-trigger context — long enough for the
281/// dual-snapshot delta to detect slow drift, short enough that the
282/// storage cost stays within the 60-300 MiB envelope of the per-VM
283/// budget for incremental capture.
284pub const DEFAULT_SNAPSHOT_RING_DEPTH: usize = 60;
285
286/// One incremental snapshot — opaque raw bytes captured at a
287/// particular freeze instant + the wall-clock ts of capture.
288///
289/// "Opaque bytes" because the producer captures BPF state without
290/// rendering it. Render is deferred until trigger fires; the
291/// failure dump's renderer parses the buffer through the same
292/// pipeline as a regular failure dump.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[non_exhaustive]
295#[allow(dead_code)]
296pub struct IncrementalSnapshot {
297 /// Wall-clock timestamp at freeze instant (CLOCK_REALTIME ns).
298 /// `0` when the producer didn't stamp it (test fixtures only).
299 pub captured_ns: u64,
300 /// Boot-time (CLOCK_MONOTONIC_RAW) ns at freeze instant.
301 /// Co-axial with `captured_ns`; the dump renderer pairs the
302 /// two so the timeline is anchored against both wall-clock
303 /// (operator readability) and monotonic (delta math).
304 pub monotonic_ns: u64,
305 /// Raw captured bytes. Shape is producer-defined: typically
306 /// the `.bss` value buffer of the scheduler's BPF object,
307 /// concatenated with a serialized `super::scx_walker`
308 /// snapshot. The renderer treats it as opaque until
309 /// trigger time.
310 pub bytes: Vec<u8>,
311}
312
313/// Bounded ring of [`IncrementalSnapshot`] values.
314///
315/// Newest snapshot at the back; the oldest is dropped when the ring
316/// fills (single-producer-single-consumer pattern — the freeze
317/// coordinator pushes, the failure-dump renderer drains).
318///
319/// Cheap to clone for sidecar persistence: `Vec` of `Vec<u8>`
320/// shares no internal state, so the capture-mode binary can
321/// snapshot the ring at trigger time without blocking the
322/// coordinator's continued sampling.
323#[derive(Debug, Clone, Default)]
324#[allow(dead_code)]
325pub struct SnapshotRing {
326 capacity: usize,
327 snapshots: std::collections::VecDeque<IncrementalSnapshot>,
328}
329
330impl SnapshotRing {
331 /// New ring with the requested capacity. Pass
332 /// [`DEFAULT_SNAPSHOT_RING_DEPTH`] for the storage-budget-tuned
333 /// default (60 entries).
334 #[allow(dead_code)]
335 pub fn new(capacity: usize) -> Self {
336 Self {
337 capacity: capacity.max(1),
338 snapshots: std::collections::VecDeque::with_capacity(capacity.max(1)),
339 }
340 }
341
342 /// Push a new snapshot. When the ring is full, the oldest
343 /// entry is dropped (FIFO eviction).
344 #[allow(dead_code)]
345 pub fn push(&mut self, snap: IncrementalSnapshot) {
346 if self.snapshots.len() == self.capacity {
347 self.snapshots.pop_front();
348 }
349 self.snapshots.push_back(snap);
350 }
351
352 /// Number of snapshots currently held.
353 #[allow(dead_code)]
354 pub fn len(&self) -> usize {
355 self.snapshots.len()
356 }
357
358 /// True when the ring holds no snapshots.
359 #[allow(dead_code)]
360 pub fn is_empty(&self) -> bool {
361 self.snapshots.is_empty()
362 }
363
364 /// Capacity (the depth this ring was constructed with).
365 #[allow(dead_code)]
366 pub fn capacity(&self) -> usize {
367 self.capacity
368 }
369
370 /// Drain every held snapshot into a vec, oldest-first. Used by
371 /// the failure-dump renderer at trigger time to consume the
372 /// pre-trigger window.
373 #[allow(dead_code)]
374 pub fn drain(&mut self) -> Vec<IncrementalSnapshot> {
375 self.snapshots.drain(..).collect()
376 }
377
378 /// Borrow the held snapshots for read-only inspection (e.g.
379 /// the capture-mode binary's "show me what's in the ring
380 /// right now" diagnostic).
381 #[allow(dead_code)]
382 pub fn snapshots(&self) -> impl Iterator<Item = &IncrementalSnapshot> {
383 self.snapshots.iter()
384 }
385}
386
387/// Capture handle the freeze coordinator passes into the dump
388/// pipeline when periodic incremental snapshots are enabled.
389///
390/// Defined for a future dump-pipeline integration; not yet consumed
391/// by any renderer ([`super::dump::FailureDumpReport`] has no
392/// incremental-snapshot field, and this type carries
393/// `#[allow(dead_code)]`).
394/// `None` capture means the freeze coordinator wasn't running the
395/// periodic loop — typical for one-shot dumps where the
396/// dual-snapshot delta is enough.
397#[derive(Debug, Clone, Default)]
398#[allow(dead_code)]
399pub struct IncrementalCapture {
400 /// Pre-trigger ring of raw snapshots. Producer drains the
401 /// ring into this vec at trigger time; the dump renderer
402 /// parses each snapshot's bytes via the same path as a
403 /// regular failure dump.
404 pub snapshots: Vec<IncrementalSnapshot>,
405 /// Steady-state sampling frequency (Hz) — typically 1.
406 /// Surfaced so the operator can correlate snapshot timing
407 /// against the failure window.
408 pub steady_hz: f64,
409 /// Escalation frequency (Hz) used during stall detection —
410 /// typically 10. Reflects the actual frequency at trigger
411 /// time, not the configured ceiling.
412 pub trigger_hz: f64,
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 /// `TimelineEventRaw` matches the BPF-side struct timeline_event
420 /// in size and field offsets. Drift here is a wire-protocol
421 /// break; the test catches it at compile + run time.
422 ///
423 /// Verdict-routed so a multi-field layout regression (e.g.
424 /// somebody re-orders the struct) surfaces every drift in one
425 /// run rather than failing on the first mismatch.
426 #[test]
427 fn timeline_event_layout_pinned() {
428 use crate::assert::Verdict;
429
430 let total_size = std::mem::size_of::<TimelineEventRaw>();
431 let off_type = std::mem::offset_of!(TimelineEventRaw, type_);
432 let off_cpu = std::mem::offset_of!(TimelineEventRaw, cpu);
433 let off_ts = std::mem::offset_of!(TimelineEventRaw, ts);
434 let off_prev_pid = std::mem::offset_of!(TimelineEventRaw, prev_pid);
435 let off_next_pid = std::mem::offset_of!(TimelineEventRaw, next_pid);
436 let off_a = std::mem::offset_of!(TimelineEventRaw, a);
437 let off_b = std::mem::offset_of!(TimelineEventRaw, b);
438
439 let mut v = Verdict::new();
440 // Total: 4 + 4 + 8 + 4 + 4 + 8 + 8 = 40 bytes.
441 crate::claim!(v, total_size).eq(40usize);
442 // Field offsets matching src/bpf/intf.h::struct timeline_event.
443 crate::claim!(v, off_type).eq(0usize);
444 crate::claim!(v, off_cpu).eq(4usize);
445 crate::claim!(v, off_ts).eq(8usize);
446 crate::claim!(v, off_prev_pid).eq(16usize);
447 crate::claim!(v, off_next_pid).eq(20usize);
448 crate::claim!(v, off_a).eq(24usize);
449 crate::claim!(v, off_b).eq(32usize);
450 let r = v.into_result();
451 assert!(
452 r.is_pass(),
453 "timeline_event layout drift detected: {:?}",
454 r.outcomes,
455 );
456 }
457
458 fn raw(type_: u32, cpu: u32, ts: u64, p: u32, n: u32, a: u64, b: u64) -> Vec<u8> {
459 let r = TimelineEventRaw {
460 type_,
461 cpu,
462 ts,
463 prev_pid: p,
464 next_pid: n,
465 a,
466 b,
467 };
468 // Safety: r is plain-data; reading its bytes is well-defined.
469 let bytes = unsafe {
470 std::slice::from_raw_parts(
471 &r as *const TimelineEventRaw as *const u8,
472 std::mem::size_of::<TimelineEventRaw>(),
473 )
474 };
475 bytes.to_vec()
476 }
477
478 /// Switch record decodes with prev/next pids + prev_state +
479 /// preempt bool. Verdict-routed so every field surfaces its own
480 /// labeled detail on regression.
481 #[test]
482 fn parse_switch_record() {
483 use crate::assert::Verdict;
484
485 let bytes = raw(tl_evt::SWITCH, 3, 1_000_000, 100, 200, 0x402, 1);
486 let ev = parse_timeline_record(&bytes).unwrap();
487 match ev {
488 TimelineEvent::Switch {
489 ts,
490 cpu,
491 prev_pid,
492 next_pid,
493 prev_state,
494 preempt,
495 } => {
496 let mut v = Verdict::new();
497 crate::claim!(v, ts).eq(1_000_000u64);
498 crate::claim!(v, cpu).eq(3u32);
499 crate::claim!(v, prev_pid).eq(100u32);
500 crate::claim!(v, next_pid).eq(200u32);
501 crate::claim!(v, prev_state).eq(0x402u64);
502 crate::claim!(v, preempt).eq(true);
503 let r = v.into_result();
504 assert!(r.is_pass(), "Switch record decode drift: {:?}", r.outcomes,);
505 }
506 other => panic!("expected Switch, got {other:?}"),
507 }
508 }
509
510 /// Migrate record decodes with pid + orig_cpu + dest_cpu.
511 /// Per intf.h: a = dest_cpu, b = orig_cpu.
512 #[test]
513 fn parse_migrate_record() {
514 let bytes = raw(tl_evt::MIGRATE, 1, 2_000_000, 555, 0, 7, 2);
515 let ev = parse_timeline_record(&bytes).unwrap();
516 match ev {
517 TimelineEvent::Migrate {
518 pid,
519 orig_cpu,
520 dest_cpu,
521 ..
522 } => {
523 assert_eq!(pid, 555);
524 assert_eq!(dest_cpu, 7);
525 assert_eq!(orig_cpu, 2);
526 }
527 other => panic!("expected Migrate, got {other:?}"),
528 }
529 }
530
531 /// Wakeup record decodes with pid + target_cpu.
532 #[test]
533 fn parse_wakeup_record() {
534 let bytes = raw(tl_evt::WAKEUP, 0, 3_000_000, 777, 0, 4, 0);
535 let ev = parse_timeline_record(&bytes).unwrap();
536 match ev {
537 TimelineEvent::Wakeup {
538 pid, target_cpu, ..
539 } => {
540 assert_eq!(pid, 777);
541 assert_eq!(target_cpu, 4);
542 }
543 other => panic!("expected Wakeup, got {other:?}"),
544 }
545 }
546
547 /// PiBoost carries the signed kernel prio in a/b, widened to u64
548 /// by the producer via `(u64)(s64)prio`. The decoder truncates
549 /// back to i32 — a negative prio (task boosted into the RT band)
550 /// must round-trip, which the old `(prio | class_id<<32)` split
551 /// corrupted by reading the sign-extension bits as a class id.
552 #[test]
553 fn parse_pi_boost_record() {
554 let old_a = (120i32 as i64) as u64; // prio=120 (normal band)
555 let new_b = (-1i32 as i64) as u64; // prio=-1 (sign-extended)
556 let bytes = raw(tl_evt::PI_BOOST, 2, 4_000_000, 10, 11, old_a, new_b);
557 let ev = parse_timeline_record(&bytes).unwrap();
558 match ev {
559 TimelineEvent::PiBoost {
560 prober_tid,
561 pid,
562 old_prio,
563 new_prio,
564 ..
565 } => {
566 assert_eq!(prober_tid, 10);
567 assert_eq!(pid, 11);
568 assert_eq!(old_prio, 120);
569 assert_eq!(new_prio, -1);
570 }
571 other => panic!("expected PiBoost, got {other:?}"),
572 }
573 }
574
575 /// LockContend record carries lock_kva + flags.
576 #[test]
577 fn parse_lock_contend_record() {
578 let lock_kva = 0xffff_ffff_8000_1000u64;
579 let flags = 0x4u64;
580 let bytes = raw(tl_evt::LOCK_CONTEND, 5, 5_000_000, 99, 0, lock_kva, flags);
581 let ev = parse_timeline_record(&bytes).unwrap();
582 match ev {
583 TimelineEvent::LockContend {
584 tid,
585 lock_kva: kva,
586 flags: f,
587 ..
588 } => {
589 assert_eq!(tid, 99);
590 assert_eq!(kva, lock_kva);
591 assert_eq!(f, 0x4);
592 }
593 other => panic!("expected LockContend, got {other:?}"),
594 }
595 }
596
597 /// Unknown type byte surfaces as Unknown variant — preserves
598 /// forward-compat data for newer kernels with TL_EVT_* values
599 /// the consumer doesn't yet decode.
600 #[test]
601 fn parse_unknown_type_preserves_fields() {
602 let bytes = raw(99, 7, 6_000_000, 1, 2, 3, 4);
603 let ev = parse_timeline_record(&bytes).unwrap();
604 match ev {
605 TimelineEvent::Unknown {
606 type_,
607 prev_pid,
608 a,
609 b,
610 ..
611 } => {
612 assert_eq!(type_, 99);
613 assert_eq!(prev_pid, 1);
614 assert_eq!(a, 3);
615 assert_eq!(b, 4);
616 }
617 other => panic!("expected Unknown, got {other:?}"),
618 }
619 }
620
621 /// Truncated record returns None — the drain loop stops
622 /// parsing rather than reading past end-of-buffer.
623 #[test]
624 fn parse_truncated_record_returns_none() {
625 let bytes = vec![0u8; 39]; // 1 byte short of 40
626 assert!(parse_timeline_record(&bytes).is_none());
627 }
628
629 /// `parse_timeline_buf` parses every full record in a multi-
630 /// record buffer and silently drops a partial trailing record.
631 #[test]
632 fn parse_timeline_buf_multi_record_with_partial_tail() {
633 let mut buf: Vec<u8> = Vec::new();
634 buf.extend(raw(tl_evt::SWITCH, 0, 1, 1, 2, 0, 0));
635 buf.extend(raw(tl_evt::WAKEUP, 1, 2, 3, 0, 4, 0));
636 // Append 20 bytes of partial record — must not parse.
637 buf.extend(vec![0u8; 20]);
638 let evs = parse_timeline_buf(&buf);
639 assert_eq!(evs.len(), 2);
640 assert!(matches!(evs[0], TimelineEvent::Switch { .. }));
641 assert!(matches!(evs[1], TimelineEvent::Wakeup { .. }));
642 }
643
644 /// Snapshot ring bounds at capacity and evicts oldest first.
645 #[test]
646 fn snapshot_ring_evicts_oldest() {
647 let mut ring = SnapshotRing::new(3);
648 for i in 0..5 {
649 ring.push(IncrementalSnapshot {
650 captured_ns: i,
651 monotonic_ns: i,
652 bytes: vec![i as u8],
653 });
654 }
655 assert_eq!(ring.len(), 3);
656 assert_eq!(ring.capacity(), 3);
657 let drained = ring.drain();
658 assert_eq!(drained.len(), 3);
659 // After eviction we should have ts=2,3,4 (oldest 0,1 dropped).
660 assert_eq!(drained[0].captured_ns, 2);
661 assert_eq!(drained[2].captured_ns, 4);
662 }
663
664 /// Default ring depth matches the documented 60-entry budget.
665 #[test]
666 fn default_ring_depth_pinned() {
667 assert_eq!(DEFAULT_SNAPSHOT_RING_DEPTH, 60);
668 }
669
670 /// New ring is empty and matches its capacity.
671 #[test]
672 fn snapshot_ring_starts_empty() {
673 let ring = SnapshotRing::new(8);
674 assert!(ring.is_empty());
675 assert_eq!(ring.len(), 0);
676 assert_eq!(ring.capacity(), 8);
677 }
678
679 /// `IncrementalSnapshot` round-trips through serde so off-disk
680 /// captures parse on reload.
681 #[test]
682 fn incremental_snapshot_serde_roundtrip() {
683 let snap = IncrementalSnapshot {
684 captured_ns: 1234567890,
685 monotonic_ns: 9876543210,
686 bytes: vec![1, 2, 3, 4],
687 };
688 let json = serde_json::to_string(&snap).unwrap();
689 let parsed: IncrementalSnapshot = serde_json::from_str(&json).unwrap();
690 assert_eq!(parsed.captured_ns, 1234567890);
691 assert_eq!(parsed.bytes, vec![1, 2, 3, 4]);
692 }
693
694 /// `TimelineEvent` round-trips through serde — every variant
695 /// survives the json string.
696 #[test]
697 fn timeline_event_serde_roundtrip_all_variants() {
698 let cases = vec![
699 TimelineEvent::Switch {
700 ts: 1,
701 cpu: 0,
702 prev_pid: 10,
703 next_pid: 20,
704 prev_state: 1,
705 preempt: false,
706 },
707 TimelineEvent::Migrate {
708 ts: 2,
709 cpu: 1,
710 pid: 30,
711 orig_cpu: 1,
712 dest_cpu: 2,
713 },
714 TimelineEvent::Wakeup {
715 ts: 3,
716 cpu: 2,
717 pid: 40,
718 target_cpu: 5,
719 },
720 TimelineEvent::PiBoost {
721 ts: 4,
722 cpu: 3,
723 prober_tid: 1,
724 pid: 2,
725 old_prio: 120,
726 new_prio: 100,
727 },
728 TimelineEvent::LockContend {
729 ts: 5,
730 cpu: 4,
731 tid: 99,
732 lock_kva: 0xffff_ffff,
733 flags: 0x4,
734 },
735 ];
736 for ev in cases {
737 let json = serde_json::to_string(&ev).expect("serialize");
738 let _: TimelineEvent = serde_json::from_str(&json).expect("deserialize");
739 }
740 }
741}