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:
+19
-6
@@ -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")]));
|
||||
|
||||
+44
-8
@@ -18,6 +18,29 @@ use std::time::SystemTime;
|
||||
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
|
||||
/// slurped into memory before the parser's own depth guard kicks in.
|
||||
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
|
||||
/// prevents a corrupt registry length from driving an enormous allocation.
|
||||
#[cfg(any(windows, test))]
|
||||
const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn validate_reg_len(len: u32) -> Option<usize> {
|
||||
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES)
|
||||
.then_some(len as usize / 2)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
|
||||
let units = validate_reg_len(returned_bytes)?;
|
||||
if units > buf.len() {
|
||||
return None;
|
||||
}
|
||||
buf.truncate(units);
|
||||
while buf.last() == Some(&0) {
|
||||
buf.pop();
|
||||
}
|
||||
Some(String::from_utf16_lossy(&buf))
|
||||
}
|
||||
|
||||
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
|
||||
/// client's emulated-registry text file). Returns the appid only when present and
|
||||
@@ -333,6 +356,7 @@ mod win {
|
||||
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
|
||||
//! crate. Steam stores both the live `RunningAppID` and its install path under
|
||||
//! `HKCU\Software\Valve\Steam`.
|
||||
use super::{decode_reg_sz, validate_reg_len};
|
||||
use std::path::PathBuf;
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
@@ -401,12 +425,17 @@ mod win {
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
if rc != ERROR_SUCCESS || kind != REG_SZ || len == 0 {
|
||||
if rc != ERROR_SUCCESS || kind != REG_SZ {
|
||||
// SAFETY: valid handle.
|
||||
unsafe { RegCloseKey(hkey) };
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
|
||||
let Some(units) = validate_reg_len(len) else {
|
||||
// SAFETY: valid handle.
|
||||
unsafe { RegCloseKey(hkey) };
|
||||
return None;
|
||||
};
|
||||
let mut buf = vec![0u16; units];
|
||||
let mut len2 = len;
|
||||
// SAFETY: buffer sized to the queried byte length.
|
||||
let rc = unsafe {
|
||||
@@ -421,14 +450,10 @@ mod win {
|
||||
};
|
||||
// SAFETY: valid handle.
|
||||
unsafe { RegCloseKey(hkey) };
|
||||
if rc != ERROR_SUCCESS {
|
||||
if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
|
||||
return None;
|
||||
}
|
||||
// Trim the trailing NUL(s).
|
||||
while buf.last() == Some(&0) {
|
||||
buf.pop();
|
||||
}
|
||||
Some(PathBuf::from(String::from_utf16_lossy(&buf)))
|
||||
Some(PathBuf::from(decode_reg_sz(buf, len2)?))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,6 +461,17 @@ mod win {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_string_lengths_are_bounded_and_trimmed() {
|
||||
assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16");
|
||||
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
|
||||
assert_eq!(validate_reg_len(8), Some(4));
|
||||
|
||||
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
|
||||
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
|
||||
assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_app_id_reads_nonzero_and_rejects_zero() {
|
||||
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||
|
||||
Reference in New Issue
Block a user