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}