use anyhow::Result;
use ktstr::assert::AssertResult;
use ktstr::scenario::ops::{CgroupDef, HoldSpec, Step, execute_steps};
use ktstr::test_support::{Payload, Scheduler, SchedulerSpec, sidecar_dir};
const KTSTR_SCHED: Scheduler =
Scheduler::new("ktstr_sched").binary(SchedulerSpec::Discover("scx-ktstr"));
const KTSTR_SCHED_PAYLOAD: Payload = Payload::from_scheduler(&KTSTR_SCHED);
fn failure_dump_path(test_name: &str) -> std::path::PathBuf {
sidecar_dir().join(format!("{test_name}.failure-dump.json"))
}
fn scenario_failure_dump_renders_bss_fields(ctx: &ktstr::scenario::Ctx) -> Result<AssertResult> {
let dump_path = failure_dump_path("failure_dump_renders_bss_fields");
let steps = vec![Step {
setup: vec![CgroupDef::named("cg_0").workers(ctx.workers_per_cgroup)].into(),
ops: vec![],
hold: HoldSpec::FULL,
}];
let mut result = execute_steps(ctx, steps)?;
let json = match std::fs::read_to_string(&dump_path) {
Ok(s) => s,
Err(e) => {
result.passed = false;
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"failure dump file missing at {}: {e} (freeze coordinator did \
not write — either the SCX_EXIT_ERROR_STALL latch did not \
fire, owned_accessor / dump_btf was None, or the file \
write failed silently)",
dump_path.display()
),
));
anyhow::bail!(
"failure dump file missing at {} — freeze coordinator did not \
write the JSON dump",
dump_path.display()
);
}
};
let value: serde_json::Value = serde_json::from_str(&json)
.map_err(|e| anyhow::anyhow!("dump file is not valid JSON: {e}"))?;
let maps = value
.get("maps")
.and_then(|m| m.as_array())
.ok_or_else(|| anyhow::anyhow!("dump JSON missing top-level `maps` array"))?;
let bss_map = maps
.iter()
.find(|m| {
m.get("name")
.and_then(|n| n.as_str())
.map(|n| {
n.ends_with(".bss")
&& !n.starts_with("probe_bp.")
&& !n.starts_with("fentry_p.")
})
.unwrap_or(false)
})
.ok_or_else(|| {
anyhow::anyhow!(
"dump has no scheduler `.bss` map (got {} maps): {json}",
maps.len()
)
})?;
let value_field = bss_map
.get("value")
.ok_or_else(|| anyhow::anyhow!(".bss map has no `value` field"))?;
let kind = value_field
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("");
if kind != "struct" {
anyhow::bail!(
"expected .bss value to render as a Struct (kind=\"struct\"), got kind={kind:?}: \
{value_field}"
);
}
let members = value_field
.get("members")
.and_then(|m| m.as_array())
.ok_or_else(|| anyhow::anyhow!(".bss Struct has no `members` array"))?;
let names: std::collections::HashSet<&str> = members
.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()))
.collect();
for required in ["stall", "crash"] {
if !names.contains(required) {
anyhow::bail!(
"BTF-rendered .bss missing required field `{required}` — \
either the field was renamed in scx-ktstr's main.bpf.c \
or the renderer fell through to an Unsupported branch \
instead of recursing into the Struct. members: {names:?}"
);
}
}
let stall_value = members
.iter()
.find(|m| m.get("name").and_then(|n| n.as_str()) == Some("stall"))
.and_then(|m| m.get("value"))
.ok_or_else(|| anyhow::anyhow!("`stall` member found but has no `value`"))?;
let stall_kind = stall_value
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("");
let stall_int = stall_value
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| {
anyhow::anyhow!(
"`stall` value is not a numeric u64 (kind={stall_kind:?}): {stall_value}"
)
})?;
if stall_int == 0 {
anyhow::bail!(
"`stall` rendered as 0 — the scheduler-side stall flag never flipped, \
or the freeze coordinator captured pre-stall state. Full value: {stall_value}"
);
}
let vcpu_regs = value
.get("vcpu_regs")
.and_then(|v| v.as_array())
.ok_or_else(|| {
anyhow::anyhow!(
"dump JSON missing top-level `vcpu_regs` array — \
freeze coordinator did not attach per-vCPU register \
snapshots after rendezvous"
)
})?;
if vcpu_regs.is_empty() {
anyhow::bail!(
"dump JSON `vcpu_regs` array is empty — expected at least \
one entry (BSP idx 0 plus N APs)"
);
}
let populated_with_ip: Vec<&serde_json::Value> = vcpu_regs
.iter()
.filter(|slot| {
slot.is_object()
&& slot
.get("instruction_pointer")
.and_then(|ip| ip.as_u64())
.is_some_and(|ip| ip != 0)
})
.collect();
if populated_with_ip.is_empty() {
anyhow::bail!(
"dump JSON `vcpu_regs` has no entry with non-zero \
instruction_pointer — every slot is null or has zero IP. \
Capture-on-vCPU-thread path may be broken or rendezvous \
timed out before any vCPU completed handle_freeze. \
Full vcpu_regs: {vcpu_regs:?}"
);
}
#[cfg(target_arch = "x86_64")]
{
for slot in &populated_with_ip {
assert!(
slot.get("user_page_table_root").is_none(),
"x86_64 vcpu_regs entry must NOT carry user_page_table_root \
(CR3 alone identifies the active mm); got: {slot}"
);
}
}
let alloc_count_value = members
.iter()
.find(|m| m.get("name").and_then(|n| n.as_str()) == Some("ktstr_alloc_count"))
.and_then(|m| m.get("value"))
.ok_or_else(|| {
anyhow::anyhow!(
"BTF-rendered .bss missing `ktstr_alloc_count` — \
either the field was renamed in scx-ktstr's main.bpf.c, \
or the BTF Datasec walker did not surface it. members: \
{names:?}"
)
})?;
let alloc_count_kind = alloc_count_value
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("");
let alloc_count_int = alloc_count_value
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| {
anyhow::anyhow!(
"`ktstr_alloc_count` value is not a numeric u64 \
(kind={alloc_count_kind:?}): {alloc_count_value}"
)
})?;
if alloc_count_int == 0 {
anyhow::bail!(
"`ktstr_alloc_count` rendered as 0 — the alloc path never \
ran (no `__sync_fetch_and_add` in ktstr_init_task), or \
the dump captured pre-init state. Full value: \
{alloc_count_value}"
);
}
const BPF_MAP_TYPE_ARENA: u64 = 33;
let arena_map = maps
.iter()
.find(|m| {
m.get("map_type")
.and_then(|t| t.as_u64())
.is_some_and(|t| t == BPF_MAP_TYPE_ARENA)
})
.ok_or_else(|| {
anyhow::anyhow!(
"dump has no BPF_MAP_TYPE_ARENA (33) map — scx-ktstr \
declares one via lib/arena_map.h, so either the dump \
path filtered it out, the map enumeration missed it, \
or the scheduler failed to load the arena. Got {} \
maps total: {json}",
maps.len()
)
})?;
let arena_field = arena_map.get("arena").ok_or_else(|| {
anyhow::anyhow!(
"arena map present but `arena` field absent — render_map's \
BPF_MAP_TYPE_ARENA arm did not populate ArenaSnapshot \
(likely arena_offsets was None: kernel BTF lacks \
struct bpf_arena, or BpfArenaOffsets::from_btf failed). \
arena map JSON: {arena_map}"
)
})?;
let arena_pages: &[serde_json::Value] = match arena_field.get("pages") {
Some(p) => p.as_array().map(|a| a.as_slice()).ok_or_else(|| {
anyhow::anyhow!(
"arena.pages is present but not an array — \
ArenaSnapshot serde shape changed. arena field: {arena_field}"
)
})?,
None => &[],
};
if arena_pages.is_empty() {
anyhow::bail!(
"arena.pages is empty (absent or zero-length) — snapshot_arena \
returned no pages. Either the PTE walker found no mapped \
pgoffs (kern_vm translation failed for every page), \
max_entries is 0, or scx_task_alloc never ran on any task \
(alloc_count={alloc_count_int}). arena field: {arena_field}"
);
}
let declared_pages = arena_field
.get("declared_pages")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if declared_pages == 0 {
anyhow::bail!(
"arena.declared_pages is 0 (or absent) — \
ArenaWalkPlan computed a zero-page span, meaning \
`info.max_entries` was unreadable or zero at dump time. \
arena field: {arena_field}"
);
}
if (arena_pages.len() as u64) > declared_pages {
anyhow::bail!(
"arena.pages.len() ({}) exceeds declared_pages ({}) — \
walker invariant violated; ArenaWalkPlan should never \
emit more pages than the declared capacity. arena field: \
{arena_field}",
arena_pages.len(),
declared_pages
);
}
const KTSTR_ARENA_MAGIC: u64 = 0xDEADBEEFCAFEBABE;
const KTSTR_ARENA_MAGIC_LE: [u8; 8] = KTSTR_ARENA_MAGIC.to_le_bytes();
let mut magic_hits = 0usize;
let mut total_bytes = 0usize;
for page in arena_pages {
let bytes = page
.get("bytes")
.and_then(|b| b.as_array())
.ok_or_else(|| {
anyhow::anyhow!(
"arena page missing `bytes` array — \
ArenaPage serde shape changed. page: {page}"
)
})?;
let raw: Vec<u8> = bytes
.iter()
.map(|v| {
v.as_u64()
.and_then(|n| u8::try_from(n).ok())
.ok_or_else(|| anyhow::anyhow!("arena page byte is not a u8 (0..=255): {v}"))
})
.collect::<Result<Vec<u8>>>()?;
total_bytes += raw.len();
if raw
.windows(KTSTR_ARENA_MAGIC_LE.len())
.any(|w| w == KTSTR_ARENA_MAGIC_LE)
{
magic_hits += 1;
}
}
if magic_hits == 0 {
anyhow::bail!(
"no arena page contained KTSTR_ARENA_MAGIC \
(0x{KTSTR_ARENA_MAGIC:016x}) — pages were captured but \
contain no live-stamped data. Most diagnostic case: \
alloc_count={alloc_count_int} (>0 means the alloc path \
ran, so the magic stamp was lost OR the walker captured \
the wrong pages); alloc_count=0 would mean no tasks \
were initialized in the first place. {} pages totalling \
{} bytes scanned.",
arena_pages.len(),
total_bytes
);
}
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"failure-dump file at {} contains scheduler .bss render with \
stall={stall_int}, ktstr_alloc_count={alloc_count_int}, \
member count={}, vcpu_regs entries={} ({} populated with \
non-zero IP), arena pages={} ({total_bytes} bytes, \
{magic_hits} pages with KTSTR_ARENA_MAGIC sentinel, \
declared_pages={declared_pages})",
dump_path.display(),
members.len(),
vcpu_regs.len(),
populated_with_ip.len(),
arena_pages.len(),
),
));
Ok(result)
}
#[ktstr::__private::linkme::distributed_slice(ktstr::test_support::KTSTR_TESTS)]
#[linkme(crate = ktstr::__private::linkme)]
static __KTSTR_ENTRY_FAILURE_DUMP_BSS: ktstr::test_support::KtstrTestEntry =
ktstr::test_support::KtstrTestEntry {
name: "failure_dump_renders_bss_fields",
func: scenario_failure_dump_renders_bss_fields,
scheduler: &KTSTR_SCHED_PAYLOAD,
extra_sched_args: &["--stall-after=1"],
watchdog_timeout: std::time::Duration::from_secs(3),
duration: std::time::Duration::from_secs(10),
workers_per_cgroup: 2,
expect_err: true,
..ktstr::test_support::KtstrTestEntry::DEFAULT
};
fn scenario_failure_dump_renders_capture_modules(
ctx: &ktstr::scenario::Ctx,
) -> Result<AssertResult> {
let dump_path = failure_dump_path("failure_dump_renders_capture_modules");
let num_cpus = ctx.topo.total_cpus();
let steps = vec![Step {
setup: vec![CgroupDef::named("cg_0").workers(ctx.workers_per_cgroup)].into(),
ops: vec![],
hold: HoldSpec::FULL,
}];
let mut result = execute_steps(ctx, steps)?;
let json = match std::fs::read_to_string(&dump_path) {
Ok(s) => s,
Err(e) => {
result.passed = false;
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"failure dump file missing at {}: {e} (freeze coordinator did \
not write — either the SCX_EXIT_ERROR_STALL latch did not \
fire or the file write failed silently)",
dump_path.display()
),
));
anyhow::bail!(
"failure dump file missing at {} — freeze coordinator did not \
write the JSON dump",
dump_path.display()
);
}
};
let value: serde_json::Value = serde_json::from_str(&json)
.map_err(|e| anyhow::anyhow!("dump file is not valid JSON: {e}"))?;
if let Some(reason) = value.get("scx_walker_unavailable").and_then(|r| r.as_str()) {
anyhow::bail!(
"scx_walker_unavailable={reason:?} — capture_scx::build returned \
None or the walker reached no state. Captures must always \
produce data when scx-ktstr is loaded. Full JSON: {json}"
);
}
let rq_scx_states = value
.get("rq_scx_states")
.and_then(|s| s.as_array())
.ok_or_else(|| {
anyhow::anyhow!(
"dump JSON missing `rq_scx_states` array — capture_scx \
wiring did not populate the field. Full JSON: {json}"
)
})?;
if rq_scx_states.len() != num_cpus {
anyhow::bail!(
"rq_scx_states.len()={} but expected num_cpus={num_cpus} — \
walk_rq_scx skipped some CPUs (sub-group offset resolution \
failed or per-CPU rq translate failed). Full rq_scx_states: \
{rq_scx_states:?}",
rq_scx_states.len(),
);
}
let any_active = rq_scx_states.iter().any(|s| {
let nr = s.get("nr_running").and_then(|v| v.as_u64()).unwrap_or(0);
let flags = s.get("flags").and_then(|v| v.as_u64()).unwrap_or(0);
nr > 0 || flags != 0
});
if !any_active {
anyhow::bail!(
"no rq_scx_states entry has nr_running>0 OR flags!=0 — every \
CPU's rq->scx scalar read came back zero, meaning the walker \
ran but every per-CPU scx_rq is empty. Either no scx tasks \
were ever runnable or the rq_pa translate produced wrong \
addresses. Full rq_scx_states: {rq_scx_states:?}"
);
}
let dsq_states = value
.get("dsq_states")
.and_then(|s| s.as_array())
.ok_or_else(|| {
anyhow::anyhow!(
"dump JSON missing `dsq_states` array — capture_scx \
wiring did not populate the field. Full JSON: {json}"
)
})?;
if dsq_states.is_empty() {
anyhow::bail!(
"dsq_states is empty — walk_dsqs reached no DSQs. The \
global DSQ (SCX_DSQ_GLOBAL per-node) must always be \
reachable when *scx_root is non-null. Full JSON: {json}"
);
}
if value.get("scx_sched_state").is_none()
|| value.get("scx_sched_state").is_some_and(|v| v.is_null())
{
anyhow::bail!(
"scx_sched_state is absent or null — read_scx_sched_state \
returned None. *scx_root was unreadable or the BTF offsets \
didn't resolve. Full JSON: {json}"
);
}
if let Some(reason) = value
.get("task_enrichments_unavailable")
.and_then(|r| r.as_str())
{
anyhow::bail!(
"task_enrichments_unavailable={reason:?} — capture_tasks::build \
returned None or the walker yielded zero tasks. Captures \
must always produce data when scx tasks are runnable. Full \
JSON: {json}"
);
}
let task_enrichments = value
.get("task_enrichments")
.and_then(|s| s.as_array())
.ok_or_else(|| {
anyhow::anyhow!(
"dump JSON missing `task_enrichments` array — \
capture_tasks wiring did not populate the field. \
Full JSON: {json}"
)
})?;
if task_enrichments.is_empty() {
anyhow::bail!(
"task_enrichments is empty — runnable_list walker found no \
tasks. With workers_per_cgroup>0 driving load, at least \
one task must be runnable at freeze time. Full JSON: {json}"
);
}
let has_real_task = task_enrichments.iter().any(|t| {
let pid = t.get("pid").and_then(|v| v.as_i64()).unwrap_or(0);
let comm = t.get("comm").and_then(|v| v.as_str()).unwrap_or("");
pid > 0 && !comm.is_empty()
});
if !has_real_task {
anyhow::bail!(
"no task_enrichment entry has pid>0 AND non-empty comm — \
every task_struct read produced pid<=0 or empty comm, \
meaning the slab translate fell back to garbage memory. \
Full task_enrichments: {task_enrichments:?}"
);
}
let per_node_numa = value
.get("per_node_numa")
.and_then(|s| s.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[]);
let per_node_numa_unavailable = value
.get("per_node_numa_unavailable")
.and_then(|r| r.as_str());
if per_node_numa.is_empty() && per_node_numa_unavailable.is_none() {
anyhow::bail!(
"per_node_numa is empty AND per_node_numa_unavailable is \
absent — the dump pipeline broke its own contract that \
one of the two must be populated. Full JSON: {json}"
);
}
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"failure-dump file at {} contains capture-module data: \
rq_scx_states.len()={} (num_cpus={num_cpus}), \
dsq_states.len()={}, scx_sched_state present, \
task_enrichments.len()={}, per_node_numa.len()={} \
(unavailable={:?})",
dump_path.display(),
rq_scx_states.len(),
dsq_states.len(),
task_enrichments.len(),
per_node_numa.len(),
per_node_numa_unavailable,
),
));
Ok(result)
}
#[ktstr::__private::linkme::distributed_slice(ktstr::test_support::KTSTR_TESTS)]
#[linkme(crate = ktstr::__private::linkme)]
static __KTSTR_ENTRY_FAILURE_DUMP_CAPTURES: ktstr::test_support::KtstrTestEntry =
ktstr::test_support::KtstrTestEntry {
name: "failure_dump_renders_capture_modules",
func: scenario_failure_dump_renders_capture_modules,
scheduler: &KTSTR_SCHED_PAYLOAD,
extra_sched_args: &["--stall-after=1"],
watchdog_timeout: std::time::Duration::from_secs(3),
duration: std::time::Duration::from_secs(10),
workers_per_cgroup: 2,
expect_err: true,
..ktstr::test_support::KtstrTestEntry::DEFAULT
};
fn scenario_failure_dump_renders_probe_counters(
ctx: &ktstr::scenario::Ctx,
) -> Result<AssertResult> {
let dump_path = failure_dump_path("failure_dump_renders_probe_counters");
let steps = vec![Step {
setup: vec![CgroupDef::named("cg_0").workers(ctx.workers_per_cgroup)].into(),
ops: vec![],
hold: HoldSpec::FULL,
}];
let mut result = execute_steps(ctx, steps)?;
let json = match std::fs::read_to_string(&dump_path) {
Ok(s) => s,
Err(e) => {
result.passed = false;
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"failure dump file missing at {}: {e} (freeze coordinator did \
not write — either the SCX_EXIT_ERROR_STALL latch did not \
fire or the file write failed silently)",
dump_path.display()
),
));
anyhow::bail!(
"failure dump file missing at {} — freeze coordinator did not \
write the JSON dump",
dump_path.display()
);
}
};
let value: serde_json::Value = serde_json::from_str(&json)
.map_err(|e| anyhow::anyhow!("dump file is not valid JSON: {e}"))?;
let probe_counters = value.get("probe_counters").ok_or_else(|| {
anyhow::anyhow!(
"dump JSON missing `probe_counters` field — \
decode_probe_counters_snapshot returned None. \
Probe `.bss` map absent, BTF lookup failed, or the \
`ktstr_pcpu_counters` array offset didn't resolve. \
Full JSON: {json}"
)
})?;
if probe_counters.is_null() {
anyhow::bail!(
"`probe_counters` is null — decoder ran but produced None; \
same prerequisite-missing failure modes as above. \
Full JSON: {json}"
);
}
let trigger_count = probe_counters
.get("trigger_count")
.and_then(|v| v.as_u64())
.ok_or_else(|| {
anyhow::anyhow!(
"`probe_counters.trigger_count` missing or non-numeric — \
ProbeBssCounters serde shape changed. \
probe_counters: {probe_counters}"
)
})?;
if trigger_count == 0 {
anyhow::bail!(
"`probe_counters.trigger_count == 0` — `tp_btf/sched_ext_exit` \
never fired (or the per-CPU slot didn't increment). The stall \
scenario must produce at least one tracepoint fire. \
probe_counters: {probe_counters}"
);
}
let probe_count = probe_counters
.get("probe_count")
.and_then(|v| v.as_u64())
.ok_or_else(|| {
anyhow::anyhow!(
"`probe_counters.probe_count` missing or non-numeric — \
ProbeBssCounters serde shape changed. \
probe_counters: {probe_counters}"
)
})?;
if probe_count == 0 {
anyhow::bail!(
"`probe_counters.probe_count == 0` — kprobe path never fired \
across the run. Either probe attach failed, ktstr_enabled \
never flipped to true, or the host-side sum walked the wrong \
slot index. probe_counters: {probe_counters}"
);
}
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"failure-dump file at {} contains probe_counters with \
trigger_count={trigger_count}, probe_count={probe_count} \
(per-CPU sum walked across CPUs in `.bss` \
`ktstr_pcpu_counters` array)",
dump_path.display(),
),
));
Ok(result)
}
#[ktstr::__private::linkme::distributed_slice(ktstr::test_support::KTSTR_TESTS)]
#[linkme(crate = ktstr::__private::linkme)]
static __KTSTR_ENTRY_FAILURE_DUMP_PROBE_COUNTERS: ktstr::test_support::KtstrTestEntry =
ktstr::test_support::KtstrTestEntry {
name: "failure_dump_renders_probe_counters",
func: scenario_failure_dump_renders_probe_counters,
scheduler: &KTSTR_SCHED_PAYLOAD,
extra_sched_args: &["--stall-after=1"],
watchdog_timeout: std::time::Duration::from_secs(3),
duration: std::time::Duration::from_secs(10),
workers_per_cgroup: 2,
expect_err: true,
..ktstr::test_support::KtstrTestEntry::DEFAULT
};