feat(kernel): KRNBUG-AUDIT-003 — vtable/RTTI class probe at handle creation + wait

Adds a read-only MSVC RTTI traversal helper (`read_class_at_this`)
and a `probe_create_stack_classes` integration that walks each
captured back-chain frame for handle creates in `--trace-handles-focus`
and probes each frame's most-likely `this` candidate (live r31/r30/r3
for frame 0; saved-r31/r30 from the prologue spill area at [fp-12]/
[fp-16] for deeper frames). False-positive guard rejects the CRT
static-init iterator pattern (vtable's first two slots must be image-
range function pointers — PPC instruction words like `mflr r12` are
not in 0x82xxxxxx).

`dump_thread_diagnostic` now takes `&GuestMemory` so the FOCUS report
prints, for each parked waiter, a WAIT-THREAD block with full back-
chain frames and per-slot saved-register dump for offline lookup.

End-to-end finding (-n 500M producer-trace):
  * Handle 0x100c dispatcher = 0x828F3D08 (image rdata; verified by
    sub_82181750 disasm + xref table). [this+0] = -1 sentinel — POD
    job queue, NOT a C++ polymorphic class.
  * Handle 0x15e0 dispatcher = 0x828F4070 (same shape).
  * Handle 0x1004's 8-instance pool members still TBD (MSVC ctors
    didn't preserve `this` in r31).
  * 0x42450b5c is a separate audit class (heap-allocated, parks via
    non-`do_wait_single` path).

Decisive xref audit: every reference to 0x828F3D08 / 0x828F4070 in
the static analysis is in a ctor or the CRT init driver. NO producer
code references either dispatcher base. Confirms `signal_attempts=0`
is unreachable-producer, not broken-producer.

Tests: 581 → 586 green (+5: RTTI-intact / RTTI-stripped / non-object
/ cstring / probe_create_stack integration). `--stable-digest -n
100M` instructions=100000002 unchanged. Master HEAD prior: 6440261.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-03 21:14:56 +02:00
parent 6440261e2e
commit f84e947547
4 changed files with 636 additions and 7 deletions

View File

@@ -1279,7 +1279,7 @@ fn cmd_exec_inner(
db.finalize_traces()?;
}
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
dump_thread_diagnostic(&kernel, quiet);
dump_thread_diagnostic(&kernel, &*mem_arc, quiet);
info!(
wall_ms = started.elapsed().as_millis() as u64,
instructions = stats.instruction_count,
@@ -2968,7 +2968,11 @@ fn print_summary(
/// session see *which* thread is stuck *where* without having to re-derive
/// it from ring-buffer / event-count math. Mirrors the halt-on-deadlock
/// dump in `run_execution` but runs on the normal `-n` exit path too.
fn dump_thread_diagnostic(kernel: &xenia_kernel::KernelState, quiet: bool) {
fn dump_thread_diagnostic(
kernel: &xenia_kernel::KernelState,
mem: &xenia_memory::GuestMemory,
quiet: bool,
) {
if quiet {
return;
}
@@ -3216,6 +3220,15 @@ fn dump_thread_diagnostic(kernel: &xenia_kernel::KernelState, quiet: bool) {
);
}
}
if !t.created_class_probes.is_empty() {
println!(
" created-class probes ({}):",
t.created_class_probes.len(),
);
for line in &t.created_class_probes {
println!(" {}", line);
}
}
}
// Producer-class classification. Source strings are stable
// labels passed to `record_signal` at the export sites.
@@ -3299,6 +3312,96 @@ fn dump_thread_diagnostic(kernel: &xenia_kernel::KernelState, quiet: bool) {
"not stuck — signals consumed correctly"
};
println!(" => {}", conclusion);
// KRNBUG-AUDIT-003. For each thread parked on this focus
// handle, walk its back-chain and probe `this` candidates
// for an MSVC C++ class name via vtable[-4] → COL → TD.
// Frame 0 uses the live r31/r30/r3 of the parked thread;
// deeper frames recover saved r31/r30 from the standard
// PPC prologue spill area at [fp - 12] / [fp - 16]. This
// names the dispatcher / queue / pool that owns each
// parked thread, converting "who should signal handle X"
// into "who should call Class::Submit but doesn't."
if let Some(obj) = kernel.objects.get(&h) {
let waiters: Vec<xenia_cpu::ThreadRef> = match obj {
KernelObject::Event { waiters, .. }
| KernelObject::Semaphore { waiters, .. }
| KernelObject::Timer { waiters, .. }
| KernelObject::Thread { waiters, .. }
| KernelObject::Mutex { waiters, .. } => waiters.iter().copied().collect(),
KernelObject::File { .. } => Vec::new(),
};
for w in waiters {
let Some(slot) = kernel.scheduler.slots.get(w.hw_id as usize) else {
continue;
};
let Some(thread) = slot.runqueue.get(w.idx as usize) else {
continue;
};
let ctx = &thread.ctx;
let frames = xenia_kernel::state::walk_guest_back_chain(
ctx.gpr[1] as u32,
ctx.lr as u32,
mem,
6,
);
println!(
" WAIT-THREAD tid={} pc={:#010x} lr={:#010x} sp={:#010x} r3={:#010x} r30={:#010x} r31={:#010x}",
thread.tid,
ctx.pc,
ctx.lr as u32,
ctx.gpr[1] as u32,
ctx.gpr[3] as u32,
ctx.gpr[30] as u32,
ctx.gpr[31] as u32,
);
for (frame_idx, (fp, frame_lr)) in frames.iter().enumerate() {
let saved_r31 = mem.read_u32(fp.wrapping_sub(12));
let saved_r30 = mem.read_u32(fp.wrapping_sub(16));
let saved_r29 = mem.read_u32(fp.wrapping_sub(20));
let saved_r28 = mem.read_u32(fp.wrapping_sub(24));
println!(
" FRAME {} fp={:#010x} lr={:#010x} saved r31={:#010x} r30={:#010x} r29={:#010x} r28={:#010x}",
frame_idx, fp, frame_lr, saved_r31, saved_r30, saved_r29, saved_r28,
);
let candidates: Vec<(u32, &'static str)> = if frame_idx == 0 {
vec![
(ctx.gpr[31] as u32, "r31"),
(ctx.gpr[30] as u32, "r30"),
(ctx.gpr[29] as u32, "r29"),
(ctx.gpr[28] as u32, "r28"),
(ctx.gpr[3] as u32, "r3"),
]
} else {
vec![
(saved_r31, "saved-r31"),
(saved_r30, "saved-r30"),
(saved_r29, "saved-r29"),
(saved_r28, "saved-r28"),
]
};
for (this_ptr, label) in candidates {
use xenia_kernel::state::ClassReadout;
match xenia_kernel::state::read_class_at_this(this_ptr, mem) {
ClassReadout::Named { vtable, mangled } => {
println!(
" WAIT-CLASS frame={} {}={:#010x} vtable={:#010x} class={}",
frame_idx, label, this_ptr, vtable, mangled,
);
}
ClassReadout::VtableOnly { vtable, virtuals } => {
println!(
" WAIT-CLASS frame={} {}={:#010x} vtable={:#010x} virtuals=[{:#010x},{:#010x},{:#010x},{:#010x}] (RTTI stripped)",
frame_idx, label, this_ptr, vtable,
virtuals[0], virtuals[1], virtuals[2], virtuals[3],
);
}
ClassReadout::NotAnObject => {}
}
}
}
}
}
}
}
}
@@ -3337,7 +3440,7 @@ fn run_with_ui(
let worker_span = tracing::info_span!("cpu_worker");
let worker = std::thread::Builder::new()
.name("xenia-cpu".into())
.spawn(move || -> Result<(ExecStats, xenia_kernel::KernelState, xenia_debugger::Debugger, Option<xenia_analysis::DbWriter>)> {
.spawn(move || -> Result<(ExecStats, xenia_memory::GuestMemory, xenia_kernel::KernelState, xenia_debugger::Debugger, Option<xenia_analysis::DbWriter>)> {
let _guard = worker_span.enter();
let stats = run_execution(
&mut mem,
@@ -3354,7 +3457,7 @@ fn run_with_ui(
if let Some(ref mut db) = db_writer {
db.finalize_traces()?;
}
Ok((stats, kernel, debugger, db_writer))
Ok((stats, mem, kernel, debugger, db_writer))
})
.map_err(|e| anyhow::anyhow!("spawn CPU worker: {e}"))?;
@@ -3362,7 +3465,7 @@ fn run_with_ui(
// flips the shutdown flag itself (e.g. after max_instructions).
xenia_ui::run(event_loop, ui_handles, &title_owned)?;
let (stats, kernel, debugger, db_writer) = match worker.join() {
let (stats, mem, kernel, debugger, db_writer) = match worker.join() {
Ok(res) => res?,
Err(_) => {
return Err(anyhow::anyhow!("CPU worker thread panicked"));
@@ -3370,7 +3473,7 @@ fn run_with_ui(
};
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
dump_thread_diagnostic(&kernel, quiet);
dump_thread_diagnostic(&kernel, &mem, quiet);
info!(
wall_ms = started.elapsed().as_millis() as u64,
instructions = stats.instruction_count,