ktstr/test_support/entry.rs
1//! `#[ktstr_test]` entry registration and scheduler configuration.
2//!
3//! Every `#[ktstr_test]` proc-macro expansion writes a static
4//! [`KtstrTestEntry`] into [`KTSTR_TESTS`] via `linkme`; programmatic
5//! callers can do the same by pushing their own `KtstrTestEntry` values
6//! into the slice. [`find_test`] is the name-to-entry lookup used by
7//! host-side dispatch.
8//!
9//! Each entry points at a [`Scheduler`] definition — a `&'static` value
10//! that captures the scheduler binary ([`SchedulerSpec`]), guest
11//! [`Sysctl`]s, kernel args, [`CgroupPath`] parent, topology and
12//! [`TopologyConstraints`], and monitor assertions. [`BpfMapWrite`]
13//! describes a host-side map write the runtime performs mid-run.
14
15use anyhow::Result;
16use linkme::distributed_slice;
17use std::time::Duration;
18
19use crate::assert::AssertResult;
20use crate::scenario::Ctx;
21
22/// Re-exports of topology types for use in [`KtstrTestEntry`] statics
23/// generated by the `#[ktstr_test]` macro.
24pub use crate::vmm::topology::{MemSideCache, NumaDistance, NumaNode, Topology};
25
26/// How to specify the scheduler for an `#[ktstr_test]`.
27///
28/// The four variants form a semantic taxonomy, not a syntactic one:
29/// [`Eevdf`](Self::Eevdf) is the no-scx control ("kernel default,
30/// don't launch anything"); [`Discover`](Self::Discover) and
31/// [`Path`](Self::Path) both locate a userspace scheduler binary, by
32/// name-lookup vs. explicit filesystem path respectively; and
33/// [`KernelBuiltin`](Self::KernelBuiltin) activates an in-kernel
34/// scheduling policy via shell commands rather than any binary.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum SchedulerSpec {
37 /// No userspace scheduler — run under the kernel's default
38 /// scheduler. On current kernels that's EEVDF; the variant
39 /// name is fixed so the assertion isn't coupled to the kernel
40 /// version's default.
41 Eevdf,
42 /// Auto-discover the scheduler binary by name (looks in
43 /// `KTSTR_SCHEDULER` env, the ktstr binary's sibling dir,
44 /// `target/debug/`, `target/release/`, and invokes
45 /// `cargo build` if nothing's found).
46 Discover(&'static str),
47 /// Explicit filesystem path to a scheduler binary. The file must
48 /// already exist; `resolve_scheduler` does not auto-build this
49 /// variant.
50 Path(&'static str),
51 /// Kernel-built scheduler (e.g. BPF-less sched_ext or
52 /// debugfs-tuned). Activated/deactivated via shell commands
53 /// rather than a userspace binary.
54 KernelBuiltin {
55 /// Shell commands invoked before the scenario runs to switch
56 /// the kernel into this scheduling policy (e.g. write to
57 /// `/sys/kernel/debug/sched/...`).
58 enable: &'static [&'static str],
59 /// Shell commands invoked after the scenario finishes to
60 /// restore the kernel's baseline scheduling policy.
61 disable: &'static [&'static str],
62 },
63}
64
65impl SchedulerSpec {
66 /// Whether this spec represents an active scheduling policy
67 /// (anything other than the kernel default EEVDF).
68 ///
69 /// Returns `true` for every variant except [`Self::Eevdf`] —
70 /// including [`Self::KernelBuiltin`], which runs an in-kernel
71 /// non-default policy via shell enable/disable commands but
72 /// has no userspace BPF binary. Callers that need "the test
73 /// runs SOMETHING other than EEVDF" — e.g. enabling
74 /// repro-section render, switching verbose-output mode on,
75 /// gating non-default-only logic — want this method.
76 ///
77 /// Callers that need "the test runs a userspace BPF
78 /// scheduler binary with verifier-stats / struct_ops slab
79 /// activity" — e.g. wiring monitor thresholds, surfacing
80 /// `verifier_stats` warnings, gating the auto-repro probe —
81 /// want the narrower [`Self::has_bpf_scheduler`] instead.
82 /// `has_active_scheduling` returns `true` for `KernelBuiltin`,
83 /// which is the wrong gate for any code path that assumes a
84 /// BPF binary is attached.
85 ///
86 /// Quick gate-picker: use `has_active_scheduling` when the
87 /// predicate is "non-default scheduling policy"; use
88 /// [`Self::has_bpf_scheduler`] when the predicate is
89 /// "userspace BPF binary attached".
90 pub const fn has_active_scheduling(&self) -> bool {
91 !matches!(self, SchedulerSpec::Eevdf)
92 }
93
94 /// Whether this spec drives a userspace BPF scheduler binary
95 /// (i.e. attaches a `struct sched_ext_ops` to the kernel).
96 ///
97 /// Returns `true` for [`Self::Discover`] and [`Self::Path`] —
98 /// both variants point at a userspace BPF binary that
99 /// `resolve_scheduler` locates and launches. Returns `false`
100 /// for [`Self::Eevdf`] (no scheduler) and [`Self::KernelBuiltin`]
101 /// (in-kernel policy switched via shell commands, no userspace
102 /// binary, no BPF verifier output, no struct_ops slab activity).
103 ///
104 /// Distinct from [`Self::has_active_scheduling`]: that returns
105 /// `true` for `KernelBuiltin` too (because the kernel IS running
106 /// a non-default scheduling policy), which is the wrong gate
107 /// for code paths that read `verifier_stats`, wire BPF-attach
108 /// monitor thresholds, or probe for the userspace scheduler
109 /// binary's auto-repro hooks. Use this method whenever the
110 /// downstream consumer assumes "BPF binary is loaded and
111 /// running" rather than "the kernel is on a non-EEVDF policy".
112 pub const fn has_bpf_scheduler(&self) -> bool {
113 matches!(self, SchedulerSpec::Discover(_) | SchedulerSpec::Path(_))
114 }
115
116 /// Short, human-readable name for logging and sidecar output.
117 ///
118 /// Maps `Eevdf` → `"eevdf"`, `Discover(n)` → `n`, `Path(p)` → `p`,
119 /// `KernelBuiltin { .. }` → `"kernel"`. Single source of truth
120 /// for "what do we call this scheduler when telling the user" —
121 /// sidecar serialization and failure-header formatting both use
122 /// this, and any future consumer gets identical naming for free.
123 pub const fn display_name(&self) -> &'static str {
124 match self {
125 SchedulerSpec::Eevdf => "eevdf",
126 SchedulerSpec::Discover(n) => n,
127 SchedulerSpec::Path(p) => p,
128 SchedulerSpec::KernelBuiltin { .. } => "kernel",
129 }
130 }
131
132 /// Best-effort git commit of the scheduler binary used for this
133 /// run, or `None` when the commit cannot be determined honestly.
134 ///
135 /// Currently ALWAYS returns `None`. The field is reserved on the
136 /// sidecar schema so stats tooling can enrich it once a reliable
137 /// source exists, but no variant today has one:
138 ///
139 /// - `Eevdf` — no userspace scheduler binary at all. Kernel
140 /// default; the running kernel's identity belongs in
141 /// `host.kernel_release`, not here.
142 /// - `Discover(_)` — `resolve_scheduler` has a 5-path cascade
143 /// (`KTSTR_SCHEDULER` env override → sibling of the ktstr
144 /// binary → `target/debug/` → `target/release/` → cargo
145 /// rebuild fallback). Only the rebuild path guarantees the
146 /// resulting binary was built from the current tree; the
147 /// four pre-built discovery paths can point at a binary
148 /// whose commit is unknown to this process. Synthesizing a
149 /// commit would be a lie in 4 of 5 cases and would silently
150 /// attribute regressions to the wrong commit. A future
151 /// enhancement can probe the binary itself (e.g.
152 /// `--version` output, an ELF note) and return `Some(..)`
153 /// ONLY when the actual commit is introspected; until then,
154 /// `None` is the only honest answer.
155 /// - `Path(p)` — arbitrary externally-built binary. No
156 /// reliable introspection path (no shared ABI, no required
157 /// `--version` format).
158 /// - `KernelBuiltin` — in-kernel scheduler, no userspace
159 /// binary commit to record.
160 ///
161 /// Returning `None` rather than `Some("unknown")` keeps the
162 /// sidecar schema's nullable semantics honest: `perf-delta`
163 /// distinguishes "unset" from "set to a sentinel" without a
164 /// magic string, and a future enhancement that learns to
165 /// introspect a scheduler binary can flip a single arm to
166 /// `Some(..)` without retrofitting consumers to strip a
167 /// sentinel.
168 pub const fn scheduler_commit(&self) -> Option<&'static str> {
169 match self {
170 SchedulerSpec::Eevdf
171 | SchedulerSpec::Discover(_)
172 | SchedulerSpec::Path(_)
173 | SchedulerSpec::KernelBuiltin { .. } => None,
174 }
175 }
176}
177
178/// A `key=value` sysctl applied to the guest before the scheduler
179/// starts, injected into the guest kernel cmdline as
180/// `sysctl.<key>=<value>`.
181///
182/// Use the **dot-separated** form for `key` (e.g. `"kernel.foo"`, not
183/// `"kernel/foo"`). Duplicate keys in a scheduler's sysctls slice are
184/// applied in order; the last write wins.
185///
186/// Construct with [`Sysctl::new`], which const-asserts the key format
187/// at compile time. Direct struct-literal construction is rejected
188/// (fields are crate-private) to ensure every constructed `Sysctl`
189/// passed through the format gate.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub struct Sysctl {
192 key: &'static str,
193 value: &'static str,
194}
195
196impl Sysctl {
197 /// Const constructor for use in `static`/`const` context.
198 ///
199 /// # Panics
200 ///
201 /// Panics at compile time (or const-eval time) when:
202 /// - `key` is empty
203 /// - `key` contains `/` (common typo from sysctl conf-file paths —
204 /// sysctl-write uses the dotted form, not the slash form)
205 /// - `key` contains whitespace, `=`, or any control byte
206 /// (would corrupt the `sysctl.<key>=<value>` cmdline form)
207 /// - `key` does not contain at least one `.` (sysctls are
208 /// namespaced like `kernel.foo` / `net.core.bar`; a bare
209 /// single-segment name is almost certainly a typo)
210 /// - `key` starts or ends with `.`
211 /// - `key` contains an empty segment (`..` — kernel sysctl
212 /// parser rejects)
213 /// - `value` is empty
214 /// - `value` contains a newline / carriage return / `=`
215 /// (would corrupt the `sysctl.<key>=<value>` cmdline form)
216 pub const fn new(key: &'static str, value: &'static str) -> Self {
217 let key_bytes = key.as_bytes();
218 assert!(!key_bytes.is_empty(), "Sysctl key must not be empty");
219 assert!(
220 key_bytes[0] != b'.' && key_bytes[key_bytes.len() - 1] != b'.',
221 "Sysctl key must not start or end with `.`",
222 );
223 let mut i = 0;
224 let mut has_dot = false;
225 let mut prev = 0u8;
226 while i < key_bytes.len() {
227 let b = key_bytes[i];
228 assert!(
229 b != b'/',
230 "Sysctl key must use the dotted form (e.g. `kernel.foo`), not the slash form (`kernel/foo`)",
231 );
232 assert!(
233 b != b' ' && b != b'\t' && b != b'\n' && b != b'\r',
234 "Sysctl key must not contain whitespace (would corrupt cmdline form)",
235 );
236 assert!(
237 b != b'=',
238 "Sysctl key must not contain `=` (would corrupt `sysctl.<key>=<value>` cmdline split)",
239 );
240 assert!(
241 b >= 0x20 && b < 0x7f,
242 "Sysctl key must be printable ASCII only (no control bytes / high-bit chars)",
243 );
244 if b == b'.' {
245 has_dot = true;
246 assert!(
247 i == 0 || prev != b'.',
248 "Sysctl key must not contain `..` (empty segment — kernel sysctl parser rejects)",
249 );
250 }
251 prev = b;
252 i += 1;
253 }
254 assert!(
255 has_dot,
256 "Sysctl key must be namespaced (contain at least one `.`, e.g. `kernel.foo`)",
257 );
258 let value_bytes = value.as_bytes();
259 assert!(!value_bytes.is_empty(), "Sysctl value must not be empty");
260 let mut j = 0;
261 while j < value_bytes.len() {
262 let b = value_bytes[j];
263 assert!(
264 b != b'\n',
265 "Sysctl value must not contain a newline (would corrupt cmdline form)",
266 );
267 assert!(
268 b != b'\r',
269 "Sysctl value must not contain a carriage return (would corrupt cmdline form)",
270 );
271 assert!(
272 b != b'=',
273 "Sysctl value must not contain `=` (would corrupt `sysctl.<key>=<value>` cmdline form)",
274 );
275 j += 1;
276 }
277 Self { key, value }
278 }
279
280 /// The validated dotted sysctl key (e.g. `"kernel.sched_cfs_bandwidth_slice_us"`).
281 pub const fn key(&self) -> &'static str {
282 self.key
283 }
284
285 /// The validated sysctl value, written as a string.
286 pub const fn value(&self) -> &'static str {
287 self.value
288 }
289}
290
291/// Validated cgroup parent path.
292///
293/// Wraps a `&'static str` that is guaranteed to start with `/` and not
294/// be `"/"` alone. Construct via [`CgroupPath::new`] (which const-panics
295/// on invalid input) or the [`Scheduler::cgroup_parent`] builder.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub struct CgroupPath(&'static str);
298
299impl CgroupPath {
300 /// Const constructor. Mirrors the runtime gate in
301 /// `test_support::args::cell_parent_path_is_valid` byte-for-byte.
302 /// `Path::components` and `Components` are not yet const-fn
303 /// reachable, so the walk iterates bytes directly via a stable
304 /// `while` loop.
305 ///
306 /// So `cgroup_parent = "/foo/.."` panics at const-eval just as
307 /// `--cell-parent-cgroup=/foo/..` panics at test setup; the two
308 /// share validation contract, and the asymmetry that would
309 /// otherwise exist (const-fn rejecting `/foo/./bar` while the
310 /// runtime accepts it) is eliminated by the auto-strip rule.
311 ///
312 /// # Panics
313 ///
314 /// Panics at compile time (or const-eval time) when `path` would
315 /// normalize back to (or escape) the host cgroup root once
316 /// concatenated with `/sys/fs/cgroup` and canonicalized by the
317 /// kernel:
318 ///
319 /// - must start with `/`
320 /// - must not contain any `..` (ParentDir) segments
321 /// - must contain at least one non-`.`, non-empty segment
322 /// - empty segments (consecutive `/`) and `.` (CurDir) segments
323 /// are auto-stripped, matching `Path::components` semantics
324 /// used by the runtime validator
325 pub const fn new(path: &'static str) -> Self {
326 assert!(
327 !path.is_empty() && path.as_bytes()[0] == b'/',
328 "CgroupPath must begin with '/' (e.g. \"/ktstr\")"
329 );
330 assert!(
331 path.len() > 1,
332 "CgroupPath must not be \"/\" alone (that is the cgroup root)"
333 );
334 let bytes = path.as_bytes();
335 let mut seg_start: usize = 1; // skip leading `/`
336 let mut i: usize = 1;
337 let mut has_normal = false;
338 while i <= bytes.len() {
339 let at_end = i == bytes.len();
340 if at_end || bytes[i] == b'/' {
341 let seg_len = i - seg_start;
342 let is_dotdot =
343 seg_len == 2 && bytes[seg_start] == b'.' && bytes[seg_start + 1] == b'.';
344 assert!(
345 !is_dotdot,
346 "CgroupPath must not contain `..` segments \
347 (they escape `/sys/fs/cgroup` once the kernel \
348 canonicalizes the resolved path)"
349 );
350 let is_empty_or_dot = seg_len == 0 || (seg_len == 1 && bytes[seg_start] == b'.');
351 if !is_empty_or_dot {
352 has_normal = true;
353 }
354 seg_start = i + 1;
355 }
356 i += 1;
357 }
358 assert!(
359 has_normal,
360 "CgroupPath must contain at least one non-`.`/non-empty segment \
361 (paths like `/`, `///`, or `/.` normalize back to `/sys/fs/cgroup`)"
362 );
363 Self(path)
364 }
365
366 /// The raw path string (e.g. `"/ktstr"`).
367 pub const fn as_str(&self) -> &'static str {
368 self.0
369 }
370
371 /// The full sysfs cgroup directory (e.g. `"/sys/fs/cgroup/ktstr"`).
372 pub fn sysfs_path(&self) -> String {
373 format!("/sys/fs/cgroup{}", self.0)
374 }
375}
376
377impl std::ops::Deref for CgroupPath {
378 type Target = str;
379 fn deref(&self) -> &str {
380 self.0
381 }
382}
383
384impl std::fmt::Display for CgroupPath {
385 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386 f.write_str(self.0)
387 }
388}
389
390/// Host-side BPF map write performed during VM execution.
391///
392/// Applied by the host thread `freeze_coord::start_bpf_map_write` in three
393/// phases: build the guest-memory BPF-map accessor, resolve the `field`
394/// NAME to a byte offset via the target map's program BTF, then write.
395/// After the writes the host pushes `SIGNAL_BPF_WRITE_DONE` through
396/// virtio-console (`host_comms::request_bpf_map_write_done`) so a guest
397/// scenario gated on `Ctx::wait_for_map_write` unblocks.
398///
399/// Construct with [`BpfMapWrite::new`], which const-asserts the
400/// `map_name_suffix` format at compile time. Direct struct-literal
401/// construction is rejected (fields are crate-private) so every
402/// constructed `BpfMapWrite` passes through the format gate.
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
404pub struct BpfMapWrite {
405 map_name_suffix: &'static str,
406 field: &'static str,
407 value: u32,
408}
409
410impl BpfMapWrite {
411 /// Const constructor for use in `static`/`const` context.
412 ///
413 /// # Panics
414 ///
415 /// Panics at compile time (or const-eval time) when:
416 /// - `map_name_suffix` is empty
417 /// - `map_name_suffix` does not start with `.` (BPF map names
418 /// are derived from ELF section names like `.bss`, `.data`,
419 /// `.rodata`; a suffix without the leading `.` would never
420 /// match any loaded map and is almost certainly a typo)
421 /// - `map_name_suffix` is a bare `.` or starts with `..`
422 /// (no real BPF section name has that shape)
423 /// - `map_name_suffix` contains whitespace, `/`, `\`, or any
424 /// non-printable / control byte (no real BPF map name carries
425 /// those characters; NULL would truncate libbpf C-string
426 /// comparison)
427 /// - `field` is empty (it names the VAR to write — a bare `.bss`/`.data`
428 /// global like `"crash"`, or a dot-path into the value struct; the
429 /// byte offset is resolved from the map's BTF value type at write
430 /// time, so a raw offset can never land in struct padding)
431 /// - `field` starts or ends with `.`, or contains `..` (an empty
432 /// path segment the BTF resolver could never match)
433 /// - `field` contains whitespace, `/`, `\`, or any non-printable /
434 /// control byte (no real BTF VAR name carries those characters)
435 pub const fn new(map_name_suffix: &'static str, field: &'static str, value: u32) -> Self {
436 let bytes = map_name_suffix.as_bytes();
437 assert!(
438 !bytes.is_empty(),
439 "BpfMapWrite map_name_suffix must not be empty"
440 );
441 assert!(
442 bytes[0] == b'.',
443 "BpfMapWrite map_name_suffix must start with `.` (BPF map suffixes match ELF section names like `.bss`, `.data`, `.rodata`)",
444 );
445 assert!(
446 bytes.len() >= 2,
447 "BpfMapWrite map_name_suffix must be longer than a bare `.` (no real BPF section name is just `.`)",
448 );
449 assert!(
450 bytes[1] != b'.',
451 "BpfMapWrite map_name_suffix must not start with `..` (no real BPF section name has that shape)",
452 );
453 let mut i = 0;
454 while i < bytes.len() {
455 let b = bytes[i];
456 assert!(
457 b != b' ' && b != b'\t' && b != b'\n' && b != b'\r',
458 "BpfMapWrite map_name_suffix must not contain whitespace",
459 );
460 assert!(
461 b != b'/' && b != b'\\',
462 "BpfMapWrite map_name_suffix must not contain path separators",
463 );
464 assert!(
465 b >= 0x20 && b < 0x7f,
466 "BpfMapWrite map_name_suffix must be printable ASCII only (no control bytes / NULL / high-bit chars)",
467 );
468 i += 1;
469 }
470 // `field` names a VAR in the map's BTF value type — a bare global
471 // like `crash`, or a dot-path into the value struct
472 // (`sys_stat.avg_lat_cri`); the write-time resolver
473 // (`resolve_map_field_offset_width`) maps it to a byte offset. Mirror
474 // the WatchBpfMap::field gate (both feed the same resolver): non-empty,
475 // printable ASCII, no whitespace, no path separators. `.` IS allowed
476 // but must separate non-empty segments, so reject a leading / trailing
477 // / doubled `.` — an empty segment the resolver could never match. No
478 // leading-`.` section rule here (a VAR path is not a section name).
479 let fb = field.as_bytes();
480 assert!(!fb.is_empty(), "BpfMapWrite field must not be empty");
481 assert!(
482 fb[0] != b'.' && fb[fb.len() - 1] != b'.',
483 "BpfMapWrite field must not start or end with `.` (empty path segment)",
484 );
485 let mut j = 0;
486 while j < fb.len() {
487 let b = fb[j];
488 assert!(
489 b != b' ' && b != b'\t' && b != b'\n' && b != b'\r',
490 "BpfMapWrite field must not contain whitespace",
491 );
492 assert!(
493 b != b'/' && b != b'\\',
494 "BpfMapWrite field must not contain path separators",
495 );
496 assert!(
497 !(b == b'.' && j > 0 && fb[j - 1] == b'.'),
498 "BpfMapWrite field must not contain `..` (empty path segment)",
499 );
500 assert!(
501 b >= 0x20 && b < 0x7f,
502 "BpfMapWrite field must be printable ASCII only (no control bytes / NULL / high-bit chars)",
503 );
504 j += 1;
505 }
506 Self {
507 map_name_suffix,
508 field,
509 value,
510 }
511 }
512
513 /// The validated map-name suffix to match against loaded BPF maps
514 /// (e.g. `".bss"`).
515 pub const fn map_name_suffix(&self) -> &'static str {
516 self.map_name_suffix
517 }
518
519 /// The field to write within the matched map's value region: a bare
520 /// BTF VAR name (e.g. `"crash"`) or a dot-path into the value struct
521 /// (e.g. `"sys_stat.avg_lat_cri"`). The byte offset is resolved from
522 /// the map's BTF value type at write time.
523 pub const fn field(&self) -> &'static str {
524 self.field
525 }
526
527 /// u32 value to write into `field` inside the matched map.
528 pub const fn value(&self) -> u32 {
529 self.value
530 }
531}
532
533/// Aggregation for a [`WatchBpfMap`] target.
534///
535/// Picks how a watched field's per-tick reads collapse into run-level
536/// metric key(s). Choose by the field's semantic class: a GAUGE (a current
537/// level — fold as the mean) vs a monotonic COUNTER (an accumulating total —
538/// fold as the final value).
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
540pub enum BpfMapAgg {
541 /// A single scalar GAUGE field (e.g. a `.bss` global struct member like
542 /// `sys_stat.avg_lat_cri` — a current level such as latency-criticality
543 /// or headroom). Emits ONE metric key (`<prefix>_<label>`), folded as the
544 /// mean over the run's reporting samples. For a monotonic counter use
545 /// [`BpfMapAgg::ScalarCounter`] instead — a rising counter mean-folded
546 /// here reports a window-average below its final total.
547 Scalar,
548 /// A single scalar monotonic COUNTER field (e.g. a `.bss` global like
549 /// `ktstr_alloc_count` bumped via `__sync_fetch_and_add`). Emits ONE
550 /// metric key (`<prefix>_<label>`), folded as the value at the LAST
551 /// reporting sample — the accumulated total at the end of the monitoring
552 /// window, which is the meaningful count (not the mean of a rising
553 /// series).
554 ScalarCounter,
555 /// A per-CPU array field (a `BPF_MAP_TYPE_PERCPU_ARRAY` value member,
556 /// e.g. `cpu_ctx_stor.lat_headroom`). Emits TWO metric keys: the
557 /// cross-CPU mean (`<prefix>_<label>_avg`) and the cross-CPU spatial
558 /// max (`<prefix>_<label>_max`), each folded over the run's reporting
559 /// samples. The divisor is the count of CPUs that reported a value,
560 /// never the topology CPU count.
561 PerCpu,
562 /// A per-CPU array field that is a monotonic COUNTER (each CPU's slot
563 /// rises independently, e.g. a `BPF_MAP_TYPE_PERCPU_ARRAY` per-CPU event
564 /// tally). Emits ONE metric key (`<prefix>_<label>`), folded as the
565 /// CROSS-CPU SUM at the LAST reporting sample — the accumulated total
566 /// across all (reporting) CPUs at the end of the monitoring window, and
567 /// SUM-folded across runs like [`BpfMapAgg::ScalarCounter`]. Watch at u64
568 /// width: a too-narrow width truncates each per-CPU slot before the sum.
569 /// An offline CPU's accumulated count is excluded (only CPUs whose per-CPU
570 /// page is readable contribute). Use this for a rising per-CPU counter;
571 /// [`BpfMapAgg::PerCpu`] mean/max-folds (a gauge) and
572 /// [`BpfMapAgg::ScalarCounter`] is for a single scalar, not a per-CPU array.
573 PerCpuCounter,
574}
575
576/// Host-side, observer-effect-free read of a NAMED scheduler BPF-map field,
577/// surfaced as an assertable run-level metric.
578///
579/// The free-running host monitor resolves the field once (lazily, after the
580/// scheduler attaches and its maps appear) via BTF, then reads it each
581/// monitor tick WITHOUT freezing the guest — turning "the scheduler computed
582/// X" into an assertable metric. Read via
583/// [`crate::vmm::result::VmResult::run_metric`] under the key
584/// `<scheduler-obj>_<label>` (scalar, scalar-counter, or per-CPU-counter) or
585/// `<scheduler-obj>_<label>_{avg,max}` (per-CPU gauge), e.g.
586/// `scx_lavd_avg_lat_cri`, `scx_lavd_lat_headroom_avg`.
587/// `<scheduler-obj>` is libbpf's object name for the active scheduler, which
588/// can differ from its source / ops name (e.g. scx-ktstr's object is
589/// `bpf_bpf`). Each target's `label` must be unique within a test: duplicate
590/// labels resolve to one metric key and are rejected at VM build time.
591///
592/// Construct with [`WatchBpfMap::new`], which const-asserts the field
593/// formats at compile time. Direct struct-literal construction is rejected
594/// (fields are crate-private) so every constructed value passes the gate.
595#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
596pub struct WatchBpfMap {
597 map_name_suffix: &'static str,
598 field: &'static str,
599 agg: BpfMapAgg,
600 label: &'static str,
601}
602
603impl WatchBpfMap {
604 /// Const constructor for use in `static`/`const` context.
605 ///
606 /// `map_name_suffix` matches a loaded BPF map by `ends_with` (kernel map
607 /// names truncate to 15 bytes). Unlike [`BpfMapWrite::new`] it does NOT
608 /// require a leading `.`: a watched map may be a section map (`.bss`)
609 /// OR a named map (`cpu_ctx_stor`). `field` is a dot-path into the map's
610 /// value type (`sys_stat.avg_lat_cri`, or a bare `lat_headroom`).
611 /// `label` is the metric-key leaf appended to the scheduler-obj prefix.
612 ///
613 /// # Panics
614 ///
615 /// Panics at compile time when any of `map_name_suffix` / `field` /
616 /// `label` is empty or carries whitespace, a path separator (`/`, `\`),
617 /// or a non-printable / control / high-bit byte. Additionally `field`
618 /// may not have a leading/trailing `.` or a doubled `..` (an empty path
619 /// segment the resolver could never match), and `label` may not contain
620 /// `.` at all (it is a single metric-key leaf, not a path).
621 pub const fn new(
622 map_name_suffix: &'static str,
623 field: &'static str,
624 agg: BpfMapAgg,
625 label: &'static str,
626 ) -> Self {
627 // map_name_suffix: non-empty, printable, no whitespace / separators.
628 // No leading-`.` requirement (matches both `.bss` and `cpu_ctx_stor`).
629 let s = map_name_suffix.as_bytes();
630 assert!(
631 !s.is_empty(),
632 "WatchBpfMap map_name_suffix must not be empty"
633 );
634 let mut i = 0;
635 while i < s.len() {
636 let b = s[i];
637 assert!(
638 b != b' ' && b != b'\t' && b != b'\n' && b != b'\r',
639 "WatchBpfMap map_name_suffix must not contain whitespace",
640 );
641 assert!(
642 b != b'/' && b != b'\\',
643 "WatchBpfMap map_name_suffix must not contain path separators",
644 );
645 assert!(
646 b >= 0x20 && b < 0x7f,
647 "WatchBpfMap map_name_suffix must be printable ASCII only",
648 );
649 i += 1;
650 }
651 // field: non-empty, printable, no whitespace / separators. `.` IS
652 // allowed (dot-path into the value struct) but must separate non-empty
653 // segments — a leading / trailing / doubled `.` is an empty segment
654 // the resolver could never match, so reject it at compile time.
655 let f = field.as_bytes();
656 assert!(!f.is_empty(), "WatchBpfMap field must not be empty");
657 assert!(
658 f[0] != b'.' && f[f.len() - 1] != b'.',
659 "WatchBpfMap field must not start or end with `.` (empty path segment)",
660 );
661 let mut i = 0;
662 while i < f.len() {
663 let b = f[i];
664 assert!(
665 b != b' ' && b != b'\t' && b != b'\n' && b != b'\r',
666 "WatchBpfMap field must not contain whitespace",
667 );
668 assert!(
669 b != b'/' && b != b'\\',
670 "WatchBpfMap field must not contain path separators",
671 );
672 assert!(
673 !(b == b'.' && i > 0 && f[i - 1] == b'.'),
674 "WatchBpfMap field must not contain `..` (empty path segment)",
675 );
676 assert!(
677 b >= 0x20 && b < 0x7f,
678 "WatchBpfMap field must be printable ASCII only",
679 );
680 i += 1;
681 }
682 // label: non-empty, printable, no whitespace / separators / dots
683 // (it is a single metric-key leaf).
684 let l = label.as_bytes();
685 assert!(!l.is_empty(), "WatchBpfMap label must not be empty");
686 let mut i = 0;
687 while i < l.len() {
688 let b = l[i];
689 assert!(
690 b != b' ' && b != b'\t' && b != b'\n' && b != b'\r',
691 "WatchBpfMap label must not contain whitespace",
692 );
693 assert!(
694 b != b'/' && b != b'\\',
695 "WatchBpfMap label must not contain path separators",
696 );
697 assert!(
698 b != b'.',
699 "WatchBpfMap label is a single metric-key leaf and must not contain `.`",
700 );
701 assert!(
702 b >= 0x20 && b < 0x7f,
703 "WatchBpfMap label must be printable ASCII only",
704 );
705 i += 1;
706 }
707 Self {
708 map_name_suffix,
709 field,
710 agg,
711 label,
712 }
713 }
714
715 /// The validated map-name suffix to match (via `ends_with`) against
716 /// loaded BPF maps (e.g. `".bss"`, `"cpu_ctx_stor"`).
717 pub const fn map_name_suffix(&self) -> &'static str {
718 self.map_name_suffix
719 }
720
721 /// The dot-path into the map's value type
722 /// (e.g. `"sys_stat.avg_lat_cri"` or `"lat_headroom"`).
723 pub const fn field(&self) -> &'static str {
724 self.field
725 }
726
727 /// How the per-tick reads aggregate into run-level metric key(s).
728 pub const fn agg(&self) -> BpfMapAgg {
729 self.agg
730 }
731
732 /// The metric-key leaf appended to the scheduler-obj prefix.
733 pub const fn label(&self) -> &'static str {
734 self.label
735 }
736}
737
738/// A per-test performance-regression assertion that
739/// `cargo ktstr perf-delta --noise-adjust` enforces when this test is compared
740/// across two commits — and that a normal `cargo ktstr test` run IGNORES.
741///
742/// Enforced ONLY under `--noise-adjust`. A declared gate is a CI-gating perf
743/// assertion; gating on a single-run scalar comparison would flip CI on noise,
744/// so the multi-run `--noise-adjust` path (Welch + disjoint-band separation) is
745/// the only statistically sound basis. Plain `perf-delta` (scalar) does NOT
746/// evaluate declared gates — it warns that they were skipped. Declaring a gate
747/// also REQUIRES `performance_mode` (validated at compile time by the macro and
748/// at discovery time by [`KtstrTestEntry::validate`]) so the compared data is
749/// pinned.
750///
751/// Inert-as-a-test / active-under-perf-delta is by CONSTRUCTION, not a runtime
752/// flag: the in-VM verdict path consults only [`crate::assert::Assert`], never a
753/// `PerfDeltaAssertion`, so declaring one changes no normal-run verdict or exit
754/// code. It becomes active only because `perf-delta` serializes the declaration
755/// into the sidecar and consults it in the host-side `--noise-adjust` compare.
756///
757/// A declaration names a registry metric and OVERRIDES, for this test, the gate
758/// that decides a confident regression on it: a tighter relative threshold, an
759/// absolute floor, a pinned direction, and/or a phase scope. It LAYERS ON TOP of
760/// the `--noise-adjust` default all-metrics regression net (which still runs, to
761/// catch unknown-unknown regressions) — it is an explicit contract check, not a
762/// whitelist that narrows the net.
763///
764/// Declare by binding each gate to a `const` and listing it on the macro:
765/// `const RPS_GATE: PerfDeltaAssertion =
766/// PerfDeltaAssertion::new("rps_p50").with_max_regression_pct(5.0);` then
767/// `#[ktstr_test(performance_mode = true, perf_delta_assertions = [RPS_GATE])]`.
768/// Or directly on a programmatically-built [`KtstrTestEntry`]:
769/// `perf_delta_assertions: &[&RPS_GATE]` — bind the const first; a chained inline
770/// `&PerfDeltaAssertion::new(..).with_*(..)` does NOT rvalue-promote to `'static`
771/// in a non-const `KtstrTestEntry { .. }` literal (E0716). Construct via
772/// [`PerfDeltaAssertion::new`] + the `const fn` `with_*` builders; fields are
773/// crate-private so every value passes the compile-time format gate. The type is
774/// `Copy` over a `&'static str` metric (no `Drop`, E0493-safe); the owned form
775/// the sidecar serializes is a separate internal mirror,
776/// [`crate::test_support::PerfDeltaAssertionRecord`].
777#[derive(Debug, Clone, Copy, PartialEq)]
778pub struct PerfDeltaAssertion {
779 metric: &'static str,
780 direction: Option<crate::test_support::Polarity>,
781 max_regression_pct: Option<f64>,
782 min_abs: Option<f64>,
783 phase: Option<u16>,
784}
785
786impl PerfDeltaAssertion {
787 /// Const constructor for use in `static`/`const` context. `metric` is a
788 /// registry metric name (see `cargo ktstr stats list-metrics`); the
789 /// remaining knobs default to the registry values and are set via the
790 /// builders below.
791 ///
792 /// # Panics
793 ///
794 /// Panics at compile time when `metric` is empty, contains whitespace or a
795 /// path separator (`/`, `\`), or a non-printable / high-bit byte. That the
796 /// name RESOLVES in the metric registry is checked at run time by
797 /// [`KtstrTestEntry::validate`] (the registry is not const-accessible).
798 pub const fn new(metric: &'static str) -> Self {
799 let m = metric.as_bytes();
800 assert!(!m.is_empty(), "PerfDeltaAssertion metric must not be empty");
801 let mut i = 0;
802 while i < m.len() {
803 let b = m[i];
804 assert!(
805 b != b' ' && b != b'\t' && b != b'\n' && b != b'\r',
806 "PerfDeltaAssertion metric must not contain whitespace",
807 );
808 assert!(
809 b != b'/' && b != b'\\',
810 "PerfDeltaAssertion metric must not contain path separators",
811 );
812 assert!(
813 b >= 0x20 && b < 0x7f,
814 "PerfDeltaAssertion metric must be printable ASCII only",
815 );
816 i += 1;
817 }
818 Self {
819 metric,
820 direction: None,
821 max_regression_pct: None,
822 min_abs: None,
823 phase: None,
824 }
825 }
826
827 /// Override the relative-regression gate (percent) for this metric: a
828 /// worsening move larger than `pct`% of the baseline gates. `None` (unset)
829 /// inherits the registry `default_rel`. `pct` must be finite and `>= 0`
830 /// (enforced at discovery time by [`KtstrTestEntry::validate`]).
831 pub const fn with_max_regression_pct(mut self, pct: f64) -> Self {
832 self.max_regression_pct = Some(pct);
833 self
834 }
835
836 /// Override the absolute-materiality floor for this metric: a move smaller
837 /// than `min` in absolute units never gates, regardless of the relative
838 /// threshold. `None` (unset) inherits the registry `default_abs`. `min` must
839 /// be finite and `>= 0` (enforced at discovery time by
840 /// [`KtstrTestEntry::validate`]).
841 pub const fn with_min_abs(mut self, min: f64) -> Self {
842 self.min_abs = Some(min);
843 self
844 }
845
846 /// Pin the regression DIRECTION for this metric instead of inheriting the
847 /// registry polarity (e.g. assert a metric the registry treats as
848 /// `Informational` as `LowerBetter` for this test).
849 ///
850 /// # Panics
851 ///
852 /// Panics at compile time on [`crate::test_support::Polarity::TargetValue`]:
853 /// symmetric target-distance gating is not implemented, and the compare
854 /// polarity path would silently treat it as increase-is-worse. Use
855 /// `HigherBetter`, `LowerBetter`, or `Informational`.
856 pub const fn with_direction(mut self, polarity: crate::test_support::Polarity) -> Self {
857 assert!(
858 !matches!(polarity, crate::test_support::Polarity::TargetValue(_)),
859 "PerfDeltaAssertion::with_direction does not support Polarity::TargetValue \
860 (symmetric target-distance gating is unimplemented); use HigherBetter, \
861 LowerBetter, or Informational",
862 );
863 self.direction = Some(polarity);
864 self
865 }
866
867 /// Scope the assertion to a single phase (`step_index`: `0` = BASELINE,
868 /// `1..=N` = scenario `Step` ordinals). `None` (unset) gates the aggregate
869 /// (whole-run) value. Unlike the render-only per-phase spread tables, a
870 /// phase-scoped assertion DOES contribute to the `perf-delta --noise-adjust`
871 /// exit code (the scalar `perf-delta` path warns and skips all declared
872 /// gates, phase-scoped included).
873 pub const fn with_phase(mut self, step_index: u16) -> Self {
874 self.phase = Some(step_index);
875 self
876 }
877
878 /// The registry metric name this assertion gates.
879 pub const fn metric(&self) -> &'static str {
880 self.metric
881 }
882
883 /// The pinned regression direction, or `None` to inherit the registry
884 /// polarity.
885 pub const fn direction(&self) -> Option<crate::test_support::Polarity> {
886 self.direction
887 }
888
889 /// The relative-regression override (percent), or `None` for the registry
890 /// `default_rel`.
891 pub const fn max_regression_pct(&self) -> Option<f64> {
892 self.max_regression_pct
893 }
894
895 /// The absolute-materiality override, or `None` for the registry
896 /// `default_abs`.
897 pub const fn min_abs(&self) -> Option<f64> {
898 self.min_abs
899 }
900
901 /// The phase scope (`step_index`), or `None` to gate the aggregate value.
902 pub const fn phase(&self) -> Option<u16> {
903 self.phase
904 }
905}
906
907/// Gauntlet topology filtering constraints.
908///
909/// Controls which gauntlet presets are eligible for a test entry.
910/// Presets that don't meet all constraints are skipped.
911#[derive(Debug, Clone, Copy, PartialEq, Eq)]
912pub struct TopologyConstraints {
913 /// Minimum number of NUMA nodes.
914 pub min_numa_nodes: u32,
915 /// Maximum number of NUMA nodes.
916 pub max_numa_nodes: Option<u32>,
917 /// Minimum number of LLCs.
918 pub min_llcs: u32,
919 /// Maximum number of LLCs.
920 pub max_llcs: Option<u32>,
921 /// Whether the test requires SMT (threads_per_core > 1).
922 pub requires_smt: bool,
923 /// Minimum total CPU count.
924 pub min_cpus: u32,
925 /// Maximum total CPU count.
926 pub max_cpus: Option<u32>,
927}
928
929impl TopologyConstraints {
930 /// Conservative default constraints: single NUMA node, 1-12 LLCs,
931 /// no SMT requirement, 1-192 CPUs. Accepts most single-node
932 /// gauntlet presets ktstr ships while rejecting multi-NUMA presets
933 /// (numa2-*, numa4-*) and the scale-boundary single-node presets
934 /// that exceed the CPU/LLC caps (near-max-llc, max-cpu, and their
935 /// -nosmt variants). Test authors that want broader coverage must
936 /// raise `max_numa_nodes`, `max_llcs`, or `max_cpus` explicitly.
937 ///
938 /// The canonical const handle — use directly when you need an
939 /// explicit const binding (`pub const X: TopologyConstraints =
940 /// TopologyConstraints::DEFAULT;`). For struct-literal spread in
941 /// a `static` / `const` initializer, either form works (modern
942 /// Rust promotes the trivially-Copy temporary returned by the
943 /// const-fn constructor):
944 ///
945 /// ```ignore
946 /// pub static A: TopologyConstraints = TopologyConstraints {
947 /// min_llcs: 4,
948 /// ..TopologyConstraints::DEFAULT // const path
949 /// };
950 /// pub static B: TopologyConstraints = TopologyConstraints {
951 /// min_llcs: 4,
952 /// ..TopologyConstraints::new() // const-fn constructor
953 /// };
954 /// ```
955 ///
956 /// [`Self::new()`] and `Default::default()` delegate to this const
957 /// for the `const fn` / trait-impl entry points.
958 pub const DEFAULT: Self = Self {
959 min_numa_nodes: 1,
960 max_numa_nodes: Some(1),
961 min_llcs: 1,
962 max_llcs: Some(12),
963 requires_smt: false,
964 min_cpus: 1,
965 max_cpus: Some(192),
966 };
967
968 /// Build the default constraints. Equivalent to [`Self::DEFAULT`].
969 /// Either form works for struct-literal spread in `static` /
970 /// `const`: `..Self::DEFAULT` (const path) or `..Self::new()`
971 /// (const-fn constructor — modern Rust promotes the trivially-Copy
972 /// temporary). `Default::default()` is the trait-impl entry point
973 /// for non-const contexts.
974 pub const fn new() -> Self {
975 Self::DEFAULT
976 }
977}
978
979impl Default for TopologyConstraints {
980 fn default() -> Self {
981 Self::new()
982 }
983}
984
985impl TopologyConstraints {
986 /// Whether a topology preset is eligible under these constraints
987 /// and the host's physical limits.
988 pub fn accepts(
989 &self,
990 topo: &Topology,
991 host_cpus: u32,
992 host_llcs: u32,
993 host_max_cpus_per_llc: u32,
994 ) -> bool {
995 topo.num_numa_nodes() >= self.min_numa_nodes
996 && self
997 .max_numa_nodes
998 .is_none_or(|max| topo.num_numa_nodes() <= max)
999 && topo.num_llcs() >= self.min_llcs
1000 && self.max_llcs.is_none_or(|max| topo.num_llcs() <= max)
1001 && (!self.requires_smt || topo.threads_per_core >= 2)
1002 && topo.total_cpus() >= self.min_cpus
1003 && self.max_cpus.is_none_or(|max| topo.total_cpus() <= max)
1004 && topo.total_cpus() <= host_cpus
1005 && topo.num_llcs() <= host_llcs
1006 && topo.cores_per_llc * topo.threads_per_core <= host_max_cpus_per_llc
1007 }
1008
1009 /// No-perf-mode variant of [`Self::accepts`]. The VM topology is
1010 /// emulated via KVM (vCPUs, ACPI SRAT/SLIT, CPUID), not pinned to
1011 /// host hardware, so the host's NUMA-node count, LLC count, and
1012 /// per-LLC CPU width do not constrain it. Only the total-CPU
1013 /// inequality survives — the guest needs `topo.total_cpus()` host
1014 /// CPUs to schedule its vCPU threads, regardless of how those
1015 /// vCPUs are grouped into virtual LLCs and nodes.
1016 ///
1017 /// The entry's `min_numa_nodes` / `min_llcs` / `min_cpus` /
1018 /// `requires_smt` / `max_*` fields still gate which gauntlet
1019 /// presets the test author wants to exercise — those are
1020 /// expressing test scope, not host capability — so they keep
1021 /// firing here. The host-side checks (`<= host_cpus`,
1022 /// `<= host_llcs`, `<= host_max_cpus_per_llc`) collapse to the
1023 /// single CPU-budget check.
1024 pub fn accepts_no_perf_mode(&self, topo: &Topology, host_cpus: u32) -> bool {
1025 topo.num_numa_nodes() >= self.min_numa_nodes
1026 && self
1027 .max_numa_nodes
1028 .is_none_or(|max| topo.num_numa_nodes() <= max)
1029 && topo.num_llcs() >= self.min_llcs
1030 && self.max_llcs.is_none_or(|max| topo.num_llcs() <= max)
1031 && (!self.requires_smt || topo.threads_per_core >= 2)
1032 && topo.total_cpus() >= self.min_cpus
1033 && self.max_cpus.is_none_or(|max| topo.total_cpus() <= max)
1034 && topo.total_cpus() <= host_cpus
1035 }
1036
1037 /// Reject inverted ranges (any `max_*` strictly less than the
1038 /// matching `min_*`). An inverted range cannot match ANY topology
1039 /// — [`Self::accepts`] / [`Self::accepts_no_perf_mode`] would
1040 /// silently return `false` for every preset and the test would
1041 /// be skipped without diagnostic.
1042 ///
1043 /// Wired into [`KtstrTestEntry::validate`], which fires at the
1044 /// start of `run_ktstr_test` (before any VM boot or preset
1045 /// enumeration). A struct-literal like `TopologyConstraints {
1046 /// min_numa_nodes: 5, max_numa_nodes: Some(2), ..DEFAULT }`
1047 /// surfaces a loud error within seconds of test dispatch instead
1048 /// of as a silently-empty gauntlet sweep at runtime. NOTE: this
1049 /// only covers the BASE test invocation path — nextest's
1050 /// `--list` output for gauntlet variant lines still elides
1051 /// presets that don't satisfy `accepts()`, so an inverted entry
1052 /// produces zero gauntlet variant lines in `--list` output
1053 /// without a per-variant diagnostic. The base-test invocation
1054 /// surfaces the error definitively; the listing-time silent
1055 /// elision of gauntlet variants is a separate concern.
1056 pub fn validate(&self) -> anyhow::Result<()> {
1057 if let Some(max) = self.max_numa_nodes
1058 && max < self.min_numa_nodes
1059 {
1060 anyhow::bail!(
1061 "TopologyConstraints inverted: max_numa_nodes={} < \
1062 min_numa_nodes={}. No topology can satisfy both bounds, \
1063 so every gauntlet preset would silently skip.",
1064 max,
1065 self.min_numa_nodes,
1066 );
1067 }
1068 if let Some(max) = self.max_llcs
1069 && max < self.min_llcs
1070 {
1071 anyhow::bail!(
1072 "TopologyConstraints inverted: max_llcs={} < min_llcs={}. \
1073 No topology can satisfy both bounds.",
1074 max,
1075 self.min_llcs,
1076 );
1077 }
1078 if let Some(max) = self.max_cpus
1079 && max < self.min_cpus
1080 {
1081 anyhow::bail!(
1082 "TopologyConstraints inverted: max_cpus={} < min_cpus={}. \
1083 No topology can satisfy both bounds.",
1084 max,
1085 self.min_cpus,
1086 );
1087 }
1088 Ok(())
1089 }
1090}
1091
1092impl TopologyConstraints {
1093 /// Override `min_numa_nodes`.
1094 #[must_use = "builder methods consume self; bind the result"]
1095 pub const fn with_min_numa_nodes(mut self, min_numa_nodes: u32) -> Self {
1096 self.min_numa_nodes = min_numa_nodes;
1097 self
1098 }
1099
1100 /// Override `max_numa_nodes`.
1101 #[must_use = "builder methods consume self; bind the result"]
1102 pub const fn with_max_numa_nodes(mut self, max_numa_nodes: u32) -> Self {
1103 self.max_numa_nodes = Some(max_numa_nodes);
1104 self
1105 }
1106
1107 /// Clear `max_numa_nodes` (lift the upper bound).
1108 #[must_use = "builder methods consume self; bind the result"]
1109 pub const fn without_max_numa_nodes(mut self) -> Self {
1110 self.max_numa_nodes = None;
1111 self
1112 }
1113
1114 /// Override `min_llcs`.
1115 #[must_use = "builder methods consume self; bind the result"]
1116 pub const fn with_min_llcs(mut self, min_llcs: u32) -> Self {
1117 self.min_llcs = min_llcs;
1118 self
1119 }
1120
1121 /// Override `max_llcs`.
1122 #[must_use = "builder methods consume self; bind the result"]
1123 pub const fn with_max_llcs(mut self, max_llcs: u32) -> Self {
1124 self.max_llcs = Some(max_llcs);
1125 self
1126 }
1127
1128 /// Clear `max_llcs` (lift the upper bound).
1129 #[must_use = "builder methods consume self; bind the result"]
1130 pub const fn without_max_llcs(mut self) -> Self {
1131 self.max_llcs = None;
1132 self
1133 }
1134
1135 /// Override `requires_smt`.
1136 #[must_use = "builder methods consume self; bind the result"]
1137 pub const fn with_requires_smt(mut self, requires_smt: bool) -> Self {
1138 self.requires_smt = requires_smt;
1139 self
1140 }
1141
1142 /// Override `min_cpus`.
1143 #[must_use = "builder methods consume self; bind the result"]
1144 pub const fn with_min_cpus(mut self, min_cpus: u32) -> Self {
1145 self.min_cpus = min_cpus;
1146 self
1147 }
1148
1149 /// Override `max_cpus`.
1150 #[must_use = "builder methods consume self; bind the result"]
1151 pub const fn with_max_cpus(mut self, max_cpus: u32) -> Self {
1152 self.max_cpus = Some(max_cpus);
1153 self
1154 }
1155
1156 /// Clear `max_cpus` (lift the upper bound).
1157 #[must_use = "builder methods consume self; bind the result"]
1158 pub const fn without_max_cpus(mut self) -> Self {
1159 self.max_cpus = None;
1160 self
1161 }
1162}
1163
1164/// Definition of a scheduler for the test framework.
1165///
1166/// Captures everything the framework needs to know about a scheduler:
1167/// its name, binary spec, sysctls, kernel args, scheduler args,
1168/// cgroup parent, default topology, gauntlet topology constraints,
1169/// config-file plumbing, kernel sweep set, and assertion overrides.
1170///
1171/// Construct via the `declare_scheduler!` macro (the production
1172/// path) or the [`Scheduler::named`] const builder chain. Test bodies
1173/// reference declared schedulers via the `scheduler = MY_SCHED`
1174/// attribute on `#[ktstr_test]`.
1175#[derive(Debug)]
1176pub struct Scheduler {
1177 /// Short human name for the scheduler, used in logs and sidecar
1178 /// metadata.
1179 pub name: &'static str,
1180 /// Source of the scheduler: a built-in spec variant (`Eevdf`,
1181 /// `Discover`, `Path`, or `KernelBuiltin`).
1182 ///
1183 /// The `declare_scheduler!` macro exposes three pseudo-keys that
1184 /// each map to one [`SchedulerSpec`] variant — the macro never
1185 /// accepts an enum literal, so authors must pick the matching
1186 /// key:
1187 /// - `binary = "scx_name"` → [`SchedulerSpec::Discover("scx_name")`](SchedulerSpec::Discover) (PATH lookup in the guest).
1188 /// - `binary_path = "/abs/path"` → [`SchedulerSpec::Path("/abs/path")`](SchedulerSpec::Path) (explicit absolute path).
1189 /// - `kernel_builtin_enable = "…", kernel_builtin_disable = "…"` (paired) → [`SchedulerSpec::KernelBuiltin`].
1190 ///
1191 /// Code that constructs a [`Scheduler`] outside the macro
1192 /// (manual `..Scheduler::EEVDF` spread, programmatic builders)
1193 /// uses the typed [`SchedulerSpec`] enum directly — see the
1194 /// chainable [`Scheduler::binary_discover`] sugar for the
1195 /// `Discover` common case.
1196 pub binary: SchedulerSpec,
1197 /// Guest sysctls applied before the scheduler starts (injected
1198 /// into the guest kernel cmdline as `sysctl.<key>=<value>`).
1199 /// Applied in order; duplicate keys last-write-wins.
1200 pub sysctls: &'static [Sysctl],
1201 /// Extra guest kernel command-line arguments appended when booting
1202 /// the VM. This is the GUEST KERNEL cmdline, not the scheduler
1203 /// binary's CLI — use [`sched_args`](field@Self::sched_args) for that.
1204 ///
1205 /// Do not override the kargs ktstr injects itself (`console=`,
1206 /// `loglevel=`, `init=`): those break guest-side init
1207 /// and leave the VM unable to run tests.
1208 pub kargs: &'static [&'static str],
1209 /// Scheduler-wide assertion overrides merged on top of
1210 /// `Assert::default_checks()` and below each per-entry `assert`.
1211 ///
1212 /// Construct via [`crate::assert::Assert::NO_OVERRIDES`] (the
1213 /// zero-overrides baseline) chained through the `Assert` builder
1214 /// methods. The `Assert` builder surface (every overridable
1215 /// threshold + scheduler-tunable knob) is documented at the
1216 /// [Checking](https://ktstr.dev/guide/concepts/checking.html)
1217 /// guide chapter; see [`crate::assert::Assert`] for the full
1218 /// per-method threshold list.
1219 pub assert: crate::assert::Assert,
1220 /// Cgroup parent path. Must begin with `/` and must not be `"/"`
1221 /// alone (that is the cgroup root). Example: `"/ktstr"`.
1222 /// When set, the init creates the sysfs directory before starting
1223 /// the scheduler, and `--cell-parent-cgroup {path}` is injected
1224 /// into scheduler args.
1225 pub cgroup_parent: Option<CgroupPath>,
1226 /// Scheduler CLI args, prepended before per-test `extra_sched_args`.
1227 pub sched_args: &'static [&'static str],
1228 /// Default VM topology for tests using this scheduler. Tests inherit
1229 /// this topology unless they override `numa_nodes`, `llcs`, `cores`,
1230 /// or `threads` explicitly in `#[ktstr_test]`.
1231 pub topology: Topology,
1232 /// Gauntlet topology constraints. Tests inherit these unless they
1233 /// override specific fields in `#[ktstr_test]`.
1234 pub constraints: TopologyConstraints,
1235 /// Host-side path to an opaque config file passed to the scheduler
1236 /// binary inside the VM. The file is included in the initramfs at
1237 /// `/include-files/{filename}` and `--config /include-files/{filename}`
1238 /// is prepended to `sched_args`.
1239 pub config_file: Option<&'static str>,
1240 /// Declares how an inline config is passed to the scheduler.
1241 /// First element: CLI arg template with `{file}` placeholder
1242 /// (e.g. `"f:{file}"`, `"--config {file}"`). Second element:
1243 /// guest filesystem path where the JSON is written
1244 /// (e.g. `"/include-files/layered.json"`). The framework
1245 /// `mkdir -p`s the parent and writes the config content there.
1246 pub config_file_def: Option<(&'static str, &'static str)>,
1247 /// Per-scheduler filter on the verifier sweep matrix.
1248 ///
1249 /// Each entry is a string consumed by [`KernelId::parse`](crate::kernel_path::KernelId::parse)
1250 /// at verifier runtime — same parser as the
1251 /// `cargo ktstr verifier --kernel <SPEC>` CLI flag. Accepts exact
1252 /// versions (`"6.14"`), closed ranges spelled either `..` or `..=`
1253 /// (`"6.14..7.0"` or `"6.14..=7.0"` — both inclusive on both
1254 /// endpoints), git refs (`"git+URL#tag=NAME"`), paths, and cache keys.
1255 ///
1256 /// The verifier sweep matrix is driven by the operator's
1257 /// `cargo ktstr verifier --kernel <SPEC>` set (which the
1258 /// dispatcher always populates into `KTSTR_KERNEL_LIST` — even
1259 /// the no-`--kernel` case synthesizes a single auto-discovered
1260 /// entry). For each scheduler,
1261 /// `list_verifier_cells_all` in `test_support::dispatch`
1262 /// emits a cell per (kernel-list entry that passes this filter ×
1263 /// accepted gauntlet topology preset).
1264 ///
1265 /// Match semantics per spec variant:
1266 /// - `Version`: raw-label string equality OR sanitized-label
1267 /// match against the kernel-list entry.
1268 /// - `Range`: range-membership check via
1269 /// `decompose_version_for_compare` on the entry's raw version
1270 /// string. Lets a scheduler declaring
1271 /// `kernels = ["6.14..6.16"]` match any operator-supplied
1272 /// kernel whose version falls in `[6.14, 6.16]` inclusive.
1273 /// - `Path` / `CacheKey` / `Git`: sanitized-label equality.
1274 ///
1275 /// Empty (`&[]`) means no filter — the scheduler verifies
1276 /// against every entry in `KTSTR_KERNEL_LIST`.
1277 pub kernels: &'static [&'static str],
1278}
1279
1280impl Scheduler {
1281 /// Placeholder scheduler representing "no scx scheduler." Tests
1282 /// that use `Scheduler::EEVDF` run under the kernel's default
1283 /// scheduler (EEVDF on current kernels), with no scheduler binary
1284 /// launched. Useful as a baseline and for tests that exercise
1285 /// framework behavior independent of any scx scheduler.
1286 ///
1287 /// The `.name` is the compile-time-fixed string `"eevdf"` — NOT
1288 /// runtime-derived from the live kernel. A sidecar written on a
1289 /// kernel whose default is a successor scheduling class still
1290 /// records `"eevdf"` here as long as the test attributes this
1291 /// `Scheduler` to a run. The sidecar reads
1292 /// `self.name` directly via the `scheduler` field
1293 /// projection.
1294 ///
1295 /// The sidecar itself does not carry the mapping from kernel
1296 /// version to scheduling class: the `host.kernel_release`
1297 /// field records the live kernel's release string (`6.6.12`,
1298 /// `6.14.2`, …) and nothing else. Consumers that need to answer
1299 /// "did this run actually use EEVDF?" must combine
1300 /// `host.kernel_release` with version-to-class knowledge
1301 /// maintained OUTSIDE the sidecar — e.g. an external lookup
1302 /// table that records the default scheduling class per upstream
1303 /// kernel release. A sidecar alone cannot distinguish a true
1304 /// EEVDF run from a run on a successor-class kernel that
1305 /// reused the same `"eevdf"` label via
1306 /// [`Scheduler::EEVDF`]'s compile-time-fixed `.name`.
1307 pub const EEVDF: Scheduler = Scheduler {
1308 name: "eevdf",
1309 binary: SchedulerSpec::Eevdf,
1310 sysctls: &[],
1311 kargs: &[],
1312 assert: crate::assert::Assert::NO_OVERRIDES,
1313 cgroup_parent: None,
1314 sched_args: &[],
1315 topology: Topology {
1316 llcs: 1,
1317 cores_per_llc: 2,
1318 threads_per_core: 1,
1319 numa_nodes: 1,
1320 nodes: None,
1321 distances: None,
1322 },
1323 constraints: TopologyConstraints::DEFAULT,
1324 config_file: None,
1325 config_file_def: None,
1326 kernels: &[],
1327 };
1328
1329 /// Const constructor for defining schedulers in static context.
1330 /// Caller chains [`Self::binary`] (or
1331 /// [`Self::binary_discover`]) plus any of the other const-fn
1332 /// builders to override the per-field defaults; the unset
1333 /// fields stay at the values below.
1334 ///
1335 /// **Defaults** (intentional — these compose with the
1336 /// per-test `#[ktstr_test]` attributes via merge, so every
1337 /// field has a no-op identity that lets per-test overrides
1338 /// take precedence):
1339 /// - `binary`: [`SchedulerSpec::Eevdf`] — kernel default
1340 /// scheduling class, no scheduler binary launched.
1341 /// Override via [`Self::binary`] / [`Self::binary_discover`].
1342 /// - `sysctls` / `kargs` / `sched_args`: empty slices — no
1343 /// guest sysctls applied, no extra kernel cmdline args, no
1344 /// extra scheduler CLI args.
1345 /// - `assert`: [`crate::assert::Assert::NO_OVERRIDES`] — no
1346 /// threshold overrides on top of
1347 /// [`crate::assert::Assert::default_checks`].
1348 /// - `cgroup_parent`: `None` — no `--cell-parent-cgroup`
1349 /// injection.
1350 /// - `topology`: `1 numa × 1 llc × 2 cores × 1 thread` —
1351 /// smallest meaningful topology for a quick smoke
1352 /// scheduler. Override via [`Self::topology`].
1353 /// - `constraints`: [`TopologyConstraints::DEFAULT`] — no
1354 /// gauntlet limits.
1355 /// - `config_file` / `config_file_def`: `None` — no config
1356 /// file plumbing. Setting both at construction is rejected
1357 /// at validation time.
1358 /// - `kernels`: empty slice — verifies against every kernel
1359 /// in the operator's `--kernel` set (no per-scheduler
1360 /// filter).
1361 pub const fn named(name: &'static str) -> Scheduler {
1362 Scheduler {
1363 name,
1364 binary: SchedulerSpec::Eevdf,
1365 sysctls: &[],
1366 kargs: &[],
1367 assert: crate::assert::Assert::NO_OVERRIDES,
1368 cgroup_parent: None,
1369 sched_args: &[],
1370 topology: Topology {
1371 llcs: 1,
1372 cores_per_llc: 2,
1373 threads_per_core: 1,
1374 numa_nodes: 1,
1375 nodes: None,
1376 distances: None,
1377 },
1378 constraints: TopologyConstraints::DEFAULT,
1379 config_file: None,
1380 config_file_def: None,
1381 kernels: &[],
1382 }
1383 }
1384
1385 /// Set the binary spec. Returns self for const chaining.
1386 pub const fn binary(mut self, binary: SchedulerSpec) -> Self {
1387 self.binary = binary;
1388 self
1389 }
1390
1391 /// Sugar for `.binary(SchedulerSpec::Discover(name))` — the
1392 /// dominant Scheduler construction path. Use when the scheduler
1393 /// binary is on `PATH` under the named filename (e.g.
1394 /// `.binary_discover("scx_rusty")`) and the framework should
1395 /// resolve it at guest-init time via `which`-style lookup.
1396 /// Equivalent to:
1397 ///
1398 /// ```ignore
1399 /// Scheduler::named("rusty").binary(SchedulerSpec::Discover("scx_rusty"))
1400 /// // is equivalent to
1401 /// Scheduler::named("rusty").binary_discover("scx_rusty")
1402 /// ```
1403 ///
1404 /// For an explicit absolute path (no `PATH` lookup), call
1405 /// `.binary(SchedulerSpec::Path("/absolute/path"))` directly —
1406 /// the path variant has no chainable sugar because absolute
1407 /// paths are the rare case (cross-compiled trees, ad-hoc
1408 /// installs) and a path-typed setter would obscure the intent.
1409 pub const fn binary_discover(self, name: &'static str) -> Self {
1410 self.binary(SchedulerSpec::Discover(name))
1411 }
1412
1413 /// Set sysctls. Returns self for const chaining.
1414 pub const fn sysctls(mut self, sysctls: &'static [Sysctl]) -> Self {
1415 self.sysctls = sysctls;
1416 self
1417 }
1418
1419 /// Set kernel args. Returns self for const chaining.
1420 pub const fn kargs(mut self, kargs: &'static [&'static str]) -> Self {
1421 self.kargs = kargs;
1422 self
1423 }
1424
1425 /// Set assertion config. Returns self for const chaining.
1426 pub const fn assert(mut self, assert: crate::assert::Assert) -> Self {
1427 self.assert = assert;
1428 self
1429 }
1430
1431 /// Set cgroup parent path. See the [`cgroup_parent`](field@Self::cgroup_parent)
1432 /// field for path format requirements (must begin with `/`, must
1433 /// not be `"/"` alone).
1434 pub const fn cgroup_parent(mut self, path: &'static str) -> Self {
1435 self.cgroup_parent = Some(CgroupPath::new(path));
1436 self
1437 }
1438
1439 /// Set scheduler CLI args prepended before per-test
1440 /// `extra_sched_args`.
1441 pub const fn sched_args(mut self, args: &'static [&'static str]) -> Self {
1442 self.sched_args = args;
1443 self
1444 }
1445
1446 /// Set the default VM topology for tests using this scheduler.
1447 /// Tests inherit this unless they override individual dimensions
1448 /// explicitly in `#[ktstr_test]`.
1449 pub const fn topology(mut self, numa_nodes: u32, llcs: u32, cores: u32, threads: u32) -> Self {
1450 self.topology = Topology {
1451 llcs,
1452 cores_per_llc: cores,
1453 threads_per_core: threads,
1454 numa_nodes,
1455 nodes: None,
1456 distances: None,
1457 };
1458 self
1459 }
1460
1461 /// Set gauntlet topology constraints. Tests inherit these unless
1462 /// they override specific fields in `#[ktstr_test]`.
1463 pub const fn constraints(mut self, constraints: TopologyConstraints) -> Self {
1464 self.constraints = constraints;
1465 self
1466 }
1467
1468 /// Set minimum number of NUMA nodes.
1469 pub const fn min_numa_nodes(mut self, n: u32) -> Self {
1470 self.constraints.min_numa_nodes = n;
1471 self
1472 }
1473
1474 /// Set maximum number of NUMA nodes.
1475 pub const fn max_numa_nodes(mut self, n: u32) -> Self {
1476 self.constraints.max_numa_nodes = Some(n);
1477 self
1478 }
1479
1480 /// Set minimum number of LLCs.
1481 pub const fn min_llcs(mut self, n: u32) -> Self {
1482 self.constraints.min_llcs = n;
1483 self
1484 }
1485
1486 /// Set maximum number of LLCs.
1487 pub const fn max_llcs(mut self, n: u32) -> Self {
1488 self.constraints.max_llcs = Some(n);
1489 self
1490 }
1491
1492 /// Set whether the scheduler requires SMT.
1493 pub const fn requires_smt(mut self, v: bool) -> Self {
1494 self.constraints.requires_smt = v;
1495 self
1496 }
1497
1498 /// Set minimum total CPU count.
1499 pub const fn min_cpus(mut self, n: u32) -> Self {
1500 self.constraints.min_cpus = n;
1501 self
1502 }
1503
1504 /// Set maximum total CPU count.
1505 pub const fn max_cpus(mut self, n: u32) -> Self {
1506 self.constraints.max_cpus = Some(n);
1507 self
1508 }
1509
1510 /// Set a host-side config file path. The file is included in the
1511 /// guest initramfs and `--config` is injected into scheduler args.
1512 pub const fn config_file(mut self, path: &'static str) -> Self {
1513 self.config_file = Some(path);
1514 self
1515 }
1516
1517 /// Declare how inline config content is passed to the scheduler.
1518 /// `arg_template`: CLI arg with `{file}` placeholder for the
1519 /// guest path (e.g. `"f:{file}"`, `"--config {file}"`).
1520 /// `guest_path`: where the JSON is written in the guest
1521 /// (e.g. `"/include-files/layered.json"`).
1522 pub const fn config_file_def(
1523 mut self,
1524 arg_template: &'static str,
1525 guest_path: &'static str,
1526 ) -> Self {
1527 self.config_file_def = Some((arg_template, guest_path));
1528 self
1529 }
1530
1531 /// Set the kernel specs the verifier should exercise this scheduler
1532 /// against. See [`Self::kernels`](field@Self::kernels) for the
1533 /// accepted string shapes.
1534 pub const fn kernels(mut self, kernels: &'static [&'static str]) -> Self {
1535 self.kernels = kernels;
1536 self
1537 }
1538
1539 /// Whether this scheduler runs an active scheduling policy
1540 /// (anything other than the kernel default EEVDF). Forwards to
1541 /// [`SchedulerSpec::has_active_scheduling`].
1542 ///
1543 /// Kept (unlike the trivial `name` / `binary` accessors that were
1544 /// dropped) because the 11+ call sites in `crate::test_support::eval` / `probe.rs`
1545 /// would otherwise spell `entry.scheduler.binary.has_active_scheduling()`
1546 /// at every dispatch decision — the `.binary.` indirection
1547 /// repeats noise without adding meaning. The forwarder is one
1548 /// line of glue for a recurring readability win.
1549 ///
1550 /// Returns `true` for `KernelBuiltin` schedulers. See
1551 /// [`Self::has_bpf_scheduler`] for the narrower gate that
1552 /// excludes them (the right gate when callers assume a
1553 /// userspace BPF binary is attached).
1554 pub const fn has_active_scheduling(&self) -> bool {
1555 self.binary.has_active_scheduling()
1556 }
1557
1558 /// Whether this scheduler attaches a userspace BPF binary.
1559 /// Forwards to [`SchedulerSpec::has_bpf_scheduler`].
1560 ///
1561 /// Same `.binary.` elision rationale as
1562 /// [`Self::has_active_scheduling`] — saves
1563 /// `entry.scheduler.binary.has_bpf_scheduler()` ceremony at
1564 /// every BPF-attach-intent dispatch site.
1565 ///
1566 /// Returns `false` for `KernelBuiltin` (no userspace binary)
1567 /// AND `Eevdf` (no scheduler). Use this whenever the caller
1568 /// would react to BPF artifacts — `verifier_stats` wiring,
1569 /// monitor thresholds, auto-repro probe gating.
1570 pub const fn has_bpf_scheduler(&self) -> bool {
1571 self.binary.has_bpf_scheduler()
1572 }
1573}
1574
1575/// Registration entry for an `#[ktstr_test]`-annotated function.
1576///
1577/// Construct via the [`#[ktstr_test]`] macro (the production path)
1578/// or struct-literal with `..KtstrTestEntry::DEFAULT` (the
1579/// integration-test / gauntlet rewriter path). The macro emits the
1580/// `linkme` distributed-slice registration; programmatic callers
1581/// register via [`KTSTR_TESTS`].
1582#[derive(Debug)]
1583pub struct KtstrTestEntry {
1584 /// Fully qualified test name as it appears in nextest output.
1585 pub name: &'static str,
1586 /// Entry point invoked once per replica, inside the guest VM when
1587 /// `host_only` is false and on the host when it is true.
1588 pub func: fn(&Ctx) -> Result<AssertResult>,
1589 /// Base virtual topology; gauntlet expansion produces additional
1590 /// variants layered on top of this baseline.
1591 pub topology: Topology,
1592 /// Host-topology constraints (CPU and LLC bounds) that gate
1593 /// whether this entry is eligible on the current machine.
1594 pub constraints: TopologyConstraints,
1595 /// Guest memory in MiB (binary mebibytes; conversion at
1596 /// VM-launch is `value << 20` bytes, not `value * 1_000_000`).
1597 pub memory_mib: u32,
1598 /// Host-CPU budget for the no-perf vCPU mask — the number of host
1599 /// CPUs the VM's vCPU threads share. `None` auto-sizes to the vCPU
1600 /// count (so a wide VM's parallel AP bringup isn't throttled);
1601 /// `Some(n)` with `n` < vCPU count forces CPU overcommit for
1602 /// contention testing. `Some(0)` is rejected (a zero budget cannot
1603 /// run a VM). Requires `no_perf_mode`: the budget sizes only the
1604 /// no-perf vCPU-thread mask, so setting it without `no_perf_mode` is
1605 /// rejected (by the `#[ktstr_test]` macro and by `validate`) rather
1606 /// than silently ignored. An explicit `--cpu-cap` / `KTSTR_CPU_CAP`
1607 /// overrides it. Set by `#[ktstr_test(cpu_budget = N)]`.
1608 pub cpu_budget: Option<u32>,
1609 /// Primary scheduler that drives the test. Defaults to
1610 /// [`Scheduler::EEVDF`] (the no-scx-scheduler placeholder; tests
1611 /// then run under the kernel's default scheduling class).
1612 ///
1613 /// Reference to a static [`Scheduler`] — typically the const
1614 /// emitted by [`declare_scheduler!`](crate::declare_scheduler).
1615 /// Pure-binary-workload tests use `Scheduler::EEVDF` here and
1616 /// supply their binary via the `payload` field below.
1617 pub scheduler: &'static crate::test_support::Scheduler,
1618 /// Additional schedulers staged into the guest initramfs alongside
1619 /// [`Self::scheduler`] so the scheduler-lifecycle ops
1620 /// ([`Op::ReplaceScheduler`](crate::scenario::ops::Op::ReplaceScheduler)
1621 /// and siblings) can swap to a different scheduler mid-experiment
1622 /// without a VM reboot. The boot-time scheduler is always
1623 /// [`Self::scheduler`] — entries here are the candidate set the
1624 /// test will swap TO via `Op::AttachScheduler` /
1625 /// `Op::ReplaceScheduler`.
1626 ///
1627 /// Each staged scheduler must have a `Scheduler::name` that is
1628 /// unique within the set AND distinct from a small reserved-name
1629 /// list (currently `scheduler`, `sched_args`, `init`, `args`,
1630 /// `exec_cmd`, `sched_enable`, `sched_disable`) that the
1631 /// framework uses for boot-time initramfs entries. Name
1632 /// collisions OR reserved-name collisions bail at
1633 /// [`Self::validate`] time with an actionable diagnostic naming
1634 /// the offending entries.
1635 ///
1636 /// The boot-time [`Self::scheduler`] is NOT auto-included in this
1637 /// set AND cannot be repeated here — [`Self::validate`] rejects
1638 /// any staged entry whose `name` matches the boot scheduler's
1639 /// `name`. Keep the boot scheduler in [`Self::scheduler`] and
1640 /// stage only the additional candidates the test wants to swap
1641 /// to. Tests that don't use scheduler-lifecycle ops leave this
1642 /// field at its `&[]` default and pay no initramfs cost for the
1643 /// staging machinery.
1644 ///
1645 /// Staged binary content is hashed into the initramfs cache key
1646 /// (see `BaseKey` in `crate::vmm::initramfs_cache`) — rebuilding a
1647 /// staged scheduler between test runs invalidates the cache
1648 /// automatically; no manual cache clean is needed.
1649 pub staged_schedulers: &'static [&'static crate::test_support::Scheduler],
1650 /// Optional binary payload to run as the primary workload. When
1651 /// `Some`, the test runs the referenced [`Payload`](crate::test_support::Payload)
1652 /// (which must be [`PayloadKind::Binary`](crate::test_support::PayloadKind::Binary))
1653 /// alongside the configured scheduler. When `None`, the test runs
1654 /// a scheduler-only scenario.
1655 ///
1656 /// Populated by `#[ktstr_test(payload = SOME_BIN)]`; direct
1657 /// programmatic callers may also set this.
1658 pub payload: Option<&'static crate::test_support::Payload>,
1659 /// Additional binary payloads composed with the primary. Each
1660 /// entry is launched via [`Ctx::payload`](crate::scenario::Ctx)
1661 /// in the test body.
1662 ///
1663 /// Populated by `#[ktstr_test(workloads = [A, B])]`.
1664 pub workloads: &'static [&'static crate::test_support::Payload],
1665 /// When true, a crash triggers an auto-repro run with BPF probes
1666 /// attached to the crash call chain.
1667 pub auto_repro: bool,
1668 /// When the downstream eval-layer wiring is in place AND this
1669 /// field is set to `true`, the test ASSERTS that the auto-repro
1670 /// path fired during the run — the intended end-state is a
1671 /// verdict inversion converting fail-with-repro-artifact into
1672 /// PASS. The canonical pattern: a test deliberately triggers a
1673 /// scheduler stall, the auto-repro path captures the repro
1674 /// artifact, and the test PASSES because the expected behavior
1675 /// (auto-repro firing) happened.
1676 ///
1677 /// Distinct from [`Self::auto_repro`]:
1678 /// - `auto_repro = true` enables the auto-repro capability —
1679 /// the path fires when the primary run fails.
1680 /// - `expect_auto_repro = true` asserts the path FIRED.
1681 ///
1682 /// Current wiring state (HEAD): macro-parser support is in
1683 /// place (`#[ktstr_test(expect_auto_repro)]` /
1684 /// `#[ktstr_test(expect_auto_repro = true)]` both parse and
1685 /// set this field), cross-attribute validation rejects
1686 /// structurally-nonsensical combinations at macro-parse
1687 /// time (`auto_repro = false`, `expect_err = true`,
1688 /// `host_only = true`, `wprof = false`, missing
1689 /// `scheduler`), AND the eval-layer inversion that makes
1690 /// the assertion observable is wired:
1691 /// `crate::test_support::eval::apply_expect_auto_repro_inversion`
1692 /// runs after `evaluate_vm_result`, probes the auto-repro
1693 /// `.repro.wprof.pb` artifact via the shape validator, and
1694 /// (when satisfied) wraps the failure `Err` with the
1695 /// `ExpectAutoReproSatisfied` marker; the dispatch arm at
1696 /// `crate::test_support::dispatch::result_to_exit_code`
1697 /// downcasts the marker and routes the verdict to
1698 /// `EXIT_PASS`. Default `false` matches prior behavior
1699 /// (no inversion, original verdict stands).
1700 pub expect_auto_repro: bool,
1701 /// Requires the `wprof` cargo feature; without it, the
1702 /// `#[ktstr_test(wprof)]` attribute fails at macro expansion.
1703 /// When true, the VM spawns `/bin/wprof` concurrently with the
1704 /// workload and ships the Perfetto `.pb` trace to the host.
1705 pub wprof: bool,
1706 /// Custom wprof CLI args (requires the `wprof` cargo feature).
1707 /// Overrides `WprofConfig::default_args` when [`Self::wprof`]
1708 /// is true. Populated by `#[ktstr_test(wprof_args = "...")]`.
1709 pub wprof_args: Option<&'static str>,
1710 /// Per-entry assertion overrides merged on top of
1711 /// `Assert::default_checks()` and the scheduler's `assert`.
1712 pub assert: crate::assert::Assert,
1713 /// Extra CLI arguments appended to the scheduler invocation.
1714 pub extra_sched_args: &'static [&'static str],
1715 /// `scx_sched.watchdog_timeout` override applied to the guest kernel.
1716 pub watchdog_timeout: Duration,
1717 /// Host-side BPF map writes to perform during VM execution.
1718 ///
1719 /// Empty slice (the default) means "no writes." The host thread
1720 /// (`freeze_coord::start_bpf_map_write`) runs three phases: (1) build
1721 /// the guest-memory BPF-map accessor once the kernel page tables are
1722 /// up; (2) resolve every entry's `field` NAME to a byte offset + width
1723 /// from the target map's program BTF; (3) write each resolved entry.
1724 /// A phase-1/2 failure (accessor init, or a field that never resolves
1725 /// within the deadline) aborts before phase 3 without signalling the
1726 /// guest, which self-unblocks on its own `wait_for_map_write` timeout.
1727 /// In phase 3 every resolved entry is attempted — a field whose width
1728 /// is not the 4 bytes the `u32` value stores is skipped (logged), and
1729 /// a failed write is logged — then `SIGNAL_BPF_WRITE_DONE` is pushed
1730 /// to the guest UNCONDITIONALLY after the loop, so a skipped or failed
1731 /// entry still unblocks the guest rather than leaving it to time out.
1732 pub bpf_map_write: &'static [&'static BpfMapWrite],
1733 /// Named scheduler BPF-map fields to read observer-effect-free into
1734 /// assertable run-level metrics. The free-running host monitor resolves
1735 /// each [`WatchBpfMap`] lazily (after the scheduler attaches and its maps
1736 /// appear) and reads it each tick without freezing the guest; the value
1737 /// is folded run-level and exposed via
1738 /// [`crate::vmm::result::VmResult::run_metric`] under
1739 /// `<scheduler-obj>_<label>` (scalar/scalar-counter/per-CPU-counter) or
1740 /// `_<label>_{avg,max}` (per-CPU gauge).
1741 pub watch_bpf_maps: &'static [&'static WatchBpfMap],
1742 /// Pin vCPU threads to host cores matching the virtual topology's LLC
1743 /// structure, use 2MB hugepages for guest memory, NUMA mbind guest
1744 /// memory to pinned vCPU nodes, and promote vCPU threads to
1745 /// SCHED_FIFO. Validates that the host has enough CPUs and LLCs to
1746 /// satisfy the request without oversubscription.
1747 ///
1748 /// On x86_64, additionally: set KVM_HINTS_REALTIME CPUID hint
1749 /// (disables PV spinlocks, PV TLB flush, PV sched_yield; enables
1750 /// haltpoll cpuidle), disable PAUSE and HLT VM exits via
1751 /// KVM_CAP_X86_DISABLE_EXITS (HLT falls back to PAUSE-only when
1752 /// mitigate_smt_rsb is active), skip KVM_CAP_HALT_POLL (guest
1753 /// haltpoll cpuidle disables host halt polling via
1754 /// MSR_KVM_POLL_CONTROL), and check TSC stability.
1755 ///
1756 /// On aarch64, KVM exit suppression and CPUID hints are not
1757 /// available. The four host-side optimizations (vCPU pinning,
1758 /// hugepages, NUMA mbind, RT scheduling) apply.
1759 pub performance_mode: bool,
1760 /// Per-test performance-regression assertions enforced ONLY by
1761 /// `cargo ktstr perf-delta --noise-adjust` (inert under a normal
1762 /// `cargo ktstr test` run — the in-VM verdict never consults them — and
1763 /// skipped-with-a-warning under a scalar `perf-delta` without
1764 /// `--noise-adjust`, since single-run gating would flip CI on noise). Each
1765 /// [`PerfDeltaAssertion`] names a registry metric and overrides the
1766 /// confident-regression gate for it (relative / absolute threshold,
1767 /// direction, phase scope), LAYERED ON TOP of the `--noise-adjust` default
1768 /// all-metrics net. Serialized into the sidecar so the host-side compare can
1769 /// read the declaration. `&[]` = no per-test assertions. Requires
1770 /// `performance_mode` (checked by [`Self::validate`] and the macro) — a
1771 /// declaration on a non-perf test would compare unpinned, noisy data.
1772 pub perf_delta_assertions: &'static [&'static PerfDeltaAssertion],
1773 /// Enable the virtio-PCI transport: a host bridge at `00:00.0`
1774 /// plus the PCI0 ECAM/CAM config-access windows. When `false`, no
1775 /// PCI host bridge is exposed and the guest cmdline carries
1776 /// `pci=off`. Surfaced by `#[ktstr_test(pci)]`; named to match the
1777 /// attribute and the bare-bool-field convention (`host_only`,
1778 /// `auto_repro`).
1779 pub pci: bool,
1780 /// Decouple virtual topology from host hardware. When set:
1781 ///
1782 /// - The VM's virtual topology (`numa_nodes`, `llcs`, `cores`,
1783 /// `threads`) is built as declared — the guest sees the full
1784 /// requested topology via KVM vCPU layout, ACPI SRAT/SLIT
1785 /// tables, etc.
1786 /// - Host-side cpuset/LLC locking still applies (the no-perf
1787 /// `LlcPlan` path), so concurrent perf-mode peers are still
1788 /// serialised against this VM.
1789 /// - Host-side performance_mode optimisations are skipped:
1790 /// no vCPU-to-host-core pinning, no 2 MB hugepages, no NUMA
1791 /// mbind, no `SCHED_FIFO` promotion, no `KVM_HINTS_REALTIME`
1792 /// CPUID hint, no `KVM_CAP_X86_DISABLE_EXITS`.
1793 /// - Host topology constraints are relaxed during gauntlet
1794 /// preset filtering — the entry's `min_numa_nodes` /
1795 /// `min_llcs` / `requires_smt` / per-LLC CPU limits are not
1796 /// compared against host hardware. The only host check that
1797 /// stays is "total host CPUs >= total vCPUs", so a test
1798 /// declaring `numa_nodes = 3` runs on a 1-NUMA-node host.
1799 ///
1800 /// Equivalent to setting `KTSTR_NO_PERF_MODE=1` per-test —
1801 /// either source forces the no-perf path. Mutually exclusive
1802 /// with `performance_mode = true`; [`KtstrTestEntry::validate`]
1803 /// rejects the combination because "I want pinning" and "I
1804 /// explicitly don't want pinning" are contradictory.
1805 pub no_perf_mode: bool,
1806 /// Workload duration.
1807 pub duration: Duration,
1808 /// When true, the test expects run_ktstr_test to return Err.
1809 /// Disables auto_repro (no point probing a deliberately failing test).
1810 pub expect_err: bool,
1811 /// When true, assert the scx scheduler SURVIVES the run — it must NOT
1812 /// die or get ejected during any hold. The positive inverse of
1813 /// [`Self::expect_err`].
1814 ///
1815 /// Survival is otherwise IMPLICIT: a scheduler that dies during a hold
1816 /// records a `DetailKind::Scheduler*` fail via the scenario liveness
1817 /// probe (`crate::scenario::ops` `build_sched_died_*`), which already
1818 /// fails the run through the failure→Err→EXIT_FAIL path. Setting this
1819 /// flag makes the intent explicit in source and attaches a survival-
1820 /// specific failure explainer that names the asserted intent. It is
1821 /// enforced for EVERY scenario: those driven through
1822 /// `execute_defs`/`execute_steps`/`execute_scenario` re-check liveness
1823 /// between steps and inside every hold, and a guest-side post-function
1824 /// probe (`enforce_survives_storm_liveness`) re-checks once more after the
1825 /// test function returns — so even a scenario that hand-rolls `Op`
1826 /// dispatch without an `execute_*` driver actively fails if the scheduler
1827 /// died or went down (scx state `disabling`/`disabled`) during the run.
1828 ///
1829 /// Mutually exclusive with both [`Self::expect_err`] (one demands
1830 /// failure, the other survival) and [`Self::expect_auto_repro`] (both
1831 /// invert the scheduler-death-fail signal — survives_storm forces it to
1832 /// EXIT_FAIL, expect_auto_repro inverts a crash-with-repro fail to
1833 /// PASS), and requires an active scheduler
1834 /// ([`SchedulerSpec::has_active_scheduling`]); all three are rejected by
1835 /// `validate`.
1836 pub survives_storm: bool,
1837 /// When true, a terminal Inconclusive verdict (e.g. zero-denominator
1838 /// ratio gate that couldn't evaluate) routes to EXIT_PASS instead
1839 /// of EXIT_INCONCLUSIVE at the dispatch layer. The test process
1840 /// exits 0 and CI gates keying off the per-test exit code see no
1841 /// failure. Use only when the test author has reason to accept an
1842 /// Inconclusive arm as not-a-failure for this specific test —
1843 /// e.g. an exploratory benchmark whose ratio gate may legitimately
1844 /// see no signal under certain host topologies. Inconclusive is
1845 /// still recorded in the sidecar so stats tooling can surface it,
1846 /// and the operator-facing failure dump still renders the
1847 /// Inconclusive diagnostic. This flag changes only the dispatch
1848 /// exit-code projection.
1849 ///
1850 /// Mutually orthogonal with [`Self::expect_err`]: when both are
1851 /// true and the result is Inconclusive, expect_err still wins
1852 /// (expect_err demands a real Fail; Inconclusive doesn't satisfy
1853 /// that and routes to EXIT_FAIL with the expect_err unsatisfied
1854 /// explainer).
1855 ///
1856 /// Populated by `#[ktstr_test(allow_inconclusive)]` /
1857 /// `#[ktstr_test(allow_inconclusive = true)]` or by direct entry
1858 /// construction.
1859 pub allow_inconclusive: bool,
1860 /// When true, the test runs directly on the host instead of
1861 /// booting a VM. Used for tests that need host tools (cargo,
1862 /// nested VMs) unavailable in the guest initramfs.
1863 pub host_only: bool,
1864 /// Extra host-side file specs beyond what the entry's
1865 /// [`scheduler`](Self::scheduler) / [`payload`](Self::payload) /
1866 /// [`workloads`](Self::workloads) declare. Unions with those
1867 /// per-payload specs at `run_ktstr_test` time; see
1868 /// [`all_include_files`](Self::all_include_files) for the
1869 /// aggregation contract. Use this slot for test-level
1870 /// dependencies that don't belong on a specific Payload —
1871 /// auxiliary data files, per-test helper scripts, fixtures.
1872 pub extra_include_files: &'static [&'static str],
1873 /// Maximum acceptable wall-clock duration of host-side VM teardown
1874 /// (BSP exit through SHM drain). Compared against
1875 /// [`VmResult::cleanup_duration`](crate::vmm::VmResult::cleanup_duration)
1876 /// in `evaluate_vm_result`; when the budget is exceeded the test's
1877 /// `AssertResult` is folded with a failing
1878 /// [`AssertDetail`](crate::assert::AssertDetail). Catches
1879 /// sub-watchdog cleanup regressions (e.g. a 30s teardown that the
1880 /// 60s host watchdog would silently absorb) at the test that
1881 /// declares the budget rather than at gross-timeout failure.
1882 /// `None` (the default) disables the check, leaving the watchdog
1883 /// as the only guard. Populated by
1884 /// `#[ktstr_test(cleanup_budget_ms = N)]` or by direct entry
1885 /// construction.
1886 pub cleanup_budget: Option<Duration>,
1887 /// Inline config content (JSON string) written to the guest path
1888 /// declared by the scheduler's `config_file_def`. The framework
1889 /// writes this string to a temp file, packs it into the initramfs,
1890 /// and passes the scheduler's arg template with `{file}` replaced.
1891 ///
1892 /// Populated by `#[ktstr_test(config = EXPR)]` (literal or path to
1893 /// a `const &'static str`) or by direct entry construction.
1894 ///
1895 /// Pairing gate: `config_content` and the scheduler's
1896 /// `config_file_def` must both be `Some(_)` or both be `None`.
1897 /// [`KtstrTestEntry::validate`] enforces this at runtime so direct
1898 /// programmatic-entry callers see the misconfiguration before VM
1899 /// boot, and the `#[ktstr_test]` macro emits a `const _: () = {
1900 /// assert!(...) };` block that catches the same mismatch at
1901 /// compile time for attribute-built entries. A `Some` here without
1902 /// a scheduler `config_file_def` would be silently dropped at
1903 /// dispatch (no `--config` flag derives from it); a `None` here
1904 /// against a scheduler that declares `config_file_def` would
1905 /// launch the scheduler binary without `--config`. Both are
1906 /// rejected.
1907 pub config_content: Option<&'static str>,
1908 /// Optional virtio-blk disk attached to the VM at `/dev/vda`.
1909 /// `None` (the default) boots without a disk; `Some(cfg)` calls
1910 /// `crate::vmm::KtstrVmBuilder::disk` in
1911 /// `crate::test_support::runtime::build_vm_builder_base` so the
1912 /// guest sees a raw block device sized per `cfg.capacity_mib`.
1913 /// Surfaced by `#[ktstr_test(disk = PATH)]`, `with_disk`, or direct
1914 /// construction via `..KtstrTestEntry::DEFAULT`. Mutually exclusive
1915 /// with `host_only`: `validate` rejects the combination because
1916 /// `host_only` skips the VM boot that owns the disk lifecycle.
1917 pub disk: Option<crate::vmm::disk_config::DiskConfig>,
1918 /// virtio-net devices attached to the VM (in-VMM loopback backend),
1919 /// one per element. Empty (the default) boots without a NIC; each
1920 /// element calls `crate::vmm::KtstrVmBuilder::network` in
1921 /// `crate::test_support::runtime::build_vm_builder_base`. On x86_64
1922 /// every element gets its own virtio-pci function (PCI slots 1..=N,
1923 /// one INTx GSI apiece); aarch64 takes a single virtio-MMIO NIC (build()
1924 /// errors on more than one). Surfaced by `#[ktstr_test(networks = [PATH, ...])]`,
1925 /// `with_networks`, or direct construction. Mutually exclusive with
1926 /// `host_only`: `validate` rejects a non-empty list because `host_only`
1927 /// skips the VM boot that owns the virtio-net devices.
1928 pub networks: &'static [crate::vmm::net_config::NetConfig],
1929 /// Host-side callback invoked after `vm.run()` returns, with
1930 /// access to the full `VmResult`. Runs on the HOST, not inside
1931 /// the guest. Use for assertions that need host-side state
1932 /// (e.g., `VmResult.snapshot_bridge` content after a snapshot
1933 /// capture pipeline fires inside the VM).
1934 ///
1935 /// `None` (the default) skips the callback. When `Some`, the
1936 /// closure receives `&VmResult` and returns `Result<()>` — an
1937 /// `Err` fails the test with the returned message.
1938 ///
1939 /// Skipped when the guest already reported a failed
1940 /// `AssertResult` — the existing guest-side fail diagnostic
1941 /// is more useful than a derivative post_vm `Err` that
1942 /// asserts on workload-derived state the crashed guest
1943 /// never produced. Tests that need the host-side check to
1944 /// run even on guest-fail should use
1945 /// [`post_vm_unconditional`](Self::post_vm_unconditional)
1946 /// instead.
1947 pub post_vm: Option<super::PostVmCallback>,
1948 /// Host-side callback invoked after `vm.run()` returns —
1949 /// like [`post_vm`](Self::post_vm) — but **UNCONDITIONAL**:
1950 /// runs even when the guest already reported a failed
1951 /// `AssertResult`.
1952 ///
1953 /// Use this when the callback must observe host-side state
1954 /// regardless of guest-side outcome — e.g. verifying that
1955 /// an artifact landed in the sidecar directory even on a
1956 /// deliberately-failing fault-injection test.
1957 ///
1958 /// Setting `post_vm_unconditional` does NOT invert the test
1959 /// verdict — a guest-reported fail still fails the test
1960 /// even when the unconditional callback returns Ok. The
1961 /// primitive lets the callback OBSERVE host-side state
1962 /// despite the fail; flipping a forced-fail test to PASS
1963 /// requires a separate framework primitive (e.g. an
1964 /// `expect_auto_repro = true` attribute that converts
1965 /// fail-plus-artifacts-present to PASS) which is intentionally
1966 /// out of scope here.
1967 ///
1968 /// Canonical callback shape (mirrors the framework's
1969 /// `default_post_vm_periodic_fired` self-guard):
1970 ///
1971 /// ```ignore
1972 /// fn my_unconditional_check(result: &VmResult) -> anyhow::Result<()> {
1973 /// // Skip when the VM run itself crashed (scheduler
1974 /// // died, watchdog fired, KVM exit during boot) — the
1975 /// // underlying failure already drives the test
1976 /// // diagnostic; layering a "missing state" error on
1977 /// // top obscures the cause.
1978 /// if !result.success {
1979 /// return Ok(());
1980 /// }
1981 /// // Now assert on whatever host-side artifact must
1982 /// // exist when the workload ran to completion.
1983 /// // ...
1984 /// Ok(())
1985 /// }
1986 /// ```
1987 ///
1988 /// Two suppression layers to be aware of:
1989 ///
1990 /// 1. FRAMEWORK level: `post_vm` is skipped entirely when
1991 /// the guest already reported a failed `AssertResult`
1992 /// (see the `guest_already_failed` gate in
1993 /// `src/test_support/eval/mod.rs::run_ktstr_test_inner_impl`'s
1994 /// post_vm dispatch site). `post_vm_unconditional`
1995 /// bypasses that suppression and always runs.
1996 /// 2. CALLBACK level: the conventional `post_vm` callback
1997 /// body short-circuits via `if !result.success { return
1998 /// Ok(()); }` to silence on crash/watchdog/KVM-boot-exit
1999 /// cases where the guest never produced an AssertResult
2000 /// for the framework gate to fire on; the framework's
2001 /// [`default_post_vm_periodic_fired`] is the canonical
2002 /// example. An unconditional callback that asserts on
2003 /// workload-derived state without an equivalent
2004 /// callback-side guard will surface a misleading
2005 /// "missing state" error on scheduler crashes.
2006 ///
2007 /// `None` (the default) skips the unconditional callback.
2008 /// Both [`post_vm`](Self::post_vm) and `post_vm_unconditional`
2009 /// may be set on the same entry — `post_vm` is suppressed on
2010 /// guest-fail per its existing contract, `post_vm_unconditional`
2011 /// always runs. If both callbacks set on the same entry both
2012 /// return `Err`, the framework surfaces both via
2013 /// `combine_post_vm_errs` chained as
2014 /// `post_vm: <conditional_err>; post_vm_unconditional: <unconditional_err>`
2015 /// so a debugging operator sees both regressions on the
2016 /// first pass rather than discovering the second one only
2017 /// after fixing the first.
2018 ///
2019 /// Setting the SAME callback fn pointer on BOTH slots will
2020 /// invoke it twice on the guest-success path (conditional +
2021 /// unconditional) and once on the guest-fail path
2022 /// (unconditional only); idempotent callbacks (read-only
2023 /// assertions) tolerate this fine, but a side-effecting
2024 /// callback (counter increment, file open) should pick exactly
2025 /// one slot.
2026 pub post_vm_unconditional: Option<super::PostVmCallback>,
2027 /// Periodic snapshot count: when non-zero, the freeze
2028 /// coordinator divides the 10 %–90 % slice of the capturable
2029 /// window into `num_snapshots + 1` equal intervals and fires a
2030 /// host-side
2031 /// `freeze_and_dispatch(FreezeMode::Capture { gate_on_exit_kind: false })`
2032 /// at each of the
2033 /// `num_snapshots` interior boundaries — e.g. `N = 1` lands a
2034 /// single capture at the window midpoint; `N = 3` lands at
2035 /// 0.3 / 0.5 / 0.7 of the window. No boundary lands at exactly
2036 /// the 0.1 / 0.9 edges — the buffers reserve those for workload
2037 /// ramp-up / ramp-down. Each boundary is stored under
2038 /// `"periodic_NNN"` (zero-padded 3-digit index) on the host's
2039 /// [`crate::scenario::snapshot::SnapshotBridge`]. The window is
2040 /// `[max(scenario_start, prereqs_ready), scenario_start +
2041 /// duration]`: the start floats to the prereq-ready moment
2042 /// (kaslr + BPF-accessor attach) so cold-boot latency cannot
2043 /// strand boundaries pre-ready, and the end is CLAMPED to the
2044 /// workload end so captures never spill into post-workload
2045 /// idle. On a WARM boot the start equals `scenario_start`, so
2046 /// the window is the full `[start, start + d]` and the fractions
2047 /// above apply as documented (`start + 0.3·d` etc., modulo
2048 /// integer-ns truncation); on a COLD boot the
2049 /// window starts later and is shorter, so the landings shift —
2050 /// cross-run compares of the window-averaged keys
2051 /// `avg_cpu_util_comp_scale` / `avg_task_lat_cri` should use
2052 /// `--noise-adjust`. Boot + verifier time before ScenarioStart
2053 /// does not eat the budget. Pauses observed via
2054 /// `MSG_TYPE_SCENARIO_PAUSE` / `MSG_TYPE_SCENARIO_RESUME` shift
2055 /// every un-fired boundary by the cumulative pause duration —
2056 /// the boundary clock is workload-time, not wall-clock, so a
2057 /// guest that pauses for `P` ns delays each remaining boundary
2058 /// by `P` ns. `0` (the default) disables periodic capture
2059 /// entirely; the coordinator's run-loop never even computes
2060 /// boundary timestamps.
2061 ///
2062 /// **Capture cost.** Each periodic boundary fires the same
2063 /// host-side
2064 /// `freeze_and_dispatch(FreezeMode::Capture { gate_on_exit_kind: false })`
2065 /// path that
2066 /// [`crate::scenario::ops::Op::CaptureSnapshot`] dispatches: every
2067 /// vCPU is parked under `FREEZE_RENDEZVOUS_TIMEOUT` (30 s
2068 /// hard ceiling), BPF maps are walked, the dump is serialised
2069 /// to JSON, and the report is stored on the
2070 /// [`crate::scenario::snapshot::SnapshotBridge`]. On a healthy
2071 /// guest with a typical scheduler-state map size the freeze
2072 /// is tens of milliseconds (10–100 ms is the steady-state
2073 /// observation; cold-cache and large guest-memory walks can
2074 /// push higher). The host-side watchdog deadline is extended
2075 /// by the freeze duration after each fire, so periodic
2076 /// captures do not eat into the workload's wall-clock budget.
2077 ///
2078 /// **Best-effort delivery.** Up to `N` captures fire; an early
2079 /// VM exit (kill flag, BSP done, rendezvous timeout, watchdog
2080 /// deadline) can cut the periodic sequence short, and the
2081 /// run-loop stops servicing periodic boundaries the moment the
2082 /// kill flag fires. Tests should assert `>= some_lower_bound`
2083 /// rather than `== num_snapshots`. `Op::CaptureSnapshot` captures
2084 /// composed by the test author land on the same bridge
2085 /// alongside the `periodic_NNN` tags; total bridge occupancy
2086 /// is `num_snapshots + user_captures` and the bridge
2087 /// FIFO-evicts past
2088 /// [`crate::scenario::snapshot::MAX_STORED_SNAPSHOTS`].
2089 /// Additionally, the run-loop abandons the remaining sequence
2090 /// after 2 consecutive rendezvous timeouts and emits a
2091 /// `tracing::warn` naming the consecutive-timeout count.
2092 ///
2093 /// **Minimum spacing.** Each capture freezes every vCPU for
2094 /// tens of milliseconds at minimum (see "Capture cost" above),
2095 /// so boundaries scheduled closer than ~100 ms apart would
2096 /// fire back-to-back without any workload progress in between.
2097 /// `validate()` rejects entries where
2098 /// `0.8 · duration / (N + 1) < 100 ms` — choose `N` and
2099 /// `duration` so the resulting interval clears that floor.
2100 ///
2101 /// **Ordering.** Periodic captures are stored in order
2102 /// (`periodic_000` first, `periodic_NNN` last). Tests that
2103 /// need to walk them in time order should call
2104 /// [`crate::scenario::snapshot::SnapshotBridge::drain_ordered`]
2105 /// rather than [`crate::scenario::snapshot::SnapshotBridge::drain`]
2106 /// — the latter returns a `HashMap` and loses ordering.
2107 ///
2108 /// `validate()` rejects `num_snapshots >
2109 /// crate::scenario::snapshot::MAX_STORED_SNAPSHOTS` (= 64
2110 /// today): the bridge enforces FIFO eviction at that cap, so a
2111 /// higher count would silently drop the earliest periodic
2112 /// samples once `store()` started evicting. Refusing the
2113 /// configuration is more honest than half-delivering it. The
2114 /// 64 cap also ensures the 3-digit `:03` width on
2115 /// `periodic_NNN` is always sufficient.
2116 pub num_snapshots: u32,
2117 /// Cgroup directory that the framework creates BEFORE the
2118 /// scheduler starts and uses as the parent for every workload
2119 /// cgroup the test author declares via [`Ctx::cgroup_def`]
2120 /// (`ctx.cgroup_def("cg_0")` etc.). When `Some(path)`, the
2121 /// guest mkdir's `/sys/fs/cgroup{path}` and the per-test
2122 /// CgroupManager places its children there
2123 /// (`/sys/fs/cgroup{path}/cg_0` etc.); when `None`, the
2124 /// framework falls back to the legacy resolution
2125 /// (`crate::test_support::args::resolve_cgroup_root` —
2126 /// `--cell-parent-cgroup` in `sched_args` overrides; default
2127 /// `/sys/fs/cgroup/ktstr`).
2128 ///
2129 /// Distinct from [`crate::test_support::Scheduler::cgroup_parent`]:
2130 /// `cgroup_parent` is a scheduler-only knob that controls the
2131 /// scheduler argv (`--cell-parent-cgroup` flag, only when the
2132 /// scheduler declaration explicitly carries it in `sched_args`);
2133 /// `workload_root_cgroup` is a framework knob for the workload
2134 /// side and never reaches the scheduler argv. A test can set
2135 /// `workload_root_cgroup` without affecting the scheduler's
2136 /// cgroup placement, and a scheduler can set `cgroup_parent`
2137 /// without affecting where workloads land.
2138 pub workload_root_cgroup: Option<CgroupPath>,
2139 /// Whether KASLR is enabled in the guest kernel for this test.
2140 /// `true` (the default) lets the guest randomize kernel virt + direct-map
2141 /// addresses (CONFIG_RANDOMIZE_BASE=y + CONFIG_RANDOMIZE_MEMORY=y in
2142 /// `ktstr.kconfig`); `false` appends `nokaslr` to the guest cmdline so
2143 /// the kernel-image slide and the `page_offset_base` direct-map randomization
2144 /// both stay at compile-time defaults. KASLR-off is the determinism escape
2145 /// for tests that depend on fixed kernel addresses or that need to reproduce
2146 /// bugs masked by randomization; the default-on case exercises ktstr's
2147 /// host-side derivation chain (MSR_LSTAR readback, KERN_ADDRS guest channel,
2148 /// /proc/kallsyms `page_offset_base` lookup) end-to-end. The
2149 /// `kaslr_*_e2e` regression tests guard the derivation; the
2150 /// `kaslr_disabled_via_macro_attribute` regression guards this opt-out.
2151 /// Operator-level alternative: `kargs = ["nokaslr"]` on the scheduler decl
2152 /// — same effect, declared once for every test that uses the scheduler.
2153 /// Combining `kaslr = false` with `kargs = ["nokaslr"]` is redundant
2154 /// but harmless — `nokaslr` appearing twice on the cmdline is a no-op
2155 /// (kernel parses it as a bool flag, not a value).
2156 pub kaslr: bool,
2157}
2158
2159/// Placeholder function for [`KtstrTestEntry::DEFAULT`].
2160///
2161/// Returns `Err` — NOT a panic — so a programmatic caller that
2162/// accidentally uses `KtstrTestEntry::DEFAULT` without overriding
2163/// `func` gets an immediate actionable failure inside the test-run
2164/// loop rather than taking down the whole dispatch process. The
2165/// `..KtstrTestEntry::DEFAULT` struct-update spread only populates
2166/// unfilled fields; if the caller spread the default without
2167/// setting `func`, this stub runs and bails with a message pointing
2168/// at the mistake.
2169fn default_test_func(_ctx: &Ctx) -> Result<AssertResult> {
2170 anyhow::bail!("KtstrTestEntry::DEFAULT func called — override func before use")
2171}
2172
2173impl KtstrTestEntry {
2174 /// Sensible defaults for all fields. Override `name`, `func`, and
2175 /// `scheduler` (at minimum) via struct update syntax. Manual
2176 /// consumers should also set `auto_repro` explicitly: the default
2177 /// `true` boots a second VM with BPF probes attached on failure,
2178 /// which roughly doubles a failing test's wall-clock time.
2179 ///
2180 /// [`Self::DEFAULT`] is the source of truth (struct-literal
2181 /// const); [`Self::new()`] is a delegating alias for method-style
2182 /// use and `Default::default()` is the trait-shim — both
2183 /// equivalent in non-const contexts. For `static` / `const`
2184 /// initializer spread sites (e.g. `#[distributed_slice(KTSTR_TESTS)]`
2185 /// macro expansions), `..Self::DEFAULT` is the canonical shape —
2186 /// it spreads the struct-literal const directly without taking a
2187 /// detour through a const-fn return.
2188 ///
2189 /// ```
2190 /// use ktstr::prelude::*;
2191 ///
2192 /// fn my_test_fn(_ctx: &Ctx) -> Result<AssertResult> {
2193 /// Ok(AssertResult::pass())
2194 /// }
2195 ///
2196 /// #[distributed_slice(KTSTR_TESTS)]
2197 /// #[linkme(crate = ktstr::linkme)]
2198 /// static ENTRY: KtstrTestEntry = KtstrTestEntry {
2199 /// name: "my_test",
2200 /// func: my_test_fn,
2201 /// scheduler: &Scheduler::EEVDF,
2202 /// ..KtstrTestEntry::DEFAULT
2203 /// };
2204 /// ```
2205 ///
2206 /// The `#[linkme(crate = ktstr::linkme)]` annotation is required
2207 /// when the downstream crate does not depend on `linkme` directly
2208 /// — see [`crate::distributed_slice`] for the full rationale.
2209 pub const DEFAULT: Self = Self {
2210 name: "",
2211 func: default_test_func,
2212 topology: Topology {
2213 llcs: 1,
2214 cores_per_llc: 2,
2215 threads_per_core: 1,
2216 numa_nodes: 1,
2217 nodes: None,
2218 distances: None,
2219 },
2220 constraints: TopologyConstraints::DEFAULT,
2221 memory_mib: 2048,
2222 cpu_budget: None,
2223 scheduler: &crate::test_support::Scheduler::EEVDF,
2224 staged_schedulers: &[],
2225 payload: None,
2226 workloads: &[],
2227 auto_repro: true,
2228 expect_auto_repro: false,
2229 wprof: false,
2230 wprof_args: None,
2231 assert: crate::assert::Assert::NO_OVERRIDES,
2232 extra_sched_args: &[],
2233 watchdog_timeout: Duration::from_secs(5),
2234 bpf_map_write: &[],
2235 watch_bpf_maps: &[],
2236 performance_mode: false,
2237 perf_delta_assertions: &[],
2238 pci: false,
2239 no_perf_mode: false,
2240 duration: Duration::from_secs(12),
2241 expect_err: false,
2242 survives_storm: false,
2243 allow_inconclusive: false,
2244 host_only: false,
2245 extra_include_files: &[],
2246 cleanup_budget: None,
2247 config_content: None,
2248 disk: None,
2249 networks: &[],
2250 post_vm: None,
2251 post_vm_unconditional: None,
2252 num_snapshots: 0,
2253 workload_root_cgroup: None,
2254 kaslr: true,
2255 };
2256
2257 /// Build the default entry. Equivalent to [`Self::DEFAULT`].
2258 /// Either `..Self::DEFAULT` or `..Self::new()` works in
2259 /// `static` / `const` initializer spread sites since `new()`
2260 /// is `const fn` and KtstrTestEntry has no Drop-bearing
2261 /// fields. `Default::default()` is the trait-shim equivalent
2262 /// for non-const contexts.
2263 pub const fn new() -> Self {
2264 Self::DEFAULT
2265 }
2266
2267 /// Aggregate every declared include-file spec: the entry's
2268 /// primary [`payload`](Self::payload) (if present) contributes
2269 /// its [`Payload::include_files`](crate::test_support::Payload::include_files),
2270 /// each entry in [`workloads`](Self::workloads) contributes its
2271 /// own, and [`extra_include_files`](Self::extra_include_files)
2272 /// contributes test-level extras. Pre-dedupe aggregation order:
2273 /// payload → workloads (in declaration order) → extras. The
2274 /// scheduler tier does not contribute — `Scheduler` has no
2275 /// `include_files` field, and the scheduler binary path is
2276 /// resolved separately at run time. Duplicate spec strings at
2277 /// this layer are NOT deduped — the framework's include-file
2278 /// pipeline at `run_ktstr_test` resolves each entry to a
2279 /// `(archive_path, host_path)` pair and dedupes on identical
2280 /// pairs while erroring on archive_path collisions with
2281 /// conflicting host_paths. This aggregation order does NOT
2282 /// survive downstream: the final resolved list is sorted
2283 /// alphabetically by archive_path after deduplication.
2284 /// Alphabetical ordering ensures deterministic initramfs layout
2285 /// regardless of declaration order.
2286 pub fn all_include_files(&self) -> Vec<&'static str> {
2287 let mut out: Vec<&'static str> = Vec::new();
2288 if let Some(p) = self.payload {
2289 out.extend(p.include_files.iter().copied());
2290 }
2291 for w in self.workloads {
2292 out.extend(w.include_files.iter().copied());
2293 }
2294 out.extend(self.extra_include_files.iter().copied());
2295 out
2296 }
2297}
2298
2299/// Programmatic builder methods. Use at runtime (let bindings, fn
2300/// returns). For `static` / `const` initializers and
2301/// `#[distributed_slice(KTSTR_TESTS)]` registration, prefer the
2302/// struct-literal `..KtstrTestEntry::DEFAULT` spread — chained
2303/// `with_X` calls fail in `const` context with E0015 ("cannot call
2304/// non-const fn in constants") because these setters are declared
2305/// `pub fn`, not `pub const fn`. See [`Self::DEFAULT`] for the
2306/// worked struct-literal example.
2307impl KtstrTestEntry {
2308 /// Override `name`.
2309 #[must_use = "builder methods consume self; bind the result"]
2310 pub fn with_name(mut self, name: &'static str) -> Self {
2311 self.name = name;
2312 self
2313 }
2314
2315 /// Override `func`.
2316 #[must_use = "builder methods consume self; bind the result"]
2317 pub fn with_func(mut self, func: fn(&Ctx) -> Result<AssertResult>) -> Self {
2318 self.func = func;
2319 self
2320 }
2321
2322 /// Override `topology`.
2323 #[must_use = "builder methods consume self; bind the result"]
2324 pub fn with_topology(mut self, topology: Topology) -> Self {
2325 self.topology = topology;
2326 self
2327 }
2328
2329 /// Override `constraints`.
2330 #[must_use = "builder methods consume self; bind the result"]
2331 pub fn with_constraints(mut self, constraints: TopologyConstraints) -> Self {
2332 self.constraints = constraints;
2333 self
2334 }
2335
2336 /// Override `memory_mib`.
2337 #[must_use = "builder methods consume self; bind the result"]
2338 pub fn with_memory_mib(mut self, memory_mib: u32) -> Self {
2339 self.memory_mib = memory_mib;
2340 self
2341 }
2342
2343 /// Override `cpu_budget` (the no-perf host-CPU budget; `n` below the
2344 /// vCPU count forces overcommit). Sets `Some(n)`; the default is
2345 /// `None` (auto-size to the vCPU count). `validate` rejects `Some(0)`
2346 /// and rejects a budget set without `no_perf_mode`.
2347 #[must_use = "builder methods consume self; bind the result"]
2348 pub fn with_cpu_budget(mut self, cpu_budget: u32) -> Self {
2349 self.cpu_budget = Some(cpu_budget);
2350 self
2351 }
2352
2353 /// Clear `cpu_budget` (auto-size the no-perf vCPU mask to the vCPU
2354 /// count).
2355 #[must_use = "builder methods consume self; bind the result"]
2356 pub fn without_cpu_budget(mut self) -> Self {
2357 self.cpu_budget = None;
2358 self
2359 }
2360
2361 /// Override `scheduler`.
2362 #[must_use = "builder methods consume self; bind the result"]
2363 pub fn with_scheduler(mut self, scheduler: &'static crate::test_support::Scheduler) -> Self {
2364 self.scheduler = scheduler;
2365 self
2366 }
2367
2368 /// Override `staged_schedulers` — the candidate set the test can
2369 /// swap to mid-experiment via the scheduler-lifecycle ops
2370 /// ([`Op::AttachScheduler`](crate::scenario::ops::Op::AttachScheduler) /
2371 /// [`Op::ReplaceScheduler`](crate::scenario::ops::Op::ReplaceScheduler)).
2372 /// See the field doc on [`Self::staged_schedulers`] for the
2373 /// per-scheduler-name uniqueness + reserved-name validation
2374 /// contract.
2375 #[must_use = "builder methods consume self; bind the result"]
2376 pub fn with_staged_schedulers(
2377 mut self,
2378 staged: &'static [&'static crate::test_support::Scheduler],
2379 ) -> Self {
2380 self.staged_schedulers = staged;
2381 self
2382 }
2383
2384 /// Override `payload`.
2385 #[must_use = "builder methods consume self; bind the result"]
2386 pub fn with_payload(mut self, payload: &'static crate::test_support::Payload) -> Self {
2387 self.payload = Some(payload);
2388 self
2389 }
2390
2391 /// Clear `payload` (run a scheduler-only scenario with no primary
2392 /// binary).
2393 #[must_use = "builder methods consume self; bind the result"]
2394 pub fn without_payload(mut self) -> Self {
2395 self.payload = None;
2396 self
2397 }
2398
2399 /// Override `workloads`.
2400 #[must_use = "builder methods consume self; bind the result"]
2401 pub fn with_workloads(
2402 mut self,
2403 workloads: &'static [&'static crate::test_support::Payload],
2404 ) -> Self {
2405 self.workloads = workloads;
2406 self
2407 }
2408
2409 /// Override `auto_repro`.
2410 #[must_use = "builder methods consume self; bind the result"]
2411 pub fn with_auto_repro(mut self, auto_repro: bool) -> Self {
2412 self.auto_repro = auto_repro;
2413 self
2414 }
2415
2416 /// Override `expect_auto_repro`.
2417 #[must_use = "builder methods consume self; bind the result"]
2418 pub fn with_expect_auto_repro(mut self, expect_auto_repro: bool) -> Self {
2419 self.expect_auto_repro = expect_auto_repro;
2420 self
2421 }
2422
2423 /// Override `assert`.
2424 ///
2425 /// Replaces the entry's per-test overrides wholesale; the assertion
2426 /// resolution at run time still layers `Assert::default_checks()`
2427 /// and the scheduler-level `assert` underneath.
2428 #[must_use = "builder methods consume self; bind the result"]
2429 pub fn with_assert(mut self, assert: crate::assert::Assert) -> Self {
2430 self.assert = assert;
2431 self
2432 }
2433
2434 /// Override `extra_sched_args`.
2435 #[must_use = "builder methods consume self; bind the result"]
2436 pub fn with_extra_sched_args(mut self, extra_sched_args: &'static [&'static str]) -> Self {
2437 self.extra_sched_args = extra_sched_args;
2438 self
2439 }
2440
2441 /// Override `watchdog_timeout`.
2442 #[must_use = "builder methods consume self; bind the result"]
2443 pub fn with_watchdog_timeout(mut self, watchdog_timeout: Duration) -> Self {
2444 self.watchdog_timeout = watchdog_timeout;
2445 self
2446 }
2447
2448 /// Override `bpf_map_write`.
2449 #[must_use = "builder methods consume self; bind the result"]
2450 pub fn with_bpf_map_write(mut self, bpf_map_write: &'static [&'static BpfMapWrite]) -> Self {
2451 self.bpf_map_write = bpf_map_write;
2452 self
2453 }
2454
2455 /// Override `watch_bpf_maps`.
2456 #[must_use = "builder methods consume self; bind the result"]
2457 pub fn with_watch_bpf_maps(mut self, watch_bpf_maps: &'static [&'static WatchBpfMap]) -> Self {
2458 self.watch_bpf_maps = watch_bpf_maps;
2459 self
2460 }
2461
2462 /// Override `performance_mode`.
2463 #[must_use = "builder methods consume self; bind the result"]
2464 pub fn with_performance_mode(mut self, performance_mode: bool) -> Self {
2465 self.performance_mode = performance_mode;
2466 self
2467 }
2468
2469 /// Override `no_perf_mode`.
2470 #[must_use = "builder methods consume self; bind the result"]
2471 pub fn with_no_perf_mode(mut self, no_perf_mode: bool) -> Self {
2472 self.no_perf_mode = no_perf_mode;
2473 self
2474 }
2475
2476 /// Override `duration`.
2477 #[must_use = "builder methods consume self; bind the result"]
2478 pub fn with_duration(mut self, duration: Duration) -> Self {
2479 self.duration = duration;
2480 self
2481 }
2482
2483 /// Override `expect_err`.
2484 #[must_use = "builder methods consume self; bind the result"]
2485 pub fn with_expect_err(mut self, expect_err: bool) -> Self {
2486 self.expect_err = expect_err;
2487 self
2488 }
2489
2490 /// Override [`Self::survives_storm`].
2491 #[must_use = "builder methods consume self; bind the result"]
2492 pub fn with_survives_storm(mut self, survives_storm: bool) -> Self {
2493 self.survives_storm = survives_storm;
2494 self
2495 }
2496
2497 /// Override [`Self::allow_inconclusive`]. When true, an
2498 /// Inconclusive terminal verdict routes to EXIT_PASS instead
2499 /// of EXIT_INCONCLUSIVE at the dispatch layer.
2500 #[must_use = "builder methods consume self; bind the result"]
2501 pub fn with_allow_inconclusive(mut self, allow_inconclusive: bool) -> Self {
2502 self.allow_inconclusive = allow_inconclusive;
2503 self
2504 }
2505
2506 /// Override `host_only`.
2507 #[must_use = "builder methods consume self; bind the result"]
2508 pub fn with_host_only(mut self, host_only: bool) -> Self {
2509 self.host_only = host_only;
2510 self
2511 }
2512
2513 /// Override `extra_include_files`.
2514 #[must_use = "builder methods consume self; bind the result"]
2515 pub fn with_extra_include_files(
2516 mut self,
2517 extra_include_files: &'static [&'static str],
2518 ) -> Self {
2519 self.extra_include_files = extra_include_files;
2520 self
2521 }
2522
2523 /// Override `cleanup_budget`.
2524 #[must_use = "builder methods consume self; bind the result"]
2525 pub fn with_cleanup_budget(mut self, cleanup_budget: Duration) -> Self {
2526 self.cleanup_budget = Some(cleanup_budget);
2527 self
2528 }
2529
2530 /// Clear `cleanup_budget` (leave the host watchdog as the only
2531 /// guard).
2532 #[must_use = "builder methods consume self; bind the result"]
2533 pub fn without_cleanup_budget(mut self) -> Self {
2534 self.cleanup_budget = None;
2535 self
2536 }
2537
2538 /// Override `config_content`.
2539 #[must_use = "builder methods consume self; bind the result"]
2540 pub fn with_config_content(mut self, config_content: &'static str) -> Self {
2541 self.config_content = Some(config_content);
2542 self
2543 }
2544
2545 /// Clear `config_content`.
2546 #[must_use = "builder methods consume self; bind the result"]
2547 pub fn without_config_content(mut self) -> Self {
2548 self.config_content = None;
2549 self
2550 }
2551
2552 /// Override `disk`.
2553 ///
2554 /// Pairs with the `host_only = false` requirement enforced by
2555 /// [`Self::validate`] — `host_only = true` with a `Some(..)` disk
2556 /// is rejected because host-only skips the VM boot that owns the
2557 /// virtio-blk lifecycle.
2558 #[must_use = "builder methods consume self; bind the result"]
2559 pub fn with_disk(mut self, disk: crate::vmm::disk_config::DiskConfig) -> Self {
2560 self.disk = Some(disk);
2561 self
2562 }
2563
2564 /// Override `networks` (one virtio-net device per element).
2565 ///
2566 /// Pairs with the `host_only = false` requirement enforced by
2567 /// [`Self::validate`] — `host_only = true` with a non-empty list
2568 /// is rejected because host-only skips the VM boot that owns the
2569 /// virtio-net devices.
2570 #[must_use = "builder methods consume self; bind the result"]
2571 pub fn with_networks(mut self, networks: &'static [crate::vmm::net_config::NetConfig]) -> Self {
2572 self.networks = networks;
2573 self
2574 }
2575
2576 /// Clear `networks` (boot without a virtio-net device).
2577 #[must_use = "builder methods consume self; bind the result"]
2578 pub fn without_networks(mut self) -> Self {
2579 self.networks = &[];
2580 self
2581 }
2582
2583 /// Clear `disk` (boot without a virtio-blk device).
2584 #[must_use = "builder methods consume self; bind the result"]
2585 pub fn without_disk(mut self) -> Self {
2586 self.disk = None;
2587 self
2588 }
2589
2590 /// Override `post_vm`.
2591 ///
2592 /// The closure runs on the host after `vm.run()` returns with
2593 /// access to the full `VmResult`; an `Err` from the closure fails
2594 /// the test with the returned message. Skipped when the guest
2595 /// already reported a failed `AssertResult` — see
2596 /// [`Self::post_vm`] for the suppression contract; use
2597 /// [`Self::with_post_vm_unconditional`] when the host-side
2598 /// check must run even on guest-fail.
2599 #[must_use = "builder methods consume self; bind the result"]
2600 pub fn with_post_vm(mut self, post_vm: super::PostVmCallback) -> Self {
2601 self.post_vm = Some(post_vm);
2602 self
2603 }
2604
2605 /// Clear `post_vm` (skip the host-side callback).
2606 #[must_use = "builder methods consume self; bind the result"]
2607 pub fn without_post_vm(mut self) -> Self {
2608 self.post_vm = None;
2609 self
2610 }
2611
2612 /// Override `post_vm_unconditional`.
2613 ///
2614 /// The closure runs on the host after `vm.run()` returns —
2615 /// like [`Self::with_post_vm`] — but bypasses the guest-fail
2616 /// suppression that gates the conditional [`Self::with_post_vm`]
2617 /// callback. An `Err` from the closure fails the test with the
2618 /// returned message.
2619 #[must_use = "builder methods consume self; bind the result"]
2620 pub fn with_post_vm_unconditional(
2621 mut self,
2622 post_vm_unconditional: super::PostVmCallback,
2623 ) -> Self {
2624 self.post_vm_unconditional = Some(post_vm_unconditional);
2625 self
2626 }
2627
2628 /// Clear `post_vm_unconditional` (skip the unconditional host-side
2629 /// callback). Does not affect the conditional [`Self::with_post_vm`]
2630 /// callback if one is set.
2631 #[must_use = "builder methods consume self; bind the result"]
2632 pub fn without_post_vm_unconditional(mut self) -> Self {
2633 self.post_vm_unconditional = None;
2634 self
2635 }
2636
2637 /// Override `num_snapshots`.
2638 #[must_use = "builder methods consume self; bind the result"]
2639 pub fn with_num_snapshots(mut self, num_snapshots: u32) -> Self {
2640 self.num_snapshots = num_snapshots;
2641 self
2642 }
2643
2644 /// Override `workload_root_cgroup` with a validated path.
2645 /// `path` must satisfy [`CgroupPath::new`]'s requirements
2646 /// (starts with `/`, not bare `/`, no `..` components); the
2647 /// const-eval gate panics on invalid input so programmatic
2648 /// callers see the same validation as the macro path.
2649 #[must_use = "builder methods consume self; bind the result"]
2650 pub const fn with_workload_root_cgroup(mut self, path: &'static str) -> Self {
2651 self.workload_root_cgroup = Some(CgroupPath::new(path));
2652 self
2653 }
2654}
2655
2656impl Default for KtstrTestEntry {
2657 fn default() -> Self {
2658 Self::new()
2659 }
2660}
2661
2662/// Distributed slice collecting all `#[ktstr_test]` entries via linkme.
2663#[distributed_slice]
2664pub static KTSTR_TESTS: [KtstrTestEntry];
2665
2666/// Distributed slice collecting all `declare_scheduler!` registrations
2667/// via linkme. Each entry is a `&'static Scheduler` pointing at a
2668/// const emitted by the macro. The verifier discovers schedulers by
2669/// spawning the test binary with `--ktstr-list-schedulers`; a per-binary
2670/// ctor walks this slice and serializes each entry to JSON.
2671#[distributed_slice]
2672pub static KTSTR_SCHEDULERS: [&'static Scheduler];
2673
2674/// Look up a registered test function by name.
2675pub fn find_test(name: &str) -> Option<&'static KtstrTestEntry> {
2676 KTSTR_TESTS.iter().find(|e| e.name == name)
2677}
2678
2679/// Look up a registered scheduler by its [`Scheduler::name`] field
2680/// (the `name = "..."` value supplied to
2681/// [`declare_scheduler!`](crate::declare_scheduler), not the
2682/// SCREAMING_SNAKE_CASE const identifier the macro emits). Returns
2683/// `None` if no registered scheduler matches.
2684///
2685/// Two `declare_scheduler!` invocations that share a `name = "..."`
2686/// value (under distinct const idents) both register in
2687/// [`KTSTR_SCHEDULERS`]; a linear scan returns the first match and
2688/// the second is unreachable. This function panics on the first call
2689/// when any duplicate exists so the misconfiguration surfaces
2690/// loudly instead of silently dropping a registration. The scan and
2691/// map construction happen once per process via a `LazyLock`.
2692pub fn find_scheduler(name: &str) -> Option<&'static Scheduler> {
2693 scheduler_index().get(name).copied()
2694}
2695
2696/// Process-wide index from [`Scheduler::name`] to the registered
2697/// `&'static Scheduler`. Built once on first lookup. Detects
2698/// duplicate names and panics with both colliding consts' addresses
2699/// so the test author can identify the two declarations.
2700fn scheduler_index() -> &'static std::collections::HashMap<&'static str, &'static Scheduler> {
2701 static INDEX: std::sync::LazyLock<std::collections::HashMap<&'static str, &'static Scheduler>> =
2702 std::sync::LazyLock::new(|| {
2703 build_scheduler_index_or_panic(KTSTR_SCHEDULERS.iter().copied())
2704 });
2705 &INDEX
2706}
2707
2708/// Build a name → scheduler map from an iterator of registered
2709/// schedulers, panicking on the first duplicate name. Factored out
2710/// so `tests` can exercise the duplicate-detection branch against a
2711/// mock slice — the real [`KTSTR_SCHEDULERS`] is the union of every
2712/// `declare_scheduler!` invocation in the linked binary and so
2713/// cannot host an intentional duplicate without poisoning every
2714/// other test.
2715fn build_scheduler_index_or_panic<I>(
2716 schedulers: I,
2717) -> std::collections::HashMap<&'static str, &'static Scheduler>
2718where
2719 I: IntoIterator<Item = &'static Scheduler>,
2720{
2721 let mut map: std::collections::HashMap<&'static str, &'static Scheduler> =
2722 std::collections::HashMap::new();
2723 for sched in schedulers {
2724 if let Some(prev) = map.insert(sched.name, sched)
2725 && !std::ptr::eq(prev, sched)
2726 {
2727 // `{:p}` prints the pointer in the standard `0x…` form
2728 // so a user diff'ing two declarations can tell at a
2729 // glance whether they're literally the same static
2730 // (re-export of the same const, harmless) or two
2731 // distinct consts with the same `name = "..."` (the
2732 // collision this guard exists for).
2733 panic!(
2734 "ktstr: duplicate scheduler name `{name}` registered \
2735 in KTSTR_SCHEDULERS:\n \
2736 first: {prev:p}\n \
2737 second: {sched:p}\n\
2738 Two `declare_scheduler!` invocations declared the \
2739 same `name = \"{name}\"`. The first registration \
2740 wins under linear scan and the second is unreachable. \
2741 Rename one of the declarations or remove the \
2742 duplicate.",
2743 name = sched.name,
2744 );
2745 }
2746 }
2747 map
2748}
2749
2750/// JSON shape projected from a registered [`Scheduler`]. Each entry
2751/// carries scheduler name, a [`BinaryKindJson`]-tagged binary
2752/// specification (Discover / Path / Eevdf / KernelBuiltin),
2753/// per-scheduler default [`TopologyJson`], always-on scheduler
2754/// args, declared kernel set, and gauntlet constraints. Internal
2755/// fields (assertion overrides, sysctls, kargs, cgroup parent,
2756/// config-file plumbing) are intentionally omitted.
2757#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2758pub struct SchedulerJson {
2759 /// Scheduler name — the `name = "..."` value supplied to
2760 /// [`declare_scheduler!`](crate::declare_scheduler) or
2761 /// [`Scheduler::named`].
2762 pub name: String,
2763 /// Binary specification: distinguishes Discover (build via cargo
2764 /// `[[bin]]` name), Path (use absolute path verbatim), Eevdf
2765 /// (kernel default scheduler, no binary), and KernelBuiltin
2766 /// (built into the kernel, enable/disable via guest commands).
2767 /// The variant tag lets the verifier dispatch exhaustively `match`
2768 /// on the binary type without parsing the string.
2769 pub binary_kind: BinaryKindJson,
2770 /// Default VM topology for tests using this scheduler. The
2771 /// verifier sweep's per-cell topology comes from gauntlet presets
2772 /// filtered through `constraints`; this field carries the
2773 /// per-scheduler baseline that test-entry plumbing inherits when
2774 /// a test does not override `numa_nodes`/`llcs`/`cores`/`threads`
2775 /// in its `#[ktstr_test]` attributes. Mirror of
2776 /// [`Scheduler::topology`].
2777 pub topology: TopologyJson,
2778 /// Always-on scheduler CLI args.
2779 pub sched_args: Vec<String>,
2780 /// Kernel specs (consumed by `cargo_ktstr::kernel::resolve_kernel_set`).
2781 pub kernels: Vec<String>,
2782 /// Gauntlet preset constraints (filter the verifier's topology sweep).
2783 pub constraints: TopologyConstraintsJson,
2784}
2785
2786/// A [`SchedulerJson`] plus the number of `#[ktstr_test]`s declared against
2787/// it. Emitted (as a JSON array) by the `--ktstr-list-schedulers` probe so
2788/// `cargo ktstr affected` can enumerate declared schedulers AND skip those
2789/// with zero tests when producing its CI matrix, in one probe.
2790#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2791pub struct SchedulerListEntry {
2792 /// The projected scheduler.
2793 pub scheduler: SchedulerJson,
2794 /// Count of registered [`KtstrTestEntry`]s whose scheduler is this one.
2795 pub test_count: usize,
2796}
2797
2798/// A single `#[ktstr_test]` paired with its declared scheduler's name. Emitted
2799/// (as a JSON array) by the `--ktstr-list-scheduler-tests` probe so `--relevant`
2800/// can map each test to its scheduler and select the tests whose scheduler a
2801/// diff affects.
2802#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2803pub struct SchedulerTestJson {
2804 /// The `#[ktstr_test]` function name, as registered.
2805 pub test: String,
2806 /// The declared scheduler's name (the `SchedulerJson::name` field).
2807 pub scheduler: String,
2808}
2809
2810/// JSON-friendly form of [`SchedulerSpec`] tagged so the verifier
2811/// dispatch can exhaustively `match` on the variant. `Discover` and
2812/// `Path` both carry a string identifier; `Eevdf` and
2813/// `KernelBuiltin` both signal "no BPF to verify".
2814#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2815#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
2816pub enum BinaryKindJson {
2817 /// Cargo `[[bin]]` name. Verifier resolves via `build_and_find_binary`.
2818 Discover(String),
2819 /// Absolute filesystem path. Verifier checks `path.exists()` and uses verbatim.
2820 Path(String),
2821 /// Kernel default scheduler (EEVDF on current kernels). No BPF, no binary.
2822 Eevdf,
2823 /// Built into the kernel (e.g. `scx_simple` enabled via sysfs). No userspace binary.
2824 KernelBuiltin,
2825}
2826
2827/// JSON-friendly mirror of `Topology` for the verifier wire format.
2828/// Captures the four-tuple shape (numa nodes × LLCs × cores × threads)
2829/// the per-scheduler baseline topology was declared with. The verifier
2830/// uses this when computing the sweep matrix — the baseline anchors
2831/// the default cell when no gauntlet preset matches the
2832/// scheduler's constraints.
2833#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2834pub struct TopologyJson {
2835 /// NUMA node count. Maps to [`Topology::num_numa_nodes`].
2836 pub num_numa_nodes: u32,
2837 /// Last-level cache (LLC) count, equivalent to "sockets" on
2838 /// pre-CCX/CCD x86. Maps to [`Topology::num_llcs`].
2839 pub num_llcs: u32,
2840 /// Physical cores per LLC. Mirrors `Topology::cores_per_llc`.
2841 pub cores_per_llc: u32,
2842 /// Hardware threads per physical core. `1` for non-SMT; `2`
2843 /// for x86 hyperthreading. Mirrors `Topology::threads_per_core`.
2844 pub threads_per_core: u32,
2845}
2846
2847impl TopologyJson {
2848 /// Single-CPU baseline: 1 NUMA node × 1 LLC × 1 core × 1 thread.
2849 /// Used by `cargo ktstr verifier` and verifier-pipeline tests as
2850 /// the no-scheduling-workload default.
2851 pub const SINGLE_CPU: Self = Self {
2852 num_numa_nodes: 1,
2853 num_llcs: 1,
2854 cores_per_llc: 1,
2855 threads_per_core: 1,
2856 };
2857}
2858
2859/// Result-based validation for wire-format topology values. Lets the
2860/// verifier dispatch surface a per-cell "topology rejected" diagnostic
2861/// instead of taking the [`Topology::new`] panic surface in the builder.
2862/// Mirrors [`Topology::validate`] — any field == 0, overflow in total
2863/// CPU count, or `llcs` not divisible by `numa_nodes` returns `Err`.
2864/// The result is a uniform-distribution [`Topology`] (`nodes = None`,
2865/// `distances = None`); explicit per-node config and distance matrices
2866/// require constructing [`Topology`] directly.
2867impl TryFrom<TopologyJson> for Topology {
2868 type Error = String;
2869
2870 fn try_from(value: TopologyJson) -> Result<Self, Self::Error> {
2871 let topo = Self {
2872 llcs: value.num_llcs,
2873 cores_per_llc: value.cores_per_llc,
2874 threads_per_core: value.threads_per_core,
2875 numa_nodes: value.num_numa_nodes,
2876 nodes: None,
2877 distances: None,
2878 };
2879 topo.validate()?;
2880 Ok(topo)
2881 }
2882}
2883
2884/// Project a [`Topology`] into its wire-format mirror. Drops the
2885/// `nodes` and `distances` fields (uniform-distribution shape only);
2886/// callers that need to preserve explicit per-node config or distance
2887/// matrices must not use this conversion. Takes [`Topology`] by value
2888/// (it derives [`Copy`]) to match the by-value shape of
2889/// [`From<TopologyConstraintsJson> for TopologyConstraints`].
2890impl From<Topology> for TopologyJson {
2891 fn from(t: Topology) -> Self {
2892 Self {
2893 num_numa_nodes: t.numa_nodes,
2894 num_llcs: t.llcs,
2895 cores_per_llc: t.cores_per_llc,
2896 threads_per_core: t.threads_per_core,
2897 }
2898 }
2899}
2900
2901/// JSON-friendly mirror of [`TopologyConstraints`] — the host-side
2902/// `Option<u32>` fields serialize as `null` (default serde behavior;
2903/// no `skip_serializing_if`) rather than the `Some(N)`/`None`-tagged
2904/// shapes serde uses for `Option` inside larger struct graphs.
2905/// Field semantics match [`TopologyConstraints`] verbatim; see that
2906/// type for per-field documentation.
2907#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2908pub struct TopologyConstraintsJson {
2909 pub min_numa_nodes: u32,
2910 pub max_numa_nodes: Option<u32>,
2911 pub min_llcs: u32,
2912 pub max_llcs: Option<u32>,
2913 pub requires_smt: bool,
2914 pub min_cpus: u32,
2915 pub max_cpus: Option<u32>,
2916}
2917
2918/// Infallible shape conversion — every field maps 1:1 to
2919/// [`TopologyConstraints`], so the verifier sweep dispatch can reuse
2920/// the same `accepts` / `accepts_no_perf_mode` filters that gauntlet
2921/// dispatch uses.
2922impl From<TopologyConstraintsJson> for TopologyConstraints {
2923 fn from(j: TopologyConstraintsJson) -> Self {
2924 Self {
2925 min_numa_nodes: j.min_numa_nodes,
2926 max_numa_nodes: j.max_numa_nodes,
2927 min_llcs: j.min_llcs,
2928 max_llcs: j.max_llcs,
2929 requires_smt: j.requires_smt,
2930 min_cpus: j.min_cpus,
2931 max_cpus: j.max_cpus,
2932 }
2933 }
2934}
2935
2936impl SchedulerJson {
2937 /// Project a `Scheduler` static into its JSON shape.
2938 pub fn from_scheduler(s: &Scheduler) -> Self {
2939 let binary_kind = match s.binary {
2940 SchedulerSpec::Discover(n) => BinaryKindJson::Discover(n.to_string()),
2941 SchedulerSpec::Path(p) => BinaryKindJson::Path(p.to_string()),
2942 SchedulerSpec::Eevdf => BinaryKindJson::Eevdf,
2943 SchedulerSpec::KernelBuiltin { .. } => BinaryKindJson::KernelBuiltin,
2944 };
2945 Self {
2946 name: s.name.to_string(),
2947 binary_kind,
2948 topology: TopologyJson {
2949 num_numa_nodes: s.topology.num_numa_nodes(),
2950 num_llcs: s.topology.num_llcs(),
2951 cores_per_llc: s.topology.cores_per_llc,
2952 threads_per_core: s.topology.threads_per_core,
2953 },
2954 sched_args: s.sched_args.iter().map(|a| a.to_string()).collect(),
2955 kernels: s.kernels.iter().map(|k| k.to_string()).collect(),
2956 constraints: TopologyConstraintsJson {
2957 min_numa_nodes: s.constraints.min_numa_nodes,
2958 max_numa_nodes: s.constraints.max_numa_nodes,
2959 min_llcs: s.constraints.min_llcs,
2960 max_llcs: s.constraints.max_llcs,
2961 requires_smt: s.constraints.requires_smt,
2962 min_cpus: s.constraints.min_cpus,
2963 max_cpus: s.constraints.max_cpus,
2964 },
2965 }
2966 }
2967}
2968
2969::ctor::declarative::ctor! {
2970/// Ctor that intercepts `--ktstr-list-schedulers` before `main()` runs.
2971/// Walks [`KTSTR_SCHEDULERS`], emits a [`SchedulerListEntry`] per scheduler
2972/// (its [`SchedulerJson`] projection plus the count of [`KTSTR_TESTS`]
2973/// declared against it) as a single JSON array on stdout, and exits with
2974/// status 0.
2975///
2976/// One ctor per binary, regardless of how many schedulers the binary
2977/// registers — walks the slices once and emits a single JSON array. The
2978/// `test_count` is what lets `cargo ktstr affected` drop zero-test schedulers
2979/// from its CI matrix without a second probe.
2980///
2981/// Uses ctor's declarative `ctor::declarative::ctor! { ... }` form;
2982/// the proc-macro `#[ctor::ctor(...)]` form is re-exported at
2983/// `crate::__private::ctor::ctor` for downstream consumers.
2984#[ctor(unsafe)]
2985fn __ktstr_list_schedulers() {
2986 if !std::env::args().any(|a| a == "--ktstr-list-schedulers") {
2987 return;
2988 }
2989 // Count declared tests per scheduler name (a scheduler with no test still
2990 // appears, with test_count 0).
2991 let mut test_counts: std::collections::HashMap<&'static str, usize> =
2992 std::collections::HashMap::new();
2993 for t in KTSTR_TESTS.iter() {
2994 *test_counts.entry(t.scheduler.name).or_insert(0) += 1;
2995 }
2996 let entries: Vec<SchedulerListEntry> = KTSTR_SCHEDULERS
2997 .iter()
2998 .map(|s| SchedulerListEntry {
2999 scheduler: SchedulerJson::from_scheduler(s),
3000 test_count: test_counts.get(s.name).copied().unwrap_or(0),
3001 })
3002 .collect();
3003 let json = ::serde_json::to_string(&entries).expect("serialize schedulers");
3004 println!("{json}");
3005 std::process::exit(0);
3006}
3007}
3008
3009::ctor::declarative::ctor! {
3010/// Ctor that intercepts `--ktstr-list-scheduler-tests` before `main()` runs.
3011/// Walks [`KTSTR_TESTS`], emits a [`SchedulerTestJson`] per test (its name and
3012/// its declared scheduler's name) as a single JSON array on stdout, and exits
3013/// 0. Distinct from `--ktstr-list-schedulers`: this is test-NAME level, spawned
3014/// only for a `--relevant` run to map each test to its scheduler.
3015#[ctor(unsafe)]
3016fn __ktstr_list_scheduler_tests() {
3017 if !std::env::args().any(|a| a == "--ktstr-list-scheduler-tests") {
3018 return;
3019 }
3020 let entries: Vec<SchedulerTestJson> = KTSTR_TESTS
3021 .iter()
3022 .map(|t| SchedulerTestJson {
3023 test: t.name.to_string(),
3024 scheduler: t.scheduler.name.to_string(),
3025 })
3026 .collect();
3027 let json = ::serde_json::to_string(&entries).expect("serialize scheduler tests");
3028 println!("{json}");
3029 std::process::exit(0);
3030}
3031}
3032
3033/// Default `post_vm` callback emitted by `#[ktstr_test]` when the
3034/// attribute omits `post_vm = ...`. Asserts that at least one
3035/// periodic snapshot produced REAL BPF state during the workload
3036/// window WHEN periodic was configured (`num_snapshots > 0`); when
3037/// periodic was disabled (`num_snapshots == 0`), the helper is a
3038/// no-op and returns `Ok`.
3039///
3040/// This is the smoke-floor for any periodic-configured test:
3041/// "the test produced meaningful data." Importantly, the floor
3042/// reads
3043/// [`SnapshotBridge::periodic_real_count`](crate::scenario::snapshot::SnapshotBridge::periodic_real_count) —
3044/// NOT [`VmResult::periodic_fired`](crate::vmm::VmResult::periodic_fired).
3045/// The latter counts every periodic boundary the coordinator
3046/// attempted, INCLUDING placeholder rows from rendezvous timeouts;
3047/// a scheduler that attaches but produces nothing but placeholders
3048/// would pass the `periodic_fired >= 1` floor and obscure the
3049/// broken-scheduler diagnosis. The real-count floor catches that
3050/// case (placeholder-only fills surface as zero) while tolerating
3051/// the realistic single-snapshot timeout flake (one real capture
3052/// among N is enough to pass the floor).
3053///
3054/// Tests that need stronger assertions (per-snapshot field reads,
3055/// per-phase ratios, etc.) supply their own `post_vm = my_checker`
3056/// instead.
3057///
3058/// The macro routes this fn pointer into [`KtstrTestEntry::post_vm`]
3059/// so the runner's existing dispatch path applies it identically
3060/// to an author-supplied callback.
3061pub fn default_post_vm_periodic_fired(result: &crate::vmm::VmResult) -> anyhow::Result<()> {
3062 // Short-circuit when the VM run already failed: the underlying
3063 // crash/timeout already drives the test failure with its own
3064 // diagnostic. Emitting a periodic-floor Err on top obscures the
3065 // real cause (e.g., a scheduler that exited before any periodic
3066 // boundary fired would surface as "0 real captures" here
3067 // instead of the scheduler-log message that explains the
3068 // exit). The runner's own success path renders the crash; let
3069 // it.
3070 if !result.success {
3071 return Ok(());
3072 }
3073 if result.periodic_target == 0 {
3074 return Ok(());
3075 }
3076 let real = result.snapshot_bridge.periodic_real_count();
3077 anyhow::ensure!(
3078 real >= 1,
3079 "no periodic snapshot produced real BPF state \
3080 (periodic_real_count=0, periodic_fired={}, target={}) — \
3081 scheduler attached but every snapshot was a placeholder \
3082 (typical cause: scheduler stalled during the workload and \
3083 the freeze rendezvous timed out fetching state; less \
3084 commonly: gate suppression rejected every capture)",
3085 result.periodic_fired,
3086 result.periodic_target,
3087 );
3088 Ok(())
3089}
3090
3091#[cfg(test)]
3092mod tests {
3093 use super::*;
3094 use crate::scenario::Ctx;
3095
3096 /// Minimal Ctx for invoking `default_test_func` without booting a
3097 /// real workload. The func signature only requires `&Ctx`; the
3098 /// stub returns Err unconditionally so no field on Ctx is read.
3099 fn dummy_ctx() -> (crate::cgroup::CgroupManager, crate::topology::TestTopology) {
3100 let cgroups = crate::cgroup::CgroupManager::new("/sys/fs/cgroup/ktstr-dummy");
3101 let topo = crate::topology::TestTopology::from_vm_topology(&Topology {
3102 llcs: 1,
3103 cores_per_llc: 1,
3104 threads_per_core: 1,
3105 numa_nodes: 1,
3106 nodes: None,
3107 distances: None,
3108 });
3109 (cgroups, topo)
3110 }
3111
3112 /// `WatchBpfMap::new` accepts the `PerCpuCounter` agg (pub-API surface); the
3113 /// const-fn validates map/field/label format, not the agg, so the variant
3114 /// is stored verbatim.
3115 #[test]
3116 fn watch_bpf_map_new_accepts_per_cpu_counter() {
3117 let w = WatchBpfMap::new(
3118 "cpu_ctx_stor",
3119 "evt_count",
3120 BpfMapAgg::PerCpuCounter,
3121 "evt_count",
3122 );
3123 assert_eq!(w.agg, BpfMapAgg::PerCpuCounter);
3124 }
3125
3126 #[test]
3127 fn ktstr_test_entry_default_fields() {
3128 let d = KtstrTestEntry::DEFAULT;
3129 assert_eq!(d.name, "");
3130 // func is the stub — verified separately in
3131 // default_test_func_returns_err.
3132 assert_eq!(d.topology.llcs, 1);
3133 assert_eq!(d.topology.cores_per_llc, 2);
3134 assert_eq!(d.topology.threads_per_core, 1);
3135 assert_eq!(d.topology.numa_nodes, 1);
3136 assert!(d.topology.nodes.is_none());
3137 assert!(d.topology.distances.is_none());
3138 assert_eq!(d.constraints, TopologyConstraints::DEFAULT);
3139 assert_eq!(d.memory_mib, 2048);
3140 // scheduler defaults to `&Scheduler::EEVDF`, whose
3141 // compile-time-fixed `.name = "eevdf"`. Read directly via
3142 // field access — no kind dispatch.
3143 assert_eq!(d.scheduler.name, "eevdf");
3144 assert!(!d.scheduler.has_active_scheduling());
3145 assert!(d.auto_repro);
3146 assert!(d.extra_sched_args.is_empty());
3147 assert_eq!(d.watchdog_timeout, Duration::from_secs(5));
3148 assert!(d.bpf_map_write.is_empty());
3149 assert!(d.watch_bpf_maps.is_empty());
3150 assert!(!d.performance_mode);
3151 assert!(!d.no_perf_mode);
3152 assert_eq!(d.duration, Duration::from_secs(12));
3153 assert!(!d.expect_err);
3154 assert!(!d.survives_storm);
3155 assert!(!d.host_only);
3156 // Payload slot defaults to None (scheduler-only entry); workloads
3157 // slice defaults to empty. Macro emits these as explicit None/&[]
3158 // so struct-update spreaders also get the right values.
3159 assert!(d.payload.is_none());
3160 assert!(d.workloads.is_empty());
3161 // post_vm_unconditional defaults to None — the unconditional
3162 // dispatch arm in `run_post_vm_callbacks` is a no-op for the default entry,
3163 // matching the macro's omit-when-unset codegen.
3164 assert!(d.post_vm_unconditional.is_none());
3165 }
3166
3167 /// Empirical pin: `..Self::new()` works in a `static` spread.
3168 /// KtstrTestEntry has no Drop-bearing transitive fields (every
3169 /// type in the struct is Copy or holds only `&'static`/`Option`/
3170 /// primitives), so `Self::new()` returning a const Self literal
3171 /// is const-evaluable AND the temporary it produces needs no
3172 /// destructor. Both DEFAULT and new() are valid in const-spread.
3173 #[allow(dead_code)]
3174 static ENTRY_VIA_NEW: KtstrTestEntry = KtstrTestEntry {
3175 name: "via_new",
3176 ..KtstrTestEntry::new()
3177 };
3178
3179 #[allow(dead_code)]
3180 static ENTRY_VIA_DEFAULT: KtstrTestEntry = KtstrTestEntry {
3181 name: "via_default",
3182 ..KtstrTestEntry::DEFAULT
3183 };
3184
3185 #[test]
3186 fn ktstr_test_entry_const_spread_works_via_both_new_and_default() {
3187 assert_eq!(ENTRY_VIA_NEW.name, "via_new");
3188 assert_eq!(ENTRY_VIA_DEFAULT.name, "via_default");
3189 // Both spread forms produce identical non-name fields.
3190 assert_eq!(ENTRY_VIA_NEW.memory_mib, ENTRY_VIA_DEFAULT.memory_mib);
3191 assert_eq!(ENTRY_VIA_NEW.duration, ENTRY_VIA_DEFAULT.duration);
3192 }
3193
3194 #[test]
3195 fn ktstr_test_entry_with_chain_overrides_target_fields() {
3196 let entry = KtstrTestEntry::DEFAULT
3197 .with_name("chain_test")
3198 .with_memory_mib(4096)
3199 .with_duration(Duration::from_secs(30))
3200 .with_auto_repro(false)
3201 .with_performance_mode(true)
3202 .with_num_snapshots(2);
3203 assert_eq!(entry.name, "chain_test");
3204 assert_eq!(entry.memory_mib, 4096);
3205 assert_eq!(entry.duration, Duration::from_secs(30));
3206 assert!(!entry.auto_repro);
3207 assert!(entry.performance_mode);
3208 assert_eq!(entry.num_snapshots, 2);
3209 // Untouched defaults survive.
3210 assert_eq!(entry.scheduler.name, "eevdf");
3211 assert!(!entry.host_only);
3212 // Validate succeeds — the chain produced a usable entry.
3213 entry.validate().expect("chained entry must validate");
3214 }
3215
3216 /// `with_cpu_budget` chains and produces a validating overcommit
3217 /// entry. cpu_budget requires no_perf_mode (it sizes the no-perf
3218 /// vCPU mask), so the two are set together — pinning the setter pair
3219 /// against the validate gate. (cpu_budget is kept out of the
3220 /// performance_mode chain above because the two are contradictory.)
3221 #[test]
3222 fn ktstr_test_entry_with_cpu_budget_chains_with_no_perf_mode() {
3223 let entry = KtstrTestEntry::DEFAULT
3224 .with_name("cpu_budget_chain")
3225 .with_cpu_budget(16)
3226 .with_no_perf_mode(true);
3227 assert_eq!(entry.cpu_budget, Some(16));
3228 assert!(entry.no_perf_mode);
3229 entry
3230 .validate()
3231 .expect("cpu_budget + no_perf_mode chain must validate");
3232 }
3233
3234 /// `without_<field>` returns the original Option<T> field to
3235 /// `None`. The chain symmetry pin: `with_X(v).without_X() ==
3236 /// DEFAULT-state for that field`.
3237 #[test]
3238 fn ktstr_test_entry_without_chain_clears_option_fields() {
3239 use crate::test_support::{OutputFormat, Payload, PayloadKind};
3240 const FIO: Payload = Payload {
3241 name: "fio",
3242 kind: PayloadKind::Binary("fio"),
3243 output: OutputFormat::Json,
3244 default_args: &[],
3245 default_checks: &[],
3246 metrics: &[],
3247 include_files: &[],
3248 uses_parent_pgrp: false,
3249 known_flags: None,
3250 };
3251 let entry = KtstrTestEntry::DEFAULT
3252 .with_name("clear_test")
3253 .with_payload(&FIO)
3254 .with_cleanup_budget(Duration::from_secs(10))
3255 .with_cpu_budget(8)
3256 .without_payload()
3257 .without_cleanup_budget()
3258 .without_cpu_budget();
3259 assert!(entry.payload.is_none());
3260 assert!(entry.cleanup_budget.is_none());
3261 assert!(entry.cpu_budget.is_none());
3262 }
3263
3264 #[test]
3265 fn ktstr_test_entry_payload_slot_can_be_populated() {
3266 use crate::test_support::{OutputFormat, Payload, PayloadKind};
3267 const FIO: Payload = Payload {
3268 name: "fio",
3269 kind: PayloadKind::Binary("fio"),
3270 output: OutputFormat::Json,
3271 default_args: &[],
3272 default_checks: &[],
3273 metrics: &[],
3274 include_files: &[],
3275 uses_parent_pgrp: false,
3276 known_flags: None,
3277 };
3278 let entry = KtstrTestEntry {
3279 name: "payload_entry",
3280 payload: Some(&FIO),
3281 ..KtstrTestEntry::DEFAULT
3282 };
3283 let p = entry.payload.expect("payload set");
3284 assert_eq!(p.name, "fio");
3285 assert!(!p.is_scheduler());
3286 }
3287
3288 #[test]
3289 fn ktstr_test_entry_workloads_slot_accepts_multiple_payloads() {
3290 use crate::test_support::{OutputFormat, Payload, PayloadKind};
3291 const FIO: Payload = Payload {
3292 name: "fio",
3293 kind: PayloadKind::Binary("fio"),
3294 output: OutputFormat::Json,
3295 default_args: &[],
3296 default_checks: &[],
3297 metrics: &[],
3298 include_files: &[],
3299 uses_parent_pgrp: false,
3300 known_flags: None,
3301 };
3302 // stress-ng emits progress / metrics / summaries to stderr; stdout
3303 // is blank. `OutputFormat::Json` yields zero metrics — stdout has
3304 // nothing JSON-shaped to parse, and the stderr fallback sees prose
3305 // rather than JSON so the extraction pipeline returns empty.
3306 const STRESS_NG: Payload = Payload {
3307 name: "stress-ng",
3308 kind: PayloadKind::Binary("stress-ng"),
3309 output: OutputFormat::ExitCode,
3310 default_args: &[],
3311 default_checks: &[],
3312 metrics: &[],
3313 include_files: &[],
3314 uses_parent_pgrp: false,
3315 known_flags: None,
3316 };
3317 let entry = KtstrTestEntry {
3318 name: "multi_workload",
3319 workloads: &[&FIO, &STRESS_NG],
3320 ..KtstrTestEntry::DEFAULT
3321 };
3322 assert_eq!(entry.workloads.len(), 2);
3323 assert_eq!(entry.workloads[0].name, "fio");
3324 assert_eq!(entry.workloads[1].name, "stress-ng");
3325 }
3326
3327 /// `validate()` rejects any `workloads[i]` that is a
3328 /// Scheduler-kind Payload — symmetric with the existing
3329 /// `payload`-slot rejection. Catches the typo where a test
3330 /// author writes `workloads = [Payload::KERNEL_DEFAULT]` instead of a
3331 /// binary payload.
3332 #[test]
3333 fn validate_rejects_scheduler_kind_in_workloads() {
3334 use crate::test_support::{OutputFormat, Payload, PayloadKind};
3335 const GOOD: Payload = Payload {
3336 name: "fio",
3337 kind: PayloadKind::Binary("fio"),
3338 output: OutputFormat::Json,
3339 default_args: &[],
3340 default_checks: &[],
3341 metrics: &[],
3342 include_files: &[],
3343 uses_parent_pgrp: false,
3344 known_flags: None,
3345 };
3346 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3347 Ok(AssertResult::pass())
3348 }
3349 let entry = KtstrTestEntry {
3350 name: "mixed_kinds",
3351 func: good_test_func,
3352 // Second workload is scheduler-kind → must bail.
3353 workloads: &[&GOOD, &Payload::KERNEL_DEFAULT],
3354 ..KtstrTestEntry::DEFAULT
3355 };
3356 let err = entry.validate().unwrap_err();
3357 let msg = format!("{err}");
3358 assert!(
3359 msg.contains("workloads[1]") && msg.contains("Scheduler-kind"),
3360 "expected workloads[1] Scheduler-kind bail, got: {msg}"
3361 );
3362 assert!(
3363 msg.contains("kernel_default"),
3364 "error must name the offending workload entry, got: {msg}"
3365 );
3366 }
3367
3368 /// Binary-only workloads slip past `validate()` cleanly — the
3369 /// Scheduler-kind check does not over-reject. Pins the happy
3370 /// path against the rejection path so future edits to the
3371 /// workloads loop don't flip polarity.
3372 #[test]
3373 fn validate_accepts_binary_only_workloads() {
3374 use crate::test_support::{OutputFormat, Payload, PayloadKind};
3375 const FIO: Payload = Payload {
3376 name: "fio",
3377 kind: PayloadKind::Binary("fio"),
3378 output: OutputFormat::Json,
3379 default_args: &[],
3380 default_checks: &[],
3381 metrics: &[],
3382 include_files: &[],
3383 uses_parent_pgrp: false,
3384 known_flags: None,
3385 };
3386 // stress-ng emits progress / metrics / summaries to stderr; stdout
3387 // is blank. `OutputFormat::Json` yields zero metrics — stdout has
3388 // nothing JSON-shaped to parse, and the stderr fallback sees prose
3389 // rather than JSON so the extraction pipeline returns empty.
3390 const STRESS_NG: Payload = Payload {
3391 name: "stress-ng",
3392 kind: PayloadKind::Binary("stress-ng"),
3393 output: OutputFormat::ExitCode,
3394 default_args: &[],
3395 default_checks: &[],
3396 metrics: &[],
3397 include_files: &[],
3398 uses_parent_pgrp: false,
3399 known_flags: None,
3400 };
3401 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3402 Ok(AssertResult::pass())
3403 }
3404 let entry = KtstrTestEntry {
3405 name: "all_binary",
3406 func: good_test_func,
3407 workloads: &[&FIO, &STRESS_NG],
3408 ..KtstrTestEntry::DEFAULT
3409 };
3410 entry.validate().expect("binary-only workloads must pass");
3411 }
3412
3413 // -- staged_schedulers validation tests --
3414 //
3415 // KtstrTestEntry::validate (in entry_validate.rs, via
3416 // validate_basics_and_staging) walks staged_schedulers and
3417 // enforces (a) per-name shape rules via the delegated
3418 // validate_staged_scheduler_name + (b) within-set uniqueness
3419 // via the BTreeSet insert. Both are silent-data-loss classes
3420 // if they regress — invalid names
3421 // would land at corrupt guest staging paths; duplicate names
3422 // would silently overwrite at the same guest path. The
3423 // delegated shape rules are independently tested at
3424 // src/test_support/staged.rs; the smoke test below proves
3425 // the delegation is wired (catches a future refactor that
3426 // inlines dup-check and forgets the shape-check).
3427
3428 /// Empty `staged_schedulers` slice — the default for every
3429 /// test that doesn't use scheduler-lifecycle ops — must validate.
3430 #[test]
3431 fn validate_accepts_empty_staged_schedulers() {
3432 let entry = KtstrTestEntry {
3433 name: "no_staged",
3434 staged_schedulers: &[],
3435 ..KtstrTestEntry::DEFAULT
3436 };
3437 entry.validate().expect("empty staged_schedulers must pass");
3438 }
3439
3440 /// Two distinct well-formed staged schedulers — both pass shape
3441 /// check, names differ. Pins the happy path for the
3442 /// mid-experiment swap use case (mitosis args A → mitosis args
3443 /// B declared as two distinct schedulers).
3444 #[test]
3445 fn validate_accepts_well_formed_unique_staged_schedulers() {
3446 static SCX_MITOSIS_A: Scheduler =
3447 Scheduler::named("scx_mitosis_a").binary_discover("scx_mitosis");
3448 static SCX_MITOSIS_B: Scheduler =
3449 Scheduler::named("scx_mitosis_b").binary_discover("scx_mitosis");
3450 // Slice itself must be `static` so the &'static [...] field
3451 // can borrow it; binding the slice literal to a static at the
3452 // call site moves the const-promotion responsibility off the
3453 // struct-literal expression where lifetime inference can't
3454 // see the longer-lived destination.
3455 static SCHEDS: &[&Scheduler] = &[&SCX_MITOSIS_A, &SCX_MITOSIS_B];
3456 let entry = KtstrTestEntry {
3457 name: "staged_two",
3458 staged_schedulers: SCHEDS,
3459 ..KtstrTestEntry::DEFAULT
3460 };
3461 entry
3462 .validate()
3463 .expect("two distinct well-formed staged schedulers must pass");
3464 }
3465
3466 /// Two staged schedulers with the SAME name collide at the
3467 /// guest-side staging path (`/staging/schedulers/<name>/`). The
3468 /// validate-time dedup catches this before any VM boot; without
3469 /// the pin, a regression that drops the BTreeSet insert check
3470 /// would silently produce a guest layout where the second
3471 /// scheduler overwrites the first.
3472 #[test]
3473 fn validate_rejects_duplicate_staged_scheduler_names() {
3474 static DUPE_A: Scheduler = Scheduler::named("scx_dupe").binary_discover("scx_first");
3475 static DUPE_B: Scheduler = Scheduler::named("scx_dupe").binary_discover("scx_second");
3476 static SCHEDS: &[&Scheduler] = &[&DUPE_A, &DUPE_B];
3477 let entry = KtstrTestEntry {
3478 name: "staged_dupe",
3479 staged_schedulers: SCHEDS,
3480 ..KtstrTestEntry::DEFAULT
3481 };
3482 let err = entry
3483 .validate()
3484 .expect_err("duplicate staged Scheduler.name must reject");
3485 let msg = err.to_string();
3486 assert!(
3487 msg.contains("duplicate"),
3488 "error must name the duplicate-name violation, got: {msg}"
3489 );
3490 assert!(
3491 msg.contains("scx_dupe"),
3492 "error must name the colliding scheduler name, got: {msg}"
3493 );
3494 assert!(
3495 msg.contains("staged_schedulers"),
3496 "error must name the field, got: {msg}"
3497 );
3498 }
3499
3500 /// A staged scheduler with a shape-violating name (path
3501 /// separator) must reject — proves the validate gate delegates
3502 /// to validate_staged_scheduler_name. The exhaustive shape
3503 /// rejections (empty, NUL, leading dot, reserved names) are
3504 /// covered by the `validate_staged_scheduler_name_*` tests in
3505 /// `test_support::staged`; this smoke test
3506 /// pins the delegation site against a future refactor that
3507 /// inlines the dup-check and forgets the shape-check.
3508 #[test]
3509 fn validate_rejects_shape_violating_staged_scheduler_name() {
3510 static BAD_SLASH: Scheduler = Scheduler::named("scx/path").binary_discover("scx_x");
3511 static SCHEDS: &[&Scheduler] = &[&BAD_SLASH];
3512 let entry = KtstrTestEntry {
3513 name: "staged_bad_shape",
3514 staged_schedulers: SCHEDS,
3515 ..KtstrTestEntry::DEFAULT
3516 };
3517 let err = entry
3518 .validate()
3519 .expect_err("path-separator Scheduler.name must reject");
3520 let msg = err.to_string();
3521 assert!(
3522 msg.contains("staged_schedulers"),
3523 "error must name the field — proves the `who` context propagated \
3524 through the delegate call, got: {msg}"
3525 );
3526 // The exact shape-message ("path separators") is the
3527 // validate_staged_scheduler_name contract pinned by
3528 // `validate_staged_scheduler_name_rejects_path_separators` in
3529 // `test_support::staged`, not duplicated here.
3530 }
3531
3532 /// A staged scheduler whose `name` matches the boot
3533 /// [`KtstrTestEntry::scheduler`]'s name must reject — the boot
3534 /// slot already provides that scheduler, and adding it to the
3535 /// staged set produces a guest layout where the boot scheduler
3536 /// is shadowed by the same binary under
3537 /// `/staging/schedulers/<name>/scheduler`. Catches the
3538 /// "stage all the schedulers I might use"
3539 /// misuse pattern at validate time.
3540 #[test]
3541 fn validate_rejects_staged_scheduler_duplicating_boot_scheduler() {
3542 static BOOT: Scheduler = Scheduler::named("scx_mitosis").binary_discover("scx_mitosis");
3543 static STAGED_COPY: Scheduler =
3544 Scheduler::named("scx_mitosis").binary_discover("scx_mitosis");
3545 static SCHEDS: &[&Scheduler] = &[&STAGED_COPY];
3546 let entry = KtstrTestEntry {
3547 name: "staged_dup_of_boot",
3548 scheduler: &BOOT,
3549 staged_schedulers: SCHEDS,
3550 ..KtstrTestEntry::DEFAULT
3551 };
3552 let err = entry
3553 .validate()
3554 .expect_err("staged scheduler duplicating boot scheduler must reject");
3555 let msg = err.to_string();
3556 assert!(
3557 msg.contains("boot scheduler"),
3558 "error must name the boot-scheduler violation, got: {msg}"
3559 );
3560 assert!(
3561 msg.contains("scx_mitosis"),
3562 "error must name the colliding scheduler, got: {msg}"
3563 );
3564 assert!(
3565 msg.contains("Op::AttachScheduler") || msg.contains("Op::ReplaceScheduler"),
3566 "error must point to the lifecycle Ops that USE the staged set, got: {msg}"
3567 );
3568 }
3569
3570 /// `validate()` rejects `host_only=true` paired with a
3571 /// `Some(DiskConfig)`. The combination is unsatisfiable today:
3572 /// `host_only` skips the VM boot that owns the virtio-blk device
3573 /// lifecycle, so the disk never attaches. Catching it at validate
3574 /// time surfaces the misconfiguration during nextest discovery
3575 /// instead of after a confusing host-only run that silently
3576 /// ignored the disk request.
3577 #[test]
3578 fn validate_rejects_host_only_with_disk() {
3579 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3580 Ok(AssertResult::pass())
3581 }
3582 let entry = KtstrTestEntry {
3583 name: "host_only_with_disk",
3584 func: good_test_func,
3585 host_only: true,
3586 disk: Some(crate::vmm::disk_config::DiskConfig::default()),
3587 ..KtstrTestEntry::DEFAULT
3588 };
3589 let err = entry
3590 .validate()
3591 .expect_err("host_only=true + disk=Some must be rejected");
3592 let msg = format!("{err}");
3593 assert!(
3594 msg.contains("host_only=true") && msg.contains("disk"),
3595 "expected host_only+disk diagnostic, got: {msg}",
3596 );
3597 assert!(
3598 msg.contains("host_only_with_disk"),
3599 "error must name the offending entry, got: {msg}",
3600 );
3601 }
3602
3603 /// `host_only=true` with `disk=None` is the legitimate host-side
3604 /// shape: a host-only test running without any VM device. Pins
3605 /// the happy path against the rejection path so future edits
3606 /// to the host_only/disk gate don't flip polarity (rejecting a
3607 /// legitimate combination would silently break every host-only
3608 /// test author).
3609 #[test]
3610 fn validate_accepts_host_only_without_disk() {
3611 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3612 Ok(AssertResult::pass())
3613 }
3614 let entry = KtstrTestEntry {
3615 name: "host_only_no_disk",
3616 func: good_test_func,
3617 host_only: true,
3618 disk: None,
3619 ..KtstrTestEntry::DEFAULT
3620 };
3621 entry
3622 .validate()
3623 .expect("host_only=true + disk=None must validate");
3624 }
3625
3626 /// `host_only=false` (the default) with `disk=Some(..)` is the
3627 /// canonical disk-attached VM test. Pins that the gate fires
3628 /// only on the actual conflict (host_only=true) and not on any
3629 /// `disk=Some(..)` entry — a future tightening that rejected
3630 /// every Some(DiskConfig) would break the entire disk
3631 /// integration test surface.
3632 #[test]
3633 fn validate_accepts_vm_with_disk() {
3634 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3635 Ok(AssertResult::pass())
3636 }
3637 let entry = KtstrTestEntry {
3638 name: "vm_with_disk",
3639 func: good_test_func,
3640 host_only: false,
3641 disk: Some(crate::vmm::disk_config::DiskConfig::default()),
3642 ..KtstrTestEntry::DEFAULT
3643 };
3644 entry
3645 .validate()
3646 .expect("host_only=false + disk=Some must validate");
3647 }
3648
3649 /// `host_only=true` with a non-empty `networks` is rejected for the same
3650 /// reason as disk: host_only skips the VM boot that owns the virtio-net
3651 /// device lifecycle, so the NICs would never attach. Mirrors the disk
3652 /// gate so the macro-surfaced `networks =` attribute can't silently
3653 /// pair with `host_only`.
3654 #[test]
3655 fn validate_rejects_host_only_with_network() {
3656 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3657 Ok(AssertResult::pass())
3658 }
3659 let entry = KtstrTestEntry {
3660 name: "host_only_with_network",
3661 func: good_test_func,
3662 host_only: true,
3663 networks: &[crate::vmm::net_config::NetConfig::DEFAULT],
3664 ..KtstrTestEntry::DEFAULT
3665 };
3666 let err = entry
3667 .validate()
3668 .expect_err("host_only=true + non-empty networks must be rejected");
3669 let msg = format!("{err}");
3670 assert!(
3671 msg.contains("host_only=true") && msg.contains("networks"),
3672 "expected host_only+networks diagnostic, got: {msg}",
3673 );
3674 assert!(
3675 msg.contains("host_only_with_network"),
3676 "error must name the offending entry, got: {msg}",
3677 );
3678 }
3679
3680 /// `host_only=true` with an empty `networks` is the legitimate host-side
3681 /// shape (no NIC). Pins the happy path so a future edit to the gate
3682 /// doesn't flip polarity and reject legitimate host-only tests.
3683 #[test]
3684 fn validate_accepts_host_only_without_network() {
3685 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3686 Ok(AssertResult::pass())
3687 }
3688 let entry = KtstrTestEntry {
3689 name: "host_only_no_network",
3690 func: good_test_func,
3691 host_only: true,
3692 networks: &[],
3693 ..KtstrTestEntry::DEFAULT
3694 };
3695 entry
3696 .validate()
3697 .expect("host_only=true + empty networks must validate");
3698 }
3699
3700 /// `host_only=false` (the default) with a non-empty `networks` is the
3701 /// canonical NIC-attached VM test. Pins that the gate fires only on
3702 /// the host_only conflict, not on every NIC-bearing entry.
3703 #[test]
3704 fn validate_accepts_vm_with_network() {
3705 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3706 Ok(AssertResult::pass())
3707 }
3708 let entry = KtstrTestEntry {
3709 name: "vm_with_network",
3710 func: good_test_func,
3711 host_only: false,
3712 networks: &[crate::vmm::net_config::NetConfig::DEFAULT],
3713 ..KtstrTestEntry::DEFAULT
3714 };
3715 entry
3716 .validate()
3717 .expect("host_only=false + non-empty networks must validate");
3718 }
3719
3720 /// `validate()` rejects `cpu_budget = Some(0)` — a zero host-CPU
3721 /// budget cannot run a VM (the builder would otherwise silently
3722 /// clamp it to 1). Pins the programmatic-construction guard that
3723 /// mirrors the macro's compile-time zero-reject.
3724 #[test]
3725 fn validate_rejects_cpu_budget_zero() {
3726 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3727 Ok(AssertResult::pass())
3728 }
3729 let entry = KtstrTestEntry {
3730 name: "cpu_budget_zero",
3731 func: good_test_func,
3732 cpu_budget: Some(0),
3733 no_perf_mode: true,
3734 ..KtstrTestEntry::DEFAULT
3735 };
3736 let err = entry
3737 .validate()
3738 .expect_err("cpu_budget=Some(0) must be rejected");
3739 let msg = format!("{err}");
3740 assert!(
3741 msg.contains("cpu_budget") && msg.contains("cpu_budget_zero"),
3742 "expected cpu_budget zero diagnostic naming the entry, got: {msg}",
3743 );
3744 }
3745
3746 /// `validate()` rejects `cpu_budget` set without `no_perf_mode` —
3747 /// the budget sizes only the no-perf vCPU mask, so it would be a
3748 /// silent no-op under performance/default mode. Pins the
3749 /// programmatic-construction guard mirroring the macro's gate.
3750 #[test]
3751 fn validate_rejects_cpu_budget_without_no_perf_mode() {
3752 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3753 Ok(AssertResult::pass())
3754 }
3755 let entry = KtstrTestEntry {
3756 name: "cpu_budget_no_no_perf",
3757 func: good_test_func,
3758 cpu_budget: Some(4),
3759 no_perf_mode: false,
3760 ..KtstrTestEntry::DEFAULT
3761 };
3762 let err = entry
3763 .validate()
3764 .expect_err("cpu_budget without no_perf_mode must be rejected");
3765 let msg = format!("{err}");
3766 assert!(
3767 msg.contains("cpu_budget") && msg.contains("no_perf_mode"),
3768 "expected cpu_budget/no_perf_mode diagnostic, got: {msg}",
3769 );
3770 }
3771
3772 /// `cpu_budget` with `no_perf_mode = true` is the legitimate
3773 /// overcommit shape — pins the happy path against the two rejection
3774 /// paths so a future edit can't flip polarity and reject every
3775 /// valid overcommit test.
3776 #[test]
3777 fn validate_accepts_cpu_budget_with_no_perf_mode() {
3778 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3779 Ok(AssertResult::pass())
3780 }
3781 let entry = KtstrTestEntry {
3782 name: "cpu_budget_with_no_perf",
3783 func: good_test_func,
3784 cpu_budget: Some(4),
3785 no_perf_mode: true,
3786 ..KtstrTestEntry::DEFAULT
3787 };
3788 entry
3789 .validate()
3790 .expect("cpu_budget + no_perf_mode must validate");
3791 }
3792
3793 /// `validate()` rejects `perf_delta_assertions` without
3794 /// `performance_mode` — a declared regression gate is only meaningful on
3795 /// a pinned run (mirrors the macro's compile-time reject). Guards the
3796 /// programmatic-construction path.
3797 #[test]
3798 fn validate_rejects_perf_delta_assertions_without_performance_mode() {
3799 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3800 Ok(AssertResult::pass())
3801 }
3802 const GATE: PerfDeltaAssertion = PerfDeltaAssertion::new("worst_spread");
3803 let entry = KtstrTestEntry {
3804 name: "gate_no_perf_mode",
3805 func: good_test_func,
3806 perf_delta_assertions: &[&GATE],
3807 performance_mode: false,
3808 ..KtstrTestEntry::DEFAULT
3809 };
3810 let err = entry
3811 .validate()
3812 .expect_err("perf_delta_assertions without performance_mode must be rejected");
3813 let msg = format!("{err}");
3814 assert!(
3815 msg.contains("perf_delta_assertions") && msg.contains("performance_mode"),
3816 "expected perf_delta_assertions/performance_mode diagnostic, got: {msg}",
3817 );
3818 }
3819
3820 /// `validate()` rejects a `perf_delta_assertions` metric that does not
3821 /// resolve in the registry — `PerfDeltaAssertion::new` validates only the
3822 /// NAME FORMAT at compile time (the registry is not const-accessible), so a
3823 /// typo'd-but-well-formed metric name is caught here at run time. Without it
3824 /// the gate would silently never match a captured field.
3825 #[test]
3826 fn validate_rejects_perf_delta_assertions_unknown_metric() {
3827 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3828 Ok(AssertResult::pass())
3829 }
3830 const GATE: PerfDeltaAssertion = PerfDeltaAssertion::new("totally_made_up_metric");
3831 let entry = KtstrTestEntry {
3832 name: "gate_unknown_metric",
3833 func: good_test_func,
3834 perf_delta_assertions: &[&GATE],
3835 performance_mode: true,
3836 ..KtstrTestEntry::DEFAULT
3837 };
3838 let err = entry
3839 .validate()
3840 .expect_err("an unregistered metric must be rejected");
3841 let msg = format!("{err}");
3842 assert!(
3843 msg.contains("totally_made_up_metric") && msg.contains("unknown metric"),
3844 "expected unknown-metric diagnostic, got: {msg}",
3845 );
3846 }
3847
3848 /// A well-formed, registry-resolving gate under `performance_mode` is the
3849 /// legitimate shape — pins the happy path against the two rejection paths so
3850 /// a future edit can't flip polarity and reject every valid declared gate.
3851 #[test]
3852 fn validate_accepts_perf_delta_assertions_with_performance_mode_and_known_metric() {
3853 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3854 Ok(AssertResult::pass())
3855 }
3856 const GATE: PerfDeltaAssertion = PerfDeltaAssertion::new("worst_spread")
3857 .with_max_regression_pct(5.0)
3858 .with_min_abs(0.5);
3859 let entry = KtstrTestEntry {
3860 name: "gate_ok",
3861 func: good_test_func,
3862 perf_delta_assertions: &[&GATE],
3863 performance_mode: true,
3864 ..KtstrTestEntry::DEFAULT
3865 };
3866 entry
3867 .validate()
3868 .expect("a known metric under performance_mode must validate");
3869 }
3870
3871 /// `validate()` rejects a gate on a render-suppressed rate COMPONENT: it
3872 /// resolves in the registry (so the unknown-metric check passes) but never
3873 /// surfaces a compare finding, so the gate could never fire.
3874 #[test]
3875 fn validate_rejects_perf_delta_assertions_render_suppressed_component() {
3876 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3877 Ok(AssertResult::pass())
3878 }
3879 const GATE: PerfDeltaAssertion = PerfDeltaAssertion::new("total_phase_iterations");
3880 let entry = KtstrTestEntry {
3881 name: "gate_suppressed_component",
3882 func: good_test_func,
3883 perf_delta_assertions: &[&GATE],
3884 performance_mode: true,
3885 ..KtstrTestEntry::DEFAULT
3886 };
3887 let err = entry
3888 .validate()
3889 .expect_err("a render-suppressed component gate must be rejected");
3890 let msg = format!("{err}");
3891 assert!(
3892 msg.contains("total_phase_iterations") && msg.contains("render-suppressed"),
3893 "expected render-suppressed-component diagnostic, got: {msg}",
3894 );
3895 }
3896
3897 /// `validate()` rejects a negative or NaN threshold override — either would
3898 /// silently disable or invert the gate in classify_noise.
3899 #[test]
3900 fn validate_rejects_perf_delta_assertions_negative_or_nan_threshold() {
3901 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3902 Ok(AssertResult::pass())
3903 }
3904 const NEG: PerfDeltaAssertion =
3905 PerfDeltaAssertion::new("worst_spread").with_max_regression_pct(-1.0);
3906 let e1 = KtstrTestEntry {
3907 name: "gate_neg_pct",
3908 func: good_test_func,
3909 perf_delta_assertions: &[&NEG],
3910 performance_mode: true,
3911 ..KtstrTestEntry::DEFAULT
3912 };
3913 assert!(
3914 format!("{}", e1.validate().unwrap_err()).contains("max_regression_pct"),
3915 "a negative relative threshold must be rejected",
3916 );
3917 const NAN_ABS: PerfDeltaAssertion =
3918 PerfDeltaAssertion::new("worst_spread").with_min_abs(f64::NAN);
3919 let e2 = KtstrTestEntry {
3920 name: "gate_nan_abs",
3921 func: good_test_func,
3922 perf_delta_assertions: &[&NAN_ABS],
3923 performance_mode: true,
3924 ..KtstrTestEntry::DEFAULT
3925 };
3926 assert!(
3927 format!("{}", e2.validate().unwrap_err()).contains("min_abs"),
3928 "a NaN absolute floor must be rejected",
3929 );
3930 }
3931
3932 /// `validate()` rejects two gates on the same (metric, phase): the compare
3933 /// applies only the first via `.find()` and silently drops the rest.
3934 #[test]
3935 fn validate_rejects_perf_delta_assertions_duplicate_metric_phase() {
3936 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3937 Ok(AssertResult::pass())
3938 }
3939 const A: PerfDeltaAssertion =
3940 PerfDeltaAssertion::new("worst_spread").with_max_regression_pct(5.0);
3941 const B: PerfDeltaAssertion = PerfDeltaAssertion::new("worst_spread").with_min_abs(1.0);
3942 let entry = KtstrTestEntry {
3943 name: "gate_dup",
3944 func: good_test_func,
3945 perf_delta_assertions: &[&A, &B],
3946 performance_mode: true,
3947 ..KtstrTestEntry::DEFAULT
3948 };
3949 let err = entry
3950 .validate()
3951 .expect_err("duplicate (metric, phase) gates must be rejected");
3952 let msg = format!("{err}");
3953 assert!(
3954 msg.contains("more than one gate") && msg.contains("worst_spread"),
3955 "expected duplicate-gate diagnostic, got: {msg}",
3956 );
3957 }
3958
3959 /// `PerfDeltaAssertion::with_direction` panics on `Polarity::TargetValue`:
3960 /// symmetric target-distance gating is unimplemented and the compare would
3961 /// misclassify it as increase-is-worse.
3962 #[test]
3963 #[should_panic(expected = "TargetValue")]
3964 fn with_direction_rejects_target_value() {
3965 let _ = PerfDeltaAssertion::new("worst_spread")
3966 .with_direction(crate::test_support::Polarity::TargetValue(5.0));
3967 }
3968
3969 /// `validate()` rejects `num_snapshots` greater than the bridge
3970 /// cap (`MAX_STORED_SNAPSHOTS == 64`). Without the gate the
3971 /// periodic loop would publish all `N` boundary captures but the
3972 /// bridge's FIFO eviction at `store()` would silently drop the
3973 /// earliest samples — a periodic run with `N == 128` would only
3974 /// retain `periodic_064..periodic_127`. Refusing the entry is
3975 /// more honest than half-delivering it.
3976 #[test]
3977 fn validate_rejects_num_snapshots_above_max_stored() {
3978 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
3979 Ok(AssertResult::pass())
3980 }
3981 let cap = crate::scenario::snapshot::MAX_STORED_SNAPSHOTS as u32;
3982 let entry = KtstrTestEntry {
3983 name: "too_many_snapshots",
3984 func: good_test_func,
3985 num_snapshots: cap + 1,
3986 ..KtstrTestEntry::DEFAULT
3987 };
3988 let err = entry
3989 .validate()
3990 .expect_err("num_snapshots > MAX_STORED_SNAPSHOTS must reject");
3991 let msg = format!("{err}");
3992 assert!(
3993 msg.contains("num_snapshots") && msg.contains("MAX_STORED_SNAPSHOTS"),
3994 "expected num_snapshots/MAX_STORED_SNAPSHOTS diagnostic, got: {msg}",
3995 );
3996 assert!(
3997 msg.contains("too_many_snapshots"),
3998 "error must name the offending entry, got: {msg}",
3999 );
4000 }
4001
4002 /// `num_snapshots == MAX_STORED_SNAPSHOTS` (the boundary case)
4003 /// must validate when `duration` is large enough to keep every
4004 /// inter-boundary interval at or above the 100 ms minimum spacing
4005 /// (see `validate_rejects_tight_periodic_spacing` below). 64
4006 /// captures over a long duration is the documented happy path —
4007 /// pinning it here prevents an off-by-one in the cap gate from
4008 /// silently rejecting the canonical maximum.
4009 #[test]
4010 fn validate_accepts_num_snapshots_at_max_stored() {
4011 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4012 Ok(AssertResult::pass())
4013 }
4014 let cap = crate::scenario::snapshot::MAX_STORED_SNAPSHOTS as u32;
4015 // 100 s · 0.8 / 65 ≈ 1.23 s per inter-boundary interval —
4016 // comfortably above the 100 ms minimum spacing gate.
4017 let entry = KtstrTestEntry {
4018 name: "max_snapshots_ok",
4019 func: good_test_func,
4020 num_snapshots: cap,
4021 duration: Duration::from_secs(100),
4022 ..KtstrTestEntry::DEFAULT
4023 };
4024 entry
4025 .validate()
4026 .expect("num_snapshots == MAX_STORED_SNAPSHOTS at long duration must validate");
4027 }
4028
4029 /// `validate()` rejects `num_snapshots > 0` paired with
4030 /// `host_only == true`. Periodic capture freezes guest vCPUs via
4031 /// the freeze coordinator's rendezvous; a host-only entry never
4032 /// boots a VM, so there are no vCPUs to freeze and the periodic
4033 /// boundaries would fire against an empty bridge. Catching the
4034 /// combination at validate time surfaces the misconfiguration
4035 /// before dispatch instead of after a confusing host-only run
4036 /// that silently produced zero captures.
4037 #[test]
4038 fn validate_rejects_num_snapshots_with_host_only() {
4039 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4040 Ok(AssertResult::pass())
4041 }
4042 let entry = KtstrTestEntry {
4043 name: "host_only_periodic",
4044 func: good_test_func,
4045 host_only: true,
4046 num_snapshots: 1,
4047 ..KtstrTestEntry::DEFAULT
4048 };
4049 let err = entry
4050 .validate()
4051 .expect_err("host_only=true + num_snapshots>0 must be rejected");
4052 let msg = format!("{err}");
4053 assert!(
4054 msg.contains("host_only") && msg.contains("num_snapshots"),
4055 "expected host_only/num_snapshots diagnostic, got: {msg}",
4056 );
4057 assert!(
4058 msg.contains("host_only_periodic"),
4059 "error must name the offending entry, got: {msg}",
4060 );
4061 }
4062
4063 /// `host_only == true` with `num_snapshots == 0` (the default)
4064 /// is the legitimate host-side shape and must continue to
4065 /// validate. Pins the gate to fire only on the actual conflict
4066 /// (host_only=true AND num_snapshots>0) and not on every
4067 /// host-only entry — a future tightening that rejected every
4068 /// host-only test author would silently break the host-only
4069 /// surface.
4070 #[test]
4071 fn validate_accepts_host_only_with_zero_snapshots() {
4072 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4073 Ok(AssertResult::pass())
4074 }
4075 let entry = KtstrTestEntry {
4076 name: "host_only_no_periodic",
4077 func: good_test_func,
4078 host_only: true,
4079 num_snapshots: 0,
4080 ..KtstrTestEntry::DEFAULT
4081 };
4082 entry
4083 .validate()
4084 .expect("host_only=true + num_snapshots=0 must validate");
4085 }
4086
4087 /// `validate()` rejects an entry whose periodic boundaries would
4088 /// land closer than 100 ms apart. The freeze coordinator's
4089 /// periodic loop divides the 80 % usable span (10 % pre-buffer +
4090 /// 10 % post-buffer = 20 % buffer; the remainder is the usable
4091 /// span) into `num_snapshots + 1` equal intervals; if any
4092 /// interval falls under 100 ms the captures crowd into
4093 /// rendezvous serialisation slack and one or more boundaries
4094 /// will defer-and-drop. Refusing the entry is more honest than
4095 /// emitting a partial timeline.
4096 ///
4097 /// Bound math: a 1 s duration with `num_snapshots == 8` yields
4098 /// `(0.8 · 1e9 ns) / 9 ≈ 88.9 ms` per interval — under the 100 ms
4099 /// floor, so the gate must reject. Below the cap (8 < 64) so the
4100 /// rejection MUST come from the spacing check, not the
4101 /// MAX_STORED_SNAPSHOTS gate.
4102 #[test]
4103 fn validate_rejects_tight_periodic_spacing() {
4104 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4105 Ok(AssertResult::pass())
4106 }
4107 let entry = KtstrTestEntry {
4108 name: "tight_periodic_spacing",
4109 func: good_test_func,
4110 duration: Duration::from_secs(1),
4111 num_snapshots: 8,
4112 ..KtstrTestEntry::DEFAULT
4113 };
4114 let err = entry
4115 .validate()
4116 .expect_err("tight periodic spacing must be rejected");
4117 let msg = format!("{err}");
4118 assert!(
4119 msg.contains("100"),
4120 "error must mention the 100 ms floor, got: {msg}",
4121 );
4122 assert!(
4123 msg.contains("num_snapshots") || msg.contains("periodic"),
4124 "error must mention num_snapshots or periodic, got: {msg}",
4125 );
4126 assert!(
4127 msg.contains("tight_periodic_spacing"),
4128 "error must name the offending entry, got: {msg}",
4129 );
4130 }
4131
4132 /// Periodic spacing gate must NOT fire when boundaries are at
4133 /// or above the 100 ms floor. 12 s default duration · 0.8 / 2
4134 /// = 4.8 s per interval (`N == 1`) is comfortably above the
4135 /// floor. Pins the polarity of the spacing gate so a future
4136 /// tightening that rejected every reasonable cadence would
4137 /// surface here as a regression instead of silently breaking
4138 /// every periodic test.
4139 #[test]
4140 fn validate_accepts_loose_periodic_spacing() {
4141 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4142 Ok(AssertResult::pass())
4143 }
4144 let entry = KtstrTestEntry {
4145 name: "loose_periodic_spacing",
4146 func: good_test_func,
4147 // 12 s default duration · 0.8 / (1 + 1) = 4.8 s per
4148 // interval — well above 100 ms.
4149 num_snapshots: 1,
4150 ..KtstrTestEntry::DEFAULT
4151 };
4152 entry
4153 .validate()
4154 .expect("loose periodic spacing (4.8 s per boundary) must validate");
4155 }
4156
4157 /// `validate()` rejects an entry whose scheduler declares
4158 /// `config_file_def` but provides no `config_content`. The macro
4159 /// emits a `const _: () = assert!(...)` for attribute-built
4160 /// entries; this branch covers programmatic construction so
4161 /// dispatch never runs the scheduler binary without `--config`.
4162 #[test]
4163 fn validate_rejects_config_file_def_without_content() {
4164 static SCHED_WITH_CFG: Scheduler = Scheduler {
4165 name: "sched_with_cfg",
4166 binary: SchedulerSpec::Discover("sched_with_cfg_bin"),
4167 sysctls: &[],
4168 kargs: &[],
4169 assert: crate::assert::Assert::NO_OVERRIDES,
4170 cgroup_parent: None,
4171 sched_args: &[],
4172 topology: Topology {
4173 llcs: 1,
4174 cores_per_llc: 2,
4175 threads_per_core: 1,
4176 numa_nodes: 1,
4177 nodes: None,
4178 distances: None,
4179 },
4180 constraints: TopologyConstraints::DEFAULT,
4181 config_file: None,
4182 config_file_def: Some(("--config {file}", "/include-files/cfg.json")),
4183 kernels: &[],
4184 };
4185 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4186 Ok(AssertResult::pass())
4187 }
4188 let entry = KtstrTestEntry {
4189 name: "missing_config",
4190 func: good_test_func,
4191 scheduler: &SCHED_WITH_CFG,
4192 config_content: None,
4193 ..KtstrTestEntry::DEFAULT
4194 };
4195 let err = entry
4196 .validate()
4197 .expect_err("config_file_def without config_content must reject");
4198 let msg = format!("{err}");
4199 assert!(
4200 msg.contains("config_file_def") && msg.contains("config_content"),
4201 "expected config_file_def/config_content bail, got: {msg}"
4202 );
4203 assert!(
4204 msg.contains("missing_config"),
4205 "error must name the offending entry, got: {msg}"
4206 );
4207 }
4208
4209 /// `validate()` rejects an entry that supplies `config_content`
4210 /// while pairing with a scheduler that declares no
4211 /// `config_file_def`. Symmetric with the missing-content gate:
4212 /// the content would be silently dropped at dispatch.
4213 #[test]
4214 fn validate_rejects_content_without_config_file_def() {
4215 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4216 Ok(AssertResult::pass())
4217 }
4218 let entry = KtstrTestEntry {
4219 name: "stray_config",
4220 func: good_test_func,
4221 scheduler: &crate::test_support::Scheduler::EEVDF,
4222 config_content: Some("{}"),
4223 ..KtstrTestEntry::DEFAULT
4224 };
4225 let err = entry
4226 .validate()
4227 .expect_err("config_content without config_file_def must reject");
4228 let msg = format!("{err}");
4229 assert!(
4230 msg.contains("config_content") && msg.contains("config_file_def"),
4231 "expected config_content/config_file_def bail, got: {msg}"
4232 );
4233 assert!(
4234 msg.contains("stray_config"),
4235 "error must name the offending entry, got: {msg}"
4236 );
4237 }
4238
4239 /// `validate()` accepts both legitimate pairings: a scheduler
4240 /// with `config_file_def` paired with `config_content`, and a
4241 /// scheduler without `config_file_def` paired with no
4242 /// `config_content`. Pins the happy paths so a future tightening
4243 /// of the pairing gate can't silently break valid entries.
4244 #[test]
4245 fn validate_accepts_config_pairing() {
4246 static SCHED_WITH_CFG: Scheduler = Scheduler {
4247 name: "sched_paired",
4248 binary: SchedulerSpec::Discover("sched_paired_bin"),
4249 sysctls: &[],
4250 kargs: &[],
4251 assert: crate::assert::Assert::NO_OVERRIDES,
4252 cgroup_parent: None,
4253 sched_args: &[],
4254 topology: Topology {
4255 llcs: 1,
4256 cores_per_llc: 2,
4257 threads_per_core: 1,
4258 numa_nodes: 1,
4259 nodes: None,
4260 distances: None,
4261 },
4262 constraints: TopologyConstraints::DEFAULT,
4263 config_file: None,
4264 config_file_def: Some(("f:{file}", "/include-files/p.json")),
4265 kernels: &[],
4266 };
4267 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4268 Ok(AssertResult::pass())
4269 }
4270 // Scheduler with def + content set: accepted.
4271 let entry_paired = KtstrTestEntry {
4272 name: "paired_present",
4273 func: good_test_func,
4274 scheduler: &SCHED_WITH_CFG,
4275 config_content: Some("{\"layers\":[]}"),
4276 ..KtstrTestEntry::DEFAULT
4277 };
4278 entry_paired
4279 .validate()
4280 .expect("scheduler with config_file_def + content must validate");
4281 // Scheduler without def + content unset: also accepted.
4282 let entry_none = KtstrTestEntry {
4283 name: "neither_present",
4284 func: good_test_func,
4285 scheduler: &crate::test_support::Scheduler::EEVDF,
4286 config_content: None,
4287 ..KtstrTestEntry::DEFAULT
4288 };
4289 entry_none
4290 .validate()
4291 .expect("no config_file_def + no content must validate");
4292 }
4293
4294 /// `validate()` rejects an entry that sets
4295 /// `expect_scx_bpf_error_contains` without also setting
4296 /// `expect_err = true`. The matcher narrows which failure counts
4297 /// as the expected bug, so it only makes sense on expected-error
4298 /// tests. Without the gate a matcher on a pass-expected entry
4299 /// would be silently inert.
4300 #[test]
4301 fn validate_rejects_expect_scx_bpf_error_contains_without_expect_err() {
4302 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4303 Ok(AssertResult::pass())
4304 }
4305 let entry = KtstrTestEntry {
4306 name: "bad_contains",
4307 func: good_test_func,
4308 assert: crate::assert::Assert::NO_OVERRIDES
4309 .expect_scx_bpf_error_contains("apply_cell_config"),
4310 expect_err: false,
4311 ..KtstrTestEntry::DEFAULT
4312 };
4313 let err = entry
4314 .validate()
4315 .expect_err("matcher without expect_err must be rejected");
4316 let msg = format!("{err}");
4317 assert!(
4318 msg.contains("expect_scx_bpf_error_contains") && msg.contains("expect_err"),
4319 "diagnostic must name BOTH the matcher field AND expect_err: {msg}",
4320 );
4321 assert!(
4322 msg.contains("bad_contains"),
4323 "error must name the offending entry: {msg}",
4324 );
4325 }
4326
4327 /// Symmetric with the `_contains` rejection: setting
4328 /// `expect_scx_bpf_error_matches` without `expect_err = true`
4329 /// must also reject. Pins that the gate triggers on either
4330 /// matcher field — a future addition that only checked one
4331 /// would leave the other silently inert.
4332 #[test]
4333 fn validate_rejects_expect_scx_bpf_error_matches_without_expect_err() {
4334 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4335 Ok(AssertResult::pass())
4336 }
4337 let entry = KtstrTestEntry {
4338 name: "bad_matches",
4339 func: good_test_func,
4340 assert: crate::assert::Assert::NO_OVERRIDES
4341 .expect_scx_bpf_error_matches("apply_cell_config:[0-9]+"),
4342 expect_err: false,
4343 ..KtstrTestEntry::DEFAULT
4344 };
4345 let err = entry
4346 .validate()
4347 .expect_err("matcher without expect_err must be rejected");
4348 let msg = format!("{err}");
4349 assert!(
4350 msg.contains("expect_scx_bpf_error_matches") && msg.contains("expect_err"),
4351 "diagnostic must name BOTH the matcher field AND expect_err: {msg}",
4352 );
4353 assert!(
4354 msg.contains("bad_matches"),
4355 "error must name the offending entry: {msg}",
4356 );
4357 }
4358
4359 /// `survives_storm = true` + `expect_err = true` is rejected at
4360 /// validate (the programmatic path that bypasses the macro mutex) —
4361 /// contradictory polarity. The expect_err mutex is checked before the
4362 /// no-scheduler one, so this fires even on the DEFAULT (no-scheduler)
4363 /// entry.
4364 #[test]
4365 fn validate_rejects_survives_storm_with_expect_err() {
4366 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4367 Ok(AssertResult::pass())
4368 }
4369 let entry = KtstrTestEntry {
4370 name: "bad_survives_expect_err",
4371 func: good_test_func,
4372 survives_storm: true,
4373 expect_err: true,
4374 ..KtstrTestEntry::DEFAULT
4375 };
4376 let err = entry
4377 .validate()
4378 .expect_err("survives_storm + expect_err must be rejected");
4379 let msg = format!("{err}");
4380 assert!(
4381 msg.contains("survives_storm") && msg.contains("expect_err"),
4382 "diagnostic must name both survives_storm and expect_err: {msg}",
4383 );
4384 assert!(
4385 msg.contains("bad_survives_expect_err"),
4386 "error must name the offending entry: {msg}",
4387 );
4388 }
4389
4390 /// `survives_storm = true` with no active scheduler (the DEFAULT
4391 /// kernel-default) is rejected — nothing to die or be ejected.
4392 #[test]
4393 fn validate_rejects_survives_storm_without_active_scheduler() {
4394 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4395 Ok(AssertResult::pass())
4396 }
4397 let entry = KtstrTestEntry {
4398 name: "bad_survives_no_sched",
4399 func: good_test_func,
4400 survives_storm: true,
4401 ..KtstrTestEntry::DEFAULT
4402 };
4403 let err = entry
4404 .validate()
4405 .expect_err("survives_storm without an active scheduler must be rejected");
4406 let msg = format!("{err}");
4407 assert!(
4408 msg.contains("survives_storm") && msg.contains("scheduler"),
4409 "diagnostic must name survives_storm and the missing scheduler: {msg}",
4410 );
4411 }
4412
4413 /// `survives_storm = true` + `expect_auto_repro = true` is rejected at
4414 /// validate — both are inversion intents (survives_storm forces a
4415 /// death fail to EXIT_FAIL; expect_auto_repro inverts a crash-with-repro
4416 /// fail to PASS), contradictory like survives_storm + expect_err. The
4417 /// macro twin is `macro_rejects_survives_storm_with_expect_auto_repro`.
4418 #[test]
4419 fn validate_rejects_survives_storm_with_expect_auto_repro() {
4420 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4421 Ok(AssertResult::pass())
4422 }
4423 let entry = KtstrTestEntry {
4424 name: "bad_survives_expect_auto_repro",
4425 func: good_test_func,
4426 survives_storm: true,
4427 expect_auto_repro: true,
4428 ..KtstrTestEntry::DEFAULT
4429 };
4430 let err = entry
4431 .validate()
4432 .expect_err("survives_storm + expect_auto_repro must be rejected");
4433 let msg = format!("{err}");
4434 assert!(
4435 msg.contains("survives_storm") && msg.contains("expect_auto_repro"),
4436 "diagnostic must name both survives_storm and expect_auto_repro: {msg}",
4437 );
4438 }
4439
4440 /// `with_survives_storm` setter roundtrips onto the entry.
4441 #[test]
4442 fn with_survives_storm_sets_field() {
4443 let e = KtstrTestEntry::DEFAULT.with_survives_storm(true);
4444 assert!(e.survives_storm);
4445 }
4446
4447 /// Happy path: scx_bpf_error matchers paired with
4448 /// `expect_err = true` must validate cleanly. Pins the polarity
4449 /// of the gate so a future tightening that rejected every entry
4450 /// with a matcher would surface here instead of silently
4451 /// breaking every reproducer test.
4452 #[test]
4453 fn validate_accepts_expect_scx_bpf_error_matchers_with_expect_err() {
4454 fn good_test_func(_: &Ctx) -> Result<AssertResult> {
4455 Ok(AssertResult::pass())
4456 }
4457 let entry_contains = KtstrTestEntry {
4458 name: "good_contains",
4459 func: good_test_func,
4460 assert: crate::assert::Assert::NO_OVERRIDES
4461 .expect_scx_bpf_error_contains("apply_cell_config"),
4462 expect_err: true,
4463 ..KtstrTestEntry::DEFAULT
4464 };
4465 entry_contains
4466 .validate()
4467 .expect("contains matcher + expect_err=true must validate");
4468 let entry_matches = KtstrTestEntry {
4469 name: "good_matches",
4470 func: good_test_func,
4471 assert: crate::assert::Assert::NO_OVERRIDES
4472 .expect_scx_bpf_error_matches("apply_cell_config:[0-9]+"),
4473 expect_err: true,
4474 ..KtstrTestEntry::DEFAULT
4475 };
4476 entry_matches
4477 .validate()
4478 .expect("matches matcher + expect_err=true must validate");
4479 let entry_both = KtstrTestEntry {
4480 name: "good_both",
4481 func: good_test_func,
4482 assert: crate::assert::Assert::NO_OVERRIDES
4483 .expect_scx_bpf_error_contains("apply_cell_config")
4484 .expect_scx_bpf_error_matches("apply_cell_config:[0-9]+"),
4485 expect_err: true,
4486 ..KtstrTestEntry::DEFAULT
4487 };
4488 entry_both
4489 .validate()
4490 .expect("both matchers + expect_err=true must validate");
4491 }
4492
4493 #[test]
4494 fn ktstr_test_entry_default_rejected_by_empty_name() {
4495 // DEFAULT has name = "" which validate() rejects — so the
4496 // stub entry cannot accidentally dispatch. This pins that
4497 // invariant.
4498 let err = KtstrTestEntry::DEFAULT.validate().unwrap_err();
4499 let msg = format!("{err}");
4500 assert!(
4501 msg.contains("name") && msg.contains("non-empty"),
4502 "expected name-non-empty bail, got: {msg}"
4503 );
4504 }
4505
4506 #[test]
4507 fn default_test_func_returns_err() {
4508 // The stub bails with Err — NOT a panic. Callers that
4509 // accidentally leave `func: default_test_func` in their
4510 // entry must see a clean Err to surface the mistake.
4511 let (cgroups, topo) = dummy_ctx();
4512 let ctx = Ctx::builder(&cgroups, &topo)
4513 .duration(Duration::from_millis(1))
4514 .assert(crate::assert::Assert::NO_OVERRIDES)
4515 .build();
4516 let result = default_test_func(&ctx);
4517 let err = result.expect_err("default_test_func must return Err, not Ok");
4518 let msg = format!("{err}");
4519 assert!(
4520 msg.contains("KtstrTestEntry::DEFAULT func called"),
4521 "expected DEFAULT-called bail message, got: {msg}"
4522 );
4523 assert!(
4524 msg.contains("override func before use"),
4525 "expected actionable hint, got: {msg}"
4526 );
4527 }
4528
4529 // -- Scheduler method tests --
4530
4531 use super::super::test_helpers::validate_entry;
4532
4533 #[test]
4534 fn scheduler_eevdf_defaults() {
4535 let s = &Scheduler::EEVDF;
4536 assert_eq!(s.name, "eevdf");
4537 assert!(s.sysctls.is_empty());
4538 assert!(s.kargs.is_empty());
4539 assert!(s.assert.not_starved.is_none());
4540 assert!(s.assert.max_imbalance_ratio.is_none());
4541 }
4542
4543 #[test]
4544 fn scheduler_named_builder() {
4545 static TEST_SYSCTLS: &[Sysctl] =
4546 &[Sysctl::new("kernel.sched_cfs_bandwidth_slice_us", "1000")];
4547 let s = Scheduler::named("test_sched")
4548 .binary(SchedulerSpec::Discover("test_bin"))
4549 .sysctls(TEST_SYSCTLS)
4550 .kargs(&["nosmt"]);
4551 assert_eq!(s.name, "test_sched");
4552 // Assert the routed content, not just the length: a setter that
4553 // stored a different non-empty slice (or swapped sysctls<->kargs)
4554 // would pass length-only checks but fail these.
4555 assert_eq!(s.sysctls.len(), 1);
4556 assert_eq!(s.sysctls[0].key(), "kernel.sched_cfs_bandwidth_slice_us");
4557 assert_eq!(s.sysctls[0].value(), "1000");
4558 assert_eq!(s.kargs.len(), 1);
4559 assert_eq!(s.kargs[0], "nosmt");
4560 }
4561
4562 #[test]
4563 fn scheduler_with_check() {
4564 let v = crate::assert::Assert::NO_OVERRIDES
4565 .check_not_starved()
4566 .max_imbalance_ratio(3.0);
4567 let s = Scheduler::named("sched").assert(v);
4568 assert_eq!(s.assert.not_starved, Some(true));
4569 assert_eq!(s.assert.max_imbalance_ratio, Some(3.0));
4570 }
4571
4572 #[test]
4573 fn scheduler_named_default_topology_matches_macro_hardcode() {
4574 // The `declare_scheduler!` macro at
4575 // `ktstr-macros/src/lib.rs` hardcodes
4576 // `(numa=1, llcs=1, cores_per_llc=2, threads_per_core=1)`
4577 // as the fallback when the user omits `topology = (...)`.
4578 // That hardcode mirrors `Scheduler::named`'s default Topology.
4579 // The two sites are mechanically coupled by convention but
4580 // not by code: a change to `Scheduler::named` here would
4581 // silently let the macro check stale values, producing
4582 // misleading "effective topology llcs (N)" errors and/or
4583 // false-positives. Pin the defaults here so a drift fails
4584 // this test loudly and points at both sites.
4585 let s = Scheduler::named("__macro_default_topology_pin__");
4586 assert_eq!(s.topology.numa_nodes, 1);
4587 assert_eq!(s.topology.llcs, 1);
4588 assert_eq!(s.topology.cores_per_llc, 2);
4589 assert_eq!(s.topology.threads_per_core, 1);
4590 }
4591
4592 // -- KtstrTestEntry::validate coverage --
4593
4594 #[test]
4595 fn ktstr_test_entry_validate_accepts_defaults() {
4596 let e = validate_entry("ok", 512, Duration::from_secs(2));
4597 e.validate().unwrap();
4598 }
4599
4600 #[test]
4601 fn ktstr_test_entry_validate_rejects_empty_name() {
4602 let e = validate_entry("", 512, Duration::from_secs(2));
4603 let err = e.validate().unwrap_err();
4604 let msg = format!("{err}");
4605 assert!(
4606 msg.contains("name") && msg.contains("non-empty"),
4607 "got: {msg}"
4608 );
4609 }
4610
4611 #[test]
4612 fn ktstr_test_entry_validate_rejects_zero_memory() {
4613 let e = validate_entry("t", 0, Duration::from_secs(2));
4614 let err = e.validate().unwrap_err();
4615 let msg = format!("{err}");
4616 assert!(
4617 msg.contains("memory_mib") && msg.contains("> 0") && msg.contains("'t'"),
4618 "got: {msg}"
4619 );
4620 }
4621
4622 #[test]
4623 fn ktstr_test_entry_validate_rejects_zero_duration() {
4624 let e = validate_entry("t", 512, Duration::ZERO);
4625 let err = e.validate().unwrap_err();
4626 let msg = format!("{err}");
4627 assert!(
4628 msg.contains("duration") && msg.contains("> 0"),
4629 "got: {msg}"
4630 );
4631 }
4632
4633 // -- TopologyConstraints tests --
4634
4635 #[test]
4636 fn topology_constraints_default_has_max_values() {
4637 let c = TopologyConstraints::DEFAULT;
4638 assert_eq!(c.max_llcs, Some(12));
4639 assert_eq!(c.max_numa_nodes, Some(1));
4640 assert_eq!(c.max_cpus, Some(192));
4641 }
4642
4643 #[test]
4644 fn topology_constraints_max_fields_set() {
4645 let c = TopologyConstraints {
4646 max_llcs: Some(16),
4647 max_numa_nodes: Some(4),
4648 max_cpus: Some(128),
4649 ..TopologyConstraints::DEFAULT
4650 };
4651 assert_eq!(c.max_llcs, Some(16));
4652 assert_eq!(c.max_numa_nodes, Some(4));
4653 assert_eq!(c.max_cpus, Some(128));
4654 assert_eq!(c.min_numa_nodes, 1);
4655 assert_eq!(c.min_llcs, 1);
4656 assert_eq!(c.min_cpus, 1);
4657 }
4658
4659 #[test]
4660 fn topology_constraints_with_chain_overrides_target_fields_only() {
4661 let c = TopologyConstraints::DEFAULT
4662 .with_min_numa_nodes(2)
4663 .with_max_numa_nodes(8)
4664 .with_min_llcs(3)
4665 .with_max_llcs(16)
4666 .with_requires_smt(true)
4667 .with_min_cpus(4)
4668 .with_max_cpus(64);
4669 assert_eq!(c.min_numa_nodes, 2);
4670 assert_eq!(c.max_numa_nodes, Some(8));
4671 assert_eq!(c.min_llcs, 3);
4672 assert_eq!(c.max_llcs, Some(16));
4673 assert!(c.requires_smt);
4674 assert_eq!(c.min_cpus, 4);
4675 assert_eq!(c.max_cpus, Some(64));
4676 }
4677
4678 #[test]
4679 fn topology_constraints_without_chain_clears_option_fields() {
4680 let c = TopologyConstraints::DEFAULT
4681 .without_max_numa_nodes()
4682 .without_max_llcs()
4683 .without_max_cpus();
4684 assert!(c.max_numa_nodes.is_none());
4685 assert!(c.max_llcs.is_none());
4686 assert!(c.max_cpus.is_none());
4687 // min fields untouched.
4688 assert_eq!(c.min_numa_nodes, 1);
4689 assert_eq!(c.min_llcs, 1);
4690 assert_eq!(c.min_cpus, 1);
4691 }
4692
4693 #[test]
4694 fn topology_constraints_with_chain_const_evaluable() {
4695 const C: TopologyConstraints = TopologyConstraints::DEFAULT
4696 .with_min_llcs(2)
4697 .with_max_llcs(4);
4698 assert_eq!(C.min_llcs, 2);
4699 assert_eq!(C.max_llcs, Some(4));
4700 }
4701
4702 #[test]
4703 fn topology_constraints_equality() {
4704 let a = TopologyConstraints::DEFAULT;
4705 let b = TopologyConstraints::DEFAULT;
4706 assert_eq!(a, b);
4707
4708 let c = TopologyConstraints {
4709 max_llcs: Some(8),
4710 ..TopologyConstraints::DEFAULT
4711 };
4712 assert_ne!(a, c);
4713 }
4714
4715 #[test]
4716 fn accepts_default_allows_within_limits() {
4717 let c = TopologyConstraints::DEFAULT;
4718 // 1 NUMA, 8 LLCs, 4 cores, 2 threads = 64 CPUs
4719 let t = Topology::new(1, 8, 4, 2);
4720 assert!(c.accepts(&t, 128, 16, 32));
4721 }
4722
4723 #[test]
4724 fn accepts_default_rejects_multi_numa() {
4725 let c = TopologyConstraints::DEFAULT;
4726 // 2 NUMA, 8 LLCs, 4 cores, 2 threads = 64 CPUs
4727 let t = Topology::new(2, 8, 4, 2);
4728 assert!(!c.accepts(&t, 128, 16, 32));
4729 }
4730
4731 #[test]
4732 fn accepts_default_rejects_too_many_llcs() {
4733 let c = TopologyConstraints::DEFAULT;
4734 // 16 LLCs exceeds max_llcs=12
4735 let t = Topology::new(1, 16, 2, 1);
4736 assert!(!c.accepts(&t, 128, 32, 32));
4737 }
4738
4739 #[test]
4740 fn accepts_none_means_no_limit() {
4741 let c = TopologyConstraints {
4742 max_llcs: None,
4743 max_numa_nodes: None,
4744 max_cpus: None,
4745 ..TopologyConstraints::DEFAULT
4746 };
4747 // 4 NUMA, 16 LLCs, 8 cores, 2 threads = 256 CPUs
4748 let t = Topology::new(4, 16, 8, 2);
4749 assert!(c.accepts(&t, 512, 32, 32));
4750 }
4751
4752 #[test]
4753 fn accepts_rejects_too_many_llcs() {
4754 let c = TopologyConstraints {
4755 max_llcs: Some(4),
4756 ..TopologyConstraints::DEFAULT
4757 };
4758 let t = Topology::new(1, 8, 2, 1);
4759 assert!(!c.accepts(&t, 128, 16, 32));
4760 }
4761
4762 #[test]
4763 fn accepts_allows_llcs_at_max() {
4764 let c = TopologyConstraints {
4765 max_llcs: Some(4),
4766 ..TopologyConstraints::DEFAULT
4767 };
4768 let t = Topology::new(1, 4, 2, 1);
4769 assert!(c.accepts(&t, 128, 16, 32));
4770 }
4771
4772 #[test]
4773 fn accepts_rejects_too_many_numa_nodes() {
4774 let c = TopologyConstraints {
4775 max_numa_nodes: Some(2),
4776 ..TopologyConstraints::DEFAULT
4777 };
4778 let t = Topology::new(4, 4, 2, 1);
4779 assert!(!c.accepts(&t, 128, 16, 32));
4780 }
4781
4782 #[test]
4783 fn accepts_allows_numa_at_max() {
4784 let c = TopologyConstraints {
4785 max_numa_nodes: Some(2),
4786 ..TopologyConstraints::DEFAULT
4787 };
4788 let t = Topology::new(2, 4, 2, 1);
4789 assert!(c.accepts(&t, 128, 16, 32));
4790 }
4791
4792 #[test]
4793 fn accepts_rejects_too_many_cpus() {
4794 let c = TopologyConstraints {
4795 max_cpus: Some(16),
4796 ..TopologyConstraints::DEFAULT
4797 };
4798 // 4 LLCs * 4 cores * 2 threads = 32 CPUs
4799 let t = Topology::new(1, 4, 4, 2);
4800 assert!(!c.accepts(&t, 128, 16, 32));
4801 }
4802
4803 #[test]
4804 fn accepts_allows_cpus_at_max() {
4805 let c = TopologyConstraints {
4806 max_cpus: Some(16),
4807 ..TopologyConstraints::DEFAULT
4808 };
4809 // 2 LLCs * 4 cores * 2 threads = 16 CPUs
4810 let t = Topology::new(1, 2, 4, 2);
4811 assert!(c.accepts(&t, 128, 16, 32));
4812 }
4813
4814 #[test]
4815 fn accepts_rejects_too_few_llcs() {
4816 let c = TopologyConstraints {
4817 min_llcs: 4,
4818 ..TopologyConstraints::DEFAULT
4819 };
4820 let t = Topology::new(1, 2, 4, 1);
4821 assert!(!c.accepts(&t, 128, 16, 32));
4822 }
4823
4824 #[test]
4825 fn accepts_rejects_exceeding_host_cpus() {
4826 let c = TopologyConstraints::DEFAULT;
4827 let t = Topology::new(1, 4, 4, 2); // 32 CPUs
4828 assert!(!c.accepts(&t, 16, 16, 32)); // host has only 16
4829 }
4830
4831 #[test]
4832 fn accepts_rejects_exceeding_host_llcs() {
4833 let c = TopologyConstraints::DEFAULT;
4834 let t = Topology::new(1, 8, 2, 1);
4835 assert!(!c.accepts(&t, 128, 4, 32)); // host has only 4 LLCs
4836 }
4837
4838 #[test]
4839 fn accepts_combined_min_and_max() {
4840 let c = TopologyConstraints {
4841 min_llcs: 2,
4842 max_llcs: Some(8),
4843 min_cpus: 4,
4844 max_cpus: Some(32),
4845 ..TopologyConstraints::DEFAULT
4846 };
4847 // 1 LLC, 4 CPUs -- rejected (min_llcs=2)
4848 assert!(!c.accepts(&Topology::new(1, 1, 4, 1), 128, 16, 32));
4849 // 2 LLCs, 4 CPUs -- accepted
4850 assert!(c.accepts(&Topology::new(1, 2, 2, 1), 128, 16, 32));
4851 // 16 LLCs, 32 CPUs -- rejected (max_llcs=8)
4852 assert!(!c.accepts(&Topology::new(1, 16, 2, 1), 128, 16, 32));
4853 // 8 LLCs, 16 CPUs -- accepted
4854 assert!(c.accepts(&Topology::new(1, 8, 2, 1), 128, 16, 32));
4855 }
4856
4857 #[test]
4858 fn accepts_requires_smt() {
4859 let c = TopologyConstraints {
4860 requires_smt: true,
4861 ..TopologyConstraints::DEFAULT
4862 };
4863 let no_smt = Topology::new(1, 2, 4, 1);
4864 let with_smt = Topology::new(1, 2, 4, 2);
4865 assert!(!c.accepts(&no_smt, 128, 16, 32));
4866 assert!(c.accepts(&with_smt, 128, 16, 32));
4867 }
4868
4869 #[test]
4870 fn accepts_rejects_too_few_numa_nodes() {
4871 let c = TopologyConstraints {
4872 min_numa_nodes: 2,
4873 max_numa_nodes: None,
4874 ..TopologyConstraints::DEFAULT
4875 };
4876 let t = Topology::new(1, 4, 4, 1);
4877 assert!(!c.accepts(&t, 128, 16, 32));
4878 }
4879
4880 #[test]
4881 fn accepts_rejects_too_few_cpus() {
4882 let c = TopologyConstraints {
4883 min_cpus: 32,
4884 ..TopologyConstraints::DEFAULT
4885 };
4886 // 2 LLCs * 4 cores * 2 threads = 16 CPUs
4887 let t = Topology::new(1, 2, 4, 2);
4888 assert!(!c.accepts(&t, 128, 16, 32));
4889 }
4890
4891 #[test]
4892 fn accepts_rejects_exceeding_host_cpus_per_llc() {
4893 let c = TopologyConstraints::DEFAULT;
4894 // cores_per_llc=8, threads_per_core=2 → 16 CPUs/LLC
4895 let t = Topology::new(1, 2, 8, 2);
4896 assert!(!c.accepts(&t, 128, 16, 8));
4897 }
4898
4899 // -- TopologyConstraints::validate --
4900
4901 #[test]
4902 fn validate_accepts_default_constraints() {
4903 // DEFAULT has min_numa_nodes=1, max_numa_nodes=Some(1);
4904 // min_llcs=1, max_llcs=Some(12); min_cpus=1, max_cpus=Some(192).
4905 // All min<=max — must pass.
4906 TopologyConstraints::DEFAULT
4907 .validate()
4908 .expect("DEFAULT constraints must be self-consistent");
4909 }
4910
4911 #[test]
4912 fn validate_rejects_inverted_numa_nodes() {
4913 let c = TopologyConstraints {
4914 min_numa_nodes: 5,
4915 max_numa_nodes: Some(2),
4916 ..TopologyConstraints::DEFAULT
4917 };
4918 let err = c
4919 .validate()
4920 .expect_err("inverted min/max_numa_nodes must be rejected");
4921 let msg = err.to_string();
4922 assert!(
4923 msg.contains("max_numa_nodes=2") && msg.contains("min_numa_nodes=5"),
4924 "diagnostic must name both bounds: got {msg}",
4925 );
4926 }
4927
4928 #[test]
4929 fn validate_rejects_inverted_llcs() {
4930 let c = TopologyConstraints {
4931 min_llcs: 8,
4932 max_llcs: Some(2),
4933 ..TopologyConstraints::DEFAULT
4934 };
4935 let err = c
4936 .validate()
4937 .expect_err("inverted min/max_llcs must be rejected");
4938 let msg = err.to_string();
4939 assert!(
4940 msg.contains("max_llcs=2") && msg.contains("min_llcs=8"),
4941 "diagnostic must name both bounds: got {msg}",
4942 );
4943 }
4944
4945 #[test]
4946 fn validate_rejects_inverted_cpus() {
4947 let c = TopologyConstraints {
4948 min_cpus: 64,
4949 max_cpus: Some(16),
4950 ..TopologyConstraints::DEFAULT
4951 };
4952 let err = c
4953 .validate()
4954 .expect_err("inverted min/max_cpus must be rejected");
4955 let msg = err.to_string();
4956 assert!(
4957 msg.contains("max_cpus=16") && msg.contains("min_cpus=64"),
4958 "diagnostic must name both bounds: got {msg}",
4959 );
4960 }
4961
4962 #[test]
4963 fn validate_accepts_max_equal_min() {
4964 // max == min is satisfiable (any topology with that exact
4965 // value matches), so the validator must allow it.
4966 let c = TopologyConstraints {
4967 min_numa_nodes: 3,
4968 max_numa_nodes: Some(3),
4969 min_llcs: 4,
4970 max_llcs: Some(4),
4971 min_cpus: 16,
4972 max_cpus: Some(16),
4973 ..TopologyConstraints::DEFAULT
4974 };
4975 c.validate()
4976 .expect("max==min is satisfiable and must be accepted");
4977 }
4978
4979 #[test]
4980 fn validate_accepts_open_upper_bound() {
4981 // None for max_* means "no upper bound" — never inverted.
4982 let c = TopologyConstraints {
4983 min_numa_nodes: 16,
4984 max_numa_nodes: None,
4985 min_llcs: 32,
4986 max_llcs: None,
4987 min_cpus: 1024,
4988 max_cpus: None,
4989 ..TopologyConstraints::DEFAULT
4990 };
4991 c.validate()
4992 .expect("None upper bounds must always validate");
4993 }
4994
4995 // -- KtstrTestEntry::validate wire-in for TopologyConstraints --
4996
4997 /// Pin that an inverted per-entry `constraints` field surfaces
4998 /// through `KtstrTestEntry::validate` with the documented
4999 /// `KtstrTestEntry '<name>'.constraints:` prefix wrap. Catches a
5000 /// regression that drops the per-entry `.validate()?` call at the
5001 /// wire-in site in `KtstrTestEntry::validate`.
5002 #[test]
5003 fn entry_validate_propagates_per_entry_constraints_error() {
5004 let entry = KtstrTestEntry {
5005 name: "test_inverted_entry",
5006 constraints: TopologyConstraints {
5007 min_numa_nodes: 5,
5008 max_numa_nodes: Some(2),
5009 ..TopologyConstraints::DEFAULT
5010 },
5011 ..KtstrTestEntry::DEFAULT
5012 };
5013 let err = entry
5014 .validate()
5015 .expect_err("inverted per-entry constraints must surface");
5016 let msg = err.to_string();
5017 assert!(
5018 msg.contains("KtstrTestEntry 'test_inverted_entry'.constraints:"),
5019 "wrap prefix must name the entry + constraints field: got {msg}",
5020 );
5021 assert!(
5022 msg.contains("max_numa_nodes=2") && msg.contains("min_numa_nodes=5"),
5023 "underlying validator diagnostic must be preserved through map_err: got {msg}",
5024 );
5025 }
5026
5027 /// Pin that an inverted scheduler-level `constraints` field
5028 /// surfaces through `KtstrTestEntry::validate` with the
5029 /// `KtstrTestEntry '<name>'.scheduler '<sched>'.constraints:`
5030 /// prefix wrap. Catches a regression that drops the
5031 /// `.scheduler.constraints.validate()?` call at the wire-in site.
5032 #[test]
5033 fn entry_validate_propagates_scheduler_constraints_error() {
5034 static BAD_SCHED: Scheduler = Scheduler {
5035 name: "bad_sched",
5036 binary: SchedulerSpec::Eevdf,
5037 sysctls: &[],
5038 kargs: &[],
5039 assert: crate::assert::Assert::NO_OVERRIDES,
5040 cgroup_parent: None,
5041 sched_args: &[],
5042 topology: Topology {
5043 llcs: 1,
5044 cores_per_llc: 1,
5045 threads_per_core: 1,
5046 numa_nodes: 1,
5047 nodes: None,
5048 distances: None,
5049 },
5050 constraints: TopologyConstraints {
5051 min_llcs: 8,
5052 max_llcs: Some(2),
5053 ..TopologyConstraints::DEFAULT
5054 },
5055 config_file: None,
5056 config_file_def: None,
5057 kernels: &[],
5058 };
5059 let entry = KtstrTestEntry {
5060 name: "test_inverted_scheduler",
5061 scheduler: &BAD_SCHED,
5062 ..KtstrTestEntry::DEFAULT
5063 };
5064 let err = entry
5065 .validate()
5066 .expect_err("inverted scheduler-level constraints must surface");
5067 let msg = err.to_string();
5068 assert!(
5069 msg.contains(
5070 "KtstrTestEntry 'test_inverted_scheduler'.scheduler 'bad_sched'.constraints:"
5071 ),
5072 "wrap prefix must name the entry + scheduler + constraints: got {msg}",
5073 );
5074 assert!(
5075 msg.contains("max_llcs=2") && msg.contains("min_llcs=8"),
5076 "underlying validator diagnostic must be preserved: got {msg}",
5077 );
5078 }
5079
5080 // -- SchedulerSpec::display_name --
5081
5082 #[test]
5083 fn display_name_eevdf() {
5084 assert_eq!(SchedulerSpec::Eevdf.display_name(), "eevdf");
5085 }
5086
5087 #[test]
5088 fn display_name_discover_returns_binary_name() {
5089 assert_eq!(
5090 SchedulerSpec::Discover("scx_mitosis").display_name(),
5091 "scx_mitosis"
5092 );
5093 }
5094
5095 #[test]
5096 fn display_name_path_returns_path_string() {
5097 assert_eq!(
5098 SchedulerSpec::Path("/usr/bin/scx_my_sched").display_name(),
5099 "/usr/bin/scx_my_sched"
5100 );
5101 }
5102
5103 #[test]
5104 fn display_name_kernel_builtin_returns_kernel() {
5105 assert_eq!(
5106 SchedulerSpec::KernelBuiltin {
5107 enable: &[],
5108 disable: &[],
5109 }
5110 .display_name(),
5111 "kernel"
5112 );
5113 }
5114
5115 // -- SchedulerSpec::scheduler_commit --
5116 //
5117 // Conservative by design: EVERY variant currently returns
5118 // None, including `Discover(_)`. `resolve_scheduler`'s 5-path
5119 // cascade can pick up a binary whose commit is unknown to this
5120 // process in four of the five paths, so `Discover` returns
5121 // None to avoid lying. The sidecar's nullable
5122 // semantics distinguish "unset" from a sentinel so consumers
5123 // can tell "no userspace binary" (Eevdf, KernelBuiltin) from
5124 // "external binary, commit unknown" (Path) and "discovered
5125 // binary, provenance unverified" (Discover). A future
5126 // introspection path (`binary --version`, ELF note, BuildId
5127 // lookup) can flip a variant to Some(..) when an authoritative
5128 // commit is available; until then, None keeps consumers from
5129 // attributing regressions to the wrong commit. See the method
5130 // doc for the per-variant rationale.
5131
5132 #[test]
5133 fn scheduler_commit_eevdf_returns_none() {
5134 assert!(
5135 SchedulerSpec::Eevdf.scheduler_commit().is_none(),
5136 "Eevdf has no userspace binary — scheduler_commit must \
5137 be None so the sidecar field distinguishes this case \
5138 from `Path(_)` (external, unknown commit). Got: {:?}",
5139 SchedulerSpec::Eevdf.scheduler_commit(),
5140 );
5141 }
5142
5143 #[test]
5144 fn scheduler_commit_discover_returns_none() {
5145 // `Discover` is resolved by `resolve_scheduler`'s 5-path
5146 // cascade. Only the rebuild fallback guarantees the binary
5147 // matches the current tree; the four pre-built discovery
5148 // paths (KTSTR_SCHEDULER env, ktstr-binary sibling dir,
5149 // target/debug/, target/release/) can pick up a binary
5150 // whose commit is unknown to this process. Synthesizing a
5151 // commit would be a lie in 4 of 5 cases — so the honest
5152 // answer today is `None`. A future enhancement that probes
5153 // the binary (e.g. `--version`, ELF note) can flip this to
5154 // `Some(..)` when an authoritative commit is available;
5155 // until then, `None` keeps consumers from attributing
5156 // regressions to the wrong commit.
5157 assert!(
5158 SchedulerSpec::Discover("scx_mitosis")
5159 .scheduler_commit()
5160 .is_none(),
5161 "Discover(_) must return None — resolve_scheduler's \
5162 cascade can pick up a binary whose commit doesn't \
5163 match the workspace. Got: {:?}",
5164 SchedulerSpec::Discover("scx_mitosis").scheduler_commit(),
5165 );
5166 }
5167
5168 #[test]
5169 fn scheduler_commit_path_returns_none() {
5170 // External binaries have no reliable introspection path;
5171 // synthesizing a commit here would be a lie when the
5172 // binary was built from a different tree.
5173 assert!(
5174 SchedulerSpec::Path("/usr/bin/scx_external")
5175 .scheduler_commit()
5176 .is_none(),
5177 "Path(_) points at an externally-built binary — \
5178 scheduler_commit must be None so consumers don't treat \
5179 a fabricated commit as authoritative. Got: {:?}",
5180 SchedulerSpec::Path("/usr/bin/scx_external").scheduler_commit(),
5181 );
5182 }
5183
5184 #[test]
5185 fn scheduler_commit_kernel_builtin_returns_none() {
5186 // In-kernel schedulers have no userspace binary. The
5187 // running kernel's identity belongs in
5188 // `host.kernel_release`, not here.
5189 let spec = SchedulerSpec::KernelBuiltin {
5190 enable: &[],
5191 disable: &[],
5192 };
5193 assert!(
5194 spec.scheduler_commit().is_none(),
5195 "KernelBuiltin has no userspace binary — \
5196 scheduler_commit must be None. Got: {:?}",
5197 spec.scheduler_commit(),
5198 );
5199 }
5200
5201 // -- all_include_files aggregation tests --
5202 //
5203 // Pins the scheduler → payload → workloads → extras order. The
5204 // dedupe + archive-collision policy lives downstream in
5205 // `eval::dedupe_include_files`; this aggregator just gathers
5206 // raw spec strings.
5207
5208 /// No Payload and no extras declare include_files → empty result.
5209 /// Regression guard for `all_include_files` returning an implicit
5210 /// non-empty list (e.g. leaking a default).
5211 #[test]
5212 fn all_include_files_empty_when_nothing_declared() {
5213 let entry = KtstrTestEntry {
5214 name: "t",
5215 ..KtstrTestEntry::DEFAULT
5216 };
5217 assert!(entry.all_include_files().is_empty());
5218 }
5219
5220 /// Payload + workloads + extras merge in declaration order. Pins:
5221 /// - order is payload → workloads (preserving the slice order) →
5222 /// extra_include_files
5223 /// - duplicates are NOT deduped at this layer (that's eval's job)
5224 /// - the scheduler field is `&Scheduler` (no `include_files`
5225 /// surface), so the scheduler tier contributes nothing here
5226 #[test]
5227 fn all_include_files_merges_sources_in_order() {
5228 static PRIMARY: crate::test_support::Payload = crate::test_support::Payload {
5229 name: "primary",
5230 kind: crate::test_support::PayloadKind::Binary("fio"),
5231 output: crate::test_support::OutputFormat::ExitCode,
5232 default_args: &[],
5233 default_checks: &[],
5234 metrics: &[],
5235 include_files: &["fio"],
5236 uses_parent_pgrp: false,
5237 known_flags: None,
5238 };
5239 static WL_A: crate::test_support::Payload = crate::test_support::Payload {
5240 name: "wl_a",
5241 kind: crate::test_support::PayloadKind::Binary("stress-ng"),
5242 output: crate::test_support::OutputFormat::ExitCode,
5243 default_args: &[],
5244 default_checks: &[],
5245 metrics: &[],
5246 include_files: &["stress-ng"],
5247 uses_parent_pgrp: false,
5248 known_flags: None,
5249 };
5250 static WL_B: crate::test_support::Payload = crate::test_support::Payload {
5251 name: "wl_b",
5252 kind: crate::test_support::PayloadKind::Binary("schbench"),
5253 output: crate::test_support::OutputFormat::ExitCode,
5254 default_args: &[],
5255 default_checks: &[],
5256 metrics: &[],
5257 include_files: &["schbench"],
5258 uses_parent_pgrp: false,
5259 known_flags: None,
5260 };
5261 static WORKLOADS: &[&crate::test_support::Payload] = &[&WL_A, &WL_B];
5262 let entry = KtstrTestEntry {
5263 name: "t",
5264 payload: Some(&PRIMARY),
5265 workloads: WORKLOADS,
5266 extra_include_files: &["test-fixture.json"],
5267 ..KtstrTestEntry::DEFAULT
5268 };
5269 let got = entry.all_include_files();
5270 assert_eq!(
5271 got,
5272 vec!["fio", "stress-ng", "schbench", "test-fixture.json"],
5273 "aggregation order must be payload → workloads → extras",
5274 );
5275 }
5276
5277 /// Absent optional `payload` slot contributes nothing — the
5278 /// aggregator skips it without the `None → empty-push` misbehavior
5279 /// a future refactor might introduce. Scheduler tier no longer
5280 /// contributes after the `&Payload`→`&Scheduler` field-type change.
5281 #[test]
5282 fn all_include_files_skips_absent_payload() {
5283 let entry = KtstrTestEntry {
5284 name: "t",
5285 payload: None,
5286 workloads: &[],
5287 extra_include_files: &[],
5288 ..KtstrTestEntry::DEFAULT
5289 };
5290 assert!(entry.all_include_files().is_empty());
5291 }
5292
5293 /// Dedup-detection: two distinct `&'static Scheduler` consts with
5294 /// the same `name` field must panic when `find_scheduler` builds
5295 /// its name → scheduler map. Exercises the
5296 /// `build_scheduler_index_or_panic` branch against a mock slice
5297 /// since the real `KTSTR_SCHEDULERS` is the union of every
5298 /// `declare_scheduler!` registration in the linked test binary
5299 /// and so cannot host an intentional duplicate without
5300 /// poisoning every other test that calls `find_scheduler`.
5301 #[test]
5302 #[should_panic(expected = "duplicate scheduler name `dup_name_test`")]
5303 fn build_scheduler_index_or_panic_rejects_duplicate_names() {
5304 // Two consts with the same name. Address-distinct so
5305 // `std::ptr::eq` returns false and the dedup branch fires.
5306 static A: Scheduler = Scheduler::named("dup_name_test");
5307 static B: Scheduler = Scheduler::named("dup_name_test");
5308 let _ = build_scheduler_index_or_panic([&A, &B]);
5309 }
5310
5311 /// Distinct names build a populated index without panicking, and
5312 /// the returned map points back at the same `&'static Scheduler`
5313 /// references for lookup parity with `find_scheduler`.
5314 #[test]
5315 fn build_scheduler_index_or_panic_accepts_distinct_names() {
5316 static A: Scheduler = Scheduler::named("dup_name_a");
5317 static B: Scheduler = Scheduler::named("dup_name_b");
5318 let map = build_scheduler_index_or_panic([&A, &B]);
5319 assert!(std::ptr::eq(*map.get("dup_name_a").unwrap(), &A));
5320 assert!(std::ptr::eq(*map.get("dup_name_b").unwrap(), &B));
5321 }
5322
5323 /// Same `&'static Scheduler` passed twice (a benign re-export of
5324 /// the same const, not a true duplicate-name collision across
5325 /// distinct consts) does not panic — `std::ptr::eq` short-circuits
5326 /// the dedup branch. Linkme's distributed slice is allowed to
5327 /// emit the same registration through multiple paths; only a
5328 /// pointer-distinct duplicate is a misconfiguration.
5329 #[test]
5330 fn build_scheduler_index_or_panic_tolerates_pointer_identity_aliases() {
5331 static A: Scheduler = Scheduler::named("alias_test");
5332 let map = build_scheduler_index_or_panic([&A, &A]);
5333 assert!(std::ptr::eq(*map.get("alias_test").unwrap(), &A));
5334 }
5335
5336 #[test]
5337 fn topology_json_try_into_topology_accepts_single_cpu() {
5338 let topo: Topology = TopologyJson::SINGLE_CPU
5339 .try_into()
5340 .expect("SINGLE_CPU valid");
5341 assert_eq!(topo.numa_nodes, 1);
5342 assert_eq!(topo.llcs, 1);
5343 assert_eq!(topo.cores_per_llc, 1);
5344 assert_eq!(topo.threads_per_core, 1);
5345 assert!(topo.nodes.is_none());
5346 assert!(topo.distances.is_none());
5347 }
5348
5349 #[test]
5350 fn topology_json_try_into_topology_accepts_multi_cpu() {
5351 let json = TopologyJson {
5352 num_numa_nodes: 2,
5353 num_llcs: 4,
5354 cores_per_llc: 8,
5355 threads_per_core: 2,
5356 };
5357 let topo: Topology = json.try_into().expect("2x4x8x2 valid");
5358 assert_eq!(topo.numa_nodes, 2);
5359 assert_eq!(topo.llcs, 4);
5360 assert_eq!(topo.cores_per_llc, 8);
5361 assert_eq!(topo.threads_per_core, 2);
5362 }
5363
5364 #[test]
5365 fn topology_json_try_into_topology_rejects_zero_numa_nodes() {
5366 let json = TopologyJson {
5367 num_numa_nodes: 0,
5368 num_llcs: 1,
5369 cores_per_llc: 1,
5370 threads_per_core: 1,
5371 };
5372 let err = Topology::try_from(json).expect_err("zero numa_nodes must reject");
5373 assert!(
5374 err.contains("numa_nodes"),
5375 "error should mention numa_nodes: {err}"
5376 );
5377 }
5378
5379 #[test]
5380 fn topology_json_try_into_topology_rejects_zero_llcs() {
5381 let json = TopologyJson {
5382 num_numa_nodes: 1,
5383 num_llcs: 0,
5384 cores_per_llc: 1,
5385 threads_per_core: 1,
5386 };
5387 let err = Topology::try_from(json).expect_err("zero llcs must reject");
5388 assert!(err.contains("llcs"), "error should mention llcs: {err}");
5389 }
5390
5391 #[test]
5392 fn topology_json_try_into_topology_rejects_zero_cores() {
5393 let json = TopologyJson {
5394 num_numa_nodes: 1,
5395 num_llcs: 1,
5396 cores_per_llc: 0,
5397 threads_per_core: 1,
5398 };
5399 let err = Topology::try_from(json).expect_err("zero cores_per_llc must reject");
5400 assert!(
5401 err.contains("cores_per_llc"),
5402 "error should mention cores_per_llc: {err}"
5403 );
5404 }
5405
5406 #[test]
5407 fn topology_json_try_into_topology_rejects_zero_threads() {
5408 let json = TopologyJson {
5409 num_numa_nodes: 1,
5410 num_llcs: 1,
5411 cores_per_llc: 1,
5412 threads_per_core: 0,
5413 };
5414 let err = Topology::try_from(json).expect_err("zero threads_per_core must reject");
5415 assert!(
5416 err.contains("threads_per_core"),
5417 "error should mention threads_per_core: {err}"
5418 );
5419 }
5420
5421 #[test]
5422 fn topology_json_try_into_topology_rejects_indivisible_llcs() {
5423 // 3 LLCs across 2 NUMA nodes — not divisible.
5424 let json = TopologyJson {
5425 num_numa_nodes: 2,
5426 num_llcs: 3,
5427 cores_per_llc: 1,
5428 threads_per_core: 1,
5429 };
5430 let err = Topology::try_from(json).expect_err("indivisible llcs must reject");
5431 assert!(
5432 err.contains("divisible"),
5433 "error should mention divisibility: {err}"
5434 );
5435 }
5436
5437 #[test]
5438 fn topology_json_try_into_topology_rejects_overflow_total_cpus() {
5439 // 2 LLCs × (u32::MAX / 4) cores × 4 threads overflows u32.
5440 let json = TopologyJson {
5441 num_numa_nodes: 1,
5442 num_llcs: 2,
5443 cores_per_llc: u32::MAX / 4,
5444 threads_per_core: 4,
5445 };
5446 let err = Topology::try_from(json).expect_err("u32 overflow on total cpus must reject");
5447 assert!(
5448 err.contains("overflow"),
5449 "error should mention overflow: {err}"
5450 );
5451 }
5452
5453 #[test]
5454 fn topology_into_topology_json_drops_explicit_nodes_distances() {
5455 let topo = Topology {
5456 llcs: 4,
5457 cores_per_llc: 8,
5458 threads_per_core: 2,
5459 numa_nodes: 2,
5460 nodes: None,
5461 distances: None,
5462 };
5463 let json: TopologyJson = topo.into();
5464 assert_eq!(json.num_numa_nodes, 2);
5465 assert_eq!(json.num_llcs, 4);
5466 assert_eq!(json.cores_per_llc, 8);
5467 assert_eq!(json.threads_per_core, 2);
5468 }
5469
5470 #[test]
5471 fn topology_constraints_json_into_topology_constraints_preserves_fields() {
5472 let json = TopologyConstraintsJson {
5473 min_numa_nodes: 1,
5474 max_numa_nodes: Some(4),
5475 min_llcs: 2,
5476 max_llcs: Some(8),
5477 requires_smt: true,
5478 min_cpus: 4,
5479 max_cpus: Some(64),
5480 };
5481 let c: TopologyConstraints = json.into();
5482 assert_eq!(c.min_numa_nodes, 1);
5483 assert_eq!(c.max_numa_nodes, Some(4));
5484 assert_eq!(c.min_llcs, 2);
5485 assert_eq!(c.max_llcs, Some(8));
5486 assert!(c.requires_smt);
5487 assert_eq!(c.min_cpus, 4);
5488 assert_eq!(c.max_cpus, Some(64));
5489 }
5490
5491 #[test]
5492 fn topology_constraints_json_into_topology_constraints_handles_none_options() {
5493 let json = TopologyConstraintsJson {
5494 min_numa_nodes: 1,
5495 max_numa_nodes: None,
5496 min_llcs: 1,
5497 max_llcs: None,
5498 requires_smt: false,
5499 min_cpus: 1,
5500 max_cpus: None,
5501 };
5502 let c: TopologyConstraints = json.into();
5503 assert!(c.max_numa_nodes.is_none());
5504 assert!(c.max_llcs.is_none());
5505 assert!(c.max_cpus.is_none());
5506 }
5507
5508 /// Const-fn parity with the runtime `--cell-parent-cgroup` gate:
5509 /// a `cgroup_parent` declaration containing `..` must compile-fail
5510 /// (or panic at runtime when the const fn is evaluated
5511 /// dynamically). Mirrors `runtime::append_base_sched_args`
5512 /// rejecting `--cell-parent-cgroup=/foo/..` so declarative and
5513 /// CLI sources share the same validation contract.
5514 #[test]
5515 #[should_panic(expected = "must not contain `..` segments")]
5516 fn cgroup_path_new_panics_on_parent_dir_segment() {
5517 let _ = CgroupPath::new("/foo/..");
5518 }
5519
5520 /// Bare `/..` (ParentDir immediately after RootDir) — same
5521 /// rejection class as `/foo/..`, distinct shape.
5522 #[test]
5523 #[should_panic(expected = "must not contain `..` segments")]
5524 fn cgroup_path_new_panics_on_bare_parent_dir() {
5525 let _ = CgroupPath::new("/..");
5526 }
5527
5528 /// `/.` — only a CurDir segment after root, no Normal anywhere.
5529 /// Hits the `has_normal=false` final assert (CurDir segments are
5530 /// stripped per the auto-strip rule, matching Path::components).
5531 #[test]
5532 #[should_panic(expected = "at least one non-`.`/non-empty segment")]
5533 fn cgroup_path_new_panics_on_only_dot_segment() {
5534 let _ = CgroupPath::new("/.");
5535 }
5536
5537 /// Multiple consecutive slashes only — `///` produces 3 empty
5538 /// segments after the leading `/`. has_normal stays false →
5539 /// rejected by the final assert. Mirrors Path::components which
5540 /// collapses repeated separators and yields just `[RootDir]`.
5541 #[test]
5542 #[should_panic(expected = "at least one non-`.`/non-empty segment")]
5543 fn cgroup_path_new_panics_on_only_slashes() {
5544 let _ = CgroupPath::new("///");
5545 }
5546
5547 /// `/foo/./bar` is ACCEPTED — the const-fn auto-strips the
5548 /// CurDir segment, matching `Path::components` semantics that
5549 /// the runtime validator uses. The two validators stay in
5550 /// lockstep on this shape (the runtime test
5551 /// `append_base_sched_args_accepts_embedded_dot_segment` pins
5552 /// the same accept behavior on the runtime side).
5553 #[test]
5554 fn cgroup_path_new_accepts_embedded_dot_segment() {
5555 let _ = CgroupPath::new("/foo/./bar");
5556 }
5557
5558 /// Normal paths still pass — pin the accept path so a future
5559 /// over-tightening of the segment walker (e.g. rejecting
5560 /// trailing slash, or rejecting any segment of length 1) is
5561 /// caught.
5562 #[test]
5563 fn cgroup_path_new_accepts_normal_paths() {
5564 let _ = CgroupPath::new("/ktstr");
5565 let _ = CgroupPath::new("/sys/fs/cgroup/ktstr");
5566 let _ = CgroupPath::new("/a/b/c");
5567 let _ = CgroupPath::new("/foo/"); // trailing slash OK
5568 }
5569
5570 /// GAP 14: pin that `SchedulerSpec` values survive a
5571 /// `HashSet` insert/lookup roundtrip — i.e. the Hash + Eq
5572 /// derives are present and agree on every variant. A
5573 /// regression that dropped Hash (e.g. swapping to a String
5574 /// payload without re-deriving) would silently break dedup
5575 /// in gauntlet expansion (which uses HashSet to collapse
5576 /// duplicate scheduler variants across topology combinations).
5577 #[test]
5578 fn scheduler_spec_hashset_roundtrip() {
5579 use std::collections::HashSet;
5580 let mut set: HashSet<SchedulerSpec> = HashSet::new();
5581 set.insert(SchedulerSpec::Eevdf);
5582 set.insert(SchedulerSpec::Discover("scx_lavd"));
5583 set.insert(SchedulerSpec::Path("./scx_rusty"));
5584 set.insert(SchedulerSpec::KernelBuiltin {
5585 enable: &["CONFIG_SCHED_DEBUG"],
5586 disable: &["CONFIG_SCHED_AUTOGROUP"],
5587 });
5588 assert_eq!(set.len(), 4);
5589 assert!(set.contains(&SchedulerSpec::Eevdf));
5590 assert!(set.contains(&SchedulerSpec::Discover("scx_lavd")));
5591 let dup_added = set.insert(SchedulerSpec::Eevdf);
5592 assert!(
5593 !dup_added,
5594 "duplicate SchedulerSpec insert must collapse via Hash+Eq"
5595 );
5596 assert_eq!(set.len(), 4);
5597 }
5598
5599 /// GAP 14 sibling: pin that `BpfMapWrite` values survive a
5600 /// `HashSet` insert/lookup roundtrip — same rationale as
5601 /// `scheduler_spec_hashset_roundtrip` (the field uses an
5602 /// `&'static [&'static BpfMapWrite]` slice on `KtstrTestEntry`
5603 /// and dedup logic relies on the derived Hash+Eq).
5604 #[test]
5605 fn bpf_map_write_hashset_roundtrip() {
5606 use std::collections::HashSet;
5607 let w1 = BpfMapWrite::new(".data", "crash", 1);
5608 let w2 = BpfMapWrite::new(".data", "stall", 2);
5609 let w3 = BpfMapWrite::new(".other", "crash", 1);
5610 let mut set: HashSet<BpfMapWrite> = HashSet::new();
5611 set.insert(w1);
5612 set.insert(w2);
5613 set.insert(w3);
5614 assert_eq!(set.len(), 3);
5615 assert!(set.contains(&w1));
5616 let dup_added = set.insert(w1);
5617 assert!(
5618 !dup_added,
5619 "duplicate BpfMapWrite insert must collapse via Hash+Eq"
5620 );
5621 assert_eq!(set.len(), 3);
5622 }
5623
5624 // -- BpfMapWrite::new + Sysctl::new format-validation pins --
5625 //
5626 // Each `#[should_panic]` pins one branch of the const-assert
5627 // chain. A future relaxation of the format rules (e.g. dropping
5628 // the leading-`.` requirement on map_name_suffix) would have to
5629 // explicitly delete the corresponding test, surfacing the
5630 // semantic change rather than letting it pass silently.
5631
5632 /// Empty `map_name_suffix` is intent-only invalid (matches no
5633 /// loaded BPF map). const-assert catches it at construction.
5634 #[test]
5635 #[should_panic(expected = "map_name_suffix must not be empty")]
5636 fn bpf_map_write_new_rejects_empty_suffix() {
5637 let _ = BpfMapWrite::new("", "crash", 0);
5638 }
5639
5640 /// BPF section names start with `.` (`.bss`, `.data`, `.rodata`);
5641 /// a suffix without the leading `.` matches no real map.
5642 #[test]
5643 #[should_panic(expected = "must start with `.`")]
5644 fn bpf_map_write_new_rejects_missing_dot_prefix() {
5645 let _ = BpfMapWrite::new("bss", "crash", 0);
5646 }
5647
5648 #[test]
5649 #[should_panic(expected = "must not contain whitespace")]
5650 fn bpf_map_write_new_rejects_whitespace_in_suffix() {
5651 let _ = BpfMapWrite::new(".b s", "crash", 0);
5652 }
5653
5654 #[test]
5655 #[should_panic(expected = "must not contain path separators")]
5656 fn bpf_map_write_new_rejects_path_separator_in_suffix() {
5657 let _ = BpfMapWrite::new(".bss/data", "crash", 0);
5658 }
5659
5660 /// Valid construction round-trips through the getters. Pinned to
5661 /// catch a getter-vs-field divergence (e.g. if a future refactor
5662 /// caches a transformed value but forgets to update the getter).
5663 #[test]
5664 fn bpf_map_write_new_valid_round_trips() {
5665 let w = BpfMapWrite::new(".bss", "crash", 42);
5666 assert_eq!(w.map_name_suffix(), ".bss");
5667 assert_eq!(w.field(), "crash");
5668 assert_eq!(w.value(), 42);
5669 }
5670
5671 /// Empty `field` names no VAR; the write-time BTF resolver would find
5672 /// nothing. const-assert catches it at construction.
5673 #[test]
5674 #[should_panic(expected = "field must not be empty")]
5675 fn bpf_map_write_new_rejects_empty_field() {
5676 let _ = BpfMapWrite::new(".bss", "", 0);
5677 }
5678
5679 /// A BTF VAR name carries no whitespace; a spaced `field` is a typo
5680 /// that would never resolve.
5681 #[test]
5682 #[should_panic(expected = "field must not contain whitespace")]
5683 fn bpf_map_write_new_rejects_whitespace_in_field() {
5684 let _ = BpfMapWrite::new(".bss", "cr ash", 0);
5685 }
5686
5687 #[test]
5688 #[should_panic(expected = "field must not contain path separators")]
5689 fn bpf_map_write_new_rejects_path_separator_in_field() {
5690 let _ = BpfMapWrite::new(".bss", "a/b", 0);
5691 }
5692
5693 #[test]
5694 #[should_panic(expected = "field must be printable ASCII only")]
5695 fn bpf_map_write_new_rejects_control_byte_in_field() {
5696 let _ = BpfMapWrite::new(".bss", "cr\x01ash", 0);
5697 }
5698
5699 /// `.` is a dot-path separator; a leading/trailing `.` is an empty
5700 /// segment the BTF resolver could never match.
5701 #[test]
5702 #[should_panic(expected = "must not start or end with `.`")]
5703 fn bpf_map_write_new_rejects_trailing_dot_in_field() {
5704 let _ = BpfMapWrite::new(".bss", "crash.", 0);
5705 }
5706
5707 /// The leading half of the same start/end-dot guard (sibling
5708 /// `Sysctl::new` splits leading and trailing into separate pins; a
5709 /// regression dropping only the leading check must not slip past).
5710 #[test]
5711 #[should_panic(expected = "must not start or end with `.`")]
5712 fn bpf_map_write_new_rejects_leading_dot_in_field() {
5713 let _ = BpfMapWrite::new(".bss", ".crash", 0);
5714 }
5715
5716 /// A doubled `..` is an empty dot-path segment.
5717 #[test]
5718 #[should_panic(expected = "field must not contain `..`")]
5719 fn bpf_map_write_new_rejects_doubled_dot_in_field() {
5720 let _ = BpfMapWrite::new(".bss", "a..b", 0);
5721 }
5722
5723 /// An interior `.` separates non-empty dot-path segments and IS
5724 /// accepted — the field doc promises dot-paths (e.g.
5725 /// `sys_stat.avg_lat_cri`); pins that a future validator over-tightening
5726 /// to reject all `.` would break this.
5727 #[test]
5728 fn bpf_map_write_new_accepts_interior_dot_in_field() {
5729 assert_eq!(BpfMapWrite::new(".bss", "a.b", 1).field(), "a.b");
5730 }
5731
5732 #[test]
5733 #[should_panic(expected = "Sysctl key must not be empty")]
5734 fn sysctl_new_rejects_empty_key() {
5735 let _ = Sysctl::new("", "1");
5736 }
5737
5738 /// Common operator typo: writing the sysctl path in slash-form
5739 /// (`kernel/foo`) instead of dotted form (`kernel.foo`).
5740 #[test]
5741 #[should_panic(expected = "must use the dotted form")]
5742 fn sysctl_new_rejects_slash_in_key() {
5743 let _ = Sysctl::new("kernel/foo", "1");
5744 }
5745
5746 /// A bare single-segment key (`foo` instead of `kernel.foo`) is
5747 /// almost certainly a typo — the sysctl tree is always at least
5748 /// 2 segments deep.
5749 #[test]
5750 #[should_panic(expected = "must be namespaced")]
5751 fn sysctl_new_rejects_undotted_key() {
5752 let _ = Sysctl::new("foo", "1");
5753 }
5754
5755 #[test]
5756 #[should_panic(expected = "must not start or end with `.`")]
5757 fn sysctl_new_rejects_leading_dot_key() {
5758 let _ = Sysctl::new(".foo", "1");
5759 }
5760
5761 #[test]
5762 #[should_panic(expected = "must not start or end with `.`")]
5763 fn sysctl_new_rejects_trailing_dot_key() {
5764 let _ = Sysctl::new("foo.", "1");
5765 }
5766
5767 #[test]
5768 #[should_panic(expected = "value must not be empty")]
5769 fn sysctl_new_rejects_empty_value() {
5770 let _ = Sysctl::new("kernel.foo", "");
5771 }
5772
5773 #[test]
5774 #[should_panic(expected = "must not contain a newline")]
5775 fn sysctl_new_rejects_newline_in_value() {
5776 let _ = Sysctl::new("kernel.foo", "1\n2");
5777 }
5778
5779 /// `=` in the value would corrupt the `sysctl.<key>=<value>`
5780 /// cmdline form (parser would split on the wrong `=`).
5781 #[test]
5782 #[should_panic(expected = "must not contain `=`")]
5783 fn sysctl_new_rejects_equals_in_value() {
5784 let _ = Sysctl::new("kernel.foo", "1=2");
5785 }
5786
5787 #[test]
5788 fn sysctl_new_valid_round_trips() {
5789 let s = Sysctl::new("kernel.sched_cfs_bandwidth_slice_us", "1000");
5790 assert_eq!(s.key(), "kernel.sched_cfs_bandwidth_slice_us");
5791 assert_eq!(s.value(), "1000");
5792 }
5793
5794 // -- additional Sysctl key/value rejection cases --
5795
5796 #[test]
5797 #[should_panic(expected = "must not contain whitespace")]
5798 fn sysctl_new_rejects_space_in_key() {
5799 let _ = Sysctl::new(" kernel.foo", "1");
5800 }
5801
5802 #[test]
5803 #[should_panic(expected = "must not contain whitespace")]
5804 fn sysctl_new_rejects_tab_in_key() {
5805 let _ = Sysctl::new("kernel\t.foo", "1");
5806 }
5807
5808 #[test]
5809 #[should_panic(expected = "must not contain whitespace")]
5810 fn sysctl_new_rejects_newline_in_key() {
5811 let _ = Sysctl::new("kernel\n.foo", "1");
5812 }
5813
5814 #[test]
5815 #[should_panic(expected = "must not contain `=`")]
5816 fn sysctl_new_rejects_equals_in_key() {
5817 let _ = Sysctl::new("kernel=foo.bar", "1");
5818 }
5819
5820 #[test]
5821 #[should_panic(expected = "must be printable ASCII")]
5822 fn sysctl_new_rejects_control_byte_in_key() {
5823 let _ = Sysctl::new("kernel.\x01foo", "1");
5824 }
5825
5826 #[test]
5827 #[should_panic(expected = "must not contain `..`")]
5828 fn sysctl_new_rejects_double_dot_in_key() {
5829 let _ = Sysctl::new("kernel..foo", "1");
5830 }
5831
5832 #[test]
5833 #[should_panic(expected = "must not contain a carriage return")]
5834 fn sysctl_new_rejects_carriage_return_in_value() {
5835 let _ = Sysctl::new("kernel.foo", "1\r2");
5836 }
5837
5838 // -- additional BpfMapWrite suffix rejection cases --
5839
5840 #[test]
5841 #[should_panic(expected = "must be longer than a bare `.`")]
5842 fn bpf_map_write_new_rejects_bare_dot() {
5843 let _ = BpfMapWrite::new(".", "crash", 0);
5844 }
5845
5846 #[test]
5847 #[should_panic(expected = "must not start with `..`")]
5848 fn bpf_map_write_new_rejects_leading_double_dot() {
5849 let _ = BpfMapWrite::new("..bss", "crash", 0);
5850 }
5851
5852 #[test]
5853 #[should_panic(expected = "must be printable ASCII")]
5854 fn bpf_map_write_new_rejects_null_byte_in_suffix() {
5855 let _ = BpfMapWrite::new(".bss\0", "crash", 0);
5856 }
5857
5858 #[test]
5859 #[should_panic(expected = "must be printable ASCII")]
5860 fn bpf_map_write_new_rejects_control_byte_in_suffix() {
5861 let _ = BpfMapWrite::new(".b\x01ss", "crash", 0);
5862 }
5863
5864 /// Lock-step pin: `KtstrTestEntry::default()` must agree with
5865 /// `KtstrTestEntry::DEFAULT` field-by-field. The trait impl
5866 /// today delegates to the const, but a future divergence (e.g.
5867 /// someone "improves" `Default` to seed a different memory
5868 /// budget) would silently produce two construction paths
5869 /// disagreeing on what a baseline entry looks like —
5870 /// `..Default::default()` callers and `..KtstrTestEntry::DEFAULT`
5871 /// callers would yield different runs from "identical" code.
5872 #[test]
5873 fn ktstr_test_entry_default_matches_const() {
5874 let from_const = KtstrTestEntry::DEFAULT;
5875 let from_trait: KtstrTestEntry = Default::default();
5876 assert_eq!(from_trait.name, from_const.name);
5877 assert!(std::ptr::fn_addr_eq(from_trait.func, from_const.func));
5878 assert_eq!(from_trait.topology.llcs, from_const.topology.llcs);
5879 assert_eq!(
5880 from_trait.topology.cores_per_llc,
5881 from_const.topology.cores_per_llc
5882 );
5883 assert_eq!(
5884 from_trait.topology.threads_per_core,
5885 from_const.topology.threads_per_core
5886 );
5887 assert_eq!(
5888 from_trait.topology.numa_nodes,
5889 from_const.topology.numa_nodes
5890 );
5891 assert_eq!(from_trait.memory_mib, from_const.memory_mib);
5892 // Both default paths delegate to `Self::DEFAULT`, which sets
5893 // `scheduler: &Scheduler::EEVDF`. Rust may or may not dedupe
5894 // the `&CONST` materializations to the same address — pointer
5895 // equality is an implementation detail, not a contract.
5896 // Verify the scheduler-identity contract by NAME (the EEVDF
5897 // baseline has a unique stable name).
5898 assert_eq!(from_trait.scheduler.name, from_const.scheduler.name);
5899 assert_eq!(from_trait.scheduler.name, "eevdf");
5900 assert!(from_trait.payload.is_none() && from_const.payload.is_none());
5901 // Element-wise slice equality catches same-length content
5902 // drift (e.g. two different `&["--baseline=X"]` slices both
5903 // length 1 but with different X). Length-only compare would
5904 // miss that.
5905 assert_eq!(
5906 from_trait.workloads.len(),
5907 from_const.workloads.len(),
5908 "workloads count drift"
5909 );
5910 for (i, (a, b)) in from_trait
5911 .workloads
5912 .iter()
5913 .zip(from_const.workloads.iter())
5914 .enumerate()
5915 {
5916 assert!(
5917 std::ptr::eq(*a, *b),
5918 "workloads[{i}] pointer identity drift"
5919 );
5920 }
5921 assert_eq!(from_trait.auto_repro, from_const.auto_repro);
5922 assert_eq!(
5923 from_trait.extra_sched_args, from_const.extra_sched_args,
5924 "extra_sched_args content drift"
5925 );
5926 assert_eq!(from_trait.watchdog_timeout, from_const.watchdog_timeout);
5927 assert_eq!(
5928 from_trait.bpf_map_write.len(),
5929 from_const.bpf_map_write.len(),
5930 "bpf_map_write count drift"
5931 );
5932 for (i, (a, b)) in from_trait
5933 .bpf_map_write
5934 .iter()
5935 .zip(from_const.bpf_map_write.iter())
5936 .enumerate()
5937 {
5938 assert!(
5939 std::ptr::eq(*a, *b),
5940 "bpf_map_write[{i}] pointer identity drift"
5941 );
5942 }
5943 assert_eq!(
5944 from_trait.watch_bpf_maps.len(),
5945 from_const.watch_bpf_maps.len(),
5946 "watch_bpf_maps count drift"
5947 );
5948 for (i, (a, b)) in from_trait
5949 .watch_bpf_maps
5950 .iter()
5951 .zip(from_const.watch_bpf_maps.iter())
5952 .enumerate()
5953 {
5954 assert!(
5955 std::ptr::eq(*a, *b),
5956 "watch_bpf_maps[{i}] pointer identity drift"
5957 );
5958 }
5959 assert_eq!(from_trait.performance_mode, from_const.performance_mode);
5960 assert_eq!(from_trait.no_perf_mode, from_const.no_perf_mode);
5961 assert_eq!(from_trait.duration, from_const.duration);
5962 assert_eq!(from_trait.expect_err, from_const.expect_err);
5963 assert_eq!(from_trait.host_only, from_const.host_only);
5964 assert_eq!(
5965 from_trait.extra_include_files, from_const.extra_include_files,
5966 "extra_include_files content drift"
5967 );
5968 assert_eq!(from_trait.cleanup_budget, from_const.cleanup_budget);
5969 assert_eq!(from_trait.config_content, from_const.config_content);
5970 assert!(from_trait.disk.is_none() && from_const.disk.is_none());
5971 assert!(from_trait.post_vm.is_none() && from_const.post_vm.is_none());
5972 assert_eq!(from_trait.num_snapshots, from_const.num_snapshots);
5973 // `assert` field lock-step: Assert does not derive PartialEq,
5974 // so compare via `format_human()` (renders every
5975 // threshold field) plus the explicit bool field that
5976 // format_human omits.
5977 assert_eq!(
5978 from_trait.assert.format_human(),
5979 from_const.assert.format_human(),
5980 "Assert threshold-field drift between Default::default() and DEFAULT"
5981 );
5982 assert_eq!(
5983 from_trait.assert.enforce_monitor_thresholds,
5984 from_const.assert.enforce_monitor_thresholds,
5985 "Assert.enforce_monitor_thresholds drift between Default::default() and DEFAULT"
5986 );
5987 }
5988
5989 /// `default_post_vm_periodic_fired` MUST return Ok when periodic
5990 /// was NOT configured (`periodic_target == 0`) — the helper is
5991 /// a no-op for non-periodic tests so they pass through the
5992 /// macro-default path without spurious assertions.
5993 #[test]
5994 fn default_post_vm_periodic_fired_skips_when_periodic_disabled() {
5995 let r = crate::vmm::VmResult {
5996 periodic_target: 0,
5997 periodic_fired: 0,
5998 ..crate::vmm::VmResult::test_fixture()
5999 };
6000 super::default_post_vm_periodic_fired(&r)
6001 .expect("periodic_target == 0 must short-circuit to Ok");
6002 }
6003
6004 /// Build a synthetic non-placeholder `FailureDumpReport` —
6005 /// minimal but with `is_placeholder = false`. Used in the
6006 /// helpers below to drive the bridge's `periodic_real_count`
6007 /// without booting a real VM.
6008 fn real_report() -> crate::monitor::dump::FailureDumpReport {
6009 // Default puts `is_placeholder = false`; spell it explicitly
6010 // so a future Default-shape change doesn't silently flip the
6011 // test fixture.
6012 crate::monitor::dump::FailureDumpReport {
6013 schema: crate::monitor::dump::SCHEMA_SINGLE.to_string(),
6014 is_placeholder: false,
6015 ..Default::default()
6016 }
6017 }
6018
6019 fn placeholder_report(reason: &str) -> crate::monitor::dump::FailureDumpReport {
6020 crate::monitor::dump::FailureDumpReport::placeholder(reason)
6021 }
6022
6023 /// When periodic WAS configured AND at least one REAL (non-
6024 /// placeholder) snapshot landed on the bridge, the helper
6025 /// returns Ok. Pins the smoke-floor contract.
6026 #[test]
6027 fn default_post_vm_periodic_fired_ok_when_at_least_one_real_landed() {
6028 let r = crate::vmm::VmResult {
6029 periodic_target: 5,
6030 periodic_fired: 1,
6031 ..crate::vmm::VmResult::test_fixture()
6032 };
6033 r.snapshot_bridge.store("periodic_000", real_report());
6034 super::default_post_vm_periodic_fired(&r)
6035 .expect("at least one real periodic capture must surface as Ok");
6036 }
6037
6038 /// When periodic WAS configured AND every fired snapshot was
6039 /// only a placeholder (rendezvous-timeout or gate-suppressed),
6040 /// the helper MUST fail — the scheduler attached but produced
6041 /// zero useful BPF state. This is the case the new semantic
6042 /// catches that the old `periodic_fired >= 1` floor missed.
6043 #[test]
6044 fn default_post_vm_periodic_fired_fails_when_only_placeholders_landed() {
6045 let r = crate::vmm::VmResult {
6046 periodic_target: 3,
6047 periodic_fired: 3,
6048 ..crate::vmm::VmResult::test_fixture()
6049 };
6050 // All three fires produced placeholders.
6051 r.snapshot_bridge
6052 .store("periodic_000", placeholder_report("rendezvous timed out"));
6053 r.snapshot_bridge
6054 .store("periodic_001", placeholder_report("rendezvous timed out"));
6055 r.snapshot_bridge
6056 .store("periodic_002", placeholder_report("rendezvous timed out"));
6057 let err = super::default_post_vm_periodic_fired(&r)
6058 .expect_err("placeholder-only fills must surface as Err");
6059 let msg = err.to_string();
6060 assert!(
6061 msg.contains("no periodic snapshot produced real BPF state")
6062 && msg.contains("periodic_real_count=0")
6063 && msg.contains("periodic_fired=3")
6064 && msg.contains("target=3"),
6065 "diagnostic must name the real-count floor + carry both counters; got {msg}",
6066 );
6067 }
6068
6069 /// Pins the load-bearing tag-prefix discrimination: a bridge
6070 /// that holds ONLY non-periodic tagged reports (user
6071 /// `Op::CaptureSnapshot` captures, watchpoint fires, etc.)
6072 /// MUST surface `periodic_real_count == 0` even when those
6073 /// non-periodic reports are real (non-placeholder) and present
6074 /// in number. A regression that conflated "any real report" with
6075 /// "periodic real report" would pass this state spuriously.
6076 #[test]
6077 fn default_post_vm_periodic_fired_fails_when_only_non_periodic_tagged_reports_present() {
6078 let r = crate::vmm::VmResult {
6079 periodic_target: 3,
6080 periodic_fired: 3,
6081 ..crate::vmm::VmResult::test_fixture()
6082 };
6083 // Populate ONLY non-periodic-tagged reports — these come
6084 // from user `Op::CaptureSnapshot` calls or watchpoint
6085 // fires, not from the periodic dispatch loop. They MUST
6086 // NOT pollute the periodic floor.
6087 r.snapshot_bridge.store("user_capture", real_report());
6088 r.snapshot_bridge.store("snapshot_baseline", real_report());
6089 r.snapshot_bridge.store("watch_my_var", real_report());
6090 let err = super::default_post_vm_periodic_fired(&r).expect_err(
6091 "non-periodic tags MUST NOT count toward periodic_real_count; \
6092 bridge has 3 real reports but none periodic → must Err",
6093 );
6094 let msg = err.to_string();
6095 assert!(
6096 msg.contains("periodic_real_count=0"),
6097 "diagnostic must name the zero real-count; got {msg}",
6098 );
6099 }
6100
6101 /// When periodic WAS configured AND ONE real capture landed
6102 /// alongside N placeholder timeouts (the realistic flake case),
6103 /// the helper MUST return Ok — single-snapshot timeouts under
6104 /// load shouldn't punish a working scheduler. Pins the
6105 /// tolerance gap that the strict `== periodic_target` floor
6106 /// would have failed.
6107 #[test]
6108 fn default_post_vm_periodic_fired_ok_when_one_real_among_placeholders() {
6109 let r = crate::vmm::VmResult {
6110 periodic_target: 5,
6111 periodic_fired: 5,
6112 ..crate::vmm::VmResult::test_fixture()
6113 };
6114 r.snapshot_bridge
6115 .store("periodic_000", placeholder_report("rendezvous timed out"));
6116 r.snapshot_bridge.store("periodic_001", real_report());
6117 r.snapshot_bridge
6118 .store("periodic_002", placeholder_report("rendezvous timed out"));
6119 r.snapshot_bridge
6120 .store("periodic_003", placeholder_report("rendezvous timed out"));
6121 r.snapshot_bridge
6122 .store("periodic_004", placeholder_report("rendezvous timed out"));
6123 super::default_post_vm_periodic_fired(&r).expect(
6124 "one real capture among placeholders must Ok — tolerance for single-snapshot flakes",
6125 );
6126 }
6127
6128 /// Pins the early-return semantic in `default_post_vm_periodic_fired`
6129 /// (its `if result.periodic_target == 0` early return): the helper
6130 /// short-circuits to Ok on `periodic_target == 0` BEFORE
6131 /// checking `periodic_fired`. A regression that swapped these
6132 /// checks (e.g., "require fired matches target unconditionally")
6133 /// would break tests with `target=0, fired=5` (synthesized
6134 /// firings without a configured target). Currently impossible
6135 /// in production but cheap to pin.
6136 #[test]
6137 fn default_post_vm_periodic_fired_target_zero_fired_nonzero_returns_ok() {
6138 let r = crate::vmm::VmResult {
6139 periodic_target: 0,
6140 periodic_fired: 5,
6141 ..crate::vmm::VmResult::test_fixture()
6142 };
6143 super::default_post_vm_periodic_fired(&r)
6144 .expect("target == 0 takes priority over fired count; must Ok");
6145 }
6146
6147 /// When `result.success == false` the underlying VM run
6148 /// failed (crash / timeout / signal). The default helper MUST
6149 /// short-circuit to Ok so the runner's own failure-rendering
6150 /// path drives the diagnostic; emitting `periodic_fired=0` on
6151 /// top would mask the real cause. Even if periodic was
6152 /// configured AND no snapshot fired, the success=false branch
6153 /// dominates.
6154 #[test]
6155 fn default_post_vm_periodic_fired_skips_when_vm_run_failed() {
6156 let r = crate::vmm::VmResult {
6157 success: false,
6158 periodic_target: 5,
6159 periodic_fired: 0,
6160 ..crate::vmm::VmResult::test_fixture()
6161 };
6162 super::default_post_vm_periodic_fired(&r)
6163 .expect("vm-run-failed must short-circuit to Ok so the runner's diagnostic dominates");
6164 }
6165
6166 /// When periodic WAS configured BUT NO snapshot fired, the
6167 /// helper MUST return Err carrying both observed counters in
6168 /// the diagnostic. Pins that a regression to "always Ok" would
6169 /// surface immediately.
6170 #[test]
6171 fn default_post_vm_periodic_fired_fails_when_none_fired() {
6172 let r = crate::vmm::VmResult {
6173 periodic_target: 5,
6174 periodic_fired: 0,
6175 ..crate::vmm::VmResult::test_fixture()
6176 };
6177 let err = super::default_post_vm_periodic_fired(&r)
6178 .expect_err("periodic_fired == 0 with target > 0 must surface as Err");
6179 let msg = err.to_string();
6180 assert!(
6181 msg.contains("periodic_fired=0") && msg.contains("target=5"),
6182 "diagnostic must carry both counters; got {msg}",
6183 );
6184 }
6185
6186 /// `KtstrTestEntry::DEFAULT.expect_auto_repro` is `false`.
6187 /// Backward-compat pin: an existing entry constructed via
6188 /// `..KtstrTestEntry::DEFAULT` (the canonical spread-init
6189 /// pattern in the macro codegen and in user fixtures) MUST
6190 /// carry `expect_auto_repro = false` so the new assertion
6191 /// opt-in stays off by default. A regression that flipped the
6192 /// default to `true` would silently invert every failing
6193 /// test's verdict through the dispatch arm.
6194 #[test]
6195 fn default_expect_auto_repro_is_false() {
6196 const {
6197 assert!(
6198 !super::KtstrTestEntry::DEFAULT.expect_auto_repro,
6199 "DEFAULT must keep expect_auto_repro at false for backward-compat opt-in semantics"
6200 );
6201 }
6202 }
6203
6204 /// `with_expect_auto_repro(true)` flips the field on; passing
6205 /// `false` flips it back off. Pins the builder symmetry
6206 /// against the sibling `with_auto_repro` / `with_expect_err` /
6207 /// `with_host_only` setters and guards against a missing-
6208 /// setter regression for the new pub field.
6209 #[test]
6210 fn with_expect_auto_repro_sets_field() {
6211 let entry = super::KtstrTestEntry::DEFAULT.with_expect_auto_repro(true);
6212 assert!(
6213 entry.expect_auto_repro,
6214 "with_expect_auto_repro(true) must set the field true"
6215 );
6216 let entry = entry.with_expect_auto_repro(false);
6217 assert!(
6218 !entry.expect_auto_repro,
6219 "with_expect_auto_repro(false) must flip the field back to false"
6220 );
6221 }
6222
6223 // -- with_disk / without_disk builder methods --
6224 //
6225 // The field-via-struct-literal path is covered by the
6226 // validate_* host_only/disk tests above; these pin the
6227 // chainable setter/clearer pair (the documented programmatic
6228 // construction path) so a missing-setter or
6229 // sets-the-wrong-field regression surfaces. DiskConfig does not
6230 // derive PartialEq, so the round-trip is asserted on the
6231 // distinguishing `capacity_mib` field rather than whole-struct
6232 // equality.
6233
6234 #[test]
6235 fn with_disk_sets_some_and_carries_config() {
6236 // Distinguishing capacity (512 != DEFAULT 256) proves the
6237 // setter stores the SUPPLIED config, not a fresh default.
6238 let cfg = crate::vmm::disk_config::DiskConfig::DEFAULT.capacity_mib(512);
6239 let entry = KtstrTestEntry::DEFAULT
6240 .with_name("disk_setter")
6241 .with_disk(cfg);
6242 let got = entry.disk.expect("with_disk must populate Some");
6243 assert_eq!(
6244 got.capacity_mib, 512,
6245 "with_disk must carry the supplied DiskConfig's capacity_mib"
6246 );
6247 // DEFAULT starts with no disk — the setter is what changed it.
6248 assert!(
6249 KtstrTestEntry::DEFAULT.disk.is_none(),
6250 "DEFAULT must boot without a disk; with_disk is the only mutator here"
6251 );
6252 }
6253
6254 #[test]
6255 fn without_disk_clears_to_none() {
6256 let cfg = crate::vmm::disk_config::DiskConfig::DEFAULT.capacity_mib(512);
6257 let entry = KtstrTestEntry::DEFAULT
6258 .with_name("disk_clear")
6259 .with_disk(cfg)
6260 .without_disk();
6261 assert!(
6262 entry.disk.is_none(),
6263 "without_disk must return the disk field to None"
6264 );
6265 }
6266
6267 // -- with_networks / without_networks builder methods --
6268
6269 #[test]
6270 fn with_networks_carries_config_and_order() {
6271 // Distinguishing MACs (!= DEFAULT 02:00:00:00:00:01) prove the
6272 // setter stores the SUPPLIED list, in order. Two NICs pin the
6273 // multi-element path.
6274 const MAC0: [u8; 6] = [0x52, 0x54, 0x00, 0xab, 0xcd, 0xef];
6275 const MAC1: [u8; 6] = [0x52, 0x54, 0x00, 0x11, 0x22, 0x33];
6276 // A `const` slice binding promotes its borrow to `'static` (the
6277 // `.mac()` const-fn call blocks ad-hoc promotion of an inline `&[..]`).
6278 const NETS: &[crate::vmm::net_config::NetConfig] = &[
6279 crate::vmm::net_config::NetConfig::DEFAULT.mac(MAC0),
6280 crate::vmm::net_config::NetConfig::DEFAULT.mac(MAC1),
6281 ];
6282 let entry = KtstrTestEntry::DEFAULT
6283 .with_name("net_setter")
6284 .with_networks(NETS);
6285 assert_eq!(
6286 entry.networks.len(),
6287 2,
6288 "with_networks must store every NIC"
6289 );
6290 assert_eq!(
6291 entry.networks[0].mac, MAC0,
6292 "first NIC carries MAC0 in order"
6293 );
6294 assert_eq!(
6295 entry.networks[1].mac, MAC1,
6296 "second NIC carries MAC1 in order"
6297 );
6298 assert!(
6299 KtstrTestEntry::DEFAULT.networks.is_empty(),
6300 "DEFAULT must boot without a NIC; with_networks is the only mutator here"
6301 );
6302 }
6303
6304 #[test]
6305 fn without_networks_clears_to_empty() {
6306 let entry = KtstrTestEntry::DEFAULT
6307 .with_name("net_clear")
6308 .with_networks(&[crate::vmm::net_config::NetConfig::DEFAULT])
6309 .without_networks();
6310 assert!(
6311 entry.networks.is_empty(),
6312 "without_networks must return the networks field to empty"
6313 );
6314 }
6315
6316 /// `without_disk` does not touch `networks` and vice versa — the
6317 /// two clearers are independent. Pins against a copy-paste
6318 /// regression where one clearer nulls the wrong field.
6319 #[test]
6320 fn disk_and_network_clearers_are_independent() {
6321 // DiskConfig is Clone (not Copy), so build a fresh one per entry;
6322 // the NIC list is a `const` slice binding ('static-promoted — the
6323 // `.mac()` const-fn call blocks ad-hoc promotion of an inline `&[..]`).
6324 const NET: &[crate::vmm::net_config::NetConfig] =
6325 &[crate::vmm::net_config::NetConfig::DEFAULT.mac([0x52, 0x54, 0, 0, 0, 9])];
6326 // Clear disk only: networks must survive.
6327 let e1 = KtstrTestEntry::DEFAULT
6328 .with_name("indep_disk")
6329 .with_disk(crate::vmm::disk_config::DiskConfig::DEFAULT.capacity_mib(512))
6330 .with_networks(NET)
6331 .without_disk();
6332 assert!(e1.disk.is_none(), "without_disk must clear disk");
6333 assert!(
6334 !e1.networks.is_empty(),
6335 "without_disk must NOT clear networks"
6336 );
6337 // Clear networks only: disk must survive.
6338 let e2 = KtstrTestEntry::DEFAULT
6339 .with_name("indep_net")
6340 .with_disk(crate::vmm::disk_config::DiskConfig::DEFAULT.capacity_mib(512))
6341 .with_networks(NET)
6342 .without_networks();
6343 assert!(
6344 e2.networks.is_empty(),
6345 "without_networks must clear networks"
6346 );
6347 assert!(e2.disk.is_some(), "without_networks must NOT clear disk");
6348 }
6349
6350 // -- with_num_snapshots builder method --
6351
6352 /// `with_num_snapshots` stores the exact supplied count and a
6353 /// second call overrides it (the field is a plain `u32`, not an
6354 /// accumulator). DEFAULT is `0` (periodic disabled), so the
6355 /// setter is the only mutator.
6356 #[test]
6357 fn with_num_snapshots_sets_and_overrides_exact_count() {
6358 assert_eq!(
6359 KtstrTestEntry::DEFAULT.num_snapshots,
6360 0,
6361 "DEFAULT disables periodic capture (num_snapshots == 0)"
6362 );
6363 let entry = KtstrTestEntry::DEFAULT
6364 .with_name("snap")
6365 .with_num_snapshots(4);
6366 assert_eq!(
6367 entry.num_snapshots, 4,
6368 "with_num_snapshots must store the exact supplied count"
6369 );
6370 // A second call overrides rather than accumulating.
6371 let entry = entry.with_num_snapshots(0);
6372 assert_eq!(
6373 entry.num_snapshots, 0,
6374 "with_num_snapshots must OVERRIDE, not add to, the prior count"
6375 );
6376 }
6377
6378 // -- with_post_vm_unconditional / without_post_vm_unconditional --
6379
6380 fn unconditional_cb_ok(_r: &crate::vmm::VmResult) -> anyhow::Result<()> {
6381 Ok(())
6382 }
6383 fn conditional_cb_ok(_r: &crate::vmm::VmResult) -> anyhow::Result<()> {
6384 Ok(())
6385 }
6386
6387 #[test]
6388 fn with_post_vm_unconditional_sets_field() {
6389 assert!(
6390 KtstrTestEntry::DEFAULT.post_vm_unconditional.is_none(),
6391 "DEFAULT must leave post_vm_unconditional unset"
6392 );
6393 let entry = KtstrTestEntry::DEFAULT
6394 .with_name("uncond")
6395 .with_post_vm_unconditional(unconditional_cb_ok);
6396 let cb = entry
6397 .post_vm_unconditional
6398 .expect("with_post_vm_unconditional must populate Some");
6399 // Pointer identity proves the setter stored the SUPPLIED fn
6400 // pointer, not some sentinel.
6401 assert!(
6402 std::ptr::fn_addr_eq(
6403 cb,
6404 unconditional_cb_ok as crate::test_support::PostVmCallback
6405 ),
6406 "with_post_vm_unconditional must carry the supplied callback fn pointer"
6407 );
6408 }
6409
6410 #[test]
6411 fn without_post_vm_unconditional_clears_to_none() {
6412 let entry = KtstrTestEntry::DEFAULT
6413 .with_name("uncond_clear")
6414 .with_post_vm_unconditional(unconditional_cb_ok)
6415 .without_post_vm_unconditional();
6416 assert!(
6417 entry.post_vm_unconditional.is_none(),
6418 "without_post_vm_unconditional must return the field to None"
6419 );
6420 }
6421
6422 /// `without_post_vm_unconditional` must NOT disturb the
6423 /// conditional `post_vm` slot — the two callback slots are
6424 /// independent (a guest-success entry runs both; only
6425 /// `post_vm` is gated on guest-fail). Pins the docstring claim
6426 /// on `without_post_vm_unconditional` ("Does not affect the
6427 /// conditional with_post_vm callback if one is set").
6428 #[test]
6429 fn without_post_vm_unconditional_leaves_conditional_post_vm_intact() {
6430 let entry = KtstrTestEntry::DEFAULT
6431 .with_name("both_then_clear_uncond")
6432 .with_post_vm(conditional_cb_ok)
6433 .with_post_vm_unconditional(unconditional_cb_ok)
6434 .without_post_vm_unconditional();
6435 assert!(
6436 entry.post_vm_unconditional.is_none(),
6437 "without_post_vm_unconditional must clear its own slot"
6438 );
6439 let cond = entry
6440 .post_vm
6441 .expect("conditional post_vm must survive the unconditional clear");
6442 assert!(
6443 std::ptr::fn_addr_eq(
6444 cond,
6445 conditional_cb_ok as crate::test_support::PostVmCallback
6446 ),
6447 "the surviving post_vm must still be the conditional callback"
6448 );
6449 }
6450
6451 /// Both callback slots can be set on the same entry and carry
6452 /// DISTINCT fn pointers — pins the field doc on
6453 /// `post_vm_unconditional` ("Both post_vm and
6454 /// post_vm_unconditional may be set on the same entry").
6455 #[test]
6456 fn post_vm_and_unconditional_slots_are_separate() {
6457 let entry = KtstrTestEntry::DEFAULT
6458 .with_name("both_slots")
6459 .with_post_vm(conditional_cb_ok)
6460 .with_post_vm_unconditional(unconditional_cb_ok);
6461 let cond = entry.post_vm.expect("post_vm set");
6462 let uncond = entry
6463 .post_vm_unconditional
6464 .expect("post_vm_unconditional set");
6465 assert!(
6466 std::ptr::fn_addr_eq(
6467 cond,
6468 conditional_cb_ok as crate::test_support::PostVmCallback
6469 ),
6470 "post_vm slot must hold the conditional callback"
6471 );
6472 assert!(
6473 std::ptr::fn_addr_eq(
6474 uncond,
6475 unconditional_cb_ok as crate::test_support::PostVmCallback
6476 ),
6477 "post_vm_unconditional slot must hold the unconditional callback"
6478 );
6479 assert!(
6480 !std::ptr::fn_addr_eq(cond, uncond),
6481 "the two slots must hold DISTINCT fn pointers, not alias each other"
6482 );
6483 }
6484
6485 // -- SchedulerJson::from_scheduler --
6486 //
6487 // `from_scheduler` projects a `&Scheduler` into the JSON wire
6488 // shape the `--ktstr-list-schedulers` ctor emits. The binary_kind
6489 // tag is the load-bearing projection — the verifier dispatch
6490 // `match`es on it without re-parsing the string.
6491
6492 /// Each `SchedulerSpec` variant maps to the corresponding
6493 /// `BinaryKindJson` tag, carrying the binary string for
6494 /// Discover/Path and erasing it (no BPF to verify) for
6495 /// Eevdf/KernelBuiltin. Table-driven over all four variants so a
6496 /// future variant addition that forgets a `from_scheduler` arm
6497 /// fails here.
6498 #[test]
6499 fn from_scheduler_projects_binary_kind_for_every_spec() {
6500 // Discover → Discover(name)
6501 let s = Scheduler::named("disc").binary(SchedulerSpec::Discover("scx_rusty"));
6502 assert_eq!(
6503 SchedulerJson::from_scheduler(&s).binary_kind,
6504 BinaryKindJson::Discover("scx_rusty".to_string()),
6505 );
6506 // Path → Path(p)
6507 let s = Scheduler::named("pth").binary(SchedulerSpec::Path("/usr/bin/scx_x"));
6508 assert_eq!(
6509 SchedulerJson::from_scheduler(&s).binary_kind,
6510 BinaryKindJson::Path("/usr/bin/scx_x".to_string()),
6511 );
6512 // Eevdf → Eevdf (unit, string erased)
6513 let s = Scheduler::named("ee").binary(SchedulerSpec::Eevdf);
6514 assert_eq!(
6515 SchedulerJson::from_scheduler(&s).binary_kind,
6516 BinaryKindJson::Eevdf,
6517 );
6518 // KernelBuiltin → KernelBuiltin (unit; enable/disable commands erased)
6519 let s = Scheduler::named("kb").binary(SchedulerSpec::KernelBuiltin {
6520 enable: &["echo on"],
6521 disable: &["echo off"],
6522 });
6523 assert_eq!(
6524 SchedulerJson::from_scheduler(&s).binary_kind,
6525 BinaryKindJson::KernelBuiltin,
6526 );
6527 }
6528
6529 /// `from_scheduler` copies `name`, the four topology dimensions,
6530 /// `sched_args`, `kernels`, and every constraint field into the
6531 /// wire shape. `EEVDF` defaults the topology to
6532 /// 1 numa × 1 llc × 2 cores × 1 thread (see `Scheduler::EEVDF`),
6533 /// and the constraints to `TopologyConstraints::DEFAULT`. Pins
6534 /// every projected field so a mis-mapped dimension (e.g. swapping
6535 /// llcs and cores) surfaces.
6536 #[test]
6537 fn from_scheduler_copies_name_topology_args_kernels_constraints() {
6538 let json = SchedulerJson::from_scheduler(&Scheduler::EEVDF);
6539 assert_eq!(json.name, "eevdf");
6540 // EEVDF topology baseline.
6541 assert_eq!(json.topology.num_numa_nodes, 1);
6542 assert_eq!(json.topology.num_llcs, 1);
6543 assert_eq!(json.topology.cores_per_llc, 2);
6544 assert_eq!(json.topology.threads_per_core, 1);
6545 // EEVDF carries no sched_args, no kernel filter.
6546 assert!(json.sched_args.is_empty());
6547 assert!(json.kernels.is_empty());
6548 // Constraints mirror TopologyConstraints::DEFAULT.
6549 assert_eq!(json.constraints.min_numa_nodes, 1);
6550 assert_eq!(json.constraints.max_numa_nodes, Some(1));
6551 assert_eq!(json.constraints.min_llcs, 1);
6552 assert_eq!(json.constraints.max_llcs, Some(12));
6553 assert!(!json.constraints.requires_smt);
6554 assert_eq!(json.constraints.min_cpus, 1);
6555 assert_eq!(json.constraints.max_cpus, Some(192));
6556 }
6557
6558 /// Non-default fields round-trip through the projection: a
6559 /// scheduler with an overridden topology, explicit `sched_args`,
6560 /// `kernels`, and tightened `constraints` projects each verbatim.
6561 /// `Scheduler::topology(numa, llcs, cores, threads)` maps the
6562 /// argument order to the `Topology` fields, so this also pins
6563 /// that the projection reads `num_numa_nodes()`/`num_llcs()`
6564 /// (not the raw struct fields in a swapped order).
6565 #[test]
6566 fn from_scheduler_projects_overridden_fields_verbatim() {
6567 static ARGS: &[&str] = &["--slice-us", "20000"];
6568 static KERNELS: &[&str] = &["6.14", "6.15..6.16"];
6569 let s = Scheduler::named("custom")
6570 .binary_discover("scx_custom")
6571 // 2 numa × 4 llcs × 8 cores × 2 threads
6572 .topology(2, 4, 8, 2)
6573 .sched_args(ARGS)
6574 .kernels(KERNELS)
6575 .constraints(
6576 TopologyConstraints::DEFAULT
6577 .with_min_numa_nodes(2)
6578 .with_max_numa_nodes(4)
6579 .with_requires_smt(true),
6580 );
6581 let json = SchedulerJson::from_scheduler(&s);
6582 assert_eq!(json.name, "custom");
6583 assert_eq!(
6584 json.binary_kind,
6585 BinaryKindJson::Discover("scx_custom".to_string())
6586 );
6587 assert_eq!(json.topology.num_numa_nodes, 2);
6588 assert_eq!(json.topology.num_llcs, 4);
6589 assert_eq!(json.topology.cores_per_llc, 8);
6590 assert_eq!(json.topology.threads_per_core, 2);
6591 assert_eq!(json.sched_args, vec!["--slice-us", "20000"]);
6592 assert_eq!(json.kernels, vec!["6.14", "6.15..6.16"]);
6593 assert_eq!(json.constraints.min_numa_nodes, 2);
6594 assert_eq!(json.constraints.max_numa_nodes, Some(4));
6595 assert!(json.constraints.requires_smt);
6596 }
6597
6598 /// The projected `SchedulerJson` survives a serde JSON round-trip with
6599 /// field-for-field equality — it is the `scheduler` field of each
6600 /// [`SchedulerListEntry`] the `--ktstr-list-schedulers` ctor serializes,
6601 /// so this is the exact shape a consumer deserializes. Exercises the
6602 /// `#[serde(tag = "kind", content = "value")]` adjacent tagging on
6603 /// `BinaryKindJson` (Discover serializes as
6604 /// `{"kind":"discover","value":"..."}`).
6605 #[test]
6606 fn from_scheduler_json_roundtrips_through_serde() {
6607 let s = Scheduler::named("rt")
6608 .binary_discover("scx_rt")
6609 .topology(1, 2, 4, 2)
6610 .sched_args(&["--x"])
6611 .kernels(&["6.14"]);
6612 let json = SchedulerJson::from_scheduler(&s);
6613 let text = serde_json::to_string(&json).expect("serialize SchedulerJson");
6614 // The adjacent-tagged binary_kind must surface its tag/value.
6615 assert!(
6616 text.contains("\"kind\":\"discover\"") && text.contains("\"value\":\"scx_rt\""),
6617 "Discover must serialize as kind/value adjacent tag, got: {text}"
6618 );
6619 let back: SchedulerJson = serde_json::from_str(&text).expect("deserialize SchedulerJson");
6620 assert_eq!(
6621 back, json,
6622 "SchedulerJson must round-trip through serde unchanged"
6623 );
6624 }
6625
6626 /// [`SchedulerListEntry`] — the `--ktstr-list-schedulers` wire element (a
6627 /// [`SchedulerJson`] plus `test_count`) — survives a serde round-trip
6628 /// field-for-field, the exact shape `cargo ktstr affected` deserializes.
6629 #[test]
6630 fn scheduler_list_entry_roundtrips_through_serde() {
6631 let s = Scheduler::named("rt")
6632 .binary_discover("scx_rt")
6633 .topology(1, 2, 4, 2)
6634 .kernels(&["6.14"]);
6635 let entry = SchedulerListEntry {
6636 scheduler: SchedulerJson::from_scheduler(&s),
6637 test_count: 3,
6638 };
6639 let text = serde_json::to_string(&entry).expect("serialize SchedulerListEntry");
6640 assert!(
6641 text.contains("\"test_count\":3"),
6642 "test_count must serialize, got: {text}"
6643 );
6644 let back: SchedulerListEntry =
6645 serde_json::from_str(&text).expect("deserialize SchedulerListEntry");
6646 assert_eq!(back, entry, "SchedulerListEntry must round-trip unchanged");
6647 }
6648
6649 /// [`SchedulerTestJson`] — the `--ktstr-list-scheduler-tests` wire element
6650 /// (test name + its scheduler's name) — survives a serde round-trip
6651 /// field-for-field, the exact shape `cargo ktstr --relevant` deserializes to
6652 /// map each test to its scheduler.
6653 #[test]
6654 fn scheduler_test_json_roundtrips_through_serde() {
6655 let entry = SchedulerTestJson {
6656 test: "boot_smoke".to_string(),
6657 scheduler: "scx_rt".to_string(),
6658 };
6659 let text = serde_json::to_string(&entry).expect("serialize SchedulerTestJson");
6660 assert!(
6661 text.contains("\"test\":\"boot_smoke\"") && text.contains("\"scheduler\":\"scx_rt\""),
6662 "both fields must serialize, got: {text}"
6663 );
6664 let back: SchedulerTestJson =
6665 serde_json::from_str(&text).expect("deserialize SchedulerTestJson");
6666 assert_eq!(back, entry, "SchedulerTestJson must round-trip unchanged");
6667 }
6668}