1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! End-to-end coverage for the API-gap fixes that landed in the
//! mid-2026-05-18 batch: [`VmResult::periodic_series`] sugar, the
//! [`SeriesField::ratio_across_phases`] cross-phase comparator,
//! [`SeriesField::value_at_phase`] / [`SeriesField::last_per_phase`]
//! per-phase reductions, and [`Snapshot::live_var_via`] live-pick
//! disambiguator.
//!
//! Boots a single 2-Step scenario with periodic captures across
//! both Steps so the resulting [`SampleSeries`] carries samples
//! stamped with both `Phase::step(0)` and `Phase::step(1)`. One
//! `post_vm` callback covers every helper in one drain:
//!
//! * `result.periodic_series()` returns a non-empty series — pins
//! the sugar drains the same bridge `SampleSeries::from_drained_typed`
//! would have, with `periodic_only()` applied.
//! * `series.bpf(...).value_at_phase(Phase::step(0))` returns
//! `Some(_)` and `last_per_phase()` carries the same key —
//! pins the two phase-reduction helpers are wired to the
//! periodic-axis phase stamps the framework emits.
//! * `series.bpf(...).ratio_across_phases(verdict, Step[0], Step[1])
//! .at_most(...)` lands either pass (records info note) or fail
//! (records temporal detail) — pins the comparator chain
//! propagates the verdict mutation regardless of which arm fires.
//!
//! Vacuity: the test does NOT pin which arm of `ratio_across_phases`
//! fires (pass vs fail) because the underlying counter value depends
//! on scheduler activity across the window. It pins the helper
//! returns a recorded verdict mutation either way.
use anyhow::Result;
use ktstr::assert::{AssertResult, Phase, Verdict};
use ktstr::ktstr_test;
use ktstr::prelude::VmResult;
use ktstr::scenario::Ctx;
use ktstr::scenario::ops::{CgroupDef, HoldSpec, Step, execute_steps};
use ktstr::test_support::{Scheduler, SchedulerSpec};
const KTSTR_SCHED: Scheduler =
Scheduler::named("ktstr_sched").binary(SchedulerSpec::Discover("scx-ktstr"));
/// Generous ceiling for the cross-phase ratio. scx-ktstr's
/// dispatch counter grows monotonically across the window; the
/// step(0)→step(1) ratio can easily exceed 1.0 (later phase
/// observes a larger cumulative value). A ceiling of 10⁹ keeps
/// the comparator on the pass arm regardless of host-load
/// variation while still exercising the pass-arm note record path.
const RATIO_CEILING: f64 = 1_000_000_000.0;
fn assert_api_gap_helpers(result: &VmResult) -> Result<()> {
let periodic_target = result.periodic_target;
let periodic_fired = result.periodic_fired;
anyhow::ensure!(
periodic_fired >= 1,
"periodic_fired = {periodic_fired} of {periodic_target} — the freeze \
coordinator did not produce a capture; the API-gap helpers \
cannot be exercised without at least one periodic sample",
);
// gap 5: VmResult::periodic_series sugar drains the bridge.
let series = result.periodic_series();
anyhow::ensure!(
!series.is_empty(),
"VmResult::periodic_series returned an empty series despite \
periodic_fired = {periodic_fired}; the sugar's drain path \
diverges from `from_drained_typed(...).periodic_only()`",
);
anyhow::ensure!(
series.len() == periodic_fired as usize,
"VmResult::periodic_series length {} != periodic_fired {} — \
either the periodic-only filter dropped non-periodic captures \
(which this test does NOT generate), or the bridge double-counted",
series.len(),
periodic_fired,
);
// Project to f64 because [`SeriesField::ratio_across_phases`]
// requires `T: Copy + Into<f64> + Display`, and u64→f64 is a
// lossy conversion the stdlib does not expose through `Into`.
// `as f64` lossy-converts at the projection boundary; the
// f64 range easily holds any plausible dispatch count.
let bpf_dispatched = series.bpf("nr_dispatched", |snap| {
snap.var("nr_dispatched").as_u64().map(|v| v as f64)
});
// gap 6: value_at_phase + last_per_phase consume the same series.
// The framework's phase pipeline labels the FIRST Step
// step_index = 1 and the SECOND Step step_index = 2; the
// assert::Phase encoding is 0-indexed (Phase::step(0) =
// step_index 1 = "Step[0]"). Pin that at least one of the two
// Step phases produced a value — captures may land in either
// phase depending on the freeze-coordinator's stride across
// the 10s window.
let v0 = bpf_dispatched.value_at_phase(Phase::step(0));
let v1 = bpf_dispatched.value_at_phase(Phase::step(1));
anyhow::ensure!(
v0.is_some() || v1.is_some(),
"value_at_phase returned None for BOTH Step phases despite \
periodic_fired = {periodic_fired}; the periodic samples \
landed under no Step phase, which means the CURRENT_STEP \
atomic + phase pipeline aren't producing phase stamps \
(every sample classified as BASELINE)",
);
let last_map = bpf_dispatched.last_per_phase();
anyhow::ensure!(
!last_map.is_empty(),
"last_per_phase produced an empty map despite the series \
carrying {} periodic samples; either the phase stamps are \
all None (regression in the periodic-axis phase plumbing) \
or every sample carried Err (which the test floor rules out)",
series.len(),
);
// gap 6: ratio_across_phases records either pass or fail —
// pin that the verdict mutation lands either way. Skip when
// only one phase observed a sample (the ratio is undefined
// and would record a known-failure "needs both phases" detail
// that doesn't exercise the comparison arm).
if v0.is_some() && v1.is_some() {
let mut verdict = Verdict::new();
bpf_dispatched
.ratio_across_phases(&mut verdict, Phase::step(0), Phase::step(1))
.at_most(RATIO_CEILING);
let r = verdict.into_result();
let recorded_pass_note = r
.info_notes
.iter()
.any(|n| n.message.contains("ratio_across_phases"));
let recorded_fail_detail = r
.failure_details()
.any(|d| d.message.contains("ratio_across_phases"));
anyhow::ensure!(
recorded_pass_note || recorded_fail_detail,
"ratio_across_phases.at_most produced NEITHER a pass info \
note NOR a failure detail mentioning 'ratio_across_phases' \
— the comparator silently no-op'd. info_notes = {:?}, \
details = {:?}",
r.info_notes,
r.failure_details().collect::<Vec<_>>(),
);
}
Ok(())
}
/// 2-Step scenario with periodic captures across both Steps —
/// mirrors the `phase_pipeline_two_step_e2e` shape so the
/// framework's CURRENT_STEP advance + periodic-axis phase
/// stamping produces samples in both `Phase::step(0)` and
/// `Phase::step(1)` buckets that the API-gap helpers consume.
#[ktstr_test(
scheduler = KTSTR_SCHED,
llcs = 1,
cores = 2,
threads = 1,
duration_s = 10,
watchdog_timeout_s = 20,
num_snapshots = 4,
auto_repro = false,
post_vm = assert_api_gap_helpers,
)]
fn api_gaps_periodic_series_and_phase_helpers_e2e(ctx: &Ctx) -> Result<AssertResult> {
let steps = vec![
Step {
setup: vec![CgroupDef::named("cg_step0").workers(2)].into(),
ops: vec![],
hold: HoldSpec::frac(0.5),
},
Step {
setup: vec![CgroupDef::named("cg_step1").workers(2)].into(),
ops: vec![],
hold: HoldSpec::frac(0.5),
},
];
execute_steps(ctx, steps)
}