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
+19 -6
View File
@@ -15,8 +15,10 @@ use super::{
use super::scan;
use super::steam::SteamProbe;
use std::collections::BTreeMap;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use tokio::sync::watch;
@@ -62,13 +64,17 @@ pub struct GameDetector {
inputs: Arc<DetectorInputs>,
rx: watch::Receiver<Option<DetectedGame>>,
stop: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl GameDetector {
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
/// `override_` seeds the manual override (usually `Auto`). The worker runs
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
pub fn spawn(override_: ManualOverride, process_map: BTreeMap<String, String>) -> Self {
pub fn spawn(
override_: ManualOverride,
process_map: BTreeMap<String, String>,
) -> io::Result<Self> {
let inputs = Arc::new(DetectorInputs {
override_: Mutex::new(override_),
process_map: Mutex::new(process_map),
@@ -78,12 +84,16 @@ impl GameDetector {
let worker_inputs = inputs.clone();
let worker_stop = stop.clone();
std::thread::Builder::new()
let worker = std::thread::Builder::new()
.name("game-detector".to_string())
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))
.ok();
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))?;
Self { inputs, rx, stop }
Ok(Self {
inputs,
rx,
stop,
worker: Some(worker),
})
}
/// A clone of the watch receiver for detected-game changes. The current value
@@ -112,6 +122,9 @@ impl GameDetector {
impl Drop for GameDetector {
fn drop(&mut self) {
self.stop();
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
@@ -223,7 +236,7 @@ mod tests {
fn spawn_and_stop_is_clean() {
// Smoke test the lifecycle: spawning and stopping must not panic, and the
// initial published value is None.
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new());
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new()).unwrap();
assert_eq!(*det.subscribe().borrow(), None);
det.set_override(ManualOverride::ForceNone);
det.set_process_map(map(&[("x", "X")]));