use anyhow::Result;
use ktstr::assert::AssertResult;
use ktstr::scenario::ops::{CgroupDef, HoldSpec, Step, execute_steps};
use ktstr::test_support::{Scheduler, SchedulerSpec, sidecar_dir};
const KTSTR_SCHED: Scheduler =
Scheduler::new("ktstr_sched").binary(SchedulerSpec::Discover("scx-ktstr"));
fn failure_dump_path(test_name: &str) -> std::path::PathBuf {
sidecar_dir().join(format!("{test_name}.failure-dump.json"))
}
fn find_task_storage_map(dump: &serde_json::Value) -> Result<&serde_json::Value> {
const BPF_MAP_TYPE_TASK_STORAGE: u64 = 23;
let maps = dump
.get("maps")
.and_then(|m| m.as_array())
.ok_or_else(|| anyhow::anyhow!("dump missing top-level `maps` array"))?;
maps.iter()
.find(|m| {
m.get("map_type")
.and_then(|t| t.as_u64())
.is_some_and(|t| t == BPF_MAP_TYPE_TASK_STORAGE)
})
.ok_or_else(|| {
anyhow::anyhow!(
"dump has no BPF_MAP_TYPE_TASK_STORAGE map (looked across {} maps). \
scx-ktstr declares `scx_task_map` via lib/sdt_task.bpf.c so the \
map MUST appear; absence means the walker filtered it, \
sdt_alloc was disabled, or the scheduler aborted before \
task_storage allocation. Full dump: {dump}",
maps.len()
)
})
}
fn struct_member<'a>(
parent: &'a serde_json::Value,
member_name: &str,
) -> Result<&'a serde_json::Value> {
let kind = parent
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if kind != "struct" {
anyhow::bail!(
"expected a `struct`-kind RenderedValue but got kind={kind:?}; \
parent: {parent}"
);
}
let members = parent
.get("members")
.and_then(|m| m.as_array())
.ok_or_else(|| anyhow::anyhow!("struct has no `members` array: {parent}"))?;
let member = members
.iter()
.find(|m| m.get("name").and_then(|n| n.as_str()) == Some(member_name))
.ok_or_else(|| {
let names: Vec<&str> = members
.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()))
.collect();
anyhow::anyhow!(
"struct member `{member_name}` not found; got names: {names:?}; \
parent: {parent}"
)
})?;
member
.get("value")
.ok_or_else(|| anyhow::anyhow!("member `{member_name}` has no `value` field: {member}"))
}
fn scenario_cast_analysis_chases_kernel_kptr(ctx: &ktstr::scenario::Ctx) -> Result<AssertResult> {
let dump_path = failure_dump_path("cast_analysis_chases_kernel_kptr");
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 SCX_EXIT_ERROR_STALL never latched, the \
dump path failed silently, or the run was torn down before \
the dump completed)",
dump_path.display()
),
));
anyhow::bail!("failure dump file missing at {}", dump_path.display());
}
};
let dump: serde_json::Value =
serde_json::from_str(&json).map_err(|e| anyhow::anyhow!("dump JSON parse: {e}"))?;
let task_storage = find_task_storage_map(&dump)?;
let entries = task_storage
.get("entries")
.and_then(|e| e.as_array())
.ok_or_else(|| {
anyhow::anyhow!(
"scx_task_map has no `entries` array — TASK_STORAGE walker did \
not populate the map. With workers_per_cgroup>0 driving load, \
at least one task must have a per-task ktstr_arena_ctx. \
task_storage: {task_storage}"
)
})?;
if entries.is_empty() {
anyhow::bail!(
"scx_task_map.entries is empty — `bpf_task_storage_get` was never \
called (no task ran ktstr_init_task before freeze) or the local-\
storage walker found no live owners. task_storage: {task_storage}"
);
}
let payloads: Vec<&serde_json::Value> = entries
.iter()
.filter_map(|e| e.get("payload"))
.filter(|p| !p.is_null())
.collect();
if payloads.is_empty() {
anyhow::bail!(
"no scx_task_map entry has a non-null `payload` — \
chase_sdt_data_payload returned None for every entry. The \
allocator metadata may be unresolved (no target_type_id \
discovery), the per-task `sdt_data __arena *` field offset \
was not found, or every captured arena pointer fell outside \
the kern_vm window. entry count: {}, dump: {dump}",
entries.len()
);
}
let payload = payloads
.iter()
.find(|p| {
p.get("kind").and_then(|k| k.as_str()) == Some("struct")
&& p.get("type_name").and_then(|n| n.as_str()) == Some("ktstr_arena_ctx")
})
.copied()
.ok_or_else(|| {
let kinds: Vec<String> = payloads
.iter()
.map(|p| {
let k = p
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
let n = p
.get("type_name")
.and_then(|n| n.as_str())
.unwrap_or("<no-name>");
format!("{k}/{n}")
})
.collect();
anyhow::anyhow!(
"no payload rendered as Struct(type_name=\"ktstr_arena_ctx\"); \
saw kinds/type_names: {kinds:?}; first payload: {}",
payloads[0]
)
})?;
let magic = struct_member(payload, "magic")?;
let magic_kind = magic
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if magic_kind != "uint" {
anyhow::bail!(
"NEGATIVE ASSERTION FAILED: `magic` must render as a plain Uint \
(BPF code only stores the immediate sentinel into it, never a \
typed pointer; the analyzer must not flag this field), but got \
kind={magic_kind:?}. cast intercept fired falsely on offset 0. \
magic: {magic}; full payload: {payload}"
);
}
const KTSTR_ARENA_MAGIC: u64 = 0xDEADBEEFCAFEBABE;
let magic_value = magic
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("`magic` value not a u64: {magic}"))?;
if magic_value != KTSTR_ARENA_MAGIC {
anyhow::bail!(
"`magic` value mismatch: got 0x{magic_value:016x}, expected \
0x{KTSTR_ARENA_MAGIC:016x}; magic: {magic}"
);
}
let counter = struct_member(payload, "counter")?;
let counter_kind = counter
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if counter_kind != "uint" {
anyhow::bail!(
"NEGATIVE ASSERTION FAILED: `counter` (u32) must render as Uint; \
the cast intercept's size==8 gate must reject sub-u64 fields. \
Got kind={counter_kind:?}. counter: {counter}; payload: {payload}"
);
}
let counter_bits = counter.get("bits").and_then(|b| b.as_u64()).unwrap_or(0);
if counter_bits != 32 {
anyhow::bail!(
"`counter` bits mismatch: got {counter_bits}, expected 32. counter: {counter}"
);
}
let counter_value = counter
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("`counter` value not numeric: {counter}"))?;
const KTSTR_TASK_COUNTER: u64 = 42;
if counter_value != KTSTR_TASK_COUNTER {
anyhow::bail!(
"`counter` value mismatch: got {counter_value}, expected \
{KTSTR_TASK_COUNTER}; the BPF code's `taskc->counter = \
KTSTR_TASK_COUNTER` write did not land or the captured page is \
stale. counter: {counter}"
);
}
let task_kptr = struct_member(payload, "task_kptr")?;
let task_kptr_kind = task_kptr
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if task_kptr_kind != "ptr" {
anyhow::bail!(
"PRIMARY POSITIVE ASSERTION FAILED: `task_kptr` (u64 holding a \
kernel task_struct *) must render as a Ptr after cast analysis \
rewrites it. Got kind={task_kptr_kind:?}. \
Failure modes: \
(a) cast_analysis_load did not produce a CastMap entry for \
(ktstr_arena_ctx, off=16) — the analyzer's STX detection did \
not fire on `ktstr_stash_task_kptr`'s body. \
(b) the freeze coordinator did not thread the CastMap into \
the dump's MemReader. \
(c) `MemReader::cast_lookup` did not return Some for the \
(parent, offset) the renderer asked. \
(d) `render_cast_pointer` bailed before emitting Ptr. \
task_kptr: {task_kptr}; full payload: {payload}"
);
}
let task_kptr_value = task_kptr
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("`task_kptr` Ptr has no `value` field: {task_kptr}"))?;
if task_kptr_value == 0 {
anyhow::bail!(
"`task_kptr` value is 0x0 — `ktstr_stash_task_kptr` never wrote \
a live task_struct pointer for this entry, or the captured \
arena page predates the write. The render correctly identified \
the field as a Ptr (cast analysis pipeline OK), but the source \
data is zero. task_kptr: {task_kptr}"
);
}
if let Some(reason) = task_kptr
.get("deref_skipped_reason")
.and_then(|r| r.as_str())
{
anyhow::bail!(
"`task_kptr` chase was attempted but did not complete: \
deref_skipped_reason={reason:?}. The cast analysis flagged the \
field correctly, but the renderer could not read the target \
struct. Likely causes: read_kva failed (target page unmapped), \
plausibility gate rejected the first qword as a freelist \
pointer, or the BTF size of task_struct exceeded \
POINTER_CHASE_CAP. task_kptr value: 0x{task_kptr_value:x}"
);
}
let deref = task_kptr.get("deref").ok_or_else(|| {
anyhow::anyhow!(
"`task_kptr` Ptr has no `deref` AND no `deref_skipped_reason` — \
the chase was either not attempted (depth cap, cycle, null \
value), or the JSON shape changed. task_kptr value: \
0x{task_kptr_value:x}; task_kptr: {task_kptr}"
)
})?;
let (deref_struct, was_truncated) = match deref.get("kind").and_then(|k| k.as_str()) {
Some("struct") => (deref, false),
Some("truncated") => (
deref
.get("partial")
.ok_or_else(|| anyhow::anyhow!("Truncated has no `partial`: {deref}"))?,
true,
),
Some(other) => {
anyhow::bail!(
"`task_kptr` deref must be Struct or Truncated{{partial: Struct}}, \
got kind={other:?}; deref: {deref}"
);
}
None => {
anyhow::bail!("`task_kptr` deref has no `kind` field: {deref}");
}
};
let deref_kind = deref_struct
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if deref_kind != "struct" {
anyhow::bail!(
"task_kptr deref's inner kind must be struct (post-truncation), \
got {deref_kind:?}; deref_struct: {deref_struct}"
);
}
let deref_type_name = deref_struct
.get("type_name")
.and_then(|n| n.as_str())
.ok_or_else(|| {
anyhow::anyhow!(
"deref struct has no type_name (anonymous struct?); \
deref_struct: {deref_struct}"
)
})?;
if deref_type_name != "task_struct" {
anyhow::bail!(
"deref type_name mismatch: got {deref_type_name:?}, expected \
\"task_struct\"; the cast analyzer flagged the wrong target. \
deref_struct: {deref_struct}"
);
}
let members = deref_struct
.get("members")
.and_then(|m| m.as_array())
.ok_or_else(|| anyhow::anyhow!("deref task_struct has no `members`: {deref_struct}"))?;
let names: std::collections::HashSet<&str> = members
.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()))
.collect();
let required: &[&str] = &["pid", "comm"];
for r in required {
if !names.contains(r) {
anyhow::bail!(
"task_struct deref missing required member `{r}` — the \
cast chase produced a struct render but the BTF Datasec \
walk did not surface real task_struct fields, or the \
read returned bytes shorter than the field offset. \
Got members: {names:?}; deref_struct: {deref_struct}"
);
}
}
let pid = struct_member(deref_struct, "pid")?;
let pid_kind = pid
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if pid_kind != "int" && pid_kind != "uint" {
anyhow::bail!("task_struct.pid must render as int/uint, got kind={pid_kind:?}: {pid}");
}
let pid_value = pid
.get("value")
.ok_or_else(|| anyhow::anyhow!("pid has no `value`: {pid}"))?;
let pid_int = pid_value
.as_i64()
.or_else(|| pid_value.as_u64().map(|u| u as i64));
if pid_int.is_none() {
anyhow::bail!("pid value not numeric: {pid}");
}
let comm = struct_member(deref_struct, "comm")?;
let comm_kind = comm
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if comm_kind == "unsupported" {
anyhow::bail!(
"task_struct.comm rendered as Unsupported — the BTF Datasec walk \
could not handle the field type. comm: {comm}"
);
}
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"cast analysis pipeline E2E: dump at {} carries scx_task_map \
with {} entries, {} non-null payloads. Located ktstr_arena_ctx \
render with cast-chased task_kptr=0x{task_kptr_value:x} → \
{}{deref_type_name}{{pid={pid_int:?}, comm.kind={comm_kind:?}, \
member count={}}}; magic=0x{magic_value:016x} (Uint, not chased), \
counter={counter_value} (Uint, not chased)",
dump_path.display(),
entries.len(),
payloads.len(),
if was_truncated { "truncated " } else { "" },
members.len(),
),
));
Ok(result)
}
#[ktstr::__private::linkme::distributed_slice(ktstr::test_support::KTSTR_TESTS)]
#[linkme(crate = ktstr::__private::linkme)]
static __KTSTR_ENTRY_CAST_ANALYSIS_KERNEL_KPTR: ktstr::test_support::KtstrTestEntry =
ktstr::test_support::KtstrTestEntry {
name: "cast_analysis_chases_kernel_kptr",
func: scenario_cast_analysis_chases_kernel_kptr,
scheduler: &KTSTR_SCHED,
extra_sched_args: &["--stall-after=1"],
watchdog_timeout: std::time::Duration::from_secs(3),
duration: std::time::Duration::from_secs(10),
expect_err: true,
..ktstr::test_support::KtstrTestEntry::DEFAULT
};
fn find_scheduler_bss_map(dump: &serde_json::Value) -> Result<&serde_json::Value> {
let maps = dump
.get("maps")
.and_then(|m| m.as_array())
.ok_or_else(|| anyhow::anyhow!("dump missing top-level `maps` array"))?;
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 (looked across {} maps); the \
scx-ktstr BPF program must surface a `.bss` global section. \
Full dump: {dump}",
maps.len()
)
})
}
fn scenario_cast_analysis_chases_bss_to_arena(ctx: &ktstr::scenario::Ctx) -> Result<AssertResult> {
let dump_path = failure_dump_path("cast_analysis_chases_bss_to_arena");
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 SCX_EXIT_ERROR_STALL never latched, the \
dump path failed silently, or the run was torn down before \
the dump completed)",
dump_path.display()
),
));
anyhow::bail!("failure dump file missing at {}", dump_path.display());
}
};
let dump: serde_json::Value =
serde_json::from_str(&json).map_err(|e| anyhow::anyhow!("dump JSON parse: {e}"))?;
let bss_map = find_scheduler_bss_map(&dump)?;
let bss_value = bss_map
.get("value")
.ok_or_else(|| anyhow::anyhow!(".bss map has no `value` field; bss_map: {bss_map}"))?;
let bss_kind = bss_value
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if bss_kind != "struct" {
anyhow::bail!(
".bss value must render as a Struct (the renderer maps Datasec to Struct \
with type_name set to the section name), got kind={bss_kind:?}; \
bss_value: {bss_value}"
);
}
let holder_outer = struct_member(bss_value, "ktstr_bss_arena_holder").map_err(|e| {
anyhow::anyhow!(
"{e}\n\nNo `ktstr_bss_arena_holder` Var in .bss -- either the BSS test \
fixture in scx-ktstr/src/bpf/main.bpf.c was renamed, the BTF Datasec \
walker filtered it, or the global was elided by the BPF compiler \
because no in-program writer kept it live. bss_value: {bss_value}"
)
})?;
let holder_kind = holder_outer
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if holder_kind != "struct" {
anyhow::bail!(
"`ktstr_bss_arena_holder` must render as a Struct (it's declared as \
`struct ktstr_bss_arena_holder` in BPF source), got kind={holder_kind:?}: \
{holder_outer}"
);
}
if let Some(name) = holder_outer.get("type_name").and_then(|n| n.as_str())
&& name != "ktstr_bss_arena_holder"
{
anyhow::bail!(
"ktstr_bss_arena_holder rendered with unexpected type_name={name:?}; \
holder: {holder_outer}"
);
}
let arena_target = struct_member(holder_outer, "arena_target")?;
let arena_kind = arena_target
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if arena_kind != "ptr" {
anyhow::bail!(
"PRIMARY POSITIVE ASSERTION FAILED: `arena_target` (BSS u64 holding \
an arena VA) must render as a Ptr after cast analysis rewrites it. \
Got kind={arena_kind:?}. \
Failure modes: \
(a) cast_analysis_load did not produce a CastMap entry for \
(ktstr_bss_arena_holder, off=0) -- the analyzer's LDX-side detection \
did not fire on `ktstr_train_bss_to_arena`'s body (FuncProto seeding \
missing, addr_space_cast not recognized, or the access pattern \
intersected non-uniquely against the program BTF and dropped). \
(b) the freeze coordinator did not thread the CastMap into \
the dump's MemReader. \
(c) `MemReader::cast_lookup` did not return Some for \
(ktstr_bss_arena_holder, 0). \
(d) `render_cast_pointer` bailed before emitting Ptr. \
arena_target: {arena_target}; full holder: {holder_outer}"
);
}
let arena_value = arena_target
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("`arena_target` Ptr has no `value`: {arena_target}"))?;
if arena_value == 0 {
anyhow::bail!(
"`arena_target` value is 0x0 -- `ktstr_init_task`'s write to \
`ktstr_bss_arena_holder.arena_target` never landed, or the captured \
.bss page predates every init_task invocation. The render correctly \
flagged the field (cast pipeline OK), but the source data is zero. \
arena_target: {arena_target}"
);
}
if let Some(reason) = arena_target
.get("deref_skipped_reason")
.and_then(|r| r.as_str())
{
anyhow::bail!(
"`arena_target` chase was attempted but did not complete: \
deref_skipped_reason={reason:?}. The cast analysis flagged the \
field correctly, but the renderer could not read the target \
struct. Likely causes: read_arena returned None (page outside \
captured snapshot), `is_arena_addr` rejected the value (the BSS \
write put a non-arena address into the field), or the BTF size \
of ktstr_arena_ctx was unresolvable. arena_target value: \
0x{arena_value:x}"
);
}
let deref = arena_target.get("deref").ok_or_else(|| {
anyhow::anyhow!(
"`arena_target` Ptr has no `deref` AND no `deref_skipped_reason` -- \
the chase was either not attempted (depth cap, cycle, null value), \
or the JSON shape changed. arena_target value: 0x{arena_value:x}; \
arena_target: {arena_target}"
)
})?;
let (deref_struct, was_truncated) = match deref.get("kind").and_then(|k| k.as_str()) {
Some("struct") => (deref, false),
Some("truncated") => (
deref
.get("partial")
.ok_or_else(|| anyhow::anyhow!("Truncated has no `partial`: {deref}"))?,
true,
),
Some(other) => {
anyhow::bail!(
"`arena_target` deref must be Struct or Truncated{{partial: Struct}}, \
got kind={other:?}; deref: {deref}"
);
}
None => {
anyhow::bail!("`arena_target` deref has no `kind` field: {deref}");
}
};
let deref_kind_inner = deref_struct
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if deref_kind_inner != "struct" {
anyhow::bail!(
"arena_target deref's inner kind must be struct (post-truncation), \
got {deref_kind_inner:?}; deref_struct: {deref_struct}"
);
}
let deref_type_name = deref_struct
.get("type_name")
.and_then(|n| n.as_str())
.ok_or_else(|| {
anyhow::anyhow!(
"deref struct has no type_name (anonymous struct?); \
deref_struct: {deref_struct}"
)
})?;
if deref_type_name != "ktstr_arena_ctx" {
anyhow::bail!(
"deref type_name mismatch: got {deref_type_name:?}, expected \
\"ktstr_arena_ctx\"; the cast analyzer flagged the wrong target. \
This is the correctness bar -- a wrong target struct means the \
access-pattern intersection picked a same-shape decoy out of the \
program BTF. deref_struct: {deref_struct}"
);
}
const KTSTR_ARENA_MAGIC: u64 = 0xDEADBEEFCAFEBABE;
const KTSTR_TASK_COUNTER: u64 = 42;
let magic = struct_member(deref_struct, "magic")?;
let magic_kind = magic
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if magic_kind != "uint" {
anyhow::bail!(
"chased `ktstr_arena_ctx.magic` must render as Uint (the analyzer \
must NOT recurse into magic -- it's only loaded as a sentinel, \
never as a pointer base), got kind={magic_kind:?}: {magic}"
);
}
let magic_value = magic
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("magic value not a u64: {magic}"))?;
if magic_value != KTSTR_ARENA_MAGIC {
anyhow::bail!(
"chased `ktstr_arena_ctx.magic` mismatch: got 0x{magic_value:016x}, \
expected 0x{KTSTR_ARENA_MAGIC:016x}. The cast chase completed but \
landed on bytes whose first qword is not the alloc-time sentinel. \
Either the captured arena page is stale, the user_addr in \
`arena_target` does not point at a current allocation, or a \
same-shape decoy struct in the program BTF won the access-pattern \
intersection. magic: {magic}"
);
}
let counter = struct_member(deref_struct, "counter")?;
let counter_value = counter
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("counter value not numeric: {counter}"))?;
if counter_value != KTSTR_TASK_COUNTER {
anyhow::bail!(
"chased `ktstr_arena_ctx.counter` mismatch: got {counter_value}, \
expected {KTSTR_TASK_COUNTER}. The cast chase landed on the right \
struct shape but the captured bytes do not carry the alloc-time \
value, indicating a stale arena page. counter: {counter}"
);
}
let plain_counter = struct_member(holder_outer, "bss_plain_counter")?;
let plain_kind = plain_counter
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if plain_kind != "uint" {
anyhow::bail!(
"NEGATIVE ASSERTION FAILED: `bss_plain_counter` (a u64 counter \
never used as a pointer base) must render as a plain Uint. The \
cast intercept fired falsely on offset 8 of \
ktstr_bss_arena_holder. Got kind={plain_kind:?}. \
plain_counter: {plain_counter}"
);
}
let plain_value = plain_counter
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("plain counter value not numeric: {plain_counter}"))?;
if plain_value == 0 {
anyhow::bail!(
"`bss_plain_counter` is 0 -- `ktstr_init_task` never executed the \
increment, which means the test fixture in main.bpf.c did not \
run before the freeze. plain_counter: {plain_counter}"
);
}
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"BSS->arena cast pipeline E2E: dump at {} carries `.bss` map with \
ktstr_bss_arena_holder render where arena_target=0x{arena_value:x} -> \
{}{deref_type_name}{{magic=0x{magic_value:016x}, counter={counter_value}}}; \
bss_plain_counter={plain_value} (Uint, not chased -- negative control)",
dump_path.display(),
if was_truncated { "truncated " } else { "" },
),
));
Ok(result)
}
#[ktstr::__private::linkme::distributed_slice(ktstr::test_support::KTSTR_TESTS)]
#[linkme(crate = ktstr::__private::linkme)]
static __KTSTR_ENTRY_CAST_ANALYSIS_BSS_TO_ARENA: ktstr::test_support::KtstrTestEntry =
ktstr::test_support::KtstrTestEntry {
name: "cast_analysis_chases_bss_to_arena",
func: scenario_cast_analysis_chases_bss_to_arena,
scheduler: &KTSTR_SCHED,
extra_sched_args: &["--stall-after=1"],
watchdog_timeout: std::time::Duration::from_secs(3),
duration: std::time::Duration::from_secs(10),
expect_err: true,
..ktstr::test_support::KtstrTestEntry::DEFAULT
};
fn scenario_cast_analysis_sdt_alloc_bridge_resolves_fwd(
ctx: &ktstr::scenario::Ctx,
) -> Result<AssertResult> {
let dump_path = failure_dump_path("cast_analysis_sdt_alloc_bridge_resolves_fwd");
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 SCX_EXIT_ERROR_STALL never latched, the \
dump path failed silently, or the run was torn down before \
the dump completed)",
dump_path.display()
),
));
anyhow::bail!("failure dump file missing at {}", dump_path.display());
}
};
let dump: serde_json::Value =
serde_json::from_str(&json).map_err(|e| anyhow::anyhow!("dump JSON parse: {e}"))?;
let task_storage = find_task_storage_map(&dump)?;
let entries = task_storage
.get("entries")
.and_then(|e| e.as_array())
.ok_or_else(|| {
anyhow::anyhow!("scx_task_map has no `entries` array; task_storage: {task_storage}")
})?;
if entries.is_empty() {
anyhow::bail!(
"scx_task_map.entries is empty -- ktstr_init_task never registered \
a per-task arena context for any task, so neither the surface-struct \
chase nor the payload chase has anything to operate on. \
task_storage: {task_storage}"
);
}
let mut data_members_seen: usize = 0;
let mut any_bridge_fired: bool = false;
let mut any_data_with_chase: bool = false;
for (idx, entry) in entries.iter().enumerate() {
let Some(value) = entry.get("value") else {
continue;
};
if value.is_null() {
continue;
}
let kind = value
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if kind != "struct" {
continue;
}
let Some(members) = value.get("members").and_then(|m| m.as_array()) else {
continue;
};
let Some(data) = members
.iter()
.find(|m| m.get("name").and_then(|n| n.as_str()) == Some("data"))
else {
continue;
};
data_members_seen += 1;
let data_value = data
.get("value")
.ok_or_else(|| anyhow::anyhow!("`data` member has no `value`: {data}"))?;
let data_kind = data_value
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if data_kind != "ptr" {
anyhow::bail!(
"entry[{idx}].value.data must render as Ptr (BTF Type::Ptr arm \
for `struct sdt_data __arena *`); got kind={data_kind:?}. \
data: {data}; entry: {entry}"
);
}
let data_value_u64 = data_value
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("`data` Ptr has no `value`: {data_value}"))?;
if data_value_u64 == 0 {
continue;
}
any_data_with_chase = true;
if let Some(reason) = data_value
.get("deref_skipped_reason")
.and_then(|r| r.as_str())
&& reason.contains("forward declaration")
{
anyhow::bail!(
"REGRESSION: entry[{idx}].value.data surfaced a 'forward \
declaration' skip reason -- the sdt_alloc bridge did NOT \
fire. The chased pointer 0x{data_value_u64:x} fell outside \
every known allocator slot's payload-start index, the dump \
pre-pass failed to populate the index, or \
[`MemReader::resolve_arena_type`] returned None. Without \
the bridge the per-task struct content is unrenderable on \
the surface-struct path. Skip reason: {reason:?}; \
data: {data_value}"
);
}
if let Some(ann) = data_value.get("cast_annotation").and_then(|a| a.as_str()) {
if ann == "sdt_alloc" {
any_bridge_fired = true;
} else {
anyhow::bail!(
"entry[{idx}].value.data carried unexpected \
cast_annotation={ann:?}; the BTF Type::Ptr arm only \
emits 'sdt_alloc' (no cast→ prefix) when the bridge \
fires on a Fwd target. data: {data_value}"
);
}
}
}
if data_members_seen == 0 {
anyhow::bail!(
"no scx_task_map entry exposed a `data` member in its rendered \
value -- either every value-side render dropped to hex, the \
BTF was missing, or the value type did not include the \
`struct sdt_data __arena *` field. Without surfacing `data`, \
the bridge has no chase to gate. Total entries: {}",
entries.len()
);
}
if !any_data_with_chase {
anyhow::bail!(
"every scx_task_map entry's `value.data` was 0x0 -- ktstr_init_task \
never wrote a non-null `mval->data`, or every captured map slot \
was snapshotted between the create-zeroed-entry phase and the \
populate-fields phase of `scx_task_alloc`. The bridge has \
no chase to validate. data_members_seen={data_members_seen}, \
entries={}",
entries.len()
);
}
if !any_bridge_fired {
anyhow::bail!(
"REGRESSION: scx_task_map entries carried non-null `value.data` \
pointers with no `deref_skipped_reason`, but NONE surfaced \
cast_annotation='sdt_alloc'. That means the chase succeeded \
via the BTF-only path -- which would only happen if the \
program BTF carried a complete `struct sdt_data` body, \
contradicting scx-ktstr's compiled BTF where `sdt_data` is \
emitted as a Fwd. Either the bridge ran but failed to set \
the annotation, or the test fixture's BTF shape changed in \
a way that bypassed the Fwd path. \
data_members_seen={data_members_seen}"
);
}
const KTSTR_ARENA_MAGIC: u64 = 0xDEADBEEFCAFEBABE;
const KTSTR_TASK_COUNTER: u64 = 42;
let mut payloads_inspected: usize = 0;
for (idx, entry) in entries.iter().enumerate() {
let Some(payload) = entry.get("payload") else {
continue;
};
if payload.is_null() {
continue;
}
let kind = payload
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if kind != "struct" {
continue;
}
let type_name = payload
.get("type_name")
.and_then(|n| n.as_str())
.unwrap_or("<no-type-name>");
if type_name != "ktstr_arena_ctx" {
continue;
}
payloads_inspected += 1;
let magic = struct_member(payload, "magic")?;
let magic_value = magic
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("magic value not a u64: {magic}"))?;
if magic_value != KTSTR_ARENA_MAGIC {
anyhow::bail!(
"entry[{idx}].payload.magic mismatch: got 0x{magic_value:016x}, \
expected 0x{KTSTR_ARENA_MAGIC:016x}. The chase landed on a \
ktstr_arena_ctx-shaped struct but the bytes are not the \
alloc-time sentinel -- either the upstream allocator \
metadata pointed at a stale slot, or the .data chase / \
payload chase landed on different allocator state. \
magic: {magic}"
);
}
let counter = struct_member(payload, "counter")?;
let counter_value = counter
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("counter not numeric: {counter}"))?;
if counter_value != KTSTR_TASK_COUNTER {
anyhow::bail!(
"entry[{idx}].payload.counter mismatch: got {counter_value}, \
expected {KTSTR_TASK_COUNTER}. counter: {counter}"
);
}
}
if payloads_inspected == 0 {
anyhow::bail!(
"no scx_task_map entry surfaced a Struct(type_name=\"ktstr_arena_ctx\") \
payload -- `chase_sdt_data_payload` returned None for every \
entry, sdt_alloc_meta.target_type_id was unresolved, or \
every captured arena pointer fell outside the kern_vm window. \
Without a rendered payload the bridge index has no payload \
type id to publish, so the surface-struct bridge would also \
fail. entries: {}",
entries.len()
);
}
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"sdt_alloc bridge E2E: dump at {} carries scx_task_map with \
{} entries; data_members_seen={data_members_seen}, \
any_bridge_fired={any_bridge_fired}, payloads_inspected={payloads_inspected}. \
No entry's `data` showed a 'forward declaration' skip reason; \
every chased ktstr_arena_ctx payload carries the alloc-time \
sentinel and counter.",
dump_path.display(),
entries.len(),
),
));
Ok(result)
}
fn scenario_cast_analysis_cross_subprog_arena_chase(
ctx: &ktstr::scenario::Ctx,
) -> Result<AssertResult> {
let dump_path = failure_dump_path("cast_analysis_cross_subprog_arena_chase");
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 SCX_EXIT_ERROR_STALL never latched, the \
dump path failed silently, or the run was torn down before \
the dump completed)",
dump_path.display()
),
));
anyhow::bail!("failure dump file missing at {}", dump_path.display());
}
};
let dump: serde_json::Value =
serde_json::from_str(&json).map_err(|e| anyhow::anyhow!("dump JSON parse: {e}"))?;
let task_storage = find_task_storage_map(&dump)?;
let entries = task_storage
.get("entries")
.and_then(|e| e.as_array())
.ok_or_else(|| {
anyhow::anyhow!("scx_task_map has no `entries` array; task_storage: {task_storage}")
})?;
if entries.is_empty() {
anyhow::bail!(
"scx_task_map.entries is empty -- no per-task arena context was \
allocated before freeze. task_storage: {task_storage}"
);
}
let payloads: Vec<&serde_json::Value> = entries
.iter()
.filter_map(|e| e.get("payload"))
.filter(|p| !p.is_null())
.filter(|p| {
p.get("kind").and_then(|k| k.as_str()) == Some("struct")
&& p.get("type_name").and_then(|n| n.as_str()) == Some("ktstr_arena_ctx")
})
.collect();
if payloads.is_empty() {
anyhow::bail!(
"no scx_task_map entry has a Struct(type_name=\"ktstr_arena_ctx\") \
payload -- chase_sdt_data_payload did not resolve any per-task \
arena context. entries: {}",
entries.len()
);
}
let mut any_arena_chase = false;
for payload in &payloads {
let stashed = match struct_member(payload, "stashed_arena_ptr") {
Ok(v) => v,
Err(_) => continue,
};
let kind = stashed
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("<no-kind>");
if kind == "ptr" {
any_arena_chase = true;
if let Some(ann) = stashed.get("cast_annotation").and_then(|a| a.as_str())
&& !ann.contains("arena")
{
anyhow::bail!(
"stashed_arena_ptr rendered as Ptr but cast_annotation \
does not contain 'arena': got {ann:?}. The cast \
analyzer tagged the field but with the wrong domain. \
stashed: {stashed}"
);
}
break;
}
if kind == "uint" {
let value = stashed.get("value").and_then(|v| v.as_u64()).unwrap_or(0);
if value != 0 {
anyhow::bail!(
"FIXPOINT REGRESSION: stashed_arena_ptr is a non-zero u64 \
(0x{value:x}) that rendered as plain Uint instead of Ptr. \
The cross-subprog arena typing did NOT propagate through \
the fixpoint -- the publish helper's STX into the hash \
map value's cached_ptr was not carried to the chase \
helper's LDX site across passes. stashed: {stashed}; \
payload: {payload}"
);
}
}
}
if !any_arena_chase {
anyhow::bail!(
"no ktstr_arena_ctx payload rendered stashed_arena_ptr as Ptr -- \
the cross-subprog fixpoint did not produce a cast finding for \
(ktstr_arena_ctx, 24). Either the publish helper's allocator \
return was not tagged as ArenaU64FromAlloc, or the chase \
helper's LDX through the hash map value did not inherit the \
tag, or the final STX into the per-task field was not recorded. \
Checked {} payloads.",
payloads.len()
);
}
result.details.push(ktstr::assert::AssertDetail::new(
ktstr::assert::DetailKind::Other,
format!(
"cross-subprog arena chase E2E: dump at {} carries scx_task_map \
with {} entries, {} ktstr_arena_ctx payloads. Located \
stashed_arena_ptr rendered as Ptr with arena annotation -- \
fixpoint propagation across publish→map→chase subprog boundary \
is working.",
dump_path.display(),
entries.len(),
payloads.len(),
),
));
Ok(result)
}
#[ktstr::__private::linkme::distributed_slice(ktstr::test_support::KTSTR_TESTS)]
#[linkme(crate = ktstr::__private::linkme)]
static __KTSTR_ENTRY_CAST_ANALYSIS_CROSS_SUBPROG: ktstr::test_support::KtstrTestEntry =
ktstr::test_support::KtstrTestEntry {
name: "cast_analysis_cross_subprog_arena_chase",
func: scenario_cast_analysis_cross_subprog_arena_chase,
scheduler: &KTSTR_SCHED,
extra_sched_args: &["--stall-after=1"],
watchdog_timeout: std::time::Duration::from_secs(3),
duration: std::time::Duration::from_secs(10),
expect_err: true,
..ktstr::test_support::KtstrTestEntry::DEFAULT
};
#[ktstr::__private::linkme::distributed_slice(ktstr::test_support::KTSTR_TESTS)]
#[linkme(crate = ktstr::__private::linkme)]
static __KTSTR_ENTRY_CAST_ANALYSIS_SDT_ALLOC_BRIDGE: ktstr::test_support::KtstrTestEntry =
ktstr::test_support::KtstrTestEntry {
name: "cast_analysis_sdt_alloc_bridge_resolves_fwd",
func: scenario_cast_analysis_sdt_alloc_bridge_resolves_fwd,
scheduler: &KTSTR_SCHED,
extra_sched_args: &["--stall-after=1"],
watchdog_timeout: std::time::Duration::from_secs(3),
duration: std::time::Duration::from_secs(10),
expect_err: true,
..ktstr::test_support::KtstrTestEntry::DEFAULT
};