ktstr/scenario/sample/host.rs
1//! Per-sample, per-CPU host-side timeline projection.
2//!
3//! The host-capture pipeline (see [`crate::monitor::dump`]) populates
4//! each [`FailureDumpReport::per_cpu_time`](crate::monitor::dump::FailureDumpReport::per_cpu_time)
5//! with a slice of [`PerCpuTimeStats`] taken at sample time. This
6//! module exposes those slices as a borrowed-view timeline
7//! ([`HostView`]) keyed by CPU id, with a closure-based projector
8//! that emits a [`SeriesField<u64>`] compatible with the temporal-
9//! assertion patterns in [`crate::assert::temporal`].
10//!
11//! Orthogonal to [`super::monitor`]: this view is the per-SAMPLE
12//! per-CPU TIMELINE source; the monitor view exposes the per-VM-RUN
13//! cross-CPU AGGREGATE. The two never overlap — they draw from
14//! different fields on the captured reports
15//! (`FailureDumpReport::per_cpu_time` here vs `MonitorReport.summary`
16//! for the monitor view).
17
18use crate::assert::temporal::SeriesField;
19use crate::monitor::dump::PerCpuTimeStats;
20
21use super::{SampleRow, SampleSeries, build_series_field};
22
23/// Borrowed view over the per-sample per-CPU [`PerCpuTimeStats`] data
24/// that the host capture pipeline populates into each
25/// [`FailureDumpReport::per_cpu_time`](crate::monitor::dump::FailureDumpReport::per_cpu_time). Returned by
26/// [`SampleSeries::host`]; exposes a per-CPU timeline (rows sorted
27/// ascending by elapsed-ms, stable on ties) plus a closure-based
28/// projector that emits a [`SeriesField<u64>`] compatible with the
29/// temporal-assertion patterns in [`crate::assert::temporal`].
30///
31/// Orthogonal to [`super::MonitorView`]: this view is the per-sample
32/// per-CPU TIMELINE source; `MonitorView` exposes the per-VM-run
33/// cross-CPU AGGREGATE. The two draw from different fields on the
34/// captured reports (`FailureDumpReport::per_cpu_time` here vs
35/// `MonitorReport.summary` for the monitor view) and never overlap.
36///
37/// Placeholder samples (the freeze rendezvous timed out, the
38/// capture pipeline otherwise failed) carry an empty `per_cpu_time`
39/// slice and naturally drop out of every per-CPU timeline without
40/// an explicit filter — temporal-assertion patterns see the
41/// surrounding non-placeholder samples in order.
42#[derive(Debug, Clone, Copy)]
43#[must_use = "HostView is a borrowed view; call .per_cpu_time_timeline() / .per_cpu_field_u64() / .cpus() to project"]
44#[non_exhaustive]
45pub struct HostView<'a> {
46 rows: &'a [SampleRow],
47}
48
49impl<'a> HostView<'a> {
50 /// Discover every CPU id that appears in at least one sample's
51 /// `per_cpu_time` slice. Returned in ascending order, deduped.
52 /// Useful for "fan-out over every captured CPU" assertion
53 /// loops: `for cpu in host.cpus() { ... }`.
54 pub fn cpus(&self) -> Vec<u32> {
55 let mut seen = std::collections::BTreeSet::new();
56 for row in self.rows {
57 for entry in &row.report.per_cpu_time {
58 seen.insert(entry.cpu);
59 }
60 }
61 seen.into_iter().collect()
62 }
63
64 /// Per-CPU timeline: every sample that captured `cpu`, sorted
65 /// ascending by `elapsed_ms`. Ties retain insertion order
66 /// (stable sort). Samples whose `per_cpu_time` slice didn't
67 /// include `cpu` (placeholder reports, or a kernel without
68 /// per-CPU stats) are absent from the returned timeline rather
69 /// than producing a default-zero row that would silently advance
70 /// counter-style assertions.
71 ///
72 /// Returns an empty Vec when `cpu` was not captured in any
73 /// sample. Test authors that need explicit per-sample
74 /// coverage discrimination iterate via
75 /// [`SampleSeries::iter_samples`] and consult
76 /// [`crate::scenario::snapshot::Snapshot::per_cpu_time_at`] per
77 /// sample.
78 ///
79 /// Inherits the first-match-wins contract for duplicate-cpu
80 /// entries from
81 /// [`crate::scenario::snapshot::Snapshot::per_cpu_time_at`]:
82 /// production walker (`collect_per_cpu_time`) enforces one
83 /// entry per cpu per sample, but the lookup leaves the contract
84 /// first-match for graceful degradation on a malformed report.
85 /// Samples whose `elapsed_ms` is `None` (the bridge recorded no
86 /// timestamp) are EXCLUDED: a timestamp-less sample has
87 /// no position on a time-ordered axis, and placing it at a
88 /// fabricated `0` would corrupt the ascending-by-time contract.
89 pub fn per_cpu_time_timeline(&self, cpu: u32) -> Vec<(u64, &'a PerCpuTimeStats)> {
90 let mut entries: Vec<(u64, &'a PerCpuTimeStats)> = Vec::new();
91 for row in self.rows {
92 let Some(elapsed_ms) = row.elapsed_ms else {
93 continue;
94 };
95 if let Some(stats) = row.report.per_cpu_time.iter().find(|c| c.cpu == cpu) {
96 entries.push((elapsed_ms, stats));
97 }
98 }
99 entries.sort_by_key(|(elapsed_ms, _)| *elapsed_ms);
100 entries
101 }
102
103 /// Project a single u64 field out of each per-sample
104 /// `PerCpuTimeStats` row for `cpu` into a [`SeriesField<u64>`]
105 /// suitable for the temporal-assertion patterns
106 /// (`nondecreasing`, `rate_within`, `steady_within`,
107 /// `converges_to`, etc.) in [`crate::assert::temporal`]. Mirrors
108 /// the shape of [`SampleSeries::bpf`] so identical assertion
109 /// pipelines compose against either axis.
110 ///
111 /// Samples whose `per_cpu_time` slice didn't include `cpu`
112 /// surface as a per-sample
113 /// [`SnapshotError::HostFieldUnavailable`](crate::scenario::snapshot::SnapshotError::HostFieldUnavailable)
114 /// slot — gap-tolerant temporal patterns skip with a rendered
115 /// Note, strict patterns fail the assertion so coverage gaps
116 /// can never silently slip past the call site.
117 pub fn per_cpu_field_u64(
118 &self,
119 cpu: u32,
120 label: impl Into<String>,
121 project: impl Fn(&PerCpuTimeStats) -> u64,
122 ) -> SeriesField<u64> {
123 build_series_field(self.rows, label, |row| {
124 // Placeholder reports surface as the dedicated
125 // PlaceholderSample variant — matching the series.bpf
126 // pattern so temporal-assertion sites route placeholder
127 // samples through their per-sample skip handling rather
128 // than treating them as cpu-coverage gaps. A strict
129 // pattern (always_true / each.at_least) would otherwise
130 // FAIL on placeholders instead of skipping; gap-tolerant
131 // patterns render the right diagnostic Note.
132 if row.report.is_placeholder {
133 return Err(
134 crate::scenario::snapshot::SnapshotError::PlaceholderSample {
135 tag: row.tag.clone(),
136 reason: row
137 .report
138 .scx_walker_unavailable
139 .clone()
140 .unwrap_or_else(|| "placeholder report".to_string()),
141 },
142 );
143 }
144 // Inherits the first-match-wins contract from
145 // [`crate::scenario::snapshot::Snapshot::per_cpu_time_at`]:
146 // production walker (`collect_per_cpu_time` at
147 // `crate::monitor::dump`) enforces one entry per cpu,
148 // but the closure leaves the contract first-match for
149 // graceful degradation on a malformed report.
150 match row.report.per_cpu_time.iter().find(|c| c.cpu == cpu) {
151 Some(stats) => Ok(project(stats)),
152 None => Err(
153 crate::scenario::snapshot::SnapshotError::HostFieldUnavailable {
154 tag: row.tag.clone(),
155 cpu,
156 },
157 ),
158 }
159 })
160 }
161}
162
163impl SampleSeries {
164 /// Borrowed view over the per-sample host-side per-CPU snapshot
165 /// data captured into each [`FailureDumpReport::per_cpu_time`](crate::monitor::dump::FailureDumpReport::per_cpu_time).
166 /// Returns `None` when the series is empty; otherwise yields a
167 /// [`HostView`] that exposes the per-CPU timeline (rows sorted
168 /// by elapsed-ms) and a closure-based projector compatible with
169 /// the temporal-assertion patterns in
170 /// [`crate::assert::temporal`].
171 ///
172 /// Orthogonal to [`Self::monitor`]: `host()` is the per-sample
173 /// per-CPU TIMELINE; `monitor()` is the per-VM-run cross-CPU
174 /// AGGREGATE. Tests that want both perspectives chain them
175 /// independently from the same series.
176 ///
177 /// The returned `HostView<'_>` borrows from this series, so the
178 /// series must outlive any projection chained off the view.
179 pub fn host(&self) -> Option<HostView<'_>> {
180 if self.rows.is_empty() {
181 None
182 } else {
183 Some(HostView { rows: &self.rows })
184 }
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::monitor::dump::FailureDumpReport;
192
193 #[test]
194 fn series_host_empty_series_returns_none() {
195 let series = SampleSeries::from_drained(vec![], None);
196 assert!(series.host().is_none());
197 }
198
199 /// Single-sample series with N captured CPUs:
200 /// `per_cpu_time_timeline(cpu)` returns exactly 1 row for each
201 /// captured cpu, empty Vec for any other cpu. Pins the
202 /// per-CPU filter — placeholder-or-absent CPUs MUST NOT
203 /// surface default-zero rows that would silently advance
204 /// counter-style assertions.
205 #[test]
206 fn series_host_per_cpu_time_timeline_single_sample() {
207 let report = FailureDumpReport {
208 per_cpu_time: vec![
209 PerCpuTimeStats {
210 cpu: 0,
211 cpustat_user_ns: 100,
212 ..Default::default()
213 },
214 PerCpuTimeStats {
215 cpu: 3,
216 cpustat_user_ns: 300,
217 ..Default::default()
218 },
219 ],
220 ..Default::default()
221 };
222 let series = SampleSeries::from_drained(
223 vec![("periodic_000".to_string(), report, None, Some(50u64))],
224 None,
225 );
226 let host = series.host().expect("non-empty series");
227 let t0 = host.per_cpu_time_timeline(0);
228 assert_eq!(t0.len(), 1);
229 assert_eq!(t0[0].0, 50);
230 assert_eq!(t0[0].1.cpustat_user_ns, 100);
231 let t3 = host.per_cpu_time_timeline(3);
232 assert_eq!(t3.len(), 1);
233 assert_eq!(t3[0].1.cpustat_user_ns, 300);
234 let t99 = host.per_cpu_time_timeline(99);
235 assert!(
236 t99.is_empty(),
237 "cpu not captured in any sample MUST yield empty timeline (not default-zero)"
238 );
239 assert_eq!(host.cpus(), vec![0, 3]);
240 }
241
242 /// REGRESSION: a sample whose `elapsed_ms` is `None` (the
243 /// bridge recorded no timestamp) is DROPPED from the time-ordered
244 /// timeline — it has no position on a time axis and must not be
245 /// placed at a fabricated `0` (which would sort first and corrupt the
246 /// ascending contract).
247 #[test]
248 fn series_host_per_cpu_time_timeline_drops_none_elapsed() {
249 let mk = |user_ns: u64| FailureDumpReport {
250 per_cpu_time: vec![PerCpuTimeStats {
251 cpu: 0,
252 cpustat_user_ns: user_ns,
253 ..Default::default()
254 }],
255 ..Default::default()
256 };
257 let series = SampleSeries::from_drained(
258 vec![
259 ("a".to_string(), mk(10), None, Some(100u64)),
260 // No recorded timestamp: must be dropped from the timeline.
261 ("b".to_string(), mk(20), None, None),
262 ("c".to_string(), mk(30), None, Some(300u64)),
263 ],
264 None,
265 );
266 let host = series.host().expect("non-empty series");
267 let t0 = host.per_cpu_time_timeline(0);
268 assert_eq!(
269 t0.len(),
270 2,
271 "the None-elapsed row must be dropped, not placed at a fabricated 0",
272 );
273 assert_eq!(t0[0].0, 100);
274 assert_eq!(t0[0].1.cpustat_user_ns, 10);
275 assert_eq!(t0[1].0, 300);
276 assert_eq!(t0[1].1.cpustat_user_ns, 30);
277 }
278
279 /// Multi-sample series with NON-monotonic elapsed_ms:
280 /// `per_cpu_time_timeline` returns rows sorted ascending by
281 /// elapsed_ms; ties retain insertion order (stable sort).
282 /// Pins the sort contract against drift to unstable sort or
283 /// reverse order.
284 #[test]
285 fn series_host_per_cpu_time_timeline_sorts_by_elapsed_ms_stable() {
286 let mk = |val: u64| FailureDumpReport {
287 per_cpu_time: vec![PerCpuTimeStats {
288 cpu: 0,
289 cpustat_user_ns: val,
290 ..Default::default()
291 }],
292 ..Default::default()
293 };
294 let series = SampleSeries::from_drained(
295 vec![
296 ("a".to_string(), mk(100), None, Some(100u64)),
297 ("b".to_string(), mk(200), None, Some(50u64)),
298 ("c".to_string(), mk(300), None, Some(100u64)),
299 ("d".to_string(), mk(400), None, Some(25u64)),
300 ],
301 None,
302 );
303 let host = series.host().expect("non-empty");
304 let timeline = host.per_cpu_time_timeline(0);
305 assert_eq!(timeline.len(), 4);
306 assert_eq!(timeline[0].0, 25);
307 assert_eq!(timeline[0].1.cpustat_user_ns, 400);
308 assert_eq!(timeline[1].0, 50);
309 assert_eq!(timeline[1].1.cpustat_user_ns, 200);
310 assert_eq!(
311 timeline[2].0, 100,
312 "first of the tied-elapsed-ms pair: insertion order = 'a'"
313 );
314 assert_eq!(timeline[2].1.cpustat_user_ns, 100);
315 assert_eq!(
316 timeline[3].0, 100,
317 "second of the tied-elapsed-ms pair: insertion order = 'c'"
318 );
319 assert_eq!(timeline[3].1.cpustat_user_ns, 300);
320 }
321
322 /// Placeholder samples (empty per_cpu_time) naturally drop
323 /// from the timeline without an explicit filter. Pins the
324 /// "no explicit placeholder-skip needed" contract: a
325 /// placeholder mid-stream MUST NOT inject a default-zero
326 /// row that would silently advance counter-style assertions.
327 #[test]
328 fn series_host_placeholder_naturally_drops_without_explicit_filter() {
329 let mk_real = |val: u64| FailureDumpReport {
330 per_cpu_time: vec![PerCpuTimeStats {
331 cpu: 0,
332 cpustat_user_ns: val,
333 ..Default::default()
334 }],
335 ..Default::default()
336 };
337 let placeholder = FailureDumpReport::placeholder("freeze rendezvous timed out");
338 let series = SampleSeries::from_drained(
339 vec![
340 ("real_pre".to_string(), mk_real(10), None, Some(10u64)),
341 (
342 "placeholder_mid".to_string(),
343 placeholder,
344 None,
345 Some(20u64),
346 ),
347 ("real_post".to_string(), mk_real(30), None, Some(30u64)),
348 ],
349 None,
350 );
351 let host = series.host().expect("non-empty");
352 let timeline = host.per_cpu_time_timeline(0);
353 assert_eq!(
354 timeline.len(),
355 2,
356 "placeholder MUST drop from the timeline naturally — pins the no-explicit-filter contract"
357 );
358 assert_eq!(timeline[0].0, 10);
359 assert_eq!(timeline[1].0, 30);
360 }
361
362 /// Closure-based `per_cpu_field_u64` projector emits a
363 /// [`SeriesField<u64>`] with one slot per sample. Samples
364 /// where `cpu` was captured produce `Ok(value)`; samples where
365 /// `cpu` was absent surface as
366 /// [`SnapshotError::HostFieldUnavailable`] (NOT silently
367 /// dropped, NOT default-zero) so coverage gaps reach the
368 /// temporal-assertion layer.
369 #[test]
370 fn series_host_per_cpu_field_u64_closure_projection() {
371 let mk = |val: u64| FailureDumpReport {
372 per_cpu_time: vec![PerCpuTimeStats {
373 cpu: 1,
374 cpustat_system_ns: val,
375 ..Default::default()
376 }],
377 ..Default::default()
378 };
379 let mk_missing = || FailureDumpReport {
380 per_cpu_time: vec![PerCpuTimeStats {
381 cpu: 0,
382 cpustat_system_ns: 999,
383 ..Default::default()
384 }],
385 ..Default::default()
386 };
387 let series = SampleSeries::from_drained(
388 vec![
389 ("a".to_string(), mk(100), None, Some(10u64)),
390 ("b".to_string(), mk_missing(), None, Some(20u64)),
391 ("c".to_string(), mk(300), None, Some(30u64)),
392 ],
393 None,
394 );
395 let host = series.host().expect("non-empty");
396 let field = host.per_cpu_field_u64(1, "system_ns_cpu1", |stats| stats.cpustat_system_ns);
397 let slots: Vec<_> = field.values_iter().collect();
398 assert_eq!(slots.len(), 3);
399 assert_eq!(*slots[0].as_ref().expect("cpu 1 captured in sample a"), 100);
400 match slots[1] {
401 Err(crate::scenario::snapshot::SnapshotError::HostFieldUnavailable { tag, cpu }) => {
402 assert_eq!(tag, "b");
403 assert_eq!(*cpu, 1);
404 }
405 other => panic!(
406 "cpu 1 absent in sample b MUST surface as HostFieldUnavailable, got {other:?}"
407 ),
408 }
409 assert_eq!(*slots[2].as_ref().expect("cpu 1 captured in sample c"), 300);
410 }
411
412 /// `per_cpu_field_u64` on a PLACEHOLDER sample surfaces
413 /// [`SnapshotError::PlaceholderSample`] — NOT
414 /// `HostFieldUnavailable`. Mirrors the [`SampleSeries::bpf`]
415 /// placeholder-gate pattern so temporal-assertion sites route
416 /// placeholders through their per-sample skip handling.
417 #[test]
418 fn series_host_per_cpu_field_u64_placeholder_surfaces_placeholder_sample_variant() {
419 let mk = |val: u64| FailureDumpReport {
420 per_cpu_time: vec![PerCpuTimeStats {
421 cpu: 0,
422 cpustat_user_ns: val,
423 ..Default::default()
424 }],
425 ..Default::default()
426 };
427 let placeholder = FailureDumpReport::placeholder("freeze rendezvous timed out");
428 let series = SampleSeries::from_drained(
429 vec![
430 ("real".to_string(), mk(100), None, Some(10u64)),
431 ("placeholder".to_string(), placeholder, None, Some(20u64)),
432 ],
433 None,
434 );
435 let host = series.host().expect("non-empty");
436 let field = host.per_cpu_field_u64(0, "user_ns_cpu0", |s| s.cpustat_user_ns);
437 let slots: Vec<_> = field.values_iter().collect();
438 assert_eq!(slots.len(), 2);
439 assert_eq!(*slots[0].as_ref().expect("real sample Ok"), 100);
440 match slots[1] {
441 Err(crate::scenario::snapshot::SnapshotError::PlaceholderSample { tag, .. }) => {
442 assert_eq!(tag, "placeholder");
443 }
444 other => panic!(
445 "placeholder sample MUST surface as PlaceholderSample (not HostFieldUnavailable), got {other:?}"
446 ),
447 }
448 }
449
450 /// `cpus()` returns an empty Vec on a series where every
451 /// sample is a placeholder (rows non-empty, every per_cpu_time
452 /// is empty). Pins the all-placeholder edge case at unit-test
453 /// granularity.
454 #[test]
455 fn series_host_cpus_empty_when_all_samples_are_placeholders() {
456 let series = SampleSeries::from_drained(
457 vec![
458 (
459 "p0".to_string(),
460 FailureDumpReport::placeholder("t1"),
461 None,
462 Some(10u64),
463 ),
464 (
465 "p1".to_string(),
466 FailureDumpReport::placeholder("t2"),
467 None,
468 Some(20u64),
469 ),
470 (
471 "p2".to_string(),
472 FailureDumpReport::placeholder("t3"),
473 None,
474 Some(30u64),
475 ),
476 ],
477 None,
478 );
479 let host = series.host().expect("rows non-empty");
480 assert!(
481 host.cpus().is_empty(),
482 "all-placeholder series MUST surface cpus() as empty (no per_cpu_time data anywhere)"
483 );
484 }
485
486 /// Multi-sample × multi-CPU with VARIABLE per-sample coverage
487 /// (sample A: cpus 0,1; sample B: cpus 1,2; sample C: cpus 0,2).
488 /// Pins the BTreeSet-dedup union from `cpus()` AND per-CPU
489 /// filtering in `per_cpu_time_timeline` AND mixed Ok/Err
490 /// pattern in `per_cpu_field_u64` simultaneously.
491 #[test]
492 fn series_host_interleaved_multi_cpu_multi_sample_coverage() {
493 let mk = |cpus: &[(u32, u64)]| FailureDumpReport {
494 per_cpu_time: cpus
495 .iter()
496 .map(|(c, v)| PerCpuTimeStats {
497 cpu: *c,
498 cpustat_user_ns: *v,
499 ..Default::default()
500 })
501 .collect(),
502 ..Default::default()
503 };
504 let series = SampleSeries::from_drained(
505 vec![
506 ("A".to_string(), mk(&[(0, 10), (1, 100)]), None, Some(10u64)),
507 (
508 "B".to_string(),
509 mk(&[(1, 200), (2, 300)]),
510 None,
511 Some(20u64),
512 ),
513 ("C".to_string(), mk(&[(0, 50), (2, 600)]), None, Some(30u64)),
514 ],
515 None,
516 );
517 let host = series.host().expect("non-empty");
518 // cpus() union: {0, 1, 2} sorted
519 assert_eq!(host.cpus(), vec![0, 1, 2]);
520 // per_cpu_time_timeline(0): rows from A + C (B has no cpu 0)
521 let t0 = host.per_cpu_time_timeline(0);
522 assert_eq!(t0.len(), 2);
523 assert_eq!(t0[0].0, 10);
524 assert_eq!(t0[0].1.cpustat_user_ns, 10);
525 assert_eq!(t0[1].0, 30);
526 assert_eq!(t0[1].1.cpustat_user_ns, 50);
527 // per_cpu_time_timeline(1): rows from A + B (C has no cpu 1)
528 let t1 = host.per_cpu_time_timeline(1);
529 assert_eq!(t1.len(), 2);
530 assert_eq!(t1[0].1.cpustat_user_ns, 100);
531 assert_eq!(t1[1].1.cpustat_user_ns, 200);
532 // per_cpu_time_timeline(2): rows from B + C (A has no cpu 2)
533 let t2 = host.per_cpu_time_timeline(2);
534 assert_eq!(t2.len(), 2);
535 assert_eq!(t2[0].1.cpustat_user_ns, 300);
536 assert_eq!(t2[1].1.cpustat_user_ns, 600);
537 // per_cpu_field_u64(1): A=Ok(100), B=Ok(200), C=Err(HostFieldUnavailable cpu=1)
538 let field1 = host.per_cpu_field_u64(1, "cpu1_user", |s| s.cpustat_user_ns);
539 let slots: Vec<_> = field1.values_iter().collect();
540 assert_eq!(slots.len(), 3);
541 assert_eq!(*slots[0].as_ref().unwrap(), 100);
542 assert_eq!(*slots[1].as_ref().unwrap(), 200);
543 match slots[2] {
544 Err(crate::scenario::snapshot::SnapshotError::HostFieldUnavailable { tag, cpu }) => {
545 assert_eq!(tag, "C");
546 assert_eq!(*cpu, 1);
547 }
548 other => panic!("expected HostFieldUnavailable for C/cpu=1, got {other:?}"),
549 }
550 }
551
552 /// `cpus()` is sorted ascending (BTreeSet semantic) regardless
553 /// of per_cpu_time insertion order. Pins against a regression
554 /// that switched BTreeSet → HashSet → Vec without an explicit
555 /// sort step.
556 #[test]
557 fn series_host_cpus_sorted_ascending_independent_of_insertion_order() {
558 let report = FailureDumpReport {
559 per_cpu_time: vec![
560 PerCpuTimeStats {
561 cpu: 5,
562 ..Default::default()
563 },
564 PerCpuTimeStats {
565 cpu: 1,
566 ..Default::default()
567 },
568 PerCpuTimeStats {
569 cpu: 3,
570 ..Default::default()
571 },
572 ],
573 ..Default::default()
574 };
575 let series =
576 SampleSeries::from_drained(vec![("s".to_string(), report, None, Some(0u64))], None);
577 let host = series.host().expect("non-empty");
578 assert_eq!(
579 host.cpus(),
580 vec![1, 3, 5],
581 "cpus() MUST return ascending-sorted distinct CPU ids regardless of per_cpu_time insertion order"
582 );
583 }
584
585 /// Duplicate-cpu first-match-wins contract. The production
586 /// walker (`collect_per_cpu_time`) enforces one entry per cpu
587 /// per sample, but `HostView::per_cpu_time_timeline` and
588 /// `HostView::per_cpu_field_u64` both use `iter().find(|c|
589 /// c.cpu == cpu)` which returns the FIRST match — silently
590 /// dropping subsequent entries for the same cpu. Pins the
591 /// first-match-wins contract so a regression to `last_match`,
592 /// panic-on-dup, or any other handling surfaces here.
593 #[test]
594 fn series_host_per_cpu_time_timeline_first_match_wins_on_duplicate_cpu() {
595 let report = FailureDumpReport {
596 per_cpu_time: vec![
597 PerCpuTimeStats {
598 cpu: 0,
599 cpustat_user_ns: 100,
600 ..Default::default()
601 },
602 PerCpuTimeStats {
603 cpu: 0,
604 cpustat_user_ns: 200,
605 ..Default::default()
606 },
607 ],
608 ..Default::default()
609 };
610 let series =
611 SampleSeries::from_drained(vec![("s".to_string(), report, None, Some(0u64))], None);
612 let host = series.host().expect("non-empty");
613 let timeline = host.per_cpu_time_timeline(0);
614 assert_eq!(timeline.len(), 1, "first-match-wins: timeline pushes once");
615 assert_eq!(
616 timeline[0].1.cpustat_user_ns, 100,
617 "first-match-wins: timeline returns FIRST entry (100), not second (200)"
618 );
619 let field = host.per_cpu_field_u64(0, "user_ns", |s| s.cpustat_user_ns);
620 let slots: Vec<_> = field.values_iter().collect();
621 assert_eq!(slots.len(), 1);
622 assert_eq!(
623 *slots[0].as_ref().expect("Ok(first match value)"),
624 100,
625 "first-match-wins: per_cpu_field_u64 also returns FIRST entry"
626 );
627 }
628
629 /// elapsed_ms plumbing through `per_cpu_field_u64` is verified
630 /// via `iter_full()` — pins both the elapsed_ms VALUE per slot
631 /// AND the tag string per slot against value-corruption
632 /// regressions (e.g. `elapsed.push(0)` instead of
633 /// `elapsed.push(row.elapsed_ms)`, or tag/elapsed vec swap).
634 #[test]
635 fn series_host_per_cpu_field_u64_iter_full_threads_tag_and_elapsed_correctly() {
636 let mk = |val: u64| FailureDumpReport {
637 per_cpu_time: vec![PerCpuTimeStats {
638 cpu: 0,
639 cpustat_user_ns: val,
640 ..Default::default()
641 }],
642 ..Default::default()
643 };
644 let series = SampleSeries::from_drained(
645 vec![
646 ("alpha".to_string(), mk(10), None, Some(100u64)),
647 ("beta".to_string(), mk(20), None, Some(200u64)),
648 ("gamma".to_string(), mk(30), None, Some(300u64)),
649 ],
650 None,
651 );
652 let host = series.host().expect("non-empty");
653 let field = host.per_cpu_field_u64(0, "user_ns", |s| s.cpustat_user_ns);
654 let full: Vec<_> = field.iter_full().collect();
655 assert_eq!(full.len(), 3);
656 assert_eq!(full[0].0, "alpha");
657 assert_eq!(full[0].1, Some(100));
658 assert_eq!(*full[0].2.as_ref().unwrap(), 10);
659 assert_eq!(full[1].0, "beta");
660 assert_eq!(full[1].1, Some(200));
661 assert_eq!(*full[1].2.as_ref().unwrap(), 20);
662 assert_eq!(full[2].0, "gamma");
663 assert_eq!(full[2].1, Some(300));
664 assert_eq!(*full[2].2.as_ref().unwrap(), 30);
665 }
666
667 /// Non-placeholder sample with EMPTY per_cpu_time (real capture
668 /// succeeded, BPF axis populated, but CpuTimeCapture didn't
669 /// run or returned an empty Vec) MUST surface as
670 /// `HostFieldUnavailable`, NOT `PlaceholderSample`. Pins the
671 /// `is_placeholder` gate predicate against drift to
672 /// `per_cpu_time.is_empty() || is_placeholder` (which would
673 /// mis-classify "real but no data" as a placeholder).
674 #[test]
675 fn series_host_per_cpu_field_u64_non_placeholder_empty_per_cpu_time_surfaces_host_field_unavailable()
676 {
677 // FailureDumpReport::default() has is_placeholder=false +
678 // empty per_cpu_time.
679 let report = FailureDumpReport::default();
680 let series = SampleSeries::from_drained(
681 vec![("real_no_cpu_data".to_string(), report, None, Some(10u64))],
682 None,
683 );
684 let host = series.host().expect("non-empty");
685 let field = host.per_cpu_field_u64(0, "user_ns", |s| s.cpustat_user_ns);
686 let slots: Vec<_> = field.values_iter().collect();
687 assert_eq!(slots.len(), 1);
688 match slots[0] {
689 Err(crate::scenario::snapshot::SnapshotError::HostFieldUnavailable { tag, cpu }) => {
690 assert_eq!(tag, "real_no_cpu_data");
691 assert_eq!(*cpu, 0);
692 }
693 Err(crate::scenario::snapshot::SnapshotError::PlaceholderSample { .. }) => {
694 panic!(
695 "non-placeholder sample with empty per_cpu_time MUST surface as HostFieldUnavailable, NOT PlaceholderSample (regression: empty-per_cpu_time gating as placeholder)"
696 )
697 }
698 other => panic!("expected HostFieldUnavailable, got {other:?}"),
699 }
700 }
701}