Files
xenia-rs/audit-runs/audit-048-audio-host-pump/diff-summary.txt
MechaCat02 8e709b0a24 chore: track audit-runs summary artifacts (md/csv/diff/txt/json/etc)
Snapshot of every non-log artifact under audit-runs/ from audits 003
through 058: findings.md per audit, comparison CSVs, probe diffs,
schema docs, register-dump txts, lr-trace JSONL streams, the saved
canary patch diffs, etc. ~284 files / ~52 MB total.

Excluded (per .gitignore): probe stdout/stderr/log streams (the raw
firehose), guest-memory dumps under audit-026/027/029 (4.5 GB of
.bin files; *.bin pattern added to .gitignore this commit).

Also adds the orphan audit-058-sub825070F0-activation directory that
a subagent accidentally created at project-root instead of
under xenia-rs/audit-runs/; relocated to its proper home.

Purpose: cross-machine continuity. With these summaries committed,
a fresh clone gives the next session the full per-audit context
(findings + tables + cascade predictions) without dependence on
local-only working tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:36:41 +02:00

360 lines
17 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
crates/xenia-app/src/main.rs | 94 +++++++++++++++++++++++---------------
crates/xenia-kernel/src/exports.rs | 82 ++++++++++++++++++++++++++++++++-
crates/xenia-kernel/src/state.rs | 24 ++++++----
crates/xenia-kernel/src/xaudio.rs | 41 +++++++++++++++++
4 files changed, 196 insertions(+), 45 deletions(-)
diff --git a/crates/xenia-app/src/main.rs b/crates/xenia-app/src/main.rs
index 782e4bf..0418ec9 100644
--- a/crates/xenia-app/src/main.rs
+++ b/crates/xenia-app/src/main.rs
@@ -948,16 +948,35 @@ fn cmd_exec_inner(
});
let parallel_active = parallel || parallel_via_env;
kernel.parallel_active = parallel_active;
- let xaudio_tick_via_env = std::env::var("XENIA_XAUDIO_TICK")
- .ok()
- .is_some_and(|v| {
+ // AUDIT-032: default is `KernelState::xaudio_tick_enabled = true` now
+ // that the dedicated worker eliminates HW-thread hijack regressions.
+ // Treat `--xaudio-tick` / `XENIA_XAUDIO_TICK=...` as an explicit
+ // override only when set: `0`/`false`/`no` forces it off; truthy
+ // values keep it on. CLI flag is bool so `--xaudio-tick` (present)
+ // is treated as the truthy override; absence is "use default".
+ let xaudio_tick_env_raw = std::env::var("XENIA_XAUDIO_TICK").ok();
+ let xaudio_tick_env_falsey = xaudio_tick_env_raw
+ .as_deref()
+ .map(|v| {
+ let v = v.trim().to_ascii_lowercase();
+ v == "0" || v == "false" || v == "no"
+ })
+ .unwrap_or(false);
+ let xaudio_tick_env_truthy = xaudio_tick_env_raw
+ .as_deref()
+ .map(|v| {
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes"
- });
- kernel.xaudio_tick_enabled = xaudio_tick || xaudio_tick_via_env;
+ })
+ .unwrap_or(false);
+ if xaudio_tick || xaudio_tick_env_truthy {
+ kernel.xaudio_tick_enabled = true;
+ } else if xaudio_tick_env_falsey {
+ kernel.xaudio_tick_enabled = false;
+ }
if kernel.xaudio_tick_enabled && !quiet {
tracing::info!(
- "XAudio callback ticker enabled (--xaudio-tick / XENIA_XAUDIO_TICK=1)"
+ "XAudio callback ticker enabled (AUDIT-032 default; toggle via --xaudio-tick / XENIA_XAUDIO_TICK)"
);
}
if reservations_table || reservations_via_env || parallel_active {
@@ -3247,12 +3266,15 @@ fn try_inject_graphics_interrupt(kernel: &mut xenia_kernel::KernelState) {
);
}
-/// APUBUG-PRODUCER-001 — inject a pending XAudio buffer-complete callback
-/// into the next available HW thread. Mirrors
-/// [`try_inject_graphics_interrupt`] (same victim-selection policy, same
-/// SP-pad, same saved-context restore-on-sentinel) but pulls the callback
-/// PC + r3 argument from a registered [`xenia_kernel::xaudio::XAudioClient`]
-/// instead of the graphics-callback registration.
+/// AUDIT-032 Plan B — inject a pending XAudio buffer-complete callback
+/// into the **dedicated audio worker** registered for the head-of-queue
+/// client. Mirrors
+/// [`try_inject_graphics_interrupt`] (same SP-pad, same saved-context
+/// restore-on-sentinel) but the target thread is fixed at registration
+/// time instead of selected via the random-victim policy. The pre-fix
+/// random-victim path corrupted unrelated thread state
+/// (APUBUG-PRODUCER-001 "HW-thread hijack"); per-client workers eliminate
+/// that whole class of regression.
///
/// Mutual exclusion with the graphics path is via the shared
/// `interrupts.saved` slot — if a graphics callback is already in flight,
@@ -3273,39 +3295,39 @@ fn try_inject_audio_callback(kernel: &mut xenia_kernel::KernelState) {
return;
};
- let mut victim: Option<xenia_cpu::ThreadRef> = None;
- 'outer_ready: for (hw_id, slot) in kernel.scheduler.slots.iter().enumerate() {
- for (idx, t) in slot.runqueue.iter().enumerate() {
- if matches!(t.state, HwState::Ready) {
- victim = Some(xenia_cpu::ThreadRef::new(hw_id as u8, idx as u16));
- break 'outer_ready;
- }
+ // Resolve the dedicated worker thread. Cached `worker_refs[index]` is
+ // stale-tolerant via `find_by_handle` (handle survives slot
+ // compaction). If no worker is registered (spawn failed at register
+ // time, or fix is disabled in a fork), drop the fire — no fallback to
+ // random-victim, that regressed boot trajectory.
+ let worker_handle = kernel.xaudio.worker_handles[index];
+ let target_ref = match worker_handle.and_then(|h| kernel.scheduler.find_by_handle(h)) {
+ Some(r) => {
+ kernel.xaudio.worker_refs[index] = Some(r);
+ r
}
- }
- if victim.is_none() {
- 'outer_blocked: for (hw_id, slot) in kernel.scheduler.slots.iter().enumerate() {
- for (idx, t) in slot.runqueue.iter().enumerate() {
- if matches!(t.state, HwState::Blocked(_)) {
- victim = Some(xenia_cpu::ThreadRef::new(hw_id as u8, idx as u16));
- break 'outer_blocked;
- }
- }
+ None => {
+ let _ = kernel.xaudio.take_next();
+ kernel.xaudio.dropped += 1;
+ return;
}
- }
- let Some(target_ref) = victim else {
- let _ = kernel.xaudio.take_next();
- kernel.xaudio.dropped += 1;
- return;
};
let t = kernel.scheduler.thread_mut(target_ref);
let prev_state = t.state.clone();
match prev_state {
- HwState::Ready => {}
HwState::Blocked(reason) => {
t.state = HwState::ServicingIrq(reason);
}
- _ => unreachable!("victim selection above filtered out other variants"),
+ // Worker normally re-blocks on synthetic via the LR_HALT restore
+ // path. If we ever observe it elsewhere (e.g. deadlock force-wake
+ // moved it to Ready), drop this fire rather than corrupt state —
+ // the next tick will retry once the restore path runs.
+ _ => {
+ let _ = kernel.xaudio.take_next();
+ kernel.xaudio.dropped += 1;
+ return;
+ }
}
let _ = kernel.xaudio.take_next();
@@ -3331,7 +3353,7 @@ fn try_inject_audio_callback(kernel: &mut xenia_kernel::KernelState) {
idx = target_ref.idx,
callback = format_args!("{:#010x}", client.callback_pc),
wrapped = format_args!("{:#010x}", client.wrapped_callback_arg),
- "xaudio callback: injecting"
+ "xaudio callback: injecting (dedicated worker)"
);
}
diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs
index fe62eab..8ad46db 100644
--- a/crates/xenia-kernel/src/exports.rs
+++ b/crates/xenia-kernel/src/exports.rs
@@ -3106,9 +3106,89 @@ fn xaudio_register_render_driver(ctx: &mut PpcContext, mem: &GuestMemory, state:
if driver_ptr != 0 {
mem.write_u32(driver_ptr, driver_id);
}
+
+ // AUDIT-032 Plan B: spawn a dedicated audio-worker guest thread for
+ // this client and park it on a synthetic `WaitAny` handle so
+ // `try_inject_audio_callback` can flip it to `ServicingIrq` when
+ // a buffer-complete fire is queued. Mirrors xenia-canary's
+ // `apu/audio_system.cc:84-159` host worker without spawning a host OS
+ // thread. Failure here is non-fatal (the client is still registered;
+ // the periodic ticker will queue fires that the round prologue
+ // simply drops with `dropped += 1` because there's no worker to pump).
+ let worker_stack = 0x10_000u32; // 64 KiB — half of canary's 128 KiB.
+ let worker_ref_handle = if let Some(image) =
+ crate::thread::allocate_thread_image(state, mem, worker_stack, 0)
+ {
+ use std::sync::atomic::Ordering;
+ let tid = state.next_thread_id.fetch_add(1, Ordering::Relaxed);
+ let handle = state.alloc_handle_for(KernelObject::Thread {
+ id: tid,
+ hw_id: None,
+ exit_code: None,
+ waiters: Vec::new(),
+ });
+ let tls_slot_count = state.next_tls_index.load(Ordering::Relaxed);
+ let params = SpawnParams {
+ entry: callback_pc,
+ start_context: wrapped,
+ stack_base: image.stack_base,
+ stack_size: image.stack_size,
+ pcr_base: image.pcr_base,
+ tls_base: image.tls_base,
+ thread_handle: handle,
+ guest_tid: tid,
+ create_suspended: true,
+ is_initial: false,
+ tls_slot_count,
+ affinity_mask: 0,
+ priority: 0,
+ ideal_processor: None,
+ };
+ match state.scheduler.spawn(params, &mut GuestMemoryPcr(mem)) {
+ Ok(hw_id) => {
+ if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) {
+ *slot = Some(hw_id);
+ }
+ // Flip from `Blocked(Suspended)` (set by spawn for
+ // create_suspended=true) to a `Blocked(WaitAny)` on a
+ // synthetic handle never owned by any kernel object.
+ // `wake_eligible_waiters` looks the handle up in
+ // `state.objects` and returns early on miss, so this
+ // park-state is only released by audio-callback injection.
+ let park_handle = crate::xaudio::synthetic_park_handle(index);
+ let target = ThreadRef::new(
+ hw_id,
+ (state.scheduler.slots[hw_id as usize].runqueue.len() - 1) as u16,
+ );
+ // Both Blocked(Suspended) (set by spawn) and
+ // Blocked(WaitAny) are non-runnable, so the
+ // `non_empty_runnable` bitmask is unchanged — no need to
+ // call the private `recompute_slot_runnable` helper.
+ state.scheduler.thread_mut(target).state =
+ xenia_cpu::scheduler::HwState::Blocked(BlockReason::WaitAny {
+ handles: vec![park_handle],
+ deadline: None,
+ });
+ Some((handle, target))
+ }
+ Err(_) => {
+ tracing::warn!("XAudioRegisterRenderDriverClient: spawn failed for worker idx={}", index);
+ None
+ }
+ }
+ } else {
+ tracing::warn!("XAudioRegisterRenderDriverClient: allocate_thread_image failed for worker idx={}", index);
+ None
+ };
+ if let Some((h, r)) = worker_ref_handle {
+ state.xaudio.worker_handles[index] = Some(h);
+ state.xaudio.worker_refs[index] = Some(r);
+ }
+
tracing::info!(
- "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x}",
+ "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x} worker_handle={:?}",
index, callback_pc, callback_arg, wrapped, driver_id,
+ state.xaudio.worker_handles[index],
);
ctx.gpr[3] = 0;
}
diff --git a/crates/xenia-kernel/src/state.rs b/crates/xenia-kernel/src/state.rs
index f25db5b..6e4bdcf 100644
--- a/crates/xenia-kernel/src/state.rs
+++ b/crates/xenia-kernel/src/state.rs
@@ -139,13 +139,17 @@ pub struct KernelState {
/// graphics interrupts is enforced by the injector's
/// `is_in_callback()` guard.
pub xaudio: crate::xaudio::XAudioState,
- /// Default false. When true, the round prologue runs the XAudio
- /// ticker + `try_inject_audio_callback`. Off by default because the
- /// callback firing shifts the boot trajectory under Sylpheed
- /// (regresses `swaps=2`→`1` and 12×s `imports`), which would break
- /// the `sylpheed_n*m.json` lockstep goldens. Flipped on by
- /// `--xaudio-tick` / `XENIA_XAUDIO_TICK=1` for the diagnostic
- /// producer-hunt path.
+ /// AUDIT-032 Plan B (default true). When true, the round prologue
+ /// runs the XAudio ticker + `try_inject_audio_callback`. Pre-fix this
+ /// was off by default because injection used random-victim selection
+ /// (APUBUG-PRODUCER-001 HW-thread hijack) which corrupted unrelated
+ /// state. With dedicated per-client audio workers spawned at
+ /// `XAudioRegisterRenderDriverClient`, injection only ever runs on
+ /// the registered worker so it is safe to leave on. Lockstep goldens
+ /// `sylpheed_n*m.json` will drift on this fix and need re-baselining
+ /// (handled out-of-band). The `--xaudio-tick` flag / `XENIA_XAUDIO_TICK=1`
+ /// env var now act as explicit-override; flipping it off restores the
+ /// pre-fix path (no audio callbacks fire at all).
pub xaudio_tick_enabled: bool,
/// Per-handle refcount. Since `NtDuplicateObject` aliases (returns the
/// source handle value as the "new" handle rather than minting a fresh
@@ -306,7 +310,11 @@ impl KernelState {
ui: None,
interrupts: crate::interrupts::InterruptState::default(),
xaudio: crate::xaudio::XAudioState::default(),
- xaudio_tick_enabled: false,
+ // AUDIT-032: dedicated audio worker per client (Plan B in
+ // `xaudio_register_render_driver`) — not victim hijack, so safe
+ // to enable by default. Previously gated off because the
+ // random-victim selection corrupted unrelated thread state.
+ xaudio_tick_enabled: true,
handle_refcount: HashMap::new(),
pending_timer_fires: Vec::new(),
audit: HandleAudit::default(),
diff --git a/crates/xenia-kernel/src/xaudio.rs b/crates/xenia-kernel/src/xaudio.rs
index 4c40122..c20fe94 100644
--- a/crates/xenia-kernel/src/xaudio.rs
+++ b/crates/xenia-kernel/src/xaudio.rs
@@ -16,10 +16,30 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
+use xenia_cpu::ThreadRef;
+
/// Mirrors [audio_system.h:30](../../../../xenia-canary/src/xenia/apu/audio_system.h#L30)
/// `kMaximumClientCount = 8`.
pub const XAUDIO_MAX_CLIENTS: usize = 8;
+/// AUDIT-032 Plan B: synthetic kernel-handle base for the dedicated audio
+/// worker threads' parking `WaitAny`. These handles are deliberately OUTSIDE
+/// the normal allocator range (which starts at `0x1000` and grows by 4 in
+/// [`crate::state::KernelState::alloc_handle`]) so a `state.objects` lookup
+/// always misses — meaning [`crate::exports::wake_eligible_waiters`] will
+/// never spuriously wake a worker. The only legitimate path that flips a
+/// worker out of `Blocked(WaitAny[SYNTHETIC])` is the audio-callback
+/// injection in `try_inject_audio_callback` (state→`ServicingIrq`) and the
+/// `LR_HALT` saved-context restore (state→`Blocked` again). One handle per
+/// client slot keeps wait lists per-worker (defensive — `wake_eligible` is a
+/// no-op anyway).
+pub const XAUDIO_SYNTHETIC_HANDLE_BASE: u32 = 0xF000_0000;
+
+/// Compute the synthetic park-handle for client slot `i`.
+pub const fn synthetic_park_handle(i: usize) -> u32 {
+ XAUDIO_SYNTHETIC_HANDLE_BASE | (i as u32)
+}
+
/// Source code stamped into [`crate::SavedCallbackCtx::source`] when an
/// audio callback is injected. Distinct from graphics-interrupt sources
/// (`INTERRUPT_SOURCE_VSYNC = 0`, `INTERRUPT_SOURCE_CP = 1`) so logs and
@@ -59,6 +79,19 @@ pub struct XAudioState {
pub accumulator: u64,
pub last_instr_count: u64,
pub last_instant: Option<Instant>,
+ /// AUDIT-032 Plan B: dedicated audio-worker thread per client slot.
+ /// Mirrors xenia-canary's `apu/audio_system.cc:84-159` host worker but
+ /// using a guest-side parked thread instead — registered at
+ /// `XAudioRegisterRenderDriverClient` time and lazily looked up by
+ /// `try_inject_audio_callback` via `scheduler.find_by_handle`. The
+ /// worker is parked in `Blocked(WaitAny[SYNTHETIC_HANDLE])`; injection
+ /// flips it to `ServicingIrq` and the `LR_HALT` restore path puts it
+ /// back to `Blocked`. Each slot also remembers the kernel handle so
+ /// `find_by_handle` can resolve a fresh `ThreadRef` after slot
+ /// pruning/reordering. Phantom-typed for callers that don't link
+ /// `xenia_cpu` (none currently) to keep this self-contained.
+ pub worker_handles: [Option<u32>; XAUDIO_MAX_CLIENTS],
+ pub worker_refs: [Option<ThreadRef>; XAUDIO_MAX_CLIENTS],
}
impl Default for XAudioState {
@@ -71,6 +104,8 @@ impl Default for XAudioState {
accumulator: 0,
last_instr_count: 0,
last_instant: None,
+ worker_handles: [None; XAUDIO_MAX_CLIENTS],
+ worker_refs: [None; XAUDIO_MAX_CLIENTS],
}
}
}
@@ -90,6 +125,12 @@ impl XAudioState {
if index < XAUDIO_MAX_CLIENTS {
self.clients[index] = None;
self.pending.retain(|&i| i != index);
+ // Worker thread (if any) stays parked on its synthetic handle
+ // — Sylpheed never re-registers, so leaving it Blocked is
+ // simpler than wiring a clean teardown. Clear our refs so a
+ // future `register` rebuilds them.
+ self.worker_handles[index] = None;
+ self.worker_refs[index] = None;
}
}