ktstr/test_support/payload.rs
1//! Generalized test payload — scheduler or binary workload.
2//!
3//! [`Payload`] is the primitive that `#[ktstr_test]` consumes for both
4//! the scheduler slot and the optional binary/workload slots. A
5//! payload's [`PayloadKind`] determines how it's launched: a
6//! [`Scheduler`] reference invokes the
7//! existing scheduler-spawn path; a bare binary name spawns the binary
8//! via the runtime [`PayloadRun`](crate::scenario::payload_run::PayloadRun)
9//! builder.
10//!
11//! The constants this module exposes — particularly
12//! [`Payload::KERNEL_DEFAULT`] — are used as the default scheduler
13//! slot when no `scheduler = ...` attribute is supplied on a
14//! `#[ktstr_test]`. `KERNEL_DEFAULT` wraps whatever scheduler the
15//! running kernel selects when no sched_ext scheduler is attached
16//! (EEVDF on Linux 6.6+) and surfaces on the wire as
17//! `"kernel_default"`.
18//!
19//! [`KtstrTestEntry`](crate::test_support::KtstrTestEntry) carries
20//! `payload` and `workloads` fields populated by the `#[ktstr_test]`
21//! macro's `payload = ...` and `workloads = [...]` attributes.
22
23use crate::test_support::Scheduler;
24
25// ---------------------------------------------------------------------------
26// Payload + PayloadKind
27// ---------------------------------------------------------------------------
28
29/// A test payload — either a scheduler or a userspace binary to run
30/// inside the guest VM.
31///
32/// `Payload` unifies the two launch modes under one `#[ktstr_test]`
33/// attribute surface: tests declare `scheduler = SOME_SCHED` for
34/// scheduler-centric runs, `payload = SOME_BIN` for binary runs, or
35/// both with `workloads = [...]` to compose binaries under a
36/// scheduler. See [`PayloadKind`] for the two variants.
37///
38/// Use [`Payload::KERNEL_DEFAULT`] as the default scheduler
39/// placeholder when a test doesn't attach a sched_ext scheduler —
40/// it wraps the kernel's default scheduler (EEVDF on Linux 6.6+)
41/// via [`Scheduler::EEVDF`].
42///
43/// `Payload` intentionally does NOT implement [`serde::Serialize`] /
44/// [`serde::Deserialize`]. It is a compile-time-static definition that
45/// references `&'static Scheduler` and `&'static [&'static str]`
46/// slices — lifetimes that serialization cannot round-trip. Runtime
47/// telemetry (per-payload metrics, exit codes, names) is serialized
48/// via [`PayloadMetrics`] and [`Metric`] instead; those own their
49/// data.
50///
51/// `#[non_exhaustive]` reserves the right to add fields without
52/// breaking downstream code. Out-of-crate callers cannot construct
53/// `Payload` via struct literal — use the const-fn constructors
54/// ([`Payload::new`], [`Payload::binary`]) or the
55/// `#[derive(Payload)]` macro, which routes through [`Payload::new`]
56/// under the hood.
57#[derive(Clone, Copy)]
58#[non_exhaustive]
59pub struct Payload {
60 /// Short, stable name used in logs and sidecar records.
61 pub name: &'static str,
62 /// Launch kind — scheduler reference or binary name.
63 pub kind: PayloadKind,
64 /// How the framework extracts metrics from the payload's
65 /// stdout, with stderr fallback when stdout yields no metrics.
66 /// See [`OutputFormat`] for the per-variant contract and
67 /// `scenario::payload_run` for the fallback mechanics.
68 pub output: OutputFormat,
69 /// Default CLI args appended when this payload runs. Test bodies
70 /// can extend via `.arg(...)` or replace via `.clear_args()` +
71 /// `.arg(...)` on the runtime builder.
72 pub default_args: &'static [&'static str],
73 /// Author-declared default checks evaluated against extracted
74 /// [`PayloadMetrics`]. Payloads that need exit-code gating
75 /// should include [`MetricCheck::ExitCodeEq(0)`](MetricCheck::ExitCodeEq)
76 /// here; the runtime evaluates `ExitCodeEq` as a pre-pass
77 /// before metric checks.
78 pub default_checks: &'static [MetricCheck],
79 /// Declared metric hints — polarity, unit. Unhinted metrics
80 /// extracted from output land as [`Polarity::Unknown`].
81 pub metrics: &'static [MetricHint],
82 /// Host-side file specs resolved at runtime. Each entry is
83 /// resolved through the framework's include-file pipeline — the
84 /// same resolver used by CLI `-i` / `--include-files` arguments:
85 /// bare names are searched in the host's `PATH`, explicit paths
86 /// (absolute, relative, or containing `/`) must exist on the
87 /// host, and directories are walked recursively. The entry's
88 /// scheduler / payload / workloads / extra_include_files are
89 /// aggregated at test time via
90 /// [`KtstrTestEntry::all_include_files`](crate::test_support::KtstrTestEntry::all_include_files)
91 /// and resolved through the same pipeline the `ktstr shell -i`
92 /// path uses. Populate via the
93 /// `#[include_files("helper", ...)]` attribute on
94 /// `#[derive(Payload)]` or by spelling the array in the struct
95 /// literal.
96 pub include_files: &'static [&'static str],
97 /// When `true`, the payload's spawn path does NOT place the
98 /// child into its own process group via
99 /// `CommandExt::process_group(0)`. The child inherits the
100 /// parent ktstr process's pgid. Default (`false`) keeps the
101 /// existing "fresh pgrp → killpg-reaches-descendants" model
102 /// — see `src/scenario/payload_run.rs::build_command`.
103 ///
104 /// Opt-in for tty-dependent binaries: a shell-like tool that
105 /// uses the controlling terminal's foreground process group
106 /// for signal delivery (job-control signals, SIGHUP on tty
107 /// close) reads a fresh pgrp as "no job control", which
108 /// breaks interactive shells and `less`-style readers.
109 /// Payloads that need tty job-control semantics set this
110 /// true so they stay in the parent's pgrp and keep the
111 /// inherited controlling-terminal association.
112 ///
113 /// Trade-off on the `true` branch: multi-process payloads
114 /// can no longer be killed via `killpg(child_pid, SIGKILL)`
115 /// because the child is not a pgrp leader; the kill path
116 /// falls back to single-pid `kill(pid, SIGKILL)` and any
117 /// descendants that the payload forks must either react to
118 /// SIGHUP / pipe close or run the risk of orphaning. Most
119 /// payloads should leave this `false`.
120 pub uses_parent_pgrp: bool,
121 /// When `Some`, the listed flag names form an allowlist that
122 /// `Op::RunPayload` validation checks against at scenario-
123 /// execution time (inside `apply_ops`, before the payload
124 /// spawn) — any user-supplied `--flag` whose name is not in
125 /// the allowlist produces an error surfaced through the step
126 /// executor, surfacing typos as loud errors instead of silent
127 /// no-ops that only manifest as "feature didn't activate" in
128 /// the test output.
129 ///
130 /// `None` (default) disables validation — the payload accepts
131 /// arbitrary flag sets. Use `None` for payloads that wrap
132 /// binaries with open-ended flag surfaces (stress-ng, fio,
133 /// schbench) where enumerating every accepted flag is either
134 /// impossible or high-churn.
135 ///
136 /// `Some(&[])` is legal but rarely intended: it rejects EVERY
137 /// long flag, including ones the wrapped binary legitimately
138 /// accepts. Use `None` for "no validation" and a non-empty
139 /// slice for "validate against this allowlist" — an empty
140 /// slice means "only positional args and short flags are
141 /// acceptable", which is almost never what a Payload author
142 /// wants.
143 ///
144 /// Flag names in the slice are bare (no leading `--`) and
145 /// match the syntax of `Op::RunPayload`'s per-flag slot.
146 pub known_flags: Option<&'static [&'static str]>,
147}
148
149impl std::fmt::Debug for Payload {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 // Manual impl renders the payload's identity + slice counts
152 // rather than the full struct (which would expand every
153 // nested Scheduler/Sysctl/CgroupPath into a multi-screen
154 // dump on every trace line). Scheduler IS Debug — the
155 // derived impl would just be too verbose for the routine
156 // tracing call sites this type appears in.
157 f.debug_struct("Payload")
158 .field("name", &self.name)
159 .field("kind", &self.kind)
160 .field("output", &self.output)
161 .field("default_args_len", &self.default_args.len())
162 .field("default_checks_len", &self.default_checks.len())
163 .field("metrics_len", &self.metrics.len())
164 .finish()
165 }
166}
167
168/// How a payload is launched inside the guest.
169///
170/// Two variants — scheduler and binary — map to the two launch paths
171/// in the runtime. "Kernel default" (EEVDF) is represented as
172/// `Scheduler(&Scheduler::EEVDF)` rather than a dedicated variant
173/// because [`Scheduler`] already carries the no-userspace-binary
174/// taxonomy via its own `binary: SchedulerSpec` field.
175#[derive(Clone, Copy)]
176pub enum PayloadKind {
177 /// Wraps an existing [`Scheduler`] definition. The scheduler's
178 /// own `binary: SchedulerSpec` carries the Eevdf/Discover/Path/
179 /// KernelBuiltin taxonomy — no duplication at the Payload level.
180 Scheduler(&'static Scheduler),
181 /// Bare userspace binary looked up by name in the guest. Not a
182 /// scheduler — runs as a workload under whatever scheduler the
183 /// test declares.
184 ///
185 /// # How the binary reaches the guest
186 ///
187 /// The stored `&'static str` is the executable name passed to
188 /// `std::process::Command::new` inside the guest (see
189 /// [`PayloadRun::run`](crate::scenario::payload_run::PayloadRun::run)),
190 /// which resolves it against the guest's `PATH`. The framework
191 /// resolves binaries through the include-file pipeline — for
192 /// `#[ktstr_test]` entries via declarative `include_files` /
193 /// `extra_include_files`, or via `-i` on `ktstr shell`.
194 ///
195 /// Supply a binary through the framework's include-file
196 /// pipeline. The pipeline is wired up to the `shell` subcommand
197 /// of both `ktstr` and `cargo ktstr` through the repeatable
198 /// `-i` / `--include-files` flag. Each `-i` argument accepts:
199 ///
200 /// - an explicit path (absolute, relative, or containing `/`) —
201 /// must exist on the host;
202 /// - a bare name — searched in `PATH` on the host;
203 /// - a directory — walked recursively, preserving structure under
204 /// `/include-files/<dirname>/...` in the guest.
205 ///
206 /// Every regular file ends up at `/include-files/<name>` (or
207 /// deeper for directory walks). Dynamically-linked ELFs pull in
208 /// their `DT_NEEDED` shared libraries automatically; the guest
209 /// init prepends every `/include-files/*` subdirectory containing
210 /// an executable to `PATH`, so a binary packaged with `-i` is
211 /// runnable by bare name from a test body.
212 ///
213 /// Example — launch a shell VM with `fio` available by bare name:
214 ///
215 /// ```sh
216 /// cargo ktstr shell -i fio --exec "fio --version"
217 /// ```
218 ///
219 /// The `fio` binary is resolved against the host's `PATH`, copied
220 /// to `/include-files/fio` in the guest, exposed on the guest
221 /// `PATH`, and spawnable as `fio` from any guest-side process.
222 ///
223 /// # `#[ktstr_test]` entries
224 ///
225 /// Declarative `include_files` on `#[derive(Payload)]` and
226 /// `extra_include_files` on `#[ktstr_test]` handle binary
227 /// packaging automatically — no CLI `-i` and no bespoke harness
228 /// needed.
229 ///
230 /// # Scheduler config files
231 ///
232 /// Scheduler-kind payloads that set
233 /// [`Scheduler`]'s `config_file`
234 /// field get automatic packaging: the config file is placed at
235 /// `/include-files/{filename}` without a `-i` flag — the field
236 /// is the source the harness reads.
237 ///
238 /// # Binary-kind packaging
239 ///
240 /// Payloads built via `#[derive(Payload)]` get automatic binary
241 /// packaging: the derive macro prepends the `binary = "..."`
242 /// spec to the emitted `include_files` slice, so the spawn
243 /// target is packaged into the guest without requiring a
244 /// separate `#[include_files("...")]` entry. Auxiliary files
245 /// the payload needs (helpers, config files, fixtures) still
246 /// go on `#[include_files(...)]` — the derive only injects the
247 /// primary binary.
248 ///
249 /// Payloads constructed manually via struct literal (rather
250 /// than the derive) do not get this auto-injection: the
251 /// harness does not derive `include_files` from the
252 /// `PayloadKind::Binary(name)` at aggregation time. Manual
253 /// constructions must list the binary in
254 /// [`Payload::include_files`](Payload::include_files)
255 /// themselves, or declare it on
256 /// [`extra_include_files`](crate::test_support::KtstrTestEntry::extra_include_files)
257 /// at the `#[ktstr_test]` level. A binary referenced at spawn
258 /// time but neither auto-injected nor listed as an include is
259 /// expected to already be present in the guest filesystem
260 /// (e.g. a standard `busybox` applet on the base image);
261 /// otherwise the omission surfaces as `ENOENT` at `exec` time
262 /// inside the guest.
263 ///
264 /// # Fork / kill semantics
265 ///
266 /// A binary-kind payload is spawned in its own process group via
267 /// `CommandExt::process_group(0)` in
268 /// [`build_command`](crate::scenario::payload_run) so the
269 /// framework can reach every descendant the binary forks. Direct consequences for test
270 /// authors:
271 ///
272 /// - `std::process::Child::kill()` only targets the direct child
273 /// — a `fork()`ed descendant (stress-ng worker, fio `--numjobs`,
274 /// schbench worker mode, pipeline subshells under `sh -c`)
275 /// survives. Never call `child.kill()` directly on a payload
276 /// `Child`; the handle's `kill()` wrapper fans out SIGKILL to
277 /// the whole process group via `killpg`.
278 /// - [`PayloadHandle::kill`](crate::scenario::payload_run::PayloadHandle::kill),
279 /// [`PayloadHandle::wait`](crate::scenario::payload_run::PayloadHandle::wait)
280 /// cleanup, and the panic-safety Drop arm all route through
281 /// `kill_payload_process_group`, which issues `killpg(pgid,
282 /// SIGKILL)` followed by a single-pid SIGKILL fallback so
283 /// descendants and the leader both exit. This is the only kill
284 /// path test authors need.
285 /// - Pipe drainers (stdout / stderr reader threads) block on EOF,
286 /// which only arrives after every descendant holding the
287 /// write ends closes them. A bare `child.kill()` leaves the
288 /// descendants holding the pipes open and
289 /// `wait_and_capture` hangs
290 /// forever — motivating the `killpg` requirement.
291 Binary(&'static str),
292}
293
294impl std::fmt::Debug for PayloadKind {
295 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 // Manual impl renders variant + identity summary (just the
297 // scheduler/binary name) rather than the full Scheduler
298 // struct that the derived Debug would expand. Trace lines
299 // that print a PayloadKind want one-line attribution, not a
300 // multi-screen scheduler config dump.
301 match self {
302 PayloadKind::Scheduler(s) => f.debug_tuple("Scheduler").field(&s.name).finish(),
303 PayloadKind::Binary(name) => f.debug_tuple("Binary").field(name).finish(),
304 }
305 }
306}
307
308impl Payload {
309 /// Placeholder payload that wraps the current kernel-default
310 /// scheduler — [`Scheduler::EEVDF`] on Linux 6.6+ (the "no scx
311 /// scheduler attached" case). Used as the default value of the
312 /// `scheduler` slot on
313 /// [`KtstrTestEntry`](crate::test_support::KtstrTestEntry) so
314 /// tests without an explicit `scheduler = ...` attribute still
315 /// get a valid, non-optional reference. Wire name is
316 /// `"kernel_default"` — the Rust const and the serialized form
317 /// agree, so the const describes what it selects for (the
318 /// kernel's default) rather than naming a specific scheduler that
319 /// a future kernel release could replace.
320 ///
321 /// ## `kernel_default` vs `eevdf` in sidecars
322 ///
323 /// `KERNEL_DEFAULT.name` is `"kernel_default"` (the intent-level
324 /// label), while `KERNEL_DEFAULT.scheduler_name()` returns
325 /// `"eevdf"` (the inner [`Scheduler::EEVDF`]'s `.name`). The two
326 /// names answer different questions:
327 ///
328 /// - `"kernel_default"` answers "what did the test author select?"
329 /// — a future kernel release replacing EEVDF keeps this label
330 /// stable, so an in-memory match on author intent survives
331 /// kernel upgrades.
332 /// - `"eevdf"` answers "what scheduler actually ran?" — the
333 /// concrete scheduling class in effect.
334 ///
335 /// **Sidecar serialization reads the scheduler-slot `Scheduler`
336 /// directly.** The `SidecarResult.scheduler` field
337 /// (src/test_support/sidecar/mod.rs) is populated by reading
338 /// `entry.scheduler.name` — a field access on the
339 /// `&'static Scheduler` held in the entry's scheduler slot, with
340 /// no Payload indirection. When the scheduler slot holds
341 /// `&Scheduler::EEVDF` (the framework default), the sidecar
342 /// records `"eevdf"`. The outer `KERNEL_DEFAULT.name`
343 /// (`"kernel_default"`) is NOT written to the sidecar — it stays
344 /// in-memory only, used by logs, `#[ktstr_test]`-declaration
345 /// lookups, and `Payload::display_name()` on the payload-slot
346 /// surface. Cross-kernel-version comparisons via sidecar
347 /// `scheduler` therefore see `"eevdf"` today and whatever future
348 /// scheduling class replaces EEVDF tomorrow; author-intent
349 /// filtering on `"kernel_default"` requires consulting the
350 /// in-memory `Payload::name` directly, not the sidecar.
351 pub const KERNEL_DEFAULT: Payload = Payload::new(
352 "kernel_default",
353 PayloadKind::Scheduler(&Scheduler::EEVDF),
354 OutputFormat::ExitCode,
355 &[],
356 &[],
357 &[],
358 &[],
359 false,
360 None,
361 );
362
363 /// Short, human-readable name for logging and sidecar output.
364 pub const fn display_name(&self) -> &'static str {
365 self.name
366 }
367
368 /// Return the inner [`Scheduler`] reference when this payload
369 /// wraps one. Returns `None` for [`PayloadKind::Binary`].
370 pub const fn as_scheduler(&self) -> Option<&'static Scheduler> {
371 match self.kind {
372 PayloadKind::Scheduler(s) => Some(s),
373 PayloadKind::Binary(_) => None,
374 }
375 }
376
377 /// True when this payload wraps a [`Scheduler`] (scheduler
378 /// slot). False for binary payloads.
379 pub const fn is_scheduler(&self) -> bool {
380 matches!(self.kind, PayloadKind::Scheduler(_))
381 }
382
383 /// Primary const constructor for a [`Payload`].
384 ///
385 /// Takes every field by position so the `#[derive(Payload)]`
386 /// macro can emit a single call instead of a struct-literal.
387 /// `#[non_exhaustive]` on the struct prevents out-of-crate
388 /// struct-literal construction; this constructor — defined in
389 /// the same crate as `Payload` — is not subject to that
390 /// restriction, so the macro-expanded tokens that reach
391 /// downstream crates compile cleanly.
392 ///
393 /// For one-field constructions prefer [`Payload::binary`] — it
394 /// calls into this helper and pins the non-identity fields to
395 /// the exit-code-only defaults.
396 #[allow(clippy::too_many_arguments)]
397 pub const fn new(
398 name: &'static str,
399 kind: PayloadKind,
400 output: OutputFormat,
401 default_args: &'static [&'static str],
402 default_checks: &'static [MetricCheck],
403 metrics: &'static [MetricHint],
404 include_files: &'static [&'static str],
405 uses_parent_pgrp: bool,
406 known_flags: Option<&'static [&'static str]>,
407 ) -> Payload {
408 Payload {
409 name,
410 kind,
411 output,
412 default_args,
413 default_checks,
414 metrics,
415 include_files,
416 uses_parent_pgrp,
417 known_flags,
418 }
419 }
420
421 /// Minimal const constructor for a binary-kind [`Payload`]. Fills
422 /// the non-identity fields with the exit-code-only defaults — no
423 /// CLI args, no author-declared checks, no metric hints, and
424 /// [`OutputFormat::ExitCode`] — so a `#[ktstr_test]` entry or a
425 /// direct unit test can declare a runnable binary with one line
426 /// instead of spelling out the full struct literal.
427 ///
428 /// The `binary` string is the executable name passed to
429 /// `std::process::Command::new` inside the guest. Supply it to
430 /// the guest via `-i` / `--include-files` for CLI invocations or
431 /// pre-install it in the initramfs for `#[ktstr_test]` entries —
432 /// see [`PayloadKind::Binary`] for the full packaging contract.
433 pub const fn binary(name: &'static str, binary: &'static str) -> Payload {
434 Payload::new(
435 name,
436 PayloadKind::Binary(binary),
437 OutputFormat::ExitCode,
438 &[],
439 &[],
440 &[],
441 &[],
442 false,
443 None,
444 )
445 }
446
447 // -----------------------------------------------------------------
448 // Scheduler-slot forwarding accessors
449 //
450 // These methods let every site that consumed `entry.scheduler:
451 // &Scheduler` read the equivalent field off `entry.scheduler:
452 // &Payload` without the caller having to unwrap
453 // `as_scheduler()`. For a scheduler-kind payload the accessor
454 // forwards to the inner `Scheduler`. For a binary-kind payload
455 // the accessor returns a sensible default — usually the empty
456 // slice or the no-op value — matching the semantics a binary
457 // payload in the scheduler slot should carry (no sysctls, no
458 // kargs, no scheduler-specific CLI flags).
459 //
460 // The binary-kind branch is not "best effort": a binary payload
461 // in the scheduler slot is a valid configuration (pure userspace
462 // test under the kernel default scheduler), and every accessor
463 // below returns exactly what that scenario should see.
464 // -----------------------------------------------------------------
465
466 /// The scheduler's display name.
467 ///
468 /// Returns a compile-time-fixed LABEL, not a runtime reflection
469 /// of the scheduling class the live kernel is actually running.
470 /// A sidecar written on a kernel whose default is a successor
471 /// scheduling class still records whatever string this method
472 /// returns — the label comes from the `Payload` / inner
473 /// `Scheduler` definition, nothing queries `/proc` or the live
474 /// policy. Consumers that need to know the running kernel's
475 /// scheduling class must cross-reference the sidecar's
476 /// `host.kernel_release` with kernel-version-to-scheduler
477 /// knowledge maintained outside the sidecar.
478 ///
479 /// Branch behavior:
480 /// - `PayloadKind::Scheduler(s)` → `s.name` — the label attached
481 /// to that specific scheduler, e.g. `"eevdf"` for
482 /// [`Scheduler::EEVDF`] or `"scx_rusty"` for a scx_*
483 /// scheduler. This is what scheduler-kind payloads (including
484 /// `Payload::KERNEL_DEFAULT`, which wraps [`Scheduler::EEVDF`])
485 /// surface.
486 /// - `PayloadKind::Binary(_)` → `"kernel_default"` — a binary
487 /// payload runs under whatever scheduler the test declares
488 /// elsewhere (or the kernel default if it declares none), so
489 /// the binary-kind payload carries no scheduler identity of
490 /// its own. The returned string is a LABEL ("test author did
491 /// not pin a scheduler here"), NOT a statement about which
492 /// scheduling class the VM actually ran under — the live
493 /// kernel may be running EEVDF, a successor class, or an scx
494 /// scheduler the binary's test harness attached separately;
495 /// `scheduler_name()` does not observe any of that. Only a
496 /// scheduler-kind payload explicitly wrapping
497 /// [`Scheduler::EEVDF`] returns the `"eevdf"` label; every
498 /// binary-kind payload returns `"kernel_default"` regardless
499 /// of what class is running.
500 pub const fn scheduler_name(&self) -> &'static str {
501 match self.kind {
502 PayloadKind::Scheduler(s) => s.name,
503 PayloadKind::Binary(_) => "kernel_default",
504 }
505 }
506
507 /// The scheduler's binary spec when scheduler-kind; `None` for
508 /// binary-kind payloads. Consumers that dispatch on the
509 /// `SchedulerSpec` variant (e.g. `KernelBuiltin { enable, disable }`
510 /// hook invocation) use this rather than the `scheduler_name`
511 /// shortcut.
512 pub const fn scheduler_binary(&self) -> Option<&'static crate::test_support::SchedulerSpec> {
513 match self.kind {
514 PayloadKind::Scheduler(s) => Some(&s.binary),
515 PayloadKind::Binary(_) => None,
516 }
517 }
518
519 /// True when this payload drives an active scheduling policy
520 /// (anything other than the kernel default EEVDF). Forwards to
521 /// `SchedulerSpec::has_active_scheduling` for scheduler-kind
522 /// payloads; binary-kind payloads always return `false` — a
523 /// binary runs under whatever scheduler the test declares, and
524 /// does not itself impose one.
525 ///
526 /// Returns `true` for `KernelBuiltin` scheduler-kind payloads.
527 /// See [`Self::has_bpf_scheduler`] for the narrower gate that
528 /// excludes them.
529 pub const fn has_active_scheduling(&self) -> bool {
530 match self.kind {
531 PayloadKind::Scheduler(s) => s.binary.has_active_scheduling(),
532 PayloadKind::Binary(_) => false,
533 }
534 }
535
536 /// True when this payload drives a userspace BPF scheduler
537 /// binary. Forwards to `SchedulerSpec::has_bpf_scheduler` for
538 /// scheduler-kind payloads; binary-kind payloads always return
539 /// `false`.
540 ///
541 /// Distinct from [`Self::has_active_scheduling`]: this excludes
542 /// `KernelBuiltin` scheduler-kind payloads (in-kernel policy,
543 /// no userspace BPF binary). Use whenever the caller assumes a
544 /// BPF binary is attached — verifier_stats wiring, BPF-attach
545 /// monitor thresholds, or auto-repro probe gating.
546 pub const fn has_bpf_scheduler(&self) -> bool {
547 match self.kind {
548 PayloadKind::Scheduler(s) => s.binary.has_bpf_scheduler(),
549 PayloadKind::Binary(_) => false,
550 }
551 }
552
553 /// Guest sysctls applied before the scheduler starts. Empty slice
554 /// for binary-kind payloads.
555 pub const fn sysctls(&self) -> &'static [crate::test_support::Sysctl] {
556 match self.kind {
557 PayloadKind::Scheduler(s) => s.sysctls,
558 PayloadKind::Binary(_) => &[],
559 }
560 }
561
562 /// Extra guest kernel command-line arguments appended when
563 /// booting the VM. Empty slice for binary-kind payloads.
564 pub const fn kargs(&self) -> &'static [&'static str] {
565 match self.kind {
566 PayloadKind::Scheduler(s) => s.kargs,
567 PayloadKind::Binary(_) => &[],
568 }
569 }
570
571 /// Scheduler CLI args prepended before per-test `extra_sched_args`.
572 /// Empty slice for binary-kind payloads.
573 pub const fn sched_args(&self) -> &'static [&'static str] {
574 match self.kind {
575 PayloadKind::Scheduler(s) => s.sched_args,
576 PayloadKind::Binary(_) => &[],
577 }
578 }
579
580 /// Cgroup parent path. `None` for binary-kind payloads and for
581 /// scheduler-kind payloads that did not set one.
582 pub const fn cgroup_parent(&self) -> Option<crate::test_support::CgroupPath> {
583 match self.kind {
584 PayloadKind::Scheduler(s) => s.cgroup_parent,
585 PayloadKind::Binary(_) => None,
586 }
587 }
588
589 /// Host-side path to the scheduler config file. `None` for
590 /// binary-kind payloads and for scheduler-kind payloads that
591 /// did not set one.
592 pub const fn config_file(&self) -> Option<&'static str> {
593 match self.kind {
594 PayloadKind::Scheduler(s) => s.config_file,
595 PayloadKind::Binary(_) => None,
596 }
597 }
598
599 /// Inline config file definition. `None` for binary-kind payloads
600 /// and for scheduler-kind payloads that did not set one.
601 pub const fn config_file_def(&self) -> Option<(&'static str, &'static str)> {
602 match self.kind {
603 PayloadKind::Scheduler(s) => s.config_file_def,
604 PayloadKind::Binary(_) => None,
605 }
606 }
607
608 /// Scheduler-wide assertion overrides. For binary-kind payloads
609 /// returns `Assert::NO_OVERRIDES` — the default identity value
610 /// merge that leaves per-entry assertions untouched.
611 pub const fn assert(&self) -> &'static crate::assert::Assert {
612 match self.kind {
613 PayloadKind::Scheduler(s) => &s.assert,
614 PayloadKind::Binary(_) => &crate::assert::Assert::NO_OVERRIDES,
615 }
616 }
617
618 /// Default VM topology for this payload. Scheduler-kind payloads
619 /// expose the topology declared on the inner `Scheduler` so tests
620 /// that inherit from the scheduler slot stay consistent with the
621 /// rest of the scheduler's test surface; binary-kind payloads
622 /// return a minimal placeholder
623 /// ([`Topology::DEFAULT_FOR_PAYLOAD`](crate::test_support::Topology::DEFAULT_FOR_PAYLOAD))
624 /// — a pure binary workload has no scheduler-level topology
625 /// opinion, so per-entry `#[ktstr_test(...)]` overrides are what
626 /// actually drive the VM shape.
627 pub const fn topology(&self) -> crate::test_support::Topology {
628 match self.kind {
629 PayloadKind::Scheduler(s) => s.topology,
630 PayloadKind::Binary(_) => crate::test_support::Topology::DEFAULT_FOR_PAYLOAD,
631 }
632 }
633
634 /// Gauntlet topology constraints. Scheduler-kind payloads forward
635 /// to the inner `Scheduler::constraints`; binary-kind payloads
636 /// return `TopologyConstraints::DEFAULT`.
637 pub const fn constraints(&self) -> crate::test_support::TopologyConstraints {
638 match self.kind {
639 PayloadKind::Scheduler(s) => s.constraints,
640 PayloadKind::Binary(_) => crate::test_support::TopologyConstraints::DEFAULT,
641 }
642 }
643}
644
645// ---------------------------------------------------------------------------
646// OutputFormat
647// ---------------------------------------------------------------------------
648
649/// How the framework extracts metrics from a payload's output.
650///
651/// `ExitCode` records only the exit code; no text parsing. `Json`
652/// finds a JSON document region and walks numeric leaves into
653/// [`Metric`] values.
654///
655/// For `Json`, extraction is stdout-primary with a stderr fallback:
656/// the extractor runs first against stdout, and only when that
657/// yields an empty metric set AND stderr is non-empty does it retry
658/// against stderr. Well-behaved binaries keep stdout canonical;
659/// payloads that emit structured output only on stderr (schbench's
660/// `show_latencies` → `fprintf(stderr, ...)`) still parse. The
661/// streams are never merged. `ExitCode` produces no metrics from
662/// either stream — `extract_metrics` is invoked (the control flow
663/// is variant-agnostic for simplicity) but the `ExitCode` arm
664/// returns `Ok(vec![])` immediately, so the stderr fallback runs
665/// and also returns empty. Observable behavior: exit code only, no
666/// metrics.
667#[derive(Debug, Clone, Copy)]
668pub enum OutputFormat {
669 /// Pass/fail from exit code alone. Stdout is archived for
670 /// debugging but not parsed. `extract_metrics` is still invoked
671 /// in the evaluate pipeline (variant-agnostic control flow) but
672 /// returns `Ok(vec![])` immediately for this variant; the
673 /// stderr fallback runs too and also returns empty. Observable
674 /// behavior: no metrics extracted regardless of stream content.
675 ExitCode,
676 /// Parse the primary stream (stdout, or stderr on fallback) as
677 /// JSON: find the JSON region within mixed output, extract
678 /// numeric leaves as metrics keyed by dotted path (e.g.
679 /// `jobs.0.read.iops`).
680 Json,
681}
682
683// ---------------------------------------------------------------------------
684// Polarity, MetricCheck, Metric
685// ---------------------------------------------------------------------------
686
687/// Regression direction for a metric.
688///
689/// Used by `cargo ktstr test-stats` to classify deltas between runs.
690/// Declared explicitly on [`MetricHint`]; unhinted metrics default to
691/// [`Polarity::Unknown`] and are recorded without regression
692/// classification.
693#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
694pub enum Polarity {
695 /// Bigger is better (throughput, IOPS, bogo_ops/sec). Regression
696 /// = decrease from baseline.
697 HigherBetter,
698 /// Smaller is better (latency percentiles, error rates).
699 /// Regression = increase from baseline.
700 LowerBetter,
701 /// A target value that the metric should hover near. Regression
702 /// = absolute distance exceeds a threshold, symmetric in either
703 /// direction. The inner `f64` MUST be finite (not NaN/inf);
704 /// construct via [`Polarity::target`], which enforces this at
705 /// runtime in both debug and release.
706 TargetValue(f64),
707 /// Direction not declared; the metric is recorded but not
708 /// classified as regression-relevant. This is the CONSERVATIVE
709 /// default for an UNCLASSIFIED metric: the comparison path treats
710 /// it as higher-is-worse (see `MetricDef::higher_is_worse` /
711 /// `classify_direction`) so a real regression in a metric someone
712 /// forgot to classify is still caught rather than silently ignored.
713 Unknown,
714 /// Deliberately directionless: the metric is recorded and DISPLAYED
715 /// in comparisons but is NEVER classified as a regression or
716 /// improvement and NEVER affects the exit code. Distinct from
717 /// [`Polarity::Unknown`] (the conservative higher-is-worse default
718 /// for *unclassified* metrics): `Informational` is the explicit
719 /// "this counter has no good/bad direction" choice — e.g. wakeup /
720 /// context-switch / yield counts, where more is neither inherently
721 /// better nor worse. `classify_direction` returns `None` for it.
722 Informational,
723}
724
725impl Polarity {
726 /// Map the legacy `higher_is_worse: bool` used by
727 /// `MetricDef` to a `Polarity`.
728 ///
729 /// The sense is INVERSE: `true` (bigger values are regressions)
730 /// maps to [`Polarity::LowerBetter`] (we want the metric to go
731 /// down); `false` maps to [`Polarity::HigherBetter`].
732 pub const fn from_higher_is_worse(higher_is_worse: bool) -> Polarity {
733 if higher_is_worse {
734 Polarity::LowerBetter
735 } else {
736 Polarity::HigherBetter
737 }
738 }
739
740 /// The regression direction of this polarity: `Some(true)` = an INCREASE is
741 /// a regression (`LowerBetter` / `TargetValue` / the conservative `Unknown`),
742 /// `Some(false)` = a DECREASE is a regression (`HigherBetter`), `None` =
743 /// directionless (`Informational`, never gates). The single source of truth
744 /// that `MetricDef::classify_direction` delegates to.
745 pub const fn classify_direction(&self) -> Option<bool> {
746 match self {
747 Polarity::Informational => None,
748 Polarity::HigherBetter => Some(false),
749 Polarity::LowerBetter | Polarity::TargetValue(_) | Polarity::Unknown => Some(true),
750 }
751 }
752
753 /// Construct a [`Polarity::TargetValue`] from a finite `target`.
754 ///
755 /// # Panics
756 ///
757 /// Panics in both debug and release builds when `target` is not
758 /// finite (`NaN`, `+inf`, `-inf`). Non-finite values produce
759 /// incorrect regression verdicts in the comparison pipeline, so
760 /// the check runs unconditionally rather than via
761 /// `debug_assert!`.
762 pub fn target(target: f64) -> Polarity {
763 assert!(
764 target.is_finite(),
765 "Polarity::TargetValue target must be finite, got {target}"
766 );
767 Polarity::TargetValue(target)
768 }
769}
770
771/// Payload-author metric declaration: polarity + display unit.
772///
773/// Attached to a [`Payload`] via the `metrics` field. Metrics
774/// extracted from output are looked up against this table by name to
775/// set their [`Polarity`] and [`Metric::unit`]. Unmatched metrics
776/// land with `Polarity::Unknown` and an empty unit string.
777#[derive(Debug, Clone, Copy)]
778pub struct MetricHint {
779 /// Dotted-path metric name (e.g. `jobs.0.read.iops`).
780 pub name: &'static str,
781 /// Regression direction for this metric.
782 pub polarity: Polarity,
783 /// Human-readable unit for display (e.g. `iops`, `ns`). Empty
784 /// string means "no unit"; matches the sentinel used by
785 /// `MetricDef`.
786 pub unit: &'static str,
787}
788
789/// Assertion check evaluated against an extracted
790/// [`PayloadMetrics`] (or the exit code for
791/// [`MetricCheck::ExitCodeEq`]).
792#[derive(Debug, Clone, Copy)]
793pub enum MetricCheck {
794 /// Fail when the named metric is below `value`.
795 Min { metric: &'static str, value: f64 },
796 /// Fail when the named metric exceeds `value`.
797 Max { metric: &'static str, value: f64 },
798 /// Fail when the named metric is outside `[lo, hi]`.
799 Range {
800 metric: &'static str,
801 lo: f64,
802 hi: f64,
803 },
804 /// Fail when the named metric is missing from the extracted set.
805 Exists(&'static str),
806 /// Fail when the payload's exit code is not equal to `expected`.
807 ExitCodeEq(i32),
808}
809
810impl MetricCheck {
811 /// Fail when the named metric is below `value`. Missing metric
812 /// fails loudly per the evaluation pipeline's missing-metric
813 /// contract.
814 pub const fn min(metric: &'static str, value: f64) -> MetricCheck {
815 MetricCheck::Min { metric, value }
816 }
817
818 /// Fail when the named metric exceeds `value`. Missing metric
819 /// fails loudly.
820 pub const fn max(metric: &'static str, value: f64) -> MetricCheck {
821 MetricCheck::Max { metric, value }
822 }
823
824 /// Fail when the named metric falls outside `[lo, hi]` (inclusive
825 /// on both ends). Missing metric fails loudly.
826 ///
827 /// # Panics
828 ///
829 /// Panics at construction when `lo > hi` — a reversed-bounds
830 /// range describes an empty interval that no finite metric can
831 /// satisfy, almost certainly a user error rather than an
832 /// intentional always-fails check. Failing at the constructor
833 /// surfaces the typo at the call site instead of letting the
834 /// evaluator run an unsatisfiable check against every probe
835 /// value. NaN bounds also trip this gate because `lo <= hi`
836 /// is false for any NaN argument.
837 pub const fn range(metric: &'static str, lo: f64, hi: f64) -> MetricCheck {
838 assert!(
839 lo <= hi,
840 "MetricCheck::range: lo must be <= hi (reversed bounds are an empty interval)"
841 );
842 MetricCheck::Range { metric, lo, hi }
843 }
844
845 /// Fail when the named metric is absent from the extracted set.
846 /// Presence-only — the metric value can be any finite number,
847 /// including zero or negative.
848 pub const fn exists(metric: &'static str) -> MetricCheck {
849 MetricCheck::Exists(metric)
850 }
851
852 /// Fail when the payload's exit code differs from `expected`.
853 /// Evaluated before metric-path checks so a mis-exited binary
854 /// reports the exit-code mismatch rather than chained
855 /// missing-metric failures.
856 pub const fn exit_code_eq(expected: i32) -> MetricCheck {
857 MetricCheck::ExitCodeEq(expected)
858 }
859
860 /// Typed sibling of [`Self::min`] — a typo-proof `BuiltinMetric` instead of a
861 /// registry-name string. `const` (via `BuiltinMetric::wire_name`) so it
862 /// composes in `const` payload-check tables exactly like [`Self::min`]. Use
863 /// this for a registered built-in metric; keep [`Self::min`] for the dynamic
864 /// keyspace (dotted JSON-leaf paths from [`OutputFormat::Json`], scheduler-runtime keys).
865 ///
866 /// ```
867 /// use ktstr::prelude::*;
868 /// // The typed checks compose into a `const` table — exactly the
869 /// // `&'static [MetricCheck]` a `Payload` carries in `default_checks`:
870 /// const CHECKS: &[MetricCheck] = &[
871 /// MetricCheck::min_builtin(BuiltinMetric::TaobenchTotalQps, 1000.0),
872 /// MetricCheck::exists_builtin(BuiltinMetric::SchbenchLoopCount),
873 /// ];
874 /// assert_eq!(CHECKS.len(), 2);
875 /// ```
876 pub const fn min_builtin(metric: crate::stats::BuiltinMetric, value: f64) -> MetricCheck {
877 MetricCheck::Min {
878 metric: metric.wire_name(),
879 value,
880 }
881 }
882
883 /// Typed sibling of [`Self::max`] — see [`Self::min_builtin`].
884 pub const fn max_builtin(metric: crate::stats::BuiltinMetric, value: f64) -> MetricCheck {
885 MetricCheck::Max {
886 metric: metric.wire_name(),
887 value,
888 }
889 }
890
891 /// Typed sibling of [`Self::range`] — see [`Self::min_builtin`]. Same
892 /// reversed-bounds construction panic as [`Self::range`].
893 pub const fn range_builtin(
894 metric: crate::stats::BuiltinMetric,
895 lo: f64,
896 hi: f64,
897 ) -> MetricCheck {
898 assert!(
899 lo <= hi,
900 "MetricCheck::range_builtin: lo must be <= hi (reversed bounds are an empty interval)"
901 );
902 MetricCheck::Range {
903 metric: metric.wire_name(),
904 lo,
905 hi,
906 }
907 }
908
909 /// Typed sibling of [`Self::exists`] — see [`Self::min_builtin`].
910 pub const fn exists_builtin(metric: crate::stats::BuiltinMetric) -> MetricCheck {
911 MetricCheck::Exists(metric.wire_name())
912 }
913}
914
915/// Which of the payload's output streams a [`Metric`] was extracted
916/// from.
917///
918/// Captures WHERE the bytes came from (payload stdout vs stderr).
919/// This matters for diagnosing "surprise metrics" in post-run
920/// analysis: a metric tagged [`Self::Stderr`] signals a payload
921/// whose structured output landed on the diagnostic stream —
922/// well-behaved payloads keep stdout canonical per the
923/// [`OutputFormat`] doc contract, so a stderr tag is a review hint
924/// ("is this payload misconfigured, or did the fallback
925/// intentionally pick it up?").
926///
927/// Populated by the extraction pipeline in
928/// [`crate::scenario::payload_run`]: the stdout-primary branch
929/// stamps [`Stdout`](Self::Stdout), the stderr-fallback branch
930/// stamps [`Stderr`](Self::Stderr). The streams are never merged;
931/// one or the other produces the metric set, and that identity
932/// propagates through [`Metric::stream`].
933///
934/// Status: persisted on the sidecar for future review-tooling
935/// (CI dashboards, `cargo ktstr stats`-style filters); not yet
936/// consumed by `perf-delta` or any automated pipeline. The
937/// field is wired end-to-end from the payload-pipeline to the
938/// sidecar JSON today so that downstream review tools can start
939/// filtering on it without a schema change — but no production
940/// consumer reads it yet.
941#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
942// `#[non_exhaustive]`: kept so a future probe-authored stream source
943// (e.g. a BPF-map reader) can land without a sidecar wire-format
944// migration, and so downstream matches must retain a `_ =>` arm. The
945// forward-compat guarantee is independent of the current variant count
946// — do not strip this on the grounds that the enum has only two
947// variants today.
948#[non_exhaustive]
949pub enum MetricStream {
950 /// Extracted from the payload's stdout (the happy path for
951 /// fio / stress-ng / most benchmark tools).
952 Stdout,
953 /// Extracted from the payload's stderr via the stderr-fallback
954 /// contract (for payloads that emit structured summaries to
955 /// stderr — e.g. schbench's `show_latencies` →
956 /// `fprintf(stderr, ...)`).
957 Stderr,
958}
959
960/// A single extracted metric from a payload's output.
961///
962/// Populated by the extraction pipeline after the payload exits.
963/// Sidecar serialization carries these alongside the pass/fail
964/// verdict so test-stats can classify regressions across runs.
965#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
966pub struct Metric {
967 /// Dotted-path name matching the JSON leaf.
968 pub name: String,
969 /// Numeric value.
970 pub value: f64,
971 /// Regression direction, copied from the matching
972 /// [`MetricHint`] or left as [`Polarity::Unknown`] when no hint
973 /// matches.
974 pub polarity: Polarity,
975 /// Display unit string; empty when no unit was declared.
976 pub unit: String,
977 /// Which of the payload's output streams the metric was read
978 /// from — stdout on the happy path, stderr under the
979 /// stderr-fallback contract. See [`MetricStream`] for the
980 /// "well-behaved payloads keep stdout canonical" review hint.
981 pub stream: MetricStream,
982}
983
984/// All metrics extracted from a single payload run plus the process
985/// exit code.
986///
987/// Each concurrent payload (primary or workload, foreground or
988/// background) produces one `PayloadMetrics` value. Sidecar stores
989/// these as a `Vec<PayloadMetrics>` so per-payload provenance is
990/// preserved across composed tests. Payload identity (name and
991/// cgroup placement) is carried by the enclosing sidecar record —
992/// not by `PayloadMetrics` itself, which holds only the extracted
993/// metrics and exit code.
994///
995/// The `payload_index` field stamps every per-invocation emission
996/// (one `PayloadMetrics` value per `.run()` / `.wait()` / `.kill()` /
997/// `.try_wait()` call) with a monotonically increasing per-process
998/// counter — assigned at emit time inside the guest VM.
999#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1000pub struct PayloadMetrics {
1001 /// Per-invocation index assigned at emit time. Monotonically
1002 /// increasing within a single guest VM process, starting at 0.
1003 pub payload_index: usize,
1004 /// Extracted metrics. Empty when [`OutputFormat::ExitCode`] is
1005 /// used or when JSON parsing found no numeric leaves.
1006 pub metrics: Vec<Metric>,
1007 /// Process exit code (0 = success). Used by
1008 /// [`MetricCheck::ExitCodeEq`] in the check
1009 /// evaluation pre-pass.
1010 pub exit_code: i32,
1011}
1012
1013impl PayloadMetrics {
1014 /// Look up a metric by exact name. Returns `None` when the
1015 /// metric is not in the set.
1016 pub fn get(&self, name: &str) -> Option<f64> {
1017 self.metrics
1018 .iter()
1019 .find(|m| m.name == name)
1020 .map(|m| m.value)
1021 }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027
1028 #[test]
1029 fn metric_check_builtin_constructors_carry_the_wire_name() {
1030 use crate::stats::BuiltinMetric;
1031 // The typed const constructors store the same wire name as the &str
1032 // form, so a built-in payload check is typo-proof without changing the
1033 // stored key. (MetricCheck is Copy, not PartialEq, so match on the arm.)
1034 match MetricCheck::min_builtin(BuiltinMetric::TaobenchTotalQps, 1000.0) {
1035 MetricCheck::Min { metric, value } => {
1036 assert_eq!(metric, "taobench_total_qps");
1037 assert_eq!(value, 1000.0);
1038 }
1039 other => panic!("min_builtin must produce Min, got {other:?}"),
1040 }
1041 match MetricCheck::max_builtin(BuiltinMetric::TaobenchSlowQps, 5.0) {
1042 MetricCheck::Max { metric, value } => {
1043 assert_eq!(metric, "taobench_slow_qps");
1044 assert_eq!(value, 5.0);
1045 }
1046 other => panic!("max_builtin must produce Max, got {other:?}"),
1047 }
1048 match MetricCheck::range_builtin(BuiltinMetric::WakeupP99LatencyUs, 1.0, 2.0) {
1049 MetricCheck::Range { metric, lo, hi } => {
1050 assert_eq!(metric, "wakeup_p99_latency_us");
1051 assert_eq!((lo, hi), (1.0, 2.0));
1052 }
1053 other => panic!("range_builtin must produce Range, got {other:?}"),
1054 }
1055 match MetricCheck::exists_builtin(BuiltinMetric::SchbenchLoopCount) {
1056 MetricCheck::Exists(metric) => assert_eq!(metric, "schbench_loop_count"),
1057 other => panic!("exists_builtin must produce Exists, got {other:?}"),
1058 }
1059 }
1060
1061 #[test]
1062 fn metric_check_builtin_constructors_compose_into_a_const_default_checks_table() {
1063 use crate::stats::BuiltinMetric;
1064 // The *_builtin constructors are `const fn`, so a typed check table is a
1065 // plain `const` `&'static [MetricCheck]` — exactly the type of
1066 // `Payload::default_checks`. This pins const-composition (the property the
1067 // `const fn` buys) and documents the intended usage: a typo-proof
1068 // default_checks table built from BuiltinMetric variants, not bare strings.
1069 const CHECKS: &[MetricCheck] = &[
1070 MetricCheck::min_builtin(BuiltinMetric::TaobenchTotalQps, 1000.0),
1071 MetricCheck::max_builtin(BuiltinMetric::TaobenchSlowQps, 5.0),
1072 MetricCheck::range_builtin(BuiltinMetric::WakeupP99LatencyUs, 1.0, 2.0),
1073 MetricCheck::exists_builtin(BuiltinMetric::SchbenchLoopCount),
1074 ];
1075 // Same wire names the string constructors would store, in declaration order.
1076 assert_eq!(CHECKS.len(), 4);
1077 assert!(matches!(
1078 CHECKS[0],
1079 MetricCheck::Min {
1080 metric: "taobench_total_qps",
1081 ..
1082 }
1083 ));
1084 assert!(matches!(
1085 CHECKS[1],
1086 MetricCheck::Max {
1087 metric: "taobench_slow_qps",
1088 ..
1089 }
1090 ));
1091 assert!(matches!(
1092 CHECKS[2],
1093 MetricCheck::Range {
1094 metric: "wakeup_p99_latency_us",
1095 ..
1096 }
1097 ));
1098 assert!(matches!(
1099 CHECKS[3],
1100 MetricCheck::Exists("schbench_loop_count")
1101 ));
1102
1103 // The typed table is a `&'static [MetricCheck]` (the const's lifetime is
1104 // elided) — it drops straight into a Payload's default_checks slot (the
1105 // composition the const fn enables).
1106 const _: &[MetricCheck] = CHECKS;
1107 }
1108
1109 #[test]
1110 fn payload_kernel_default_const_is_scheduler_kind() {
1111 assert!(matches!(
1112 Payload::KERNEL_DEFAULT.kind,
1113 PayloadKind::Scheduler(_)
1114 ));
1115 assert_eq!(Payload::KERNEL_DEFAULT.display_name(), "kernel_default");
1116 assert!(matches!(
1117 Payload::KERNEL_DEFAULT.output,
1118 OutputFormat::ExitCode
1119 ));
1120 assert!(Payload::KERNEL_DEFAULT.default_args.is_empty());
1121 assert!(Payload::KERNEL_DEFAULT.default_checks.is_empty());
1122 assert!(Payload::KERNEL_DEFAULT.metrics.is_empty());
1123 }
1124
1125 #[test]
1126 fn payload_kernel_default_wraps_scheduler_eevdf() {
1127 match Payload::KERNEL_DEFAULT.kind {
1128 PayloadKind::Scheduler(s) => {
1129 assert_eq!(s.name, Scheduler::EEVDF.name);
1130 }
1131 PayloadKind::Binary(_) => panic!("EEVDF should be Scheduler-kind, got Binary"),
1132 }
1133 }
1134
1135 /// [`Payload::binary`] fills a binary-kind [`Payload`] with the
1136 /// exit-code-only defaults — empty `default_args`,
1137 /// `default_checks`, `metrics`, and `OutputFormat::ExitCode`.
1138 /// Evaluated in a `const` block so any future drift that makes
1139 /// the constructor non-const surfaces here at compile time; the
1140 /// runtime assertions pin the field-level defaults so a
1141 /// drive-by change (e.g. flipping `output` to `Json`) reshapes
1142 /// every `Payload::binary(…)` call site visibly.
1143 #[test]
1144 fn payload_binary_const_constructor_shape() {
1145 const P: Payload = Payload::binary("fio_payload", "fio");
1146 assert_eq!(P.name, "fio_payload");
1147 assert!(matches!(P.kind, PayloadKind::Binary("fio")));
1148 assert!(matches!(P.output, OutputFormat::ExitCode));
1149 assert!(P.default_args.is_empty());
1150 assert!(P.default_checks.is_empty());
1151 assert!(P.metrics.is_empty());
1152 assert!(!P.is_scheduler());
1153 assert!(P.as_scheduler().is_none());
1154 }
1155
1156 #[test]
1157 fn check_constructors() {
1158 assert!(matches!(
1159 MetricCheck::min("x", 1.0),
1160 MetricCheck::Min { .. }
1161 ));
1162 assert!(matches!(
1163 MetricCheck::max("x", 1.0),
1164 MetricCheck::Max { .. }
1165 ));
1166 assert!(matches!(
1167 MetricCheck::range("x", 1.0, 2.0),
1168 MetricCheck::Range { .. }
1169 ));
1170 assert!(matches!(MetricCheck::exists("x"), MetricCheck::Exists("x")));
1171 assert!(matches!(
1172 MetricCheck::exit_code_eq(0),
1173 MetricCheck::ExitCodeEq(0)
1174 ));
1175 }
1176
1177 #[test]
1178 fn metric_set_get_returns_value() {
1179 let pm = PayloadMetrics {
1180 payload_index: 0,
1181 metrics: vec![Metric {
1182 name: "iops".to_string(),
1183 value: 1000.0,
1184 polarity: Polarity::HigherBetter,
1185 unit: "iops".to_string(),
1186 stream: MetricStream::Stdout,
1187 }],
1188 exit_code: 0,
1189 };
1190 assert_eq!(pm.get("iops"), Some(1000.0));
1191 assert_eq!(pm.get("missing"), None);
1192 }
1193
1194 #[test]
1195 fn polarity_target_value_carries_data() {
1196 let p = Polarity::TargetValue(42.0);
1197 match p {
1198 Polarity::TargetValue(v) => assert_eq!(v, 42.0),
1199 _ => panic!("expected TargetValue variant"),
1200 }
1201 }
1202
1203 /// Wire-format round-trip for every [`MetricStream`] variant.
1204 /// Pins the serde representation so a sidecar written by one
1205 /// version of ktstr deserializes under another — a silent wire
1206 /// change (rename, internal tag, numeric encoding) would
1207 /// surface here, not as a missing-field error on every
1208 /// existing sidecar.
1209 #[test]
1210 fn metric_stream_serde_round_trip() {
1211 for s in [MetricStream::Stdout, MetricStream::Stderr] {
1212 let js = serde_json::to_string(&s).expect("serialize");
1213 let de: MetricStream = serde_json::from_str(&js).expect("deserialize");
1214 assert_eq!(
1215 de, s,
1216 "MetricStream::{s:?} wire format must round-trip \
1217 identically; serialized as {js}, deserialized to \
1218 {de:?}",
1219 );
1220 }
1221 }
1222
1223 #[test]
1224 fn polarity_serde_round_trip() {
1225 for p in [
1226 Polarity::HigherBetter,
1227 Polarity::LowerBetter,
1228 Polarity::TargetValue(2.78),
1229 Polarity::Unknown,
1230 ] {
1231 let js = serde_json::to_string(&p).unwrap();
1232 let de: Polarity = serde_json::from_str(&js).unwrap();
1233 assert_eq!(de, p);
1234 }
1235 }
1236
1237 // PayloadKind::Binary construction + pattern match.
1238 #[test]
1239 fn payload_kind_binary_construction_and_match() {
1240 const FIO: Payload = Payload {
1241 name: "fio",
1242 kind: PayloadKind::Binary("fio"),
1243 output: OutputFormat::Json,
1244 default_args: &[],
1245 default_checks: &[],
1246 metrics: &[],
1247 include_files: &[],
1248 uses_parent_pgrp: false,
1249 known_flags: None,
1250 };
1251 match FIO.kind {
1252 PayloadKind::Binary(name) => assert_eq!(name, "fio"),
1253 PayloadKind::Scheduler(_) => panic!("expected Binary, got Scheduler"),
1254 }
1255 assert!(!FIO.is_scheduler());
1256 assert!(FIO.as_scheduler().is_none());
1257 }
1258
1259 // Const bindings verify const-fn actually works in const context.
1260 const _MIN: MetricCheck = MetricCheck::min("x", 1.0);
1261 const _MAX: MetricCheck = MetricCheck::max("x", 2.0);
1262 const _RANGE: MetricCheck = MetricCheck::range("x", 1.0, 2.0);
1263 const _EXISTS: MetricCheck = MetricCheck::exists("x");
1264 const _EXIT: MetricCheck = MetricCheck::exit_code_eq(0);
1265 const _KERNEL_DEFAULT_REF: &Payload = &Payload::KERNEL_DEFAULT;
1266 const _KERNEL_DEFAULT_IS_SCHED: bool = Payload::KERNEL_DEFAULT.is_scheduler();
1267 const _KERNEL_DEFAULT_DISPLAY: &str = Payload::KERNEL_DEFAULT.display_name();
1268
1269 // Proves an arbitrary `Payload` (not just `Payload::KERNEL_DEFAULT`) is
1270 // const-constructible via struct literal — the #[derive(Payload)]
1271 // proc-macro emits exactly this shape.
1272 const _PAYLOAD_CONST_BUILD: Payload = Payload {
1273 name: "fio",
1274 kind: PayloadKind::Binary("fio"),
1275 output: OutputFormat::Json,
1276 default_args: &["--output-format=json"],
1277 default_checks: &[MetricCheck::exit_code_eq(0)],
1278 metrics: &[MetricHint {
1279 name: "jobs.0.read.iops",
1280 polarity: Polarity::HigherBetter,
1281 unit: "iops",
1282 }],
1283 include_files: &[],
1284 uses_parent_pgrp: false,
1285 known_flags: None,
1286 };
1287
1288 #[test]
1289 fn const_bindings_are_usable() {
1290 assert!(matches!(_MIN, MetricCheck::Min { .. }));
1291 assert!(matches!(_MAX, MetricCheck::Max { .. }));
1292 assert!(matches!(_RANGE, MetricCheck::Range { .. }));
1293 assert!(matches!(_EXISTS, MetricCheck::Exists("x")));
1294 assert!(matches!(_EXIT, MetricCheck::ExitCodeEq(0)));
1295 assert_eq!(_KERNEL_DEFAULT_REF.name, "kernel_default");
1296 const { assert!(_KERNEL_DEFAULT_IS_SCHED) };
1297 assert_eq!(_KERNEL_DEFAULT_DISPLAY, "kernel_default");
1298 }
1299
1300 // from_higher_is_worse helper.
1301 #[test]
1302 fn polarity_from_higher_is_worse_flips_sense() {
1303 assert_eq!(Polarity::from_higher_is_worse(true), Polarity::LowerBetter);
1304 assert_eq!(
1305 Polarity::from_higher_is_worse(false),
1306 Polarity::HigherBetter
1307 );
1308 }
1309
1310 /// Round-trip bool → Polarity → bool for HigherBetter /
1311 /// LowerBetter yields the identity. Pins the "inverse sense"
1312 /// contract documented on `MetricDef::higher_is_worse` and
1313 /// `Polarity::from_higher_is_worse` so a future polarity
1314 /// refactor can't accidentally flip one direction without the
1315 /// other and silently break delta-classification downstream.
1316 ///
1317 /// The test synthesizes a throw-away `MetricDef` for each
1318 /// polarity because the production `METRICS` table's entries
1319 /// live in `stats.rs` and are test-only not importable from
1320 /// here — constructing the struct literal directly keeps the
1321 /// round-trip self-contained.
1322 #[test]
1323 fn higher_is_worse_polarity_round_trip() {
1324 use crate::stats::{MetricDef, MetricKind};
1325
1326 // true (higher-is-worse) → LowerBetter → true.
1327 let m = MetricDef {
1328 name: "t",
1329 polarity: Polarity::from_higher_is_worse(true),
1330 kind: MetricKind::Counter,
1331 default_abs: 0.0,
1332 default_rel: 0.0,
1333 display_unit: "",
1334 accessor: |_| None,
1335 };
1336 assert_eq!(m.polarity, Polarity::LowerBetter);
1337 assert!(m.higher_is_worse(), "LowerBetter → higher_is_worse = true");
1338
1339 // false (higher-is-better) → HigherBetter → false.
1340 let m = MetricDef {
1341 name: "f",
1342 polarity: Polarity::from_higher_is_worse(false),
1343 kind: MetricKind::Counter,
1344 default_abs: 0.0,
1345 default_rel: 0.0,
1346 display_unit: "",
1347 accessor: |_| None,
1348 };
1349 assert_eq!(m.polarity, Polarity::HigherBetter);
1350 assert!(
1351 !m.higher_is_worse(),
1352 "HigherBetter → higher_is_worse = false"
1353 );
1354 }
1355
1356 /// `MetricDef::higher_is_worse` is total over every `Polarity`
1357 /// variant — the current implementation lumps `LowerBetter`,
1358 /// `TargetValue`, and `Unknown` all into `true`. Pinned so a
1359 /// subtle change (e.g. TargetValue → its own category) doesn't
1360 /// silently flip regression direction for every test using
1361 /// target metrics.
1362 #[test]
1363 fn higher_is_worse_covers_all_polarity_variants() {
1364 use crate::stats::{MetricDef, MetricKind};
1365 fn make(p: Polarity) -> MetricDef {
1366 MetricDef {
1367 name: "x",
1368 polarity: p,
1369 kind: MetricKind::Counter,
1370 default_abs: 0.0,
1371 default_rel: 0.0,
1372 display_unit: "",
1373 accessor: |_| None,
1374 }
1375 }
1376 assert!(!make(Polarity::HigherBetter).higher_is_worse());
1377 assert!(make(Polarity::LowerBetter).higher_is_worse());
1378 assert!(make(Polarity::TargetValue(42.0)).higher_is_worse());
1379 assert!(make(Polarity::Unknown).higher_is_worse());
1380 }
1381
1382 #[test]
1383 fn polarity_target_accepts_finite() {
1384 let p = Polarity::target(0.5);
1385 assert_eq!(p, Polarity::TargetValue(0.5));
1386 }
1387
1388 /// `Polarity::target(NaN)` must panic in release too — non-finite
1389 /// target values produce silent incorrect regression verdicts in
1390 /// `compare_rows`, so the gate is a runtime `assert!` (not
1391 /// `debug_assert!`). Pins that a release build won't silently
1392 /// let NaN slip through.
1393 #[test]
1394 #[should_panic(expected = "Polarity::TargetValue target must be finite")]
1395 fn polarity_target_rejects_nan_panics() {
1396 let _ = Polarity::target(f64::NAN);
1397 }
1398
1399 /// `Polarity::target(+inf)` panics symmetrically with NaN.
1400 /// `compare_rows` would otherwise produce inf-vs-finite verdicts
1401 /// that depend on IEEE-754 infinity arithmetic rather than
1402 /// meaningful regression direction.
1403 #[test]
1404 #[should_panic(expected = "Polarity::TargetValue target must be finite")]
1405 fn polarity_target_rejects_positive_infinity_panics() {
1406 let _ = Polarity::target(f64::INFINITY);
1407 }
1408
1409 /// `Polarity::target(-inf)` ditto.
1410 #[test]
1411 #[should_panic(expected = "Polarity::TargetValue target must be finite")]
1412 fn polarity_target_rejects_negative_infinity_panics() {
1413 let _ = Polarity::target(f64::NEG_INFINITY);
1414 }
1415
1416 /// `Polarity::TargetValue(NaN)` — which bypasses the
1417 /// `Polarity::target` constructor's runtime assert when a hand-
1418 /// built struct literal is used — serializes to
1419 /// `{"TargetValue":null}` via serde_json because
1420 /// `serde_json::Number::from_f64` returns `None` on non-finite
1421 /// values and the default serializer falls back to `null`.
1422 /// The resulting document does NOT round-trip: deserialization
1423 /// fails because `null` can't satisfy the inner `f64` slot.
1424 /// So NaN cannot survive a sidecar write + read pair, even
1425 /// though the write step silently coerces it. Pins both halves
1426 /// of this asymmetric guard so a future serde-attribute change
1427 /// (e.g. `serialize_with = "serialize_nan_as_zero"`) or a
1428 /// custom deserializer gets surfaced here.
1429 #[test]
1430 fn polarity_target_nan_serializes_as_null_and_fails_to_round_trip() {
1431 let p = Polarity::TargetValue(f64::NAN);
1432 let s = serde_json::to_string(&p).expect("NaN→null serialization is the current behavior");
1433 assert_eq!(s, "{\"TargetValue\":null}");
1434 assert!(
1435 serde_json::from_str::<Polarity>(&s).is_err(),
1436 "the null-coerced round-trip must fail to deserialize so a NaN written \
1437 by an un-guarded producer cannot silently re-enter a run",
1438 );
1439 }
1440
1441 /// Raw `NaN` / `Infinity` tokens are not valid JSON, so a
1442 /// sidecar file hand-edited (or emitted by a non-serde writer)
1443 /// to contain them will be rejected at parse time. Pairs with
1444 /// the null-round-trip test above.
1445 #[test]
1446 fn polarity_target_nan_cannot_deserialize_from_non_json_literals() {
1447 assert!(serde_json::from_str::<Polarity>("{\"TargetValue\":NaN}").is_err());
1448 assert!(serde_json::from_str::<Polarity>("{\"TargetValue\":Infinity}").is_err());
1449 assert!(serde_json::from_str::<Polarity>("{\"TargetValue\":-Infinity}").is_err());
1450 }
1451
1452 /// `MetricCheck::range(metric, lo, hi)` panics when `lo > hi`.
1453 /// A reversed-bounds range describes an empty interval that no
1454 /// finite metric can satisfy — almost always a user error.
1455 /// Failing loudly at the constructor surfaces the typo at the
1456 /// call site rather than letting the evaluator run an
1457 /// unsatisfiable check against every probe value.
1458 #[test]
1459 #[should_panic(expected = "lo must be <= hi")]
1460 fn check_range_reversed_bounds_panics_at_construction() {
1461 let _ = MetricCheck::range("iops", 100.0, 50.0);
1462 }
1463
1464 /// `MetricCheck::range_builtin` carries the same reversed-bounds construction
1465 /// panic as [`MetricCheck::range`] (the typed sibling shares the `lo <= hi`
1466 /// assert), so the typed payload-check path fails just as loudly on a
1467 /// reversed range as the string form.
1468 #[test]
1469 #[should_panic(expected = "lo must be <= hi")]
1470 fn check_range_builtin_reversed_bounds_panics_at_construction() {
1471 let _ = MetricCheck::range_builtin(
1472 crate::stats::BuiltinMetric::WakeupP99LatencyUs,
1473 100.0,
1474 50.0,
1475 );
1476 }
1477
1478 /// Equal bounds (`lo == hi`) describe a single-point interval —
1479 /// allowed; the metric must equal that exact value. Pins the
1480 /// `<=` (not `<`) gate in the constructor.
1481 #[test]
1482 fn check_range_equal_bounds_construct() {
1483 let r = MetricCheck::range("iops", 50.0, 50.0);
1484 match r {
1485 MetricCheck::Range { metric, lo, hi } => {
1486 assert_eq!(metric, "iops");
1487 assert_eq!(lo, 50.0);
1488 assert_eq!(hi, 50.0);
1489 }
1490 _ => panic!("expected Range variant"),
1491 }
1492 }
1493
1494 /// NaN as either bound trips the `lo <= hi` gate (NaN comparisons
1495 /// always return false), so the constructor panics. Prevents an
1496 /// always-fails check from slipping into the evaluator pipeline.
1497 #[test]
1498 #[should_panic(expected = "lo must be <= hi")]
1499 fn check_range_nan_lo_panics() {
1500 let _ = MetricCheck::range("iops", f64::NAN, 50.0);
1501 }
1502
1503 #[test]
1504 #[should_panic(expected = "lo must be <= hi")]
1505 fn check_range_nan_hi_panics() {
1506 let _ = MetricCheck::range("iops", 50.0, f64::NAN);
1507 }
1508
1509 // Debug + helper method surface.
1510 #[test]
1511 fn payload_debug_renders_identity_fields() {
1512 let s = format!("{:?}", Payload::KERNEL_DEFAULT);
1513 assert!(s.contains("Payload"), "debug output: {s}");
1514 assert!(s.contains("eevdf"), "debug output: {s}");
1515 assert!(
1516 s.contains("kind: Scheduler(\"eevdf\")"),
1517 "debug output: {s}"
1518 );
1519 }
1520
1521 #[test]
1522 fn payload_kind_debug_renders_variant_and_identity() {
1523 let binary = PayloadKind::Binary("fio");
1524 let s = format!("{binary:?}");
1525 assert!(s.contains("Binary"), "debug output: {s}");
1526 assert!(s.contains("fio"), "debug output: {s}");
1527
1528 let sched = Payload::KERNEL_DEFAULT.kind;
1529 let s = format!("{sched:?}");
1530 assert!(s.contains("Scheduler"), "debug output: {s}");
1531 assert!(s.contains("eevdf"), "debug output: {s}");
1532 }
1533
1534 #[test]
1535 fn output_format_derive_debug_clone_copy() {
1536 // Debug must name the variant (mirrors
1537 // `payload_kind_debug_renders_variant_and_identity`).
1538 let s = format!("{:?}", OutputFormat::Json);
1539 assert!(s.contains("Json"), "Debug must name the variant: {s}");
1540
1541 // Copy semantics observably: after `let b = a;` the original
1542 // `a` is still usable (a move would make this fail to compile),
1543 // and both render identically.
1544 let a = OutputFormat::Json;
1545 let b = a; // Copy, not move.
1546 assert_eq!(
1547 format!("{a:?}"),
1548 format!("{b:?}"),
1549 "copied value must Debug-render identically to the original",
1550 );
1551 }
1552
1553 #[test]
1554 fn as_scheduler_extracts_ref_for_scheduler_kind() {
1555 let s = Payload::KERNEL_DEFAULT
1556 .as_scheduler()
1557 .expect("Scheduler kind");
1558 assert_eq!(s.name, "eevdf");
1559 }
1560
1561 #[test]
1562 fn payload_clone_preserves_identity() {
1563 let a = Payload::KERNEL_DEFAULT;
1564 assert_eq!(a.name, Payload::KERNEL_DEFAULT.name);
1565 assert_eq!(a.is_scheduler(), Payload::KERNEL_DEFAULT.is_scheduler());
1566 assert_eq!(a.as_scheduler().map(|s| s.name), Some("eevdf"));
1567 }
1568
1569 /// `PayloadMetrics` postcard wire-format roundtrip. The type ships
1570 /// guest→host inside `MSG_TYPE_PAYLOAD_METRICS` and the
1571 /// postcard codec rejects any nested type carrying
1572 /// `#[serde(untagged)]` or `#[serde(tag, content)]` at encode
1573 /// time (`WontImplement`) — a regression introduced by a
1574 /// contributor adding adjacent tagging to `Metric` or any
1575 /// downstream field would surface as
1576 /// `ERR_NO_TEST_FUNCTION_OUTPUT` on the host without this pin.
1577 /// Sister inventory: see comment block at src/vmm/wire.rs.
1578 #[test]
1579 fn payload_metrics_postcard_roundtrip() {
1580 let original = PayloadMetrics {
1581 payload_index: 7,
1582 metrics: vec![
1583 Metric {
1584 name: "tput".to_string(),
1585 value: 12345.6,
1586 polarity: Polarity::HigherBetter,
1587 unit: "ops/s".to_string(),
1588 stream: MetricStream::Stdout,
1589 },
1590 Metric {
1591 name: "lat_p99".to_string(),
1592 value: 0.025,
1593 polarity: Polarity::LowerBetter,
1594 unit: "s".to_string(),
1595 stream: MetricStream::Stderr,
1596 },
1597 ],
1598 exit_code: 0,
1599 };
1600 let bytes = postcard::to_allocvec(&original).expect("encode");
1601 let back: PayloadMetrics = postcard::from_bytes(&bytes).expect("decode");
1602 assert_eq!(back.payload_index, original.payload_index);
1603 assert_eq!(back.exit_code, original.exit_code);
1604 assert_eq!(back.metrics.len(), original.metrics.len());
1605 for (a, b) in original.metrics.iter().zip(back.metrics.iter()) {
1606 assert_eq!(a.name, b.name);
1607 assert_eq!(a.value, b.value);
1608 }
1609 }
1610}