feat(kernel): KRNBUG-AUDIT-002 — multi-frame guest stack capture at handle creation

Adds `walk_guest_back_chain` (PPC EABI back-chain walker) and a
`record_create_with_stack` audit hook gated on `--trace-handles-focus`.
NtCreateEvent / NtCreateSemaphore / NtCreateTimer / XamTaskSchedule now
route through the new helper so focused handles capture up to 6 stack
frames at allocation time. Diagnostic-only, read-only memory access:
unfocused handles pay one HashSet lookup, focused ones pay six
back-chain dereferences. Lockstep determinism preserved.

End-to-end finding: handles 0x1004 (8-instance pool via static ctor at
0x8280F810), 0x100c (singleton built inside main()), 0x15e0 (singleton
in distinct cluster) are silph-framework dispatcher objects whose
producer code is unreached at -n 500M. The producer hunt now has class
ownership; vtable/RTTI readout is the next step.

Tests: 576 → 581 green. `--stable-digest -n 100M` instructions=100000002
unchanged. Master HEAD prior: 9d45efe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-03 20:41:06 +02:00
parent 9d45efe5d5
commit 2a9fd1fc86
6 changed files with 403 additions and 7 deletions

View File

@@ -3207,6 +3207,15 @@ fn dump_thread_diagnostic(kernel: &xenia_kernel::KernelState, quiet: bool) {
" created cycle={} tid={} lr={:#010x} src={}",
t.created.cycle, t.created.tid, t.created.lr, t.created.source,
);
if !t.created_stack.is_empty() {
println!(" created stack ({} frames):", t.created_stack.len());
for (i, (fp, lr)) in t.created_stack.iter().enumerate() {
println!(
" [{:>2}] fp={:#010x} lr={:#010x}",
i, fp, lr,
);
}
}
}
// Producer-class classification. Source strings are stable
// labels passed to `record_signal` at the export sites.

View File

@@ -51,6 +51,14 @@ pub struct HandleAuditTrail {
pub kind: &'static str,
/// When/who/where the handle was minted.
pub created: HandleAuditEntry,
/// KRNBUG-AUDIT-002 producer-trace. Captured frames at allocation
/// time, only populated when the handle is in `HandleAudit::focus`
/// AND the create site routed through the `_with_stack` variant.
/// Frame layout: `(frame_pointer, saved_lr_for_caller_of_that_frame)`.
/// Index 0 is the live frame: `(ctx.gpr[1], ctx.lr)`. Index 1+ comes
/// from walking the PPC back-chain. An empty vec means either the
/// handle wasn't in focus or the create site didn't capture a stack.
pub created_stack: Vec<(u32, u32)>,
/// Bounded ring of signal events.
pub signals: VecDeque<HandleAuditEntry>,
/// Bounded ring of wait-entry events (one per `Wait*` call).
@@ -64,6 +72,7 @@ impl HandleAuditTrail {
Self {
kind,
created,
created_stack: Vec::new(),
signals: VecDeque::with_capacity(AUDIT_RING_CAPACITY),
waits: VecDeque::with_capacity(AUDIT_RING_CAPACITY),
wakes: VecDeque::with_capacity(AUDIT_RING_CAPACITY),
@@ -121,6 +130,26 @@ impl HandleAudit {
.insert(handle, HandleAuditTrail::new(kind, entry));
}
/// Same as `record_create`, but additionally stores a captured guest
/// stack trace on the trail (`created_stack`). Intended for handles
/// in `focus` so the dump can name the actual subsystem caller of the
/// kernel API rather than just the immediate wrapper return.
#[inline]
pub fn record_create_with_stack(
&mut self,
handle: u32,
kind: &'static str,
entry: HandleAuditEntry,
stack: Vec<(u32, u32)>,
) {
if !self.enabled {
return;
}
let mut trail = HandleAuditTrail::new(kind, entry);
trail.created_stack = stack;
self.trails.insert(handle, trail);
}
#[inline]
pub fn record_signal(&mut self, handle: u32, entry: HandleAuditEntry) {
if !self.enabled {
@@ -268,4 +297,34 @@ mod tests {
let a = HandleAudit::default();
assert!(a.counts(0x10FC).is_none());
}
#[test]
fn create_with_stack_stores_frames() {
let mut a = HandleAudit { enabled: true, ..HandleAudit::default() };
let frames = vec![
(0x7000_0100, 0x824a_9f6c),
(0x7000_0200, 0x824a_b020),
(0x7000_0300, 0x82bb_aa00),
];
a.record_create_with_stack(
0x1004,
"Event/Manual",
entry(0, "NtCreateEvent"),
frames.clone(),
);
let trail = &a.trails[&0x1004];
assert_eq!(trail.created_stack, frames);
}
#[test]
fn create_with_stack_disabled_is_noop() {
let mut a = HandleAudit::default();
a.record_create_with_stack(
0x1004,
"Event/Manual",
entry(0, "NtCreateEvent"),
vec![(0x7000_0000, 0x8200_0000)],
);
assert!(a.trails.is_empty());
}
}

View File

@@ -1462,10 +1462,11 @@ fn nt_create_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSt
signaled,
waiters: Vec::new(),
});
state.audit_create(
state.audit_create_with_ctx(
handle,
if manual_reset { "Event/Manual" } else { "Event/Auto" },
ctx.lr as u32,
ctx,
mem,
"NtCreateEvent",
);
if handle_ptr != 0 {
@@ -1484,7 +1485,7 @@ fn nt_create_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kern
max,
waiters: Vec::new(),
});
state.audit_create(handle, "Semaphore", ctx.lr as u32, "NtCreateSemaphore");
state.audit_create_with_ctx(handle, "Semaphore", ctx, mem, "NtCreateSemaphore");
if handle_ptr != 0 {
mem.write_u32(handle_ptr, handle);
}
@@ -1516,10 +1517,11 @@ fn nt_create_timer(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSt
callback_arg: 0,
waiters: Vec::new(),
});
state.audit_create(
state.audit_create_with_ctx(
handle,
if timer_type == 0 { "Timer/Manual" } else { "Timer/Auto" },
ctx.lr as u32,
ctx,
mem,
"NtCreateTimer",
);
if handle_ptr != 0 {

View File

@@ -455,6 +455,41 @@ impl KernelState {
self.audit.record_create(handle, kind, entry);
}
/// KRNBUG-AUDIT-002. Variant of `audit_create` that additionally
/// captures a 6-frame guest stack trace at allocation time when the
/// handle is in `audit.focus`. Outside the focus set this falls back
/// to plain `audit_create` (no stack walk → no extra cost on the hot
/// path of unfocused handle creation).
///
/// The walk reads the PPC EABI back-chain: `[r1] = prev_sp`, and the
/// LR saved by *that* prev frame's prologue lives at `[prev_sp - 8]`.
/// Frame 0 is the live frame `(ctx.gpr[1], ctx.lr)`. Frames 1..N walk
/// upward. A read returning 0 / 0xFFFF_FFFF, or a self-loop, ends the
/// walk early. This is read-only — guest memory and CPU state are not
/// mutated, so lockstep determinism is unaffected (a parallel run with
/// no focus is byte-identical to one without this code path).
pub fn audit_create_with_ctx(
&mut self,
handle: u32,
kind: &'static str,
ctx: &PpcContext,
mem: &GuestMemory,
source: &'static str,
) {
if !self.audit.enabled {
return;
}
let lr = ctx.lr as u32;
let entry = self.audit_entry(lr, source, 0);
if !self.audit.focus.contains(&handle) {
self.audit.record_create(handle, kind, entry);
return;
}
let stack = walk_guest_back_chain(ctx.gpr[1] as u32, lr, mem, 6);
self.audit
.record_create_with_stack(handle, kind, entry, stack);
}
/// Record a Set/Pulse/Release/etc. call against a handle. `aux` is the
/// previous signal state (or per-export-specific data).
pub fn audit_signal(&mut self, handle: u32, lr: u32, source: &'static str, aux: u64) {
@@ -675,6 +710,47 @@ impl Default for KernelState {
}
}
/// Walk the PPC EABI back-chain starting from `sp` (the value in r1 at
/// the moment of capture). Returns up to `max_frames` entries of
/// `(frame_pointer, saved_lr)`. Index 0 is the live frame
/// `(sp, live_lr)` — `live_lr` is the caller-supplied current LR, since
/// it has not yet been spilled to memory by this frame's prologue.
///
/// PPC convention reminder: a function's prologue stores the caller's
/// LR at `[old_sp - 8]` *before* bumping `r1` down to the new frame. So
/// from the live `sp`, `prev_sp = mem[sp]` and the LR saved in the
/// frame above is at `mem[prev_sp - 8]`. The walk stops on a
/// 0/0xFFFFFFFF/self-loop sentinel — those guard against
/// uninitialized stacks and the topmost frame.
///
/// This is read-only; it never mutates guest memory or CPU state.
pub fn walk_guest_back_chain(
sp: u32,
live_lr: u32,
mem: &GuestMemory,
max_frames: usize,
) -> Vec<(u32, u32)> {
let mut frames = Vec::with_capacity(max_frames);
if max_frames == 0 {
return frames;
}
frames.push((sp, live_lr));
let mut cur = sp;
while frames.len() < max_frames {
if cur == 0 || cur == 0xFFFF_FFFF {
break;
}
let prev = mem.read_u32(cur);
if prev == 0 || prev == 0xFFFF_FFFF || prev == cur {
break;
}
let saved_lr = mem.read_u32(prev.wrapping_sub(8));
frames.push((prev, saved_lr));
cur = prev;
}
frames
}
#[cfg(test)]
mod tests {
use super::*;
@@ -759,4 +835,64 @@ mod tests {
);
assert!(set.iter().all(|h| (h - 0x1000) % 4 == 0));
}
/// KRNBUG-AUDIT-002: synthesize a 3-level back-chain in mapped guest
/// memory and walk it. Verifies that frame 0 is the live-LR frame and
/// that subsequent frames pull `prev_sp` from `[sp]` and the saved LR
/// from `[prev_sp - 8]`.
#[test]
fn back_chain_walker_resolves_synthetic_frames() {
let mem = GuestMemory::new().expect("memory init");
let mut state = KernelState::new();
let base = state.heap_alloc(0x4000, &mem).expect("scratch");
// Lay out three frames inside the scratch page. Each frame gets
// its own 0x100-byte slot. Frame N's `[sp + 0]` points at frame
// N+1's sp, and frame N+1's `[sp - 8]` holds the LR saved by
// that frame for the call into frame N.
let sp0 = base + 0x100;
let sp1 = base + 0x300;
let sp2 = base + 0x500;
// Back-chain pointers
mem.write_u32(sp0, sp1);
mem.write_u32(sp1, sp2);
mem.write_u32(sp2, 0); // top of stack
// Saved LRs (the LR of the call that reached the *next* frame
// up are stored at the next frame's sp - 8)
mem.write_u32(sp1.wrapping_sub(8), 0xAAAA_BBBB);
mem.write_u32(sp2.wrapping_sub(8), 0xCCCC_DDDD);
let frames = walk_guest_back_chain(sp0, 0x1111_2222, &mem, 6);
assert_eq!(frames.len(), 3);
assert_eq!(frames[0], (sp0, 0x1111_2222));
assert_eq!(frames[1], (sp1, 0xAAAA_BBBB));
assert_eq!(frames[2], (sp2, 0xCCCC_DDDD));
}
/// Walker must not loop on a self-referential back-chain (a corrupted
/// frame where `[sp] == sp`).
#[test]
fn back_chain_walker_stops_on_self_loop() {
let mem = GuestMemory::new().expect("memory init");
let mut state = KernelState::new();
let base = state.heap_alloc(0x1000, &mem).expect("scratch");
let sp = base + 0x100;
mem.write_u32(sp, sp); // self-loop
let frames = walk_guest_back_chain(sp, 0x4242_4242, &mem, 6);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], (sp, 0x4242_4242));
}
/// Walker must terminate on the standard top-of-stack sentinel
/// (`[sp] == 0`) without spilling a bogus frame.
#[test]
fn back_chain_walker_stops_on_zero_sentinel() {
let mem = GuestMemory::new().expect("memory init");
let mut state = KernelState::new();
let base = state.heap_alloc(0x1000, &mem).expect("scratch");
let sp = base + 0x100;
mem.write_u32(sp, 0);
let frames = walk_guest_back_chain(sp, 0x8242_0000, &mem, 6);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], (sp, 0x8242_0000));
}
}

View File

@@ -215,7 +215,6 @@ fn xam_task_schedule(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kernel
let message_ptr = ctx.gpr[4] as u32;
let optional_ptr = ctx.gpr[5] as u32;
let handle_ptr = ctx.gpr[6] as u32;
let lr = ctx.lr as u32;
if optional_ptr != 0 {
let v1 = mem.read_u32(optional_ptr);
@@ -266,7 +265,7 @@ fn xam_task_schedule(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kernel
if handle_ptr != 0 {
mem.write_u32(handle_ptr, handle);
}
state.audit_create(handle, "Thread", lr, "XamTaskSchedule");
state.audit_create_with_ctx(handle, "Thread", ctx, mem, "XamTaskSchedule");
tracing::info!(
"XamTaskSchedule: tid={} handle={:#x} hw={} callback={:#010x} message={:#010x}",
tid,