use anyhow::Result;
use linkme::distributed_slice;
use std::time::Duration;
use crate::assert::AssertResult;
use crate::scenario::Ctx;
pub use crate::vmm::topology::{MemSideCache, NumaDistance, NumaNode, Topology};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SchedulerSpec {
Eevdf,
Discover(&'static str),
Path(&'static str),
KernelBuiltin {
enable: &'static [&'static str],
disable: &'static [&'static str],
},
}
impl SchedulerSpec {
pub const fn has_active_scheduling(&self) -> bool {
!matches!(self, SchedulerSpec::Eevdf)
}
pub const fn display_name(&self) -> &'static str {
match self {
SchedulerSpec::Eevdf => "eevdf",
SchedulerSpec::Discover(n) => n,
SchedulerSpec::Path(p) => p,
SchedulerSpec::KernelBuiltin { .. } => "kernel",
}
}
pub const fn scheduler_commit(&self) -> Option<&'static str> {
match self {
SchedulerSpec::Eevdf
| SchedulerSpec::Discover(_)
| SchedulerSpec::Path(_)
| SchedulerSpec::KernelBuiltin { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Sysctl {
key: &'static str,
value: &'static str,
}
impl Sysctl {
pub const fn new(key: &'static str, value: &'static str) -> Self {
let key_bytes = key.as_bytes();
assert!(!key_bytes.is_empty(), "Sysctl key must not be empty");
assert!(
key_bytes[0] != b'.' && key_bytes[key_bytes.len() - 1] != b'.',
"Sysctl key must not start or end with `.`",
);
let mut i = 0;
let mut has_dot = false;
let mut prev = 0u8;
while i < key_bytes.len() {
let b = key_bytes[i];
assert!(
b != b'/',
"Sysctl key must use the dotted form (e.g. `kernel.foo`), not the slash form (`kernel/foo`)",
);
assert!(
b != b' ' && b != b'\t' && b != b'\n' && b != b'\r',
"Sysctl key must not contain whitespace (would corrupt cmdline form)",
);
assert!(
b != b'=',
"Sysctl key must not contain `=` (would corrupt `sysctl.<key>=<value>` cmdline split)",
);
assert!(
b >= 0x20 && b < 0x7f,
"Sysctl key must be printable ASCII only (no control bytes / high-bit chars)",
);
if b == b'.' {
has_dot = true;
assert!(
i == 0 || prev != b'.',
"Sysctl key must not contain `..` (empty segment — kernel sysctl parser rejects)",
);
}
prev = b;
i += 1;
}
assert!(
has_dot,
"Sysctl key must be namespaced (contain at least one `.`, e.g. `kernel.foo`)",
);
let value_bytes = value.as_bytes();
assert!(!value_bytes.is_empty(), "Sysctl value must not be empty");
let mut j = 0;
while j < value_bytes.len() {
let b = value_bytes[j];
assert!(
b != b'\n',
"Sysctl value must not contain a newline (would corrupt cmdline form)",
);
assert!(
b != b'\r',
"Sysctl value must not contain a carriage return (would corrupt cmdline form)",
);
assert!(
b != b'=',
"Sysctl value must not contain `=` (would corrupt `sysctl.<key>=<value>` cmdline form)",
);
j += 1;
}
Self { key, value }
}
pub const fn key(&self) -> &'static str {
self.key
}
pub const fn value(&self) -> &'static str {
self.value
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CgroupPath(&'static str);
impl CgroupPath {
pub const fn new(path: &'static str) -> Self {
assert!(
!path.is_empty() && path.as_bytes()[0] == b'/',
"CgroupPath must begin with '/' (e.g. \"/ktstr\")"
);
assert!(
path.len() > 1,
"CgroupPath must not be \"/\" alone (that is the cgroup root)"
);
let bytes = path.as_bytes();
let mut seg_start: usize = 1; let mut i: usize = 1;
let mut has_normal = false;
while i <= bytes.len() {
let at_end = i == bytes.len();
if at_end || bytes[i] == b'/' {
let seg_len = i - seg_start;
let is_dotdot =
seg_len == 2 && bytes[seg_start] == b'.' && bytes[seg_start + 1] == b'.';
assert!(
!is_dotdot,
"CgroupPath must not contain `..` segments \
(they escape `/sys/fs/cgroup` once the kernel \
canonicalizes the resolved path)"
);
let is_empty_or_dot = seg_len == 0 || (seg_len == 1 && bytes[seg_start] == b'.');
if !is_empty_or_dot {
has_normal = true;
}
seg_start = i + 1;
}
i += 1;
}
assert!(
has_normal,
"CgroupPath must contain at least one non-`.`/non-empty segment \
(paths like `/`, `///`, or `/.` normalize back to `/sys/fs/cgroup`)"
);
Self(path)
}
pub const fn as_str(&self) -> &'static str {
self.0
}
pub fn sysfs_path(&self) -> String {
format!("/sys/fs/cgroup{}", self.0)
}
}
impl std::ops::Deref for CgroupPath {
type Target = str;
fn deref(&self) -> &str {
self.0
}
}
impl std::fmt::Display for CgroupPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BpfMapWrite {
map_name_suffix: &'static str,
offset: usize,
value: u32,
}
impl BpfMapWrite {
pub const fn new(map_name_suffix: &'static str, offset: usize, value: u32) -> Self {
let bytes = map_name_suffix.as_bytes();
assert!(
!bytes.is_empty(),
"BpfMapWrite map_name_suffix must not be empty"
);
assert!(
bytes[0] == b'.',
"BpfMapWrite map_name_suffix must start with `.` (BPF map suffixes match ELF section names like `.bss`, `.data`, `.rodata`)",
);
assert!(
bytes.len() >= 2,
"BpfMapWrite map_name_suffix must be longer than a bare `.` (no real BPF section name is just `.`)",
);
assert!(
bytes[1] != b'.',
"BpfMapWrite map_name_suffix must not start with `..` (no real BPF section name has that shape)",
);
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
assert!(
b != b' ' && b != b'\t' && b != b'\n' && b != b'\r',
"BpfMapWrite map_name_suffix must not contain whitespace",
);
assert!(
b != b'/' && b != b'\\',
"BpfMapWrite map_name_suffix must not contain path separators",
);
assert!(
b >= 0x20 && b < 0x7f,
"BpfMapWrite map_name_suffix must be printable ASCII only (no control bytes / NULL / high-bit chars)",
);
i += 1;
}
Self {
map_name_suffix,
offset,
value,
}
}
pub const fn map_name_suffix(&self) -> &'static str {
self.map_name_suffix
}
pub const fn offset(&self) -> usize {
self.offset
}
pub const fn value(&self) -> u32 {
self.value
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TopologyConstraints {
pub min_numa_nodes: u32,
pub max_numa_nodes: Option<u32>,
pub min_llcs: u32,
pub max_llcs: Option<u32>,
pub requires_smt: bool,
pub min_cpus: u32,
pub max_cpus: Option<u32>,
}
impl TopologyConstraints {
pub const DEFAULT: Self = Self {
min_numa_nodes: 1,
max_numa_nodes: Some(1),
min_llcs: 1,
max_llcs: Some(12),
requires_smt: false,
min_cpus: 1,
max_cpus: Some(192),
};
pub const fn new() -> Self {
Self::DEFAULT
}
}
impl Default for TopologyConstraints {
fn default() -> Self {
Self::new()
}
}
impl TopologyConstraints {
pub fn accepts(
&self,
topo: &Topology,
host_cpus: u32,
host_llcs: u32,
host_max_cpus_per_llc: u32,
) -> bool {
topo.num_numa_nodes() >= self.min_numa_nodes
&& self
.max_numa_nodes
.is_none_or(|max| topo.num_numa_nodes() <= max)
&& topo.num_llcs() >= self.min_llcs
&& self.max_llcs.is_none_or(|max| topo.num_llcs() <= max)
&& (!self.requires_smt || topo.threads_per_core >= 2)
&& topo.total_cpus() >= self.min_cpus
&& self.max_cpus.is_none_or(|max| topo.total_cpus() <= max)
&& topo.total_cpus() <= host_cpus
&& topo.num_llcs() <= host_llcs
&& topo.cores_per_llc * topo.threads_per_core <= host_max_cpus_per_llc
}
pub fn accepts_no_perf_mode(&self, topo: &Topology, host_cpus: u32) -> bool {
topo.num_numa_nodes() >= self.min_numa_nodes
&& self
.max_numa_nodes
.is_none_or(|max| topo.num_numa_nodes() <= max)
&& topo.num_llcs() >= self.min_llcs
&& self.max_llcs.is_none_or(|max| topo.num_llcs() <= max)
&& (!self.requires_smt || topo.threads_per_core >= 2)
&& topo.total_cpus() >= self.min_cpus
&& self.max_cpus.is_none_or(|max| topo.total_cpus() <= max)
&& topo.total_cpus() <= host_cpus
}
pub fn validate(&self) -> anyhow::Result<()> {
if let Some(max) = self.max_numa_nodes
&& max < self.min_numa_nodes
{
anyhow::bail!(
"TopologyConstraints inverted: max_numa_nodes={} < \
min_numa_nodes={}. No topology can satisfy both bounds, \
so every gauntlet preset would silently skip.",
max,
self.min_numa_nodes,
);
}
if let Some(max) = self.max_llcs
&& max < self.min_llcs
{
anyhow::bail!(
"TopologyConstraints inverted: max_llcs={} < min_llcs={}. \
No topology can satisfy both bounds.",
max,
self.min_llcs,
);
}
if let Some(max) = self.max_cpus
&& max < self.min_cpus
{
anyhow::bail!(
"TopologyConstraints inverted: max_cpus={} < min_cpus={}. \
No topology can satisfy both bounds.",
max,
self.min_cpus,
);
}
Ok(())
}
}
impl TopologyConstraints {
#[must_use = "builder methods consume self; bind the result"]
pub const fn with_min_numa_nodes(mut self, min_numa_nodes: u32) -> Self {
self.min_numa_nodes = min_numa_nodes;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn with_max_numa_nodes(mut self, max_numa_nodes: u32) -> Self {
self.max_numa_nodes = Some(max_numa_nodes);
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn without_max_numa_nodes(mut self) -> Self {
self.max_numa_nodes = None;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn with_min_llcs(mut self, min_llcs: u32) -> Self {
self.min_llcs = min_llcs;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn with_max_llcs(mut self, max_llcs: u32) -> Self {
self.max_llcs = Some(max_llcs);
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn without_max_llcs(mut self) -> Self {
self.max_llcs = None;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn with_requires_smt(mut self, requires_smt: bool) -> Self {
self.requires_smt = requires_smt;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn with_min_cpus(mut self, min_cpus: u32) -> Self {
self.min_cpus = min_cpus;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn with_max_cpus(mut self, max_cpus: u32) -> Self {
self.max_cpus = Some(max_cpus);
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn without_max_cpus(mut self) -> Self {
self.max_cpus = None;
self
}
}
#[derive(Debug)]
pub struct Scheduler {
pub name: &'static str,
pub binary: SchedulerSpec,
pub sysctls: &'static [Sysctl],
pub kargs: &'static [&'static str],
pub assert: crate::assert::Assert,
pub cgroup_parent: Option<CgroupPath>,
pub sched_args: &'static [&'static str],
pub topology: Topology,
pub constraints: TopologyConstraints,
pub config_file: Option<&'static str>,
pub config_file_def: Option<(&'static str, &'static str)>,
pub kernels: &'static [&'static str],
}
impl Scheduler {
pub const EEVDF: Scheduler = Scheduler {
name: "eevdf",
binary: SchedulerSpec::Eevdf,
sysctls: &[],
kargs: &[],
assert: crate::assert::Assert::NO_OVERRIDES,
cgroup_parent: None,
sched_args: &[],
topology: Topology {
llcs: 1,
cores_per_llc: 2,
threads_per_core: 1,
numa_nodes: 1,
nodes: None,
distances: None,
},
constraints: TopologyConstraints::DEFAULT,
config_file: None,
config_file_def: None,
kernels: &[],
};
pub const fn named(name: &'static str) -> Scheduler {
Scheduler {
name,
binary: SchedulerSpec::Eevdf,
sysctls: &[],
kargs: &[],
assert: crate::assert::Assert::NO_OVERRIDES,
cgroup_parent: None,
sched_args: &[],
topology: Topology {
llcs: 1,
cores_per_llc: 2,
threads_per_core: 1,
numa_nodes: 1,
nodes: None,
distances: None,
},
constraints: TopologyConstraints::DEFAULT,
config_file: None,
config_file_def: None,
kernels: &[],
}
}
pub const fn binary(mut self, binary: SchedulerSpec) -> Self {
self.binary = binary;
self
}
pub const fn binary_discover(self, name: &'static str) -> Self {
self.binary(SchedulerSpec::Discover(name))
}
pub const fn sysctls(mut self, sysctls: &'static [Sysctl]) -> Self {
self.sysctls = sysctls;
self
}
pub const fn kargs(mut self, kargs: &'static [&'static str]) -> Self {
self.kargs = kargs;
self
}
pub const fn assert(mut self, assert: crate::assert::Assert) -> Self {
self.assert = assert;
self
}
pub const fn cgroup_parent(mut self, path: &'static str) -> Self {
self.cgroup_parent = Some(CgroupPath::new(path));
self
}
pub const fn sched_args(mut self, args: &'static [&'static str]) -> Self {
self.sched_args = args;
self
}
pub const fn topology(mut self, numa_nodes: u32, llcs: u32, cores: u32, threads: u32) -> Self {
self.topology = Topology {
llcs,
cores_per_llc: cores,
threads_per_core: threads,
numa_nodes,
nodes: None,
distances: None,
};
self
}
pub const fn constraints(mut self, constraints: TopologyConstraints) -> Self {
self.constraints = constraints;
self
}
pub const fn min_numa_nodes(mut self, n: u32) -> Self {
self.constraints.min_numa_nodes = n;
self
}
pub const fn max_numa_nodes(mut self, n: u32) -> Self {
self.constraints.max_numa_nodes = Some(n);
self
}
pub const fn min_llcs(mut self, n: u32) -> Self {
self.constraints.min_llcs = n;
self
}
pub const fn max_llcs(mut self, n: u32) -> Self {
self.constraints.max_llcs = Some(n);
self
}
pub const fn requires_smt(mut self, v: bool) -> Self {
self.constraints.requires_smt = v;
self
}
pub const fn min_cpus(mut self, n: u32) -> Self {
self.constraints.min_cpus = n;
self
}
pub const fn max_cpus(mut self, n: u32) -> Self {
self.constraints.max_cpus = Some(n);
self
}
pub const fn config_file(mut self, path: &'static str) -> Self {
self.config_file = Some(path);
self
}
pub const fn config_file_def(
mut self,
arg_template: &'static str,
guest_path: &'static str,
) -> Self {
self.config_file_def = Some((arg_template, guest_path));
self
}
pub const fn kernels(mut self, kernels: &'static [&'static str]) -> Self {
self.kernels = kernels;
self
}
pub const fn has_active_scheduling(&self) -> bool {
self.binary.has_active_scheduling()
}
}
#[derive(Debug)]
pub struct KtstrTestEntry {
pub name: &'static str,
pub func: fn(&Ctx) -> Result<AssertResult>,
pub topology: Topology,
pub constraints: TopologyConstraints,
pub memory_mib: u32,
pub scheduler: &'static crate::test_support::Scheduler,
pub staged_schedulers: &'static [&'static crate::test_support::Scheduler],
pub payload: Option<&'static crate::test_support::Payload>,
pub workloads: &'static [&'static crate::test_support::Payload],
pub auto_repro: bool,
pub assert: crate::assert::Assert,
pub extra_sched_args: &'static [&'static str],
pub watchdog_timeout: Duration,
pub bpf_map_write: &'static [&'static BpfMapWrite],
pub performance_mode: bool,
pub no_perf_mode: bool,
pub duration: Duration,
pub expect_err: bool,
pub allow_inconclusive: bool,
pub host_only: bool,
pub extra_include_files: &'static [&'static str],
pub cleanup_budget: Option<Duration>,
pub config_content: Option<&'static str>,
pub disk: Option<crate::vmm::disk_config::DiskConfig>,
pub post_vm: Option<fn(&crate::vmm::VmResult) -> Result<()>>,
pub num_snapshots: u32,
pub workload_root_cgroup: Option<CgroupPath>,
pub kaslr: bool,
}
fn default_test_func(_ctx: &Ctx) -> Result<AssertResult> {
anyhow::bail!("KtstrTestEntry::DEFAULT func called — override func before use")
}
impl KtstrTestEntry {
pub const DEFAULT: Self = Self {
name: "",
func: default_test_func,
topology: Topology {
llcs: 1,
cores_per_llc: 2,
threads_per_core: 1,
numa_nodes: 1,
nodes: None,
distances: None,
},
constraints: TopologyConstraints::DEFAULT,
memory_mib: 2048,
scheduler: &crate::test_support::Scheduler::EEVDF,
staged_schedulers: &[],
payload: None,
workloads: &[],
auto_repro: true,
assert: crate::assert::Assert::NO_OVERRIDES,
extra_sched_args: &[],
watchdog_timeout: Duration::from_secs(5),
bpf_map_write: &[],
performance_mode: false,
no_perf_mode: false,
duration: Duration::from_secs(12),
expect_err: false,
allow_inconclusive: false,
host_only: false,
extra_include_files: &[],
cleanup_budget: None,
config_content: None,
disk: None,
post_vm: None,
num_snapshots: 0,
workload_root_cgroup: None,
kaslr: true,
};
pub const fn new() -> Self {
Self::DEFAULT
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.name.is_empty() {
anyhow::bail!(
"KtstrTestEntry.name must be non-empty (empty names \
collide in nextest output and sidecar lookups)"
);
}
if self.name.contains('/') || self.name.contains('\\') {
anyhow::bail!(
"KtstrTestEntry '{}' name must not contain path \
separators ('/' or '\\') — they embed in sidecar \
filenames and nextest test IDs, creating synthetic \
subdirectories in sidecar output and mangling \
nextest -E 'test(name)' filtering",
self.name,
);
}
if self.memory_mib == 0 {
anyhow::bail!(
"KtstrTestEntry '{}'.memory_mib must be > 0 (a VM with \
zero memory cannot boot)",
self.name,
);
}
if self.duration.is_zero() {
anyhow::bail!(
"KtstrTestEntry '{}'.duration must be > 0 (a zero-duration \
run never exercises the scheduler and produces no data \
for assertions)",
self.name,
);
}
if let Some(p) = self.payload
&& p.is_scheduler()
{
anyhow::bail!(
"KtstrTestEntry '{}'.payload must be PayloadKind::Binary, \
not Scheduler-kind (schedulers belong in the `scheduler` \
slot; the `payload` slot is for userspace binaries \
composed under the scheduler)",
self.name,
);
}
if self.host_only && self.disk.is_some() {
anyhow::bail!(
"KtstrTestEntry '{}'.host_only=true with disk=Some(..) — \
host_only skips the VM boot that owns the virtio-blk \
device lifecycle, so the disk would never be attached. \
Drop one of host_only or disk.",
self.name,
);
}
let mut seen_names: std::collections::BTreeSet<&'static str> =
std::collections::BTreeSet::new();
seen_names.insert(self.scheduler.name);
let staged_who = format!("KtstrTestEntry '{}'.staged_schedulers", self.name);
for staged in self.staged_schedulers {
crate::test_support::staged::validate_staged_scheduler_name(&staged_who, staged.name)?;
if !seen_names.insert(staged.name) {
if staged.name == self.scheduler.name {
anyhow::bail!(
"KtstrTestEntry '{}'.staged_schedulers cannot include \
the boot scheduler '{}' — the boot slot already \
stages it. Staged entries are the ADDITIONAL \
candidates the test will swap TO via \
Op::AttachScheduler / Op::ReplaceScheduler.",
self.name,
staged.name,
);
}
anyhow::bail!(
"KtstrTestEntry '{}'.staged_schedulers has duplicate \
Scheduler.name '{}'; each staged scheduler must have \
a unique name (the name maps 1:1 to the guest-side \
staging path)",
self.name,
staged.name,
);
}
}
if self.host_only
&& !matches!(
self.scheduler.binary,
crate::test_support::SchedulerSpec::Eevdf
)
{
anyhow::bail!(
"KtstrTestEntry '{}'.host_only=true with scheduler=&{:?} — \
host_only skips the VM boot that owns the scheduler \
lifecycle, so the declared scheduler would never attach. \
Drop one of host_only or scheduler; the host's \
currently-active scheduler (default EEVDF when none is \
loaded) runs the test under host_only.",
self.name,
self.scheduler.name,
);
}
if self.performance_mode && self.no_perf_mode {
anyhow::bail!(
"KtstrTestEntry '{}'.performance_mode=true with \
no_perf_mode=true — the two flags are contradictory \
(\"I want pinning\" vs. \"I explicitly don't want \
pinning\"). Drop one of them.",
self.name,
);
}
if (self.assert.expect_scx_bpf_error_contains.is_some()
|| self.assert.expect_scx_bpf_error_matches.is_some())
&& !self.expect_err
{
anyhow::bail!(
"KtstrTestEntry '{}' sets an scx_bpf_error matcher \
(expect_scx_bpf_error_contains or expect_scx_bpf_error_matches) \
without expect_err = true — a reproducer matcher narrows \
which failure counts as the expected bug and only \
applies to expected-error tests. Set expect_err = true \
or drop the matcher.",
self.name,
);
}
let max = crate::scenario::snapshot::MAX_STORED_SNAPSHOTS as u32;
if self.num_snapshots > max {
anyhow::bail!(
"KtstrTestEntry '{}'.num_snapshots={} exceeds \
MAX_STORED_SNAPSHOTS={} — the bridge would FIFO-evict \
the earliest periodic samples. Lower the count or split \
into multiple test entries.",
self.name,
self.num_snapshots,
max,
);
}
if self.num_snapshots > 0 {
if self.host_only {
anyhow::bail!(
"KtstrTestEntry '{}'.host_only=true with \
num_snapshots={} > 0 — host_only skips the VM \
boot that owns the freeze coordinator's \
periodic-capture loop, so no snapshot would \
ever fire. Drop one of host_only or \
num_snapshots.",
self.name,
self.num_snapshots,
);
}
let usable_span_ns = self
.duration
.as_nanos()
.saturating_sub(2u128.saturating_mul(self.duration.as_nanos() / 10));
let interval_ns = usable_span_ns / (self.num_snapshots as u128 + 1);
const MIN_INTERVAL_NS: u128 = 100 * 1_000_000; if interval_ns < MIN_INTERVAL_NS {
anyhow::bail!(
"KtstrTestEntry '{}'.num_snapshots={} with \
duration={:?} produces a periodic interval of \
{} ns ({} ms) — below the 100 ms minimum the \
freeze-and-capture path can sustain without \
back-to-back firing. Either reduce num_snapshots \
or extend duration so 0.8·duration / (N+1) >= 100 ms.",
self.name,
self.num_snapshots,
self.duration,
interval_ns,
interval_ns / 1_000_000,
);
}
}
let scheduler_has_def = self.scheduler.config_file_def.is_some();
let entry_has_content = self.config_content.is_some();
if scheduler_has_def && !entry_has_content {
anyhow::bail!(
"KtstrTestEntry '{}'.scheduler '{}' declares \
`config_file_def` but the entry does not supply \
`config_content`; the scheduler binary expects an \
inline config and would launch without `--config`. \
Set `config = ...` on `#[ktstr_test]` or assign \
`config_content` directly.",
self.name,
self.scheduler.name,
);
}
if !scheduler_has_def && entry_has_content {
anyhow::bail!(
"KtstrTestEntry '{}'.config_content is set but the \
scheduler '{}' does not declare `config_file_def`; \
the content would be silently dropped at dispatch. \
Remove `config = ...` or add \
`config_file_def(arg_template, guest_path)` to the \
scheduler.",
self.name,
self.scheduler.name,
);
}
for (idx, w) in self.workloads.iter().enumerate() {
if w.is_scheduler() {
anyhow::bail!(
"KtstrTestEntry '{}'.workloads[{idx}] (name='{}') must be \
PayloadKind::Binary, not Scheduler-kind (schedulers belong \
in the `scheduler` slot; the `workloads` slot is for \
userspace binaries composed under the scheduler)",
self.name,
w.name,
);
}
}
self.constraints
.validate()
.map_err(|e| anyhow::anyhow!("KtstrTestEntry '{}'.constraints: {e}", self.name))?;
self.scheduler.constraints.validate().map_err(|e| {
anyhow::anyhow!(
"KtstrTestEntry '{}'.scheduler '{}'.constraints: {e}",
self.name,
self.scheduler.name
)
})?;
Ok(())
}
pub fn all_include_files(&self) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
if let Some(p) = self.payload {
out.extend(p.include_files.iter().copied());
}
for w in self.workloads {
out.extend(w.include_files.iter().copied());
}
out.extend(self.extra_include_files.iter().copied());
out
}
}
impl KtstrTestEntry {
#[must_use = "builder methods consume self; bind the result"]
pub fn with_name(mut self, name: &'static str) -> Self {
self.name = name;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_func(mut self, func: fn(&Ctx) -> Result<AssertResult>) -> Self {
self.func = func;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_topology(mut self, topology: Topology) -> Self {
self.topology = topology;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_constraints(mut self, constraints: TopologyConstraints) -> Self {
self.constraints = constraints;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_memory_mib(mut self, memory_mib: u32) -> Self {
self.memory_mib = memory_mib;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_scheduler(mut self, scheduler: &'static crate::test_support::Scheduler) -> Self {
self.scheduler = scheduler;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_staged_schedulers(
mut self,
staged: &'static [&'static crate::test_support::Scheduler],
) -> Self {
self.staged_schedulers = staged;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_payload(mut self, payload: &'static crate::test_support::Payload) -> Self {
self.payload = Some(payload);
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn without_payload(mut self) -> Self {
self.payload = None;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_workloads(
mut self,
workloads: &'static [&'static crate::test_support::Payload],
) -> Self {
self.workloads = workloads;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_auto_repro(mut self, auto_repro: bool) -> Self {
self.auto_repro = auto_repro;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_assert(mut self, assert: crate::assert::Assert) -> Self {
self.assert = assert;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_extra_sched_args(mut self, extra_sched_args: &'static [&'static str]) -> Self {
self.extra_sched_args = extra_sched_args;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_watchdog_timeout(mut self, watchdog_timeout: Duration) -> Self {
self.watchdog_timeout = watchdog_timeout;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_bpf_map_write(mut self, bpf_map_write: &'static [&'static BpfMapWrite]) -> Self {
self.bpf_map_write = bpf_map_write;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_performance_mode(mut self, performance_mode: bool) -> Self {
self.performance_mode = performance_mode;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_no_perf_mode(mut self, no_perf_mode: bool) -> Self {
self.no_perf_mode = no_perf_mode;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_duration(mut self, duration: Duration) -> Self {
self.duration = duration;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_expect_err(mut self, expect_err: bool) -> Self {
self.expect_err = expect_err;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_allow_inconclusive(mut self, allow_inconclusive: bool) -> Self {
self.allow_inconclusive = allow_inconclusive;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_host_only(mut self, host_only: bool) -> Self {
self.host_only = host_only;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_extra_include_files(
mut self,
extra_include_files: &'static [&'static str],
) -> Self {
self.extra_include_files = extra_include_files;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_cleanup_budget(mut self, cleanup_budget: Duration) -> Self {
self.cleanup_budget = Some(cleanup_budget);
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn without_cleanup_budget(mut self) -> Self {
self.cleanup_budget = None;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_config_content(mut self, config_content: &'static str) -> Self {
self.config_content = Some(config_content);
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn without_config_content(mut self) -> Self {
self.config_content = None;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_disk(mut self, disk: crate::vmm::disk_config::DiskConfig) -> Self {
self.disk = Some(disk);
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn without_disk(mut self) -> Self {
self.disk = None;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_post_vm(mut self, post_vm: fn(&crate::vmm::VmResult) -> Result<()>) -> Self {
self.post_vm = Some(post_vm);
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn without_post_vm(mut self) -> Self {
self.post_vm = None;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub fn with_num_snapshots(mut self, num_snapshots: u32) -> Self {
self.num_snapshots = num_snapshots;
self
}
#[must_use = "builder methods consume self; bind the result"]
pub const fn with_workload_root_cgroup(mut self, path: &'static str) -> Self {
self.workload_root_cgroup = Some(CgroupPath::new(path));
self
}
}
impl Default for KtstrTestEntry {
fn default() -> Self {
Self::new()
}
}
#[distributed_slice]
pub static KTSTR_TESTS: [KtstrTestEntry];
#[distributed_slice]
pub static KTSTR_SCHEDULERS: [&'static Scheduler];
pub fn find_test(name: &str) -> Option<&'static KtstrTestEntry> {
KTSTR_TESTS.iter().find(|e| e.name == name)
}
pub fn find_scheduler(name: &str) -> Option<&'static Scheduler> {
scheduler_index().get(name).copied()
}
fn scheduler_index() -> &'static std::collections::HashMap<&'static str, &'static Scheduler> {
static INDEX: std::sync::LazyLock<std::collections::HashMap<&'static str, &'static Scheduler>> =
std::sync::LazyLock::new(|| {
build_scheduler_index_or_panic(KTSTR_SCHEDULERS.iter().copied())
});
&INDEX
}
fn build_scheduler_index_or_panic<I>(
schedulers: I,
) -> std::collections::HashMap<&'static str, &'static Scheduler>
where
I: IntoIterator<Item = &'static Scheduler>,
{
let mut map: std::collections::HashMap<&'static str, &'static Scheduler> =
std::collections::HashMap::new();
for sched in schedulers {
if let Some(prev) = map.insert(sched.name, sched)
&& !std::ptr::eq(prev, sched)
{
panic!(
"ktstr: duplicate scheduler name `{name}` registered \
in KTSTR_SCHEDULERS:\n \
first: {prev:p}\n \
second: {sched:p}\n\
Two `declare_scheduler!` invocations declared the \
same `name = \"{name}\"`. The first registration \
wins under linear scan and the second is unreachable. \
Rename one of the declarations or remove the \
duplicate.",
name = sched.name,
);
}
}
map
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SchedulerJson {
pub name: String,
pub binary_kind: BinaryKindJson,
pub topology: TopologyJson,
pub sched_args: Vec<String>,
pub kernels: Vec<String>,
pub constraints: TopologyConstraintsJson,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
pub enum BinaryKindJson {
Discover(String),
Path(String),
Eevdf,
KernelBuiltin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TopologyJson {
pub num_numa_nodes: u32,
pub num_llcs: u32,
pub cores_per_llc: u32,
pub threads_per_core: u32,
}
impl TopologyJson {
pub const SINGLE_CPU: Self = Self {
num_numa_nodes: 1,
num_llcs: 1,
cores_per_llc: 1,
threads_per_core: 1,
};
}
impl TryFrom<TopologyJson> for Topology {
type Error = String;
fn try_from(value: TopologyJson) -> Result<Self, Self::Error> {
let topo = Self {
llcs: value.num_llcs,
cores_per_llc: value.cores_per_llc,
threads_per_core: value.threads_per_core,
numa_nodes: value.num_numa_nodes,
nodes: None,
distances: None,
};
topo.validate()?;
Ok(topo)
}
}
impl From<Topology> for TopologyJson {
fn from(t: Topology) -> Self {
Self {
num_numa_nodes: t.numa_nodes,
num_llcs: t.llcs,
cores_per_llc: t.cores_per_llc,
threads_per_core: t.threads_per_core,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TopologyConstraintsJson {
pub min_numa_nodes: u32,
pub max_numa_nodes: Option<u32>,
pub min_llcs: u32,
pub max_llcs: Option<u32>,
pub requires_smt: bool,
pub min_cpus: u32,
pub max_cpus: Option<u32>,
}
impl From<TopologyConstraintsJson> for TopologyConstraints {
fn from(j: TopologyConstraintsJson) -> Self {
Self {
min_numa_nodes: j.min_numa_nodes,
max_numa_nodes: j.max_numa_nodes,
min_llcs: j.min_llcs,
max_llcs: j.max_llcs,
requires_smt: j.requires_smt,
min_cpus: j.min_cpus,
max_cpus: j.max_cpus,
}
}
}
impl SchedulerJson {
pub fn from_scheduler(s: &Scheduler) -> Self {
let binary_kind = match s.binary {
SchedulerSpec::Discover(n) => BinaryKindJson::Discover(n.to_string()),
SchedulerSpec::Path(p) => BinaryKindJson::Path(p.to_string()),
SchedulerSpec::Eevdf => BinaryKindJson::Eevdf,
SchedulerSpec::KernelBuiltin { .. } => BinaryKindJson::KernelBuiltin,
};
Self {
name: s.name.to_string(),
binary_kind,
topology: TopologyJson {
num_numa_nodes: s.topology.num_numa_nodes(),
num_llcs: s.topology.num_llcs(),
cores_per_llc: s.topology.cores_per_llc,
threads_per_core: s.topology.threads_per_core,
},
sched_args: s.sched_args.iter().map(|a| a.to_string()).collect(),
kernels: s.kernels.iter().map(|k| k.to_string()).collect(),
constraints: TopologyConstraintsJson {
min_numa_nodes: s.constraints.min_numa_nodes,
max_numa_nodes: s.constraints.max_numa_nodes,
min_llcs: s.constraints.min_llcs,
max_llcs: s.constraints.max_llcs,
requires_smt: s.constraints.requires_smt,
min_cpus: s.constraints.min_cpus,
max_cpus: s.constraints.max_cpus,
},
}
}
}
::ctor::declarative::ctor! {
#[ctor(unsafe)]
fn __ktstr_list_schedulers() {
if !std::env::args().any(|a| a == "--ktstr-list-schedulers") {
return;
}
let entries: Vec<SchedulerJson> = KTSTR_SCHEDULERS
.iter()
.map(|s| SchedulerJson::from_scheduler(s))
.collect();
let json = ::serde_json::to_string(&entries).expect("serialize schedulers");
println!("{json}");
std::process::exit(0);
}
}
pub fn default_post_vm_periodic_fired(result: &crate::vmm::VmResult) -> anyhow::Result<()> {
if !result.success {
return Ok(());
}
if result.periodic_target == 0 {
return Ok(());
}
let real = result.snapshot_bridge.periodic_real_count();
anyhow::ensure!(
real >= 1,
"no periodic snapshot produced real BPF state \
(periodic_real_count=0, periodic_fired={}, target={}) — \
scheduler attached but every snapshot was a placeholder \
(typical cause: scheduler stalled during the workload and \
the freeze rendezvous timed out fetching state; less \
commonly: gate suppression rejected every capture)",
result.periodic_fired,
result.periodic_target,
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scenario::Ctx;
fn dummy_ctx() -> (crate::cgroup::CgroupManager, crate::topology::TestTopology) {
let cgroups = crate::cgroup::CgroupManager::new("/sys/fs/cgroup/ktstr-dummy");
let topo = crate::topology::TestTopology::from_vm_topology(&Topology {
llcs: 1,
cores_per_llc: 1,
threads_per_core: 1,
numa_nodes: 1,
nodes: None,
distances: None,
});
(cgroups, topo)
}
#[test]
fn ktstr_test_entry_default_fields() {
let d = KtstrTestEntry::DEFAULT;
assert_eq!(d.name, "");
assert_eq!(d.topology.llcs, 1);
assert_eq!(d.topology.cores_per_llc, 2);
assert_eq!(d.topology.threads_per_core, 1);
assert_eq!(d.topology.numa_nodes, 1);
assert!(d.topology.nodes.is_none());
assert!(d.topology.distances.is_none());
assert_eq!(d.constraints, TopologyConstraints::DEFAULT);
assert_eq!(d.memory_mib, 2048);
assert_eq!(d.scheduler.name, "eevdf");
assert!(!d.scheduler.has_active_scheduling());
assert!(d.auto_repro);
assert!(d.extra_sched_args.is_empty());
assert_eq!(d.watchdog_timeout, Duration::from_secs(5));
assert!(d.bpf_map_write.is_empty());
assert!(!d.performance_mode);
assert!(!d.no_perf_mode);
assert_eq!(d.duration, Duration::from_secs(12));
assert!(!d.expect_err);
assert!(!d.host_only);
assert!(d.payload.is_none());
assert!(d.workloads.is_empty());
}
#[allow(dead_code)]
static ENTRY_VIA_NEW: KtstrTestEntry = KtstrTestEntry {
name: "via_new",
..KtstrTestEntry::new()
};
#[allow(dead_code)]
static ENTRY_VIA_DEFAULT: KtstrTestEntry = KtstrTestEntry {
name: "via_default",
..KtstrTestEntry::DEFAULT
};
#[test]
fn ktstr_test_entry_const_spread_works_via_both_new_and_default() {
assert_eq!(ENTRY_VIA_NEW.name, "via_new");
assert_eq!(ENTRY_VIA_DEFAULT.name, "via_default");
assert_eq!(ENTRY_VIA_NEW.memory_mib, ENTRY_VIA_DEFAULT.memory_mib);
assert_eq!(ENTRY_VIA_NEW.duration, ENTRY_VIA_DEFAULT.duration);
}
#[test]
fn ktstr_test_entry_with_chain_overrides_target_fields() {
let entry = KtstrTestEntry::DEFAULT
.with_name("chain_test")
.with_memory_mib(4096)
.with_duration(Duration::from_secs(30))
.with_auto_repro(false)
.with_performance_mode(true)
.with_num_snapshots(2);
assert_eq!(entry.name, "chain_test");
assert_eq!(entry.memory_mib, 4096);
assert_eq!(entry.duration, Duration::from_secs(30));
assert!(!entry.auto_repro);
assert!(entry.performance_mode);
assert_eq!(entry.num_snapshots, 2);
assert_eq!(entry.scheduler.name, "eevdf");
assert!(!entry.host_only);
entry.validate().expect("chained entry must validate");
}
#[test]
fn ktstr_test_entry_without_chain_clears_option_fields() {
use crate::test_support::{OutputFormat, Payload, PayloadKind};
const FIO: Payload = Payload {
name: "fio",
kind: PayloadKind::Binary("fio"),
output: OutputFormat::Json,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &[],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
let entry = KtstrTestEntry::DEFAULT
.with_name("clear_test")
.with_payload(&FIO)
.with_cleanup_budget(Duration::from_secs(10))
.without_payload()
.without_cleanup_budget();
assert!(entry.payload.is_none());
assert!(entry.cleanup_budget.is_none());
}
#[test]
fn ktstr_test_entry_payload_slot_can_be_populated() {
use crate::test_support::{OutputFormat, Payload, PayloadKind};
const FIO: Payload = Payload {
name: "fio",
kind: PayloadKind::Binary("fio"),
output: OutputFormat::Json,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &[],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
let entry = KtstrTestEntry {
name: "payload_entry",
payload: Some(&FIO),
..KtstrTestEntry::DEFAULT
};
let p = entry.payload.expect("payload set");
assert_eq!(p.name, "fio");
assert!(!p.is_scheduler());
}
#[test]
fn ktstr_test_entry_workloads_slot_accepts_multiple_payloads() {
use crate::test_support::{OutputFormat, Payload, PayloadKind};
const FIO: Payload = Payload {
name: "fio",
kind: PayloadKind::Binary("fio"),
output: OutputFormat::Json,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &[],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
const STRESS_NG: Payload = Payload {
name: "stress-ng",
kind: PayloadKind::Binary("stress-ng"),
output: OutputFormat::ExitCode,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &[],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
let entry = KtstrTestEntry {
name: "multi_workload",
workloads: &[&FIO, &STRESS_NG],
..KtstrTestEntry::DEFAULT
};
assert_eq!(entry.workloads.len(), 2);
assert_eq!(entry.workloads[0].name, "fio");
assert_eq!(entry.workloads[1].name, "stress-ng");
}
#[test]
fn validate_rejects_scheduler_kind_in_workloads() {
use crate::test_support::{OutputFormat, Payload, PayloadKind};
const GOOD: Payload = Payload {
name: "fio",
kind: PayloadKind::Binary("fio"),
output: OutputFormat::Json,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &[],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "mixed_kinds",
func: good_test_func,
workloads: &[&GOOD, &Payload::KERNEL_DEFAULT],
..KtstrTestEntry::DEFAULT
};
let err = entry.validate().unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("workloads[1]") && msg.contains("Scheduler-kind"),
"expected workloads[1] Scheduler-kind bail, got: {msg}"
);
assert!(
msg.contains("kernel_default"),
"error must name the offending workload entry, got: {msg}"
);
}
#[test]
fn validate_accepts_binary_only_workloads() {
use crate::test_support::{OutputFormat, Payload, PayloadKind};
const FIO: Payload = Payload {
name: "fio",
kind: PayloadKind::Binary("fio"),
output: OutputFormat::Json,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &[],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
const STRESS_NG: Payload = Payload {
name: "stress-ng",
kind: PayloadKind::Binary("stress-ng"),
output: OutputFormat::ExitCode,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &[],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "all_binary",
func: good_test_func,
workloads: &[&FIO, &STRESS_NG],
..KtstrTestEntry::DEFAULT
};
entry.validate().expect("binary-only workloads must pass");
}
#[test]
fn validate_accepts_empty_staged_schedulers() {
let entry = KtstrTestEntry {
name: "no_staged",
staged_schedulers: &[],
..KtstrTestEntry::DEFAULT
};
entry.validate().expect("empty staged_schedulers must pass");
}
#[test]
fn validate_accepts_well_formed_unique_staged_schedulers() {
static SCX_MITOSIS_A: Scheduler =
Scheduler::named("scx_mitosis_a").binary_discover("scx_mitosis");
static SCX_MITOSIS_B: Scheduler =
Scheduler::named("scx_mitosis_b").binary_discover("scx_mitosis");
static SCHEDS: &[&Scheduler] = &[&SCX_MITOSIS_A, &SCX_MITOSIS_B];
let entry = KtstrTestEntry {
name: "staged_two",
staged_schedulers: SCHEDS,
..KtstrTestEntry::DEFAULT
};
entry
.validate()
.expect("two distinct well-formed staged schedulers must pass");
}
#[test]
fn validate_rejects_duplicate_staged_scheduler_names() {
static DUPE_A: Scheduler = Scheduler::named("scx_dupe").binary_discover("scx_first");
static DUPE_B: Scheduler = Scheduler::named("scx_dupe").binary_discover("scx_second");
static SCHEDS: &[&Scheduler] = &[&DUPE_A, &DUPE_B];
let entry = KtstrTestEntry {
name: "staged_dupe",
staged_schedulers: SCHEDS,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("duplicate staged Scheduler.name must reject");
let msg = err.to_string();
assert!(
msg.contains("duplicate"),
"error must name the duplicate-name violation, got: {msg}"
);
assert!(
msg.contains("scx_dupe"),
"error must name the colliding scheduler name, got: {msg}"
);
assert!(
msg.contains("staged_schedulers"),
"error must name the field, got: {msg}"
);
}
#[test]
fn validate_rejects_shape_violating_staged_scheduler_name() {
static BAD_SLASH: Scheduler = Scheduler::named("scx/path").binary_discover("scx_x");
static SCHEDS: &[&Scheduler] = &[&BAD_SLASH];
let entry = KtstrTestEntry {
name: "staged_bad_shape",
staged_schedulers: SCHEDS,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("path-separator Scheduler.name must reject");
let msg = err.to_string();
assert!(
msg.contains("staged_schedulers"),
"error must name the field — proves the `who` context propagated \
through the delegate call, got: {msg}"
);
}
#[test]
fn validate_rejects_staged_scheduler_duplicating_boot_scheduler() {
static BOOT: Scheduler = Scheduler::named("scx_mitosis").binary_discover("scx_mitosis");
static STAGED_COPY: Scheduler =
Scheduler::named("scx_mitosis").binary_discover("scx_mitosis");
static SCHEDS: &[&Scheduler] = &[&STAGED_COPY];
let entry = KtstrTestEntry {
name: "staged_dup_of_boot",
scheduler: &BOOT,
staged_schedulers: SCHEDS,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("staged scheduler duplicating boot scheduler must reject");
let msg = err.to_string();
assert!(
msg.contains("boot scheduler"),
"error must name the boot-scheduler violation, got: {msg}"
);
assert!(
msg.contains("scx_mitosis"),
"error must name the colliding scheduler, got: {msg}"
);
assert!(
msg.contains("Op::AttachScheduler") || msg.contains("Op::ReplaceScheduler"),
"error must point to the lifecycle Ops that USE the staged set, got: {msg}"
);
}
#[test]
fn validate_rejects_host_only_with_disk() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "host_only_with_disk",
func: good_test_func,
host_only: true,
disk: Some(crate::vmm::disk_config::DiskConfig::default()),
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("host_only=true + disk=Some must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("host_only=true") && msg.contains("disk"),
"expected host_only+disk diagnostic, got: {msg}",
);
assert!(
msg.contains("host_only_with_disk"),
"error must name the offending entry, got: {msg}",
);
}
#[test]
fn validate_accepts_host_only_without_disk() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "host_only_no_disk",
func: good_test_func,
host_only: true,
disk: None,
..KtstrTestEntry::DEFAULT
};
entry
.validate()
.expect("host_only=true + disk=None must validate");
}
#[test]
fn validate_accepts_vm_with_disk() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "vm_with_disk",
func: good_test_func,
host_only: false,
disk: Some(crate::vmm::disk_config::DiskConfig::default()),
..KtstrTestEntry::DEFAULT
};
entry
.validate()
.expect("host_only=false + disk=Some must validate");
}
#[test]
fn validate_rejects_num_snapshots_above_max_stored() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let cap = crate::scenario::snapshot::MAX_STORED_SNAPSHOTS as u32;
let entry = KtstrTestEntry {
name: "too_many_snapshots",
func: good_test_func,
num_snapshots: cap + 1,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("num_snapshots > MAX_STORED_SNAPSHOTS must reject");
let msg = format!("{err}");
assert!(
msg.contains("num_snapshots") && msg.contains("MAX_STORED_SNAPSHOTS"),
"expected num_snapshots/MAX_STORED_SNAPSHOTS diagnostic, got: {msg}",
);
assert!(
msg.contains("too_many_snapshots"),
"error must name the offending entry, got: {msg}",
);
}
#[test]
fn validate_accepts_num_snapshots_at_max_stored() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let cap = crate::scenario::snapshot::MAX_STORED_SNAPSHOTS as u32;
let entry = KtstrTestEntry {
name: "max_snapshots_ok",
func: good_test_func,
num_snapshots: cap,
duration: Duration::from_secs(100),
..KtstrTestEntry::DEFAULT
};
entry
.validate()
.expect("num_snapshots == MAX_STORED_SNAPSHOTS at long duration must validate");
}
#[test]
fn validate_rejects_num_snapshots_with_host_only() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "host_only_periodic",
func: good_test_func,
host_only: true,
num_snapshots: 1,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("host_only=true + num_snapshots>0 must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("host_only") && msg.contains("num_snapshots"),
"expected host_only/num_snapshots diagnostic, got: {msg}",
);
assert!(
msg.contains("host_only_periodic"),
"error must name the offending entry, got: {msg}",
);
}
#[test]
fn validate_accepts_host_only_with_zero_snapshots() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "host_only_no_periodic",
func: good_test_func,
host_only: true,
num_snapshots: 0,
..KtstrTestEntry::DEFAULT
};
entry
.validate()
.expect("host_only=true + num_snapshots=0 must validate");
}
#[test]
fn validate_rejects_tight_periodic_spacing() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "tight_periodic_spacing",
func: good_test_func,
duration: Duration::from_secs(1),
num_snapshots: 8,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("tight periodic spacing must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("100"),
"error must mention the 100 ms floor, got: {msg}",
);
assert!(
msg.contains("num_snapshots") || msg.contains("periodic"),
"error must mention num_snapshots or periodic, got: {msg}",
);
assert!(
msg.contains("tight_periodic_spacing"),
"error must name the offending entry, got: {msg}",
);
}
#[test]
fn validate_accepts_loose_periodic_spacing() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "loose_periodic_spacing",
func: good_test_func,
num_snapshots: 1,
..KtstrTestEntry::DEFAULT
};
entry
.validate()
.expect("loose periodic spacing (4.8 s per boundary) must validate");
}
#[test]
fn validate_rejects_config_file_def_without_content() {
static SCHED_WITH_CFG: Scheduler = Scheduler {
name: "sched_with_cfg",
binary: SchedulerSpec::Discover("sched_with_cfg_bin"),
sysctls: &[],
kargs: &[],
assert: crate::assert::Assert::NO_OVERRIDES,
cgroup_parent: None,
sched_args: &[],
topology: Topology {
llcs: 1,
cores_per_llc: 2,
threads_per_core: 1,
numa_nodes: 1,
nodes: None,
distances: None,
},
constraints: TopologyConstraints::DEFAULT,
config_file: None,
config_file_def: Some(("--config {file}", "/include-files/cfg.json")),
kernels: &[],
};
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "missing_config",
func: good_test_func,
scheduler: &SCHED_WITH_CFG,
config_content: None,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("config_file_def without config_content must reject");
let msg = format!("{err}");
assert!(
msg.contains("config_file_def") && msg.contains("config_content"),
"expected config_file_def/config_content bail, got: {msg}"
);
assert!(
msg.contains("missing_config"),
"error must name the offending entry, got: {msg}"
);
}
#[test]
fn validate_rejects_content_without_config_file_def() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "stray_config",
func: good_test_func,
scheduler: &crate::test_support::Scheduler::EEVDF,
config_content: Some("{}"),
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("config_content without config_file_def must reject");
let msg = format!("{err}");
assert!(
msg.contains("config_content") && msg.contains("config_file_def"),
"expected config_content/config_file_def bail, got: {msg}"
);
assert!(
msg.contains("stray_config"),
"error must name the offending entry, got: {msg}"
);
}
#[test]
fn validate_accepts_config_pairing() {
static SCHED_WITH_CFG: Scheduler = Scheduler {
name: "sched_paired",
binary: SchedulerSpec::Discover("sched_paired_bin"),
sysctls: &[],
kargs: &[],
assert: crate::assert::Assert::NO_OVERRIDES,
cgroup_parent: None,
sched_args: &[],
topology: Topology {
llcs: 1,
cores_per_llc: 2,
threads_per_core: 1,
numa_nodes: 1,
nodes: None,
distances: None,
},
constraints: TopologyConstraints::DEFAULT,
config_file: None,
config_file_def: Some(("f:{file}", "/include-files/p.json")),
kernels: &[],
};
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry_paired = KtstrTestEntry {
name: "paired_present",
func: good_test_func,
scheduler: &SCHED_WITH_CFG,
config_content: Some("{\"layers\":[]}"),
..KtstrTestEntry::DEFAULT
};
entry_paired
.validate()
.expect("scheduler with config_file_def + content must validate");
let entry_none = KtstrTestEntry {
name: "neither_present",
func: good_test_func,
scheduler: &crate::test_support::Scheduler::EEVDF,
config_content: None,
..KtstrTestEntry::DEFAULT
};
entry_none
.validate()
.expect("no config_file_def + no content must validate");
}
#[test]
fn validate_rejects_expect_scx_bpf_error_contains_without_expect_err() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "bad_contains",
func: good_test_func,
assert: crate::assert::Assert::NO_OVERRIDES
.expect_scx_bpf_error_contains("apply_cell_config"),
expect_err: false,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("matcher without expect_err must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("expect_scx_bpf_error_contains") && msg.contains("expect_err"),
"diagnostic must name BOTH the matcher field AND expect_err: {msg}",
);
assert!(
msg.contains("bad_contains"),
"error must name the offending entry: {msg}",
);
}
#[test]
fn validate_rejects_expect_scx_bpf_error_matches_without_expect_err() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry = KtstrTestEntry {
name: "bad_matches",
func: good_test_func,
assert: crate::assert::Assert::NO_OVERRIDES
.expect_scx_bpf_error_matches("apply_cell_config:[0-9]+"),
expect_err: false,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("matcher without expect_err must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("expect_scx_bpf_error_matches") && msg.contains("expect_err"),
"diagnostic must name BOTH the matcher field AND expect_err: {msg}",
);
assert!(
msg.contains("bad_matches"),
"error must name the offending entry: {msg}",
);
}
#[test]
fn validate_accepts_expect_scx_bpf_error_matchers_with_expect_err() {
fn good_test_func(_: &Ctx) -> Result<AssertResult> {
Ok(AssertResult::pass())
}
let entry_contains = KtstrTestEntry {
name: "good_contains",
func: good_test_func,
assert: crate::assert::Assert::NO_OVERRIDES
.expect_scx_bpf_error_contains("apply_cell_config"),
expect_err: true,
..KtstrTestEntry::DEFAULT
};
entry_contains
.validate()
.expect("contains matcher + expect_err=true must validate");
let entry_matches = KtstrTestEntry {
name: "good_matches",
func: good_test_func,
assert: crate::assert::Assert::NO_OVERRIDES
.expect_scx_bpf_error_matches("apply_cell_config:[0-9]+"),
expect_err: true,
..KtstrTestEntry::DEFAULT
};
entry_matches
.validate()
.expect("matches matcher + expect_err=true must validate");
let entry_both = KtstrTestEntry {
name: "good_both",
func: good_test_func,
assert: crate::assert::Assert::NO_OVERRIDES
.expect_scx_bpf_error_contains("apply_cell_config")
.expect_scx_bpf_error_matches("apply_cell_config:[0-9]+"),
expect_err: true,
..KtstrTestEntry::DEFAULT
};
entry_both
.validate()
.expect("both matchers + expect_err=true must validate");
}
#[test]
fn ktstr_test_entry_default_rejected_by_empty_name() {
let err = KtstrTestEntry::DEFAULT.validate().unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("name") && msg.contains("non-empty"),
"expected name-non-empty bail, got: {msg}"
);
}
#[test]
fn default_test_func_returns_err() {
let (cgroups, topo) = dummy_ctx();
let ctx = Ctx::builder(&cgroups, &topo)
.duration(Duration::from_millis(1))
.assert(crate::assert::Assert::NO_OVERRIDES)
.build();
let result = default_test_func(&ctx);
let err = result.expect_err("default_test_func must return Err, not Ok");
let msg = format!("{err}");
assert!(
msg.contains("KtstrTestEntry::DEFAULT func called"),
"expected DEFAULT-called bail message, got: {msg}"
);
assert!(
msg.contains("override func before use"),
"expected actionable hint, got: {msg}"
);
}
use super::super::test_helpers::validate_entry;
#[test]
fn scheduler_eevdf_defaults() {
let s = &Scheduler::EEVDF;
assert_eq!(s.name, "eevdf");
assert!(s.sysctls.is_empty());
assert!(s.kargs.is_empty());
assert!(s.assert.not_starved.is_none());
assert!(s.assert.max_imbalance_ratio.is_none());
}
#[test]
fn scheduler_named_builder() {
static TEST_SYSCTLS: &[Sysctl] =
&[Sysctl::new("kernel.sched_cfs_bandwidth_slice_us", "1000")];
let s = Scheduler::named("test_sched")
.binary(SchedulerSpec::Discover("test_bin"))
.sysctls(TEST_SYSCTLS)
.kargs(&["nosmt"]);
assert_eq!(s.name, "test_sched");
assert_eq!(s.sysctls.len(), 1);
assert_eq!(s.kargs.len(), 1);
}
#[test]
fn scheduler_with_check() {
let v = crate::assert::Assert::NO_OVERRIDES
.check_not_starved()
.max_imbalance_ratio(3.0);
let s = Scheduler::named("sched").assert(v);
assert_eq!(s.assert.not_starved, Some(true));
assert_eq!(s.assert.max_imbalance_ratio, Some(3.0));
}
#[test]
fn scheduler_named_default_topology_matches_macro_hardcode() {
let s = Scheduler::named("__macro_default_topology_pin__");
assert_eq!(s.topology.numa_nodes, 1);
assert_eq!(s.topology.llcs, 1);
assert_eq!(s.topology.cores_per_llc, 2);
assert_eq!(s.topology.threads_per_core, 1);
}
#[test]
fn ktstr_test_entry_validate_accepts_defaults() {
let e = validate_entry("ok", 512, Duration::from_secs(2));
e.validate().unwrap();
}
#[test]
fn ktstr_test_entry_validate_rejects_empty_name() {
let e = validate_entry("", 512, Duration::from_secs(2));
let err = e.validate().unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("name") && msg.contains("non-empty"),
"got: {msg}"
);
}
#[test]
fn ktstr_test_entry_validate_rejects_zero_memory() {
let e = validate_entry("t", 0, Duration::from_secs(2));
let err = e.validate().unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("memory_mib") && msg.contains("> 0") && msg.contains("'t'"),
"got: {msg}"
);
}
#[test]
fn ktstr_test_entry_validate_rejects_zero_duration() {
let e = validate_entry("t", 512, Duration::ZERO);
let err = e.validate().unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("duration") && msg.contains("> 0"),
"got: {msg}"
);
}
#[test]
fn topology_constraints_default_has_max_values() {
let c = TopologyConstraints::DEFAULT;
assert_eq!(c.max_llcs, Some(12));
assert_eq!(c.max_numa_nodes, Some(1));
assert_eq!(c.max_cpus, Some(192));
}
#[test]
fn topology_constraints_max_fields_set() {
let c = TopologyConstraints {
max_llcs: Some(16),
max_numa_nodes: Some(4),
max_cpus: Some(128),
..TopologyConstraints::DEFAULT
};
assert_eq!(c.max_llcs, Some(16));
assert_eq!(c.max_numa_nodes, Some(4));
assert_eq!(c.max_cpus, Some(128));
assert_eq!(c.min_numa_nodes, 1);
assert_eq!(c.min_llcs, 1);
assert_eq!(c.min_cpus, 1);
}
#[test]
fn topology_constraints_with_chain_overrides_target_fields_only() {
let c = TopologyConstraints::DEFAULT
.with_min_numa_nodes(2)
.with_max_numa_nodes(8)
.with_min_llcs(3)
.with_max_llcs(16)
.with_requires_smt(true)
.with_min_cpus(4)
.with_max_cpus(64);
assert_eq!(c.min_numa_nodes, 2);
assert_eq!(c.max_numa_nodes, Some(8));
assert_eq!(c.min_llcs, 3);
assert_eq!(c.max_llcs, Some(16));
assert!(c.requires_smt);
assert_eq!(c.min_cpus, 4);
assert_eq!(c.max_cpus, Some(64));
}
#[test]
fn topology_constraints_without_chain_clears_option_fields() {
let c = TopologyConstraints::DEFAULT
.without_max_numa_nodes()
.without_max_llcs()
.without_max_cpus();
assert!(c.max_numa_nodes.is_none());
assert!(c.max_llcs.is_none());
assert!(c.max_cpus.is_none());
assert_eq!(c.min_numa_nodes, 1);
assert_eq!(c.min_llcs, 1);
assert_eq!(c.min_cpus, 1);
}
#[test]
fn topology_constraints_with_chain_const_evaluable() {
const C: TopologyConstraints = TopologyConstraints::DEFAULT
.with_min_llcs(2)
.with_max_llcs(4);
assert_eq!(C.min_llcs, 2);
assert_eq!(C.max_llcs, Some(4));
}
#[test]
fn topology_constraints_equality() {
let a = TopologyConstraints::DEFAULT;
let b = TopologyConstraints::DEFAULT;
assert_eq!(a, b);
let c = TopologyConstraints {
max_llcs: Some(8),
..TopologyConstraints::DEFAULT
};
assert_ne!(a, c);
}
#[test]
fn accepts_default_allows_within_limits() {
let c = TopologyConstraints::DEFAULT;
let t = Topology::new(1, 8, 4, 2);
assert!(c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_default_rejects_multi_numa() {
let c = TopologyConstraints::DEFAULT;
let t = Topology::new(2, 8, 4, 2);
assert!(!c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_default_rejects_too_many_llcs() {
let c = TopologyConstraints::DEFAULT;
let t = Topology::new(1, 16, 2, 1);
assert!(!c.accepts(&t, 128, 32, 32));
}
#[test]
fn accepts_none_means_no_limit() {
let c = TopologyConstraints {
max_llcs: None,
max_numa_nodes: None,
max_cpus: None,
..TopologyConstraints::DEFAULT
};
let t = Topology::new(4, 16, 8, 2);
assert!(c.accepts(&t, 512, 32, 32));
}
#[test]
fn accepts_rejects_too_many_llcs() {
let c = TopologyConstraints {
max_llcs: Some(4),
..TopologyConstraints::DEFAULT
};
let t = Topology::new(1, 8, 2, 1);
assert!(!c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_allows_llcs_at_max() {
let c = TopologyConstraints {
max_llcs: Some(4),
..TopologyConstraints::DEFAULT
};
let t = Topology::new(1, 4, 2, 1);
assert!(c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_rejects_too_many_numa_nodes() {
let c = TopologyConstraints {
max_numa_nodes: Some(2),
..TopologyConstraints::DEFAULT
};
let t = Topology::new(4, 4, 2, 1);
assert!(!c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_allows_numa_at_max() {
let c = TopologyConstraints {
max_numa_nodes: Some(2),
..TopologyConstraints::DEFAULT
};
let t = Topology::new(2, 4, 2, 1);
assert!(c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_rejects_too_many_cpus() {
let c = TopologyConstraints {
max_cpus: Some(16),
..TopologyConstraints::DEFAULT
};
let t = Topology::new(1, 4, 4, 2);
assert!(!c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_allows_cpus_at_max() {
let c = TopologyConstraints {
max_cpus: Some(16),
..TopologyConstraints::DEFAULT
};
let t = Topology::new(1, 2, 4, 2);
assert!(c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_rejects_too_few_llcs() {
let c = TopologyConstraints {
min_llcs: 4,
..TopologyConstraints::DEFAULT
};
let t = Topology::new(1, 2, 4, 1);
assert!(!c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_rejects_exceeding_host_cpus() {
let c = TopologyConstraints::DEFAULT;
let t = Topology::new(1, 4, 4, 2); assert!(!c.accepts(&t, 16, 16, 32)); }
#[test]
fn accepts_rejects_exceeding_host_llcs() {
let c = TopologyConstraints::DEFAULT;
let t = Topology::new(1, 8, 2, 1);
assert!(!c.accepts(&t, 128, 4, 32)); }
#[test]
fn accepts_combined_min_and_max() {
let c = TopologyConstraints {
min_llcs: 2,
max_llcs: Some(8),
min_cpus: 4,
max_cpus: Some(32),
..TopologyConstraints::DEFAULT
};
assert!(!c.accepts(&Topology::new(1, 1, 4, 1), 128, 16, 32));
assert!(c.accepts(&Topology::new(1, 2, 2, 1), 128, 16, 32));
assert!(!c.accepts(&Topology::new(1, 16, 2, 1), 128, 16, 32));
assert!(c.accepts(&Topology::new(1, 8, 2, 1), 128, 16, 32));
}
#[test]
fn accepts_requires_smt() {
let c = TopologyConstraints {
requires_smt: true,
..TopologyConstraints::DEFAULT
};
let no_smt = Topology::new(1, 2, 4, 1);
let with_smt = Topology::new(1, 2, 4, 2);
assert!(!c.accepts(&no_smt, 128, 16, 32));
assert!(c.accepts(&with_smt, 128, 16, 32));
}
#[test]
fn accepts_rejects_too_few_numa_nodes() {
let c = TopologyConstraints {
min_numa_nodes: 2,
max_numa_nodes: None,
..TopologyConstraints::DEFAULT
};
let t = Topology::new(1, 4, 4, 1);
assert!(!c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_rejects_too_few_cpus() {
let c = TopologyConstraints {
min_cpus: 32,
..TopologyConstraints::DEFAULT
};
let t = Topology::new(1, 2, 4, 2);
assert!(!c.accepts(&t, 128, 16, 32));
}
#[test]
fn accepts_rejects_exceeding_host_cpus_per_llc() {
let c = TopologyConstraints::DEFAULT;
let t = Topology::new(1, 2, 8, 2);
assert!(!c.accepts(&t, 128, 16, 8));
}
#[test]
fn validate_accepts_default_constraints() {
TopologyConstraints::DEFAULT
.validate()
.expect("DEFAULT constraints must be self-consistent");
}
#[test]
fn validate_rejects_inverted_numa_nodes() {
let c = TopologyConstraints {
min_numa_nodes: 5,
max_numa_nodes: Some(2),
..TopologyConstraints::DEFAULT
};
let err = c
.validate()
.expect_err("inverted min/max_numa_nodes must be rejected");
let msg = err.to_string();
assert!(
msg.contains("max_numa_nodes=2") && msg.contains("min_numa_nodes=5"),
"diagnostic must name both bounds: got {msg}",
);
}
#[test]
fn validate_rejects_inverted_llcs() {
let c = TopologyConstraints {
min_llcs: 8,
max_llcs: Some(2),
..TopologyConstraints::DEFAULT
};
let err = c
.validate()
.expect_err("inverted min/max_llcs must be rejected");
let msg = err.to_string();
assert!(
msg.contains("max_llcs=2") && msg.contains("min_llcs=8"),
"diagnostic must name both bounds: got {msg}",
);
}
#[test]
fn validate_rejects_inverted_cpus() {
let c = TopologyConstraints {
min_cpus: 64,
max_cpus: Some(16),
..TopologyConstraints::DEFAULT
};
let err = c
.validate()
.expect_err("inverted min/max_cpus must be rejected");
let msg = err.to_string();
assert!(
msg.contains("max_cpus=16") && msg.contains("min_cpus=64"),
"diagnostic must name both bounds: got {msg}",
);
}
#[test]
fn validate_accepts_max_equal_min() {
let c = TopologyConstraints {
min_numa_nodes: 3,
max_numa_nodes: Some(3),
min_llcs: 4,
max_llcs: Some(4),
min_cpus: 16,
max_cpus: Some(16),
..TopologyConstraints::DEFAULT
};
c.validate()
.expect("max==min is satisfiable and must be accepted");
}
#[test]
fn validate_accepts_open_upper_bound() {
let c = TopologyConstraints {
min_numa_nodes: 16,
max_numa_nodes: None,
min_llcs: 32,
max_llcs: None,
min_cpus: 1024,
max_cpus: None,
..TopologyConstraints::DEFAULT
};
c.validate()
.expect("None upper bounds must always validate");
}
#[test]
fn entry_validate_propagates_per_entry_constraints_error() {
let entry = KtstrTestEntry {
name: "test_inverted_entry",
constraints: TopologyConstraints {
min_numa_nodes: 5,
max_numa_nodes: Some(2),
..TopologyConstraints::DEFAULT
},
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("inverted per-entry constraints must surface");
let msg = err.to_string();
assert!(
msg.contains("KtstrTestEntry 'test_inverted_entry'.constraints:"),
"wrap prefix must name the entry + constraints field: got {msg}",
);
assert!(
msg.contains("max_numa_nodes=2") && msg.contains("min_numa_nodes=5"),
"underlying validator diagnostic must be preserved through map_err: got {msg}",
);
}
#[test]
fn entry_validate_propagates_scheduler_constraints_error() {
static BAD_SCHED: Scheduler = Scheduler {
name: "bad_sched",
binary: SchedulerSpec::Eevdf,
sysctls: &[],
kargs: &[],
assert: crate::assert::Assert::NO_OVERRIDES,
cgroup_parent: None,
sched_args: &[],
topology: Topology {
llcs: 1,
cores_per_llc: 1,
threads_per_core: 1,
numa_nodes: 1,
nodes: None,
distances: None,
},
constraints: TopologyConstraints {
min_llcs: 8,
max_llcs: Some(2),
..TopologyConstraints::DEFAULT
},
config_file: None,
config_file_def: None,
kernels: &[],
};
let entry = KtstrTestEntry {
name: "test_inverted_scheduler",
scheduler: &BAD_SCHED,
..KtstrTestEntry::DEFAULT
};
let err = entry
.validate()
.expect_err("inverted scheduler-level constraints must surface");
let msg = err.to_string();
assert!(
msg.contains(
"KtstrTestEntry 'test_inverted_scheduler'.scheduler 'bad_sched'.constraints:"
),
"wrap prefix must name the entry + scheduler + constraints: got {msg}",
);
assert!(
msg.contains("max_llcs=2") && msg.contains("min_llcs=8"),
"underlying validator diagnostic must be preserved: got {msg}",
);
}
#[test]
fn display_name_eevdf() {
assert_eq!(SchedulerSpec::Eevdf.display_name(), "eevdf");
}
#[test]
fn display_name_discover_returns_binary_name() {
assert_eq!(
SchedulerSpec::Discover("scx_mitosis").display_name(),
"scx_mitosis"
);
}
#[test]
fn display_name_path_returns_path_string() {
assert_eq!(
SchedulerSpec::Path("/usr/bin/scx_my_sched").display_name(),
"/usr/bin/scx_my_sched"
);
}
#[test]
fn display_name_kernel_builtin_returns_kernel() {
assert_eq!(
SchedulerSpec::KernelBuiltin {
enable: &[],
disable: &[],
}
.display_name(),
"kernel"
);
}
#[test]
fn scheduler_commit_eevdf_returns_none() {
assert!(
SchedulerSpec::Eevdf.scheduler_commit().is_none(),
"Eevdf has no userspace binary — scheduler_commit must \
be None so the sidecar field distinguishes this case \
from `Path(_)` (external, unknown commit). Got: {:?}",
SchedulerSpec::Eevdf.scheduler_commit(),
);
}
#[test]
fn scheduler_commit_discover_returns_none() {
assert!(
SchedulerSpec::Discover("scx_mitosis")
.scheduler_commit()
.is_none(),
"Discover(_) must return None — resolve_scheduler's \
cascade can pick up a binary whose commit doesn't \
match the workspace. Got: {:?}",
SchedulerSpec::Discover("scx_mitosis").scheduler_commit(),
);
}
#[test]
fn scheduler_commit_path_returns_none() {
assert!(
SchedulerSpec::Path("/usr/bin/scx_external")
.scheduler_commit()
.is_none(),
"Path(_) points at an externally-built binary — \
scheduler_commit must be None so consumers don't treat \
a fabricated commit as authoritative. Got: {:?}",
SchedulerSpec::Path("/usr/bin/scx_external").scheduler_commit(),
);
}
#[test]
fn scheduler_commit_kernel_builtin_returns_none() {
let spec = SchedulerSpec::KernelBuiltin {
enable: &[],
disable: &[],
};
assert!(
spec.scheduler_commit().is_none(),
"KernelBuiltin has no userspace binary — \
scheduler_commit must be None. Got: {:?}",
spec.scheduler_commit(),
);
}
#[test]
fn all_include_files_empty_when_nothing_declared() {
let entry = KtstrTestEntry {
name: "t",
..KtstrTestEntry::DEFAULT
};
assert!(entry.all_include_files().is_empty());
}
#[test]
fn all_include_files_merges_sources_in_order() {
static PRIMARY: crate::test_support::Payload = crate::test_support::Payload {
name: "primary",
kind: crate::test_support::PayloadKind::Binary("fio"),
output: crate::test_support::OutputFormat::ExitCode,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &["fio"],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
static WL_A: crate::test_support::Payload = crate::test_support::Payload {
name: "wl_a",
kind: crate::test_support::PayloadKind::Binary("stress-ng"),
output: crate::test_support::OutputFormat::ExitCode,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &["stress-ng"],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
static WL_B: crate::test_support::Payload = crate::test_support::Payload {
name: "wl_b",
kind: crate::test_support::PayloadKind::Binary("schbench"),
output: crate::test_support::OutputFormat::ExitCode,
default_args: &[],
default_checks: &[],
metrics: &[],
include_files: &["schbench"],
uses_parent_pgrp: false,
known_flags: None,
metric_bounds: None,
};
static WORKLOADS: &[&crate::test_support::Payload] = &[&WL_A, &WL_B];
let entry = KtstrTestEntry {
name: "t",
payload: Some(&PRIMARY),
workloads: WORKLOADS,
extra_include_files: &["test-fixture.json"],
..KtstrTestEntry::DEFAULT
};
let got = entry.all_include_files();
assert_eq!(
got,
vec!["fio", "stress-ng", "schbench", "test-fixture.json"],
"aggregation order must be payload → workloads → extras",
);
}
#[test]
fn all_include_files_skips_absent_payload() {
let entry = KtstrTestEntry {
name: "t",
payload: None,
workloads: &[],
extra_include_files: &[],
..KtstrTestEntry::DEFAULT
};
assert!(entry.all_include_files().is_empty());
}
#[test]
#[should_panic(expected = "duplicate scheduler name `dup_name_test`")]
fn build_scheduler_index_or_panic_rejects_duplicate_names() {
static A: Scheduler = Scheduler::named("dup_name_test");
static B: Scheduler = Scheduler::named("dup_name_test");
let _ = build_scheduler_index_or_panic([&A, &B]);
}
#[test]
fn build_scheduler_index_or_panic_accepts_distinct_names() {
static A: Scheduler = Scheduler::named("dup_name_a");
static B: Scheduler = Scheduler::named("dup_name_b");
let map = build_scheduler_index_or_panic([&A, &B]);
assert!(std::ptr::eq(*map.get("dup_name_a").unwrap(), &A));
assert!(std::ptr::eq(*map.get("dup_name_b").unwrap(), &B));
}
#[test]
fn build_scheduler_index_or_panic_tolerates_pointer_identity_aliases() {
static A: Scheduler = Scheduler::named("alias_test");
let map = build_scheduler_index_or_panic([&A, &A]);
assert!(std::ptr::eq(*map.get("alias_test").unwrap(), &A));
}
#[test]
fn topology_json_try_into_topology_accepts_single_cpu() {
let topo: Topology = TopologyJson::SINGLE_CPU
.try_into()
.expect("SINGLE_CPU valid");
assert_eq!(topo.numa_nodes, 1);
assert_eq!(topo.llcs, 1);
assert_eq!(topo.cores_per_llc, 1);
assert_eq!(topo.threads_per_core, 1);
assert!(topo.nodes.is_none());
assert!(topo.distances.is_none());
}
#[test]
fn topology_json_try_into_topology_accepts_multi_cpu() {
let json = TopologyJson {
num_numa_nodes: 2,
num_llcs: 4,
cores_per_llc: 8,
threads_per_core: 2,
};
let topo: Topology = json.try_into().expect("2x4x8x2 valid");
assert_eq!(topo.numa_nodes, 2);
assert_eq!(topo.llcs, 4);
assert_eq!(topo.cores_per_llc, 8);
assert_eq!(topo.threads_per_core, 2);
}
#[test]
fn topology_json_try_into_topology_rejects_zero_numa_nodes() {
let json = TopologyJson {
num_numa_nodes: 0,
num_llcs: 1,
cores_per_llc: 1,
threads_per_core: 1,
};
let err = Topology::try_from(json).expect_err("zero numa_nodes must reject");
assert!(
err.contains("numa_nodes"),
"error should mention numa_nodes: {err}"
);
}
#[test]
fn topology_json_try_into_topology_rejects_zero_llcs() {
let json = TopologyJson {
num_numa_nodes: 1,
num_llcs: 0,
cores_per_llc: 1,
threads_per_core: 1,
};
let err = Topology::try_from(json).expect_err("zero llcs must reject");
assert!(err.contains("llcs"), "error should mention llcs: {err}");
}
#[test]
fn topology_json_try_into_topology_rejects_zero_cores() {
let json = TopologyJson {
num_numa_nodes: 1,
num_llcs: 1,
cores_per_llc: 0,
threads_per_core: 1,
};
let err = Topology::try_from(json).expect_err("zero cores_per_llc must reject");
assert!(
err.contains("cores_per_llc"),
"error should mention cores_per_llc: {err}"
);
}
#[test]
fn topology_json_try_into_topology_rejects_zero_threads() {
let json = TopologyJson {
num_numa_nodes: 1,
num_llcs: 1,
cores_per_llc: 1,
threads_per_core: 0,
};
let err = Topology::try_from(json).expect_err("zero threads_per_core must reject");
assert!(
err.contains("threads_per_core"),
"error should mention threads_per_core: {err}"
);
}
#[test]
fn topology_json_try_into_topology_rejects_indivisible_llcs() {
let json = TopologyJson {
num_numa_nodes: 2,
num_llcs: 3,
cores_per_llc: 1,
threads_per_core: 1,
};
let err = Topology::try_from(json).expect_err("indivisible llcs must reject");
assert!(
err.contains("divisible"),
"error should mention divisibility: {err}"
);
}
#[test]
fn topology_json_try_into_topology_rejects_overflow_total_cpus() {
let json = TopologyJson {
num_numa_nodes: 1,
num_llcs: 2,
cores_per_llc: u32::MAX / 4,
threads_per_core: 4,
};
let err = Topology::try_from(json).expect_err("u32 overflow on total cpus must reject");
assert!(
err.contains("overflow"),
"error should mention overflow: {err}"
);
}
#[test]
fn topology_into_topology_json_drops_explicit_nodes_distances() {
let topo = Topology {
llcs: 4,
cores_per_llc: 8,
threads_per_core: 2,
numa_nodes: 2,
nodes: None,
distances: None,
};
let json: TopologyJson = topo.into();
assert_eq!(json.num_numa_nodes, 2);
assert_eq!(json.num_llcs, 4);
assert_eq!(json.cores_per_llc, 8);
assert_eq!(json.threads_per_core, 2);
}
#[test]
fn topology_constraints_json_into_topology_constraints_preserves_fields() {
let json = TopologyConstraintsJson {
min_numa_nodes: 1,
max_numa_nodes: Some(4),
min_llcs: 2,
max_llcs: Some(8),
requires_smt: true,
min_cpus: 4,
max_cpus: Some(64),
};
let c: TopologyConstraints = json.into();
assert_eq!(c.min_numa_nodes, 1);
assert_eq!(c.max_numa_nodes, Some(4));
assert_eq!(c.min_llcs, 2);
assert_eq!(c.max_llcs, Some(8));
assert!(c.requires_smt);
assert_eq!(c.min_cpus, 4);
assert_eq!(c.max_cpus, Some(64));
}
#[test]
fn topology_constraints_json_into_topology_constraints_handles_none_options() {
let json = TopologyConstraintsJson {
min_numa_nodes: 1,
max_numa_nodes: None,
min_llcs: 1,
max_llcs: None,
requires_smt: false,
min_cpus: 1,
max_cpus: None,
};
let c: TopologyConstraints = json.into();
assert!(c.max_numa_nodes.is_none());
assert!(c.max_llcs.is_none());
assert!(c.max_cpus.is_none());
}
#[test]
#[should_panic(expected = "must not contain `..` segments")]
fn cgroup_path_new_panics_on_parent_dir_segment() {
let _ = CgroupPath::new("/foo/..");
}
#[test]
#[should_panic(expected = "must not contain `..` segments")]
fn cgroup_path_new_panics_on_bare_parent_dir() {
let _ = CgroupPath::new("/..");
}
#[test]
#[should_panic(expected = "at least one non-`.`/non-empty segment")]
fn cgroup_path_new_panics_on_only_dot_segment() {
let _ = CgroupPath::new("/.");
}
#[test]
#[should_panic(expected = "at least one non-`.`/non-empty segment")]
fn cgroup_path_new_panics_on_only_slashes() {
let _ = CgroupPath::new("///");
}
#[test]
fn cgroup_path_new_accepts_embedded_dot_segment() {
let _ = CgroupPath::new("/foo/./bar");
}
#[test]
fn cgroup_path_new_accepts_normal_paths() {
let _ = CgroupPath::new("/ktstr");
let _ = CgroupPath::new("/sys/fs/cgroup/ktstr");
let _ = CgroupPath::new("/a/b/c");
let _ = CgroupPath::new("/foo/"); }
#[test]
fn scheduler_spec_hashset_roundtrip() {
use std::collections::HashSet;
let mut set: HashSet<SchedulerSpec> = HashSet::new();
set.insert(SchedulerSpec::Eevdf);
set.insert(SchedulerSpec::Discover("scx_lavd"));
set.insert(SchedulerSpec::Path("./scx_rusty"));
set.insert(SchedulerSpec::KernelBuiltin {
enable: &["CONFIG_SCHED_DEBUG"],
disable: &["CONFIG_SCHED_AUTOGROUP"],
});
assert_eq!(set.len(), 4);
assert!(set.contains(&SchedulerSpec::Eevdf));
assert!(set.contains(&SchedulerSpec::Discover("scx_lavd")));
let dup_added = set.insert(SchedulerSpec::Eevdf);
assert!(
!dup_added,
"duplicate SchedulerSpec insert must collapse via Hash+Eq"
);
assert_eq!(set.len(), 4);
}
#[test]
fn bpf_map_write_hashset_roundtrip() {
use std::collections::HashSet;
let w1 = BpfMapWrite::new(".data", 0, 1);
let w2 = BpfMapWrite::new(".data", 4, 2);
let w3 = BpfMapWrite::new(".other", 0, 1);
let mut set: HashSet<BpfMapWrite> = HashSet::new();
set.insert(w1);
set.insert(w2);
set.insert(w3);
assert_eq!(set.len(), 3);
assert!(set.contains(&w1));
let dup_added = set.insert(w1);
assert!(
!dup_added,
"duplicate BpfMapWrite insert must collapse via Hash+Eq"
);
assert_eq!(set.len(), 3);
}
#[test]
#[should_panic(expected = "map_name_suffix must not be empty")]
fn bpf_map_write_new_rejects_empty_suffix() {
let _ = BpfMapWrite::new("", 0, 0);
}
#[test]
#[should_panic(expected = "must start with `.`")]
fn bpf_map_write_new_rejects_missing_dot_prefix() {
let _ = BpfMapWrite::new("bss", 0, 0);
}
#[test]
#[should_panic(expected = "must not contain whitespace")]
fn bpf_map_write_new_rejects_whitespace_in_suffix() {
let _ = BpfMapWrite::new(".b s", 0, 0);
}
#[test]
#[should_panic(expected = "must not contain path separators")]
fn bpf_map_write_new_rejects_path_separator_in_suffix() {
let _ = BpfMapWrite::new(".bss/data", 0, 0);
}
#[test]
fn bpf_map_write_new_valid_round_trips() {
let w = BpfMapWrite::new(".bss", 16, 42);
assert_eq!(w.map_name_suffix(), ".bss");
assert_eq!(w.offset(), 16);
assert_eq!(w.value(), 42);
}
#[test]
#[should_panic(expected = "Sysctl key must not be empty")]
fn sysctl_new_rejects_empty_key() {
let _ = Sysctl::new("", "1");
}
#[test]
#[should_panic(expected = "must use the dotted form")]
fn sysctl_new_rejects_slash_in_key() {
let _ = Sysctl::new("kernel/foo", "1");
}
#[test]
#[should_panic(expected = "must be namespaced")]
fn sysctl_new_rejects_undotted_key() {
let _ = Sysctl::new("foo", "1");
}
#[test]
#[should_panic(expected = "must not start or end with `.`")]
fn sysctl_new_rejects_leading_dot_key() {
let _ = Sysctl::new(".foo", "1");
}
#[test]
#[should_panic(expected = "must not start or end with `.`")]
fn sysctl_new_rejects_trailing_dot_key() {
let _ = Sysctl::new("foo.", "1");
}
#[test]
#[should_panic(expected = "value must not be empty")]
fn sysctl_new_rejects_empty_value() {
let _ = Sysctl::new("kernel.foo", "");
}
#[test]
#[should_panic(expected = "must not contain a newline")]
fn sysctl_new_rejects_newline_in_value() {
let _ = Sysctl::new("kernel.foo", "1\n2");
}
#[test]
#[should_panic(expected = "must not contain `=`")]
fn sysctl_new_rejects_equals_in_value() {
let _ = Sysctl::new("kernel.foo", "1=2");
}
#[test]
fn sysctl_new_valid_round_trips() {
let s = Sysctl::new("kernel.sched_cfs_bandwidth_slice_us", "1000");
assert_eq!(s.key(), "kernel.sched_cfs_bandwidth_slice_us");
assert_eq!(s.value(), "1000");
}
#[test]
#[should_panic(expected = "must not contain whitespace")]
fn sysctl_new_rejects_space_in_key() {
let _ = Sysctl::new(" kernel.foo", "1");
}
#[test]
#[should_panic(expected = "must not contain whitespace")]
fn sysctl_new_rejects_tab_in_key() {
let _ = Sysctl::new("kernel\t.foo", "1");
}
#[test]
#[should_panic(expected = "must not contain whitespace")]
fn sysctl_new_rejects_newline_in_key() {
let _ = Sysctl::new("kernel\n.foo", "1");
}
#[test]
#[should_panic(expected = "must not contain `=`")]
fn sysctl_new_rejects_equals_in_key() {
let _ = Sysctl::new("kernel=foo.bar", "1");
}
#[test]
#[should_panic(expected = "must be printable ASCII")]
fn sysctl_new_rejects_control_byte_in_key() {
let _ = Sysctl::new("kernel.\x01foo", "1");
}
#[test]
#[should_panic(expected = "must not contain `..`")]
fn sysctl_new_rejects_double_dot_in_key() {
let _ = Sysctl::new("kernel..foo", "1");
}
#[test]
#[should_panic(expected = "must not contain a carriage return")]
fn sysctl_new_rejects_carriage_return_in_value() {
let _ = Sysctl::new("kernel.foo", "1\r2");
}
#[test]
#[should_panic(expected = "must be longer than a bare `.`")]
fn bpf_map_write_new_rejects_bare_dot() {
let _ = BpfMapWrite::new(".", 0, 0);
}
#[test]
#[should_panic(expected = "must not start with `..`")]
fn bpf_map_write_new_rejects_leading_double_dot() {
let _ = BpfMapWrite::new("..bss", 0, 0);
}
#[test]
#[should_panic(expected = "must be printable ASCII")]
fn bpf_map_write_new_rejects_null_byte_in_suffix() {
let _ = BpfMapWrite::new(".bss\0", 0, 0);
}
#[test]
#[should_panic(expected = "must be printable ASCII")]
fn bpf_map_write_new_rejects_control_byte_in_suffix() {
let _ = BpfMapWrite::new(".b\x01ss", 0, 0);
}
#[test]
fn ktstr_test_entry_default_matches_const() {
let from_const = KtstrTestEntry::DEFAULT;
let from_trait: KtstrTestEntry = Default::default();
assert_eq!(from_trait.name, from_const.name);
assert!(std::ptr::fn_addr_eq(from_trait.func, from_const.func));
assert_eq!(from_trait.topology.llcs, from_const.topology.llcs);
assert_eq!(
from_trait.topology.cores_per_llc,
from_const.topology.cores_per_llc
);
assert_eq!(
from_trait.topology.threads_per_core,
from_const.topology.threads_per_core
);
assert_eq!(
from_trait.topology.numa_nodes,
from_const.topology.numa_nodes
);
assert_eq!(from_trait.memory_mib, from_const.memory_mib);
assert_eq!(from_trait.scheduler.name, from_const.scheduler.name);
assert_eq!(from_trait.scheduler.name, "eevdf");
assert!(from_trait.payload.is_none() && from_const.payload.is_none());
assert_eq!(
from_trait.workloads.len(),
from_const.workloads.len(),
"workloads count drift"
);
for (i, (a, b)) in from_trait
.workloads
.iter()
.zip(from_const.workloads.iter())
.enumerate()
{
assert!(
std::ptr::eq(*a, *b),
"workloads[{i}] pointer identity drift"
);
}
assert_eq!(from_trait.auto_repro, from_const.auto_repro);
assert_eq!(
from_trait.extra_sched_args, from_const.extra_sched_args,
"extra_sched_args content drift"
);
assert_eq!(from_trait.watchdog_timeout, from_const.watchdog_timeout);
assert_eq!(
from_trait.bpf_map_write.len(),
from_const.bpf_map_write.len(),
"bpf_map_write count drift"
);
for (i, (a, b)) in from_trait
.bpf_map_write
.iter()
.zip(from_const.bpf_map_write.iter())
.enumerate()
{
assert!(
std::ptr::eq(*a, *b),
"bpf_map_write[{i}] pointer identity drift"
);
}
assert_eq!(from_trait.performance_mode, from_const.performance_mode);
assert_eq!(from_trait.no_perf_mode, from_const.no_perf_mode);
assert_eq!(from_trait.duration, from_const.duration);
assert_eq!(from_trait.expect_err, from_const.expect_err);
assert_eq!(from_trait.host_only, from_const.host_only);
assert_eq!(
from_trait.extra_include_files, from_const.extra_include_files,
"extra_include_files content drift"
);
assert_eq!(from_trait.cleanup_budget, from_const.cleanup_budget);
assert_eq!(from_trait.config_content, from_const.config_content);
assert!(from_trait.disk.is_none() && from_const.disk.is_none());
assert!(from_trait.post_vm.is_none() && from_const.post_vm.is_none());
assert_eq!(from_trait.num_snapshots, from_const.num_snapshots);
assert_eq!(
from_trait.assert.format_human(),
from_const.assert.format_human(),
"Assert threshold-field drift between Default::default() and DEFAULT"
);
assert_eq!(
from_trait.assert.enforce_monitor_thresholds,
from_const.assert.enforce_monitor_thresholds,
"Assert.enforce_monitor_thresholds drift between Default::default() and DEFAULT"
);
}
#[test]
fn default_post_vm_periodic_fired_skips_when_periodic_disabled() {
let r = crate::vmm::VmResult {
periodic_target: 0,
periodic_fired: 0,
..crate::vmm::VmResult::test_fixture()
};
super::default_post_vm_periodic_fired(&r)
.expect("periodic_target == 0 must short-circuit to Ok");
}
fn real_report() -> crate::monitor::dump::FailureDumpReport {
crate::monitor::dump::FailureDumpReport {
schema: crate::monitor::dump::SCHEMA_SINGLE.to_string(),
is_placeholder: false,
..Default::default()
}
}
fn placeholder_report(reason: &str) -> crate::monitor::dump::FailureDumpReport {
crate::monitor::dump::FailureDumpReport::placeholder(reason)
}
#[test]
fn default_post_vm_periodic_fired_ok_when_at_least_one_real_landed() {
let r = crate::vmm::VmResult {
periodic_target: 5,
periodic_fired: 1,
..crate::vmm::VmResult::test_fixture()
};
r.snapshot_bridge.store("periodic_000", real_report());
super::default_post_vm_periodic_fired(&r)
.expect("at least one real periodic capture must surface as Ok");
}
#[test]
fn default_post_vm_periodic_fired_fails_when_only_placeholders_landed() {
let r = crate::vmm::VmResult {
periodic_target: 3,
periodic_fired: 3,
..crate::vmm::VmResult::test_fixture()
};
r.snapshot_bridge
.store("periodic_000", placeholder_report("rendezvous timed out"));
r.snapshot_bridge
.store("periodic_001", placeholder_report("rendezvous timed out"));
r.snapshot_bridge
.store("periodic_002", placeholder_report("rendezvous timed out"));
let err = super::default_post_vm_periodic_fired(&r)
.expect_err("placeholder-only fills must surface as Err");
let msg = err.to_string();
assert!(
msg.contains("no periodic snapshot produced real BPF state")
&& msg.contains("periodic_real_count=0")
&& msg.contains("periodic_fired=3")
&& msg.contains("target=3"),
"diagnostic must name the real-count floor + carry both counters; got {msg}",
);
}
#[test]
fn default_post_vm_periodic_fired_fails_when_only_non_periodic_tagged_reports_present() {
let r = crate::vmm::VmResult {
periodic_target: 3,
periodic_fired: 3,
..crate::vmm::VmResult::test_fixture()
};
r.snapshot_bridge.store("user_capture", real_report());
r.snapshot_bridge.store("snapshot_baseline", real_report());
r.snapshot_bridge.store("watch_my_var", real_report());
let err = super::default_post_vm_periodic_fired(&r).expect_err(
"non-periodic tags MUST NOT count toward periodic_real_count; \
bridge has 3 real reports but none periodic → must Err",
);
let msg = err.to_string();
assert!(
msg.contains("periodic_real_count=0"),
"diagnostic must name the zero real-count; got {msg}",
);
}
#[test]
fn default_post_vm_periodic_fired_ok_when_one_real_among_placeholders() {
let r = crate::vmm::VmResult {
periodic_target: 5,
periodic_fired: 5,
..crate::vmm::VmResult::test_fixture()
};
r.snapshot_bridge
.store("periodic_000", placeholder_report("rendezvous timed out"));
r.snapshot_bridge.store("periodic_001", real_report());
r.snapshot_bridge
.store("periodic_002", placeholder_report("rendezvous timed out"));
r.snapshot_bridge
.store("periodic_003", placeholder_report("rendezvous timed out"));
r.snapshot_bridge
.store("periodic_004", placeholder_report("rendezvous timed out"));
super::default_post_vm_periodic_fired(&r).expect(
"one real capture among placeholders must Ok — tolerance for single-snapshot flakes",
);
}
#[test]
fn default_post_vm_periodic_fired_target_zero_fired_nonzero_returns_ok() {
let r = crate::vmm::VmResult {
periodic_target: 0,
periodic_fired: 5,
..crate::vmm::VmResult::test_fixture()
};
super::default_post_vm_periodic_fired(&r)
.expect("target == 0 takes priority over fired count; must Ok");
}
#[test]
fn default_post_vm_periodic_fired_skips_when_vm_run_failed() {
let r = crate::vmm::VmResult {
success: false,
periodic_target: 5,
periodic_fired: 0,
..crate::vmm::VmResult::test_fixture()
};
super::default_post_vm_periodic_fired(&r)
.expect("vm-run-failed must short-circuit to Ok so the runner's diagnostic dominates");
}
#[test]
fn default_post_vm_periodic_fired_fails_when_none_fired() {
let r = crate::vmm::VmResult {
periodic_target: 5,
periodic_fired: 0,
..crate::vmm::VmResult::test_fixture()
};
let err = super::default_post_vm_periodic_fired(&r)
.expect_err("periodic_fired == 0 with target > 0 must surface as Err");
let msg = err.to_string();
assert!(
msg.contains("periodic_fired=0") && msg.contains("target=5"),
"diagnostic must carry both counters; got {msg}",
);
}
}