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}