fix(audio,game): Tier A bug-sweep fixes (S-01, F-04, F-08, F-09, S-02)

Five confirmed findings from the 2026-06-22 adversarial bug sweep:

- S-01: clamp PipeWire capture chunk size to the mapped slice before
  indexing, so a bad reported size can't panic (= process abort) from
  the RT capture callback. Extracted testable for_each_capture_sample.
- F-04: reserve ring occupancy before publishing a frame on the PipeWire
  playback path (mirrors the cpal fix), preventing the RT consumer from
  popping an uncounted sample and wrapping fill_gauge to usize::MAX,
  which permanently wedged mixer pacing. Extracted publish_frame.
- F-09: GameDetector::spawn now returns io::Result and retains its
  JoinHandle (joined on Drop); core fuses a closed watch receiver to
  None via next_game_change so a dead detector can't busy-loop select!.
- F-08: collision-free recording paths — Recorder::create and the
  multitrack session dir use create_new/create_dir with bounded suffix
  retry, so two recordings in the same second no longer truncate the
  first.
- S-02: bound the Windows SteamPath registry read (<=4 KiB, even length,
  re-checked type/returned length) before allocating/decoding.

403 lib tests pass (+6), clippy --all-targets clean. Implemented by
Codex, reviewed + gates re-run by senior.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 03:34:29 -04:00
co-authored by Codex Claude Opus 4.8
parent f422150c84
commit 6b0b23ef69
6 changed files with 288 additions and 47 deletions
+73 -10
View File
@@ -151,11 +151,9 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
let data = &mut datas[0];
let size = data.chunk().size() as usize;
if let Some(slice) = data.data() {
// Each sample is 2 bytes (S16LE)
for chunk in slice[..size].chunks_exact(2) {
let sample = i16::from_le_bytes([chunk[0], chunk[1]]);
for_each_capture_sample(slice, size, |sample| {
let _ = user_data.producer.try_push(sample);
}
});
}
}
}
@@ -224,6 +222,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
Ok(())
}
/// Visit the complete S16LE samples in the portion PipeWire reports as filled.
/// Clamp the reported byte count to the mapped slice before indexing: a bad
/// chunk size must not panic from the realtime capture callback.
fn for_each_capture_sample(slice: &[u8], size: usize, mut visit: impl FnMut(i16)) {
let size = size.min(slice.len());
for chunk in slice[..size].chunks_exact(2) {
visit(i16::from_le_bytes([chunk[0], chunk[1]]));
}
}
/// Frames the playback RT callback should produce this cycle.
///
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
@@ -263,6 +271,25 @@ fn drain_loop(
}
}
/// Reserve exact occupancy before making a frame visible to the consumer.
/// `after_reserve` is empty in production and lets the regression test force a
/// consumer interleaving at the critical ordering boundary.
fn publish_frame<P: Producer<Item = i16>>(
fill: &AtomicUsize,
dropped: &AtomicU64,
producer: &mut P,
frame: &[i16],
after_reserve: impl FnOnce(),
) {
fill.fetch_add(frame.len(), Ordering::Relaxed);
after_reserve();
let pushed = producer.push_slice(frame);
if pushed != frame.len() {
fill.fetch_sub(frame.len() - pushed, Ordering::Relaxed);
dropped.fetch_add(1, Ordering::Relaxed);
}
}
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
/// Safe per-cycle fallback when the graph doesn't report a quantum.
const FALLBACK_FRAMES: usize = 1024;
@@ -522,10 +549,12 @@ fn run_playback(
worker_dropped.fetch_add(1, Ordering::Relaxed);
return;
}
for &sample in &frame {
let _ = producer.try_push(sample);
}
worker_fill.fetch_add(frame.len(), Ordering::Relaxed);
// Reserve occupancy BEFORE publishing samples. Otherwise the RT
// consumer can pop a newly-visible sample before it is counted and
// wrap the exact fill gauge to usize::MAX, wedging mixer pacing.
// `push_slice` also publishes the frame as one operation rather than
// exposing a half-written stereo pair.
publish_frame(&worker_fill, &worker_dropped, &mut producer, &frame, || {});
});
});
@@ -577,8 +606,9 @@ fn run_playback(
#[cfg(test)]
mod tests {
use super::{drain_loop, frames_to_produce};
use std::sync::atomic::{AtomicBool, Ordering};
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::{sync::mpsc, thread};
@@ -614,6 +644,39 @@ mod tests {
assert_eq!(frames_to_produce(1024, 0), 0);
}
#[test]
fn capture_size_larger_than_mapping_is_clamped() {
let mut samples = Vec::new();
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| {
samples.push(sample)
});
assert_eq!(samples, vec![1, 2]);
}
#[test]
fn occupancy_is_reserved_before_frame_is_published() {
let rb = HeapRb::<i16>::new(8);
let (mut producer, mut consumer) = rb.split();
assert!(producer.try_push(7).is_ok());
let fill = AtomicUsize::new(1);
let dropped = AtomicU64::new(0);
publish_frame(&fill, &dropped, &mut producer, &[10, 11], || {
// Force the consumer to drain the old sample after the new frame's
// occupancy is reserved but before that frame is published.
assert_eq!(consumer.try_pop(), Some(7));
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 3);
});
assert_eq!(fill.load(Ordering::Relaxed), 2);
assert_eq!(consumer.try_pop(), Some(10));
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 2);
assert_eq!(consumer.try_pop(), Some(11));
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 1);
assert_eq!(fill.load(Ordering::Relaxed), 0);
assert_eq!(dropped.load(Ordering::Relaxed), 0);
}
// --- drain_loop (A7: worker must not hang shutdown) ---
#[test]