feat(game): cancellable detector service
Step 4 of game detection. One std-thread worker owns the SteamProbe cache + Debouncer across ticks, polls the OS adapters every 3s off the async runtime, and publishes the stable detected game on a tokio watch channel only when it changes. Manual override + process map are live-updatable via shared handles; a cancellable sleep honors stop promptly; drop stops it. The per-tick decision (match + resolve + debounce) is the pure poll_once, unit-tested with synthetic Steam/process inputs (debounce, process-only match, immediate manual override). +4 tests (397 lib). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
//! The detector service (§5): one cancellable background worker that polls the OS
|
||||
//! adapters, runs the pure matcher + debouncer, and publishes the stable detected
|
||||
//! game on a watch channel — only when it changes, so a flapping detector can't
|
||||
//! spam `PeerState` re-announces.
|
||||
//!
|
||||
//! All the OS reads (Steam files / registry, the process scan) are blocking, so
|
||||
//! the worker is a dedicated `std::thread`, not a tokio task; it owns the
|
||||
//! [`SteamProbe`] cache and the [`Debouncer`] across ticks. The per-tick decision
|
||||
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
|
||||
//! debounce is unit-tested without any I/O.
|
||||
|
||||
use super::{
|
||||
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
|
||||
};
|
||||
use super::scan;
|
||||
use super::steam::SteamProbe;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// How often the detector samples Steam state + the process list.
|
||||
pub const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
/// Granularity of the cancellable sleep between polls, so a stop request is
|
||||
/// honored promptly instead of after a full [`POLL_INTERVAL`].
|
||||
const SLEEP_TICK: Duration = Duration::from_millis(200);
|
||||
|
||||
/// Apply one poll's worth of inputs to the debouncer, returning the new published
|
||||
/// value **iff it changed** (the signal to re-announce presence / switch the
|
||||
/// background). Pure: the caller supplies the already-fetched Steam detection and
|
||||
/// process list, so resolve + match + debounce are testable with zero I/O.
|
||||
pub fn poll_once(
|
||||
debouncer: &mut Debouncer,
|
||||
override_: &ManualOverride,
|
||||
steam: Option<DetectedGame>,
|
||||
processes: &[String],
|
||||
process_map: &BTreeMap<String, String>,
|
||||
denylist: &std::collections::BTreeSet<&str>,
|
||||
) -> Option<Option<DetectedGame>> {
|
||||
let matched = match_processes(processes, process_map, denylist);
|
||||
let res = resolve(override_, steam, &matched);
|
||||
if debouncer.observe(res.game, res.immediate) {
|
||||
Some(debouncer.current().cloned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared, live-updatable inputs to the detector, written by core (manual override
|
||||
/// changes, config edits to the process map) and read each poll by the worker.
|
||||
#[derive(Default)]
|
||||
pub struct DetectorInputs {
|
||||
pub override_: Mutex<ManualOverride>,
|
||||
pub process_map: Mutex<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
/// A running detector service. Holds the watch receiver for detected-game changes
|
||||
/// and the shared inputs; dropping it (or calling [`stop`](Self::stop)) ends the
|
||||
/// worker thread.
|
||||
pub struct GameDetector {
|
||||
inputs: Arc<DetectorInputs>,
|
||||
rx: watch::Receiver<Option<DetectedGame>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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 {
|
||||
let inputs = Arc::new(DetectorInputs {
|
||||
override_: Mutex::new(override_),
|
||||
process_map: Mutex::new(process_map),
|
||||
});
|
||||
let (tx, rx) = watch::channel(None);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let worker_inputs = inputs.clone();
|
||||
let worker_stop = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("game-detector".to_string())
|
||||
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))
|
||||
.ok();
|
||||
|
||||
Self { inputs, rx, stop }
|
||||
}
|
||||
|
||||
/// A clone of the watch receiver for detected-game changes. The current value
|
||||
/// is `None` until the first non-empty detection is debounced in.
|
||||
pub fn subscribe(&self) -> watch::Receiver<Option<DetectedGame>> {
|
||||
self.rx.clone()
|
||||
}
|
||||
|
||||
/// Replace the manual override (applied on the next poll, immediately,
|
||||
/// bypassing debounce).
|
||||
pub fn set_override(&self, override_: ManualOverride) {
|
||||
*self.inputs.override_.lock().unwrap() = override_;
|
||||
}
|
||||
|
||||
/// Replace the user process→name mappings (e.g. after a Settings edit).
|
||||
pub fn set_process_map(&self, map: BTreeMap<String, String>) {
|
||||
*self.inputs.process_map.lock().unwrap() = map;
|
||||
}
|
||||
|
||||
/// Signal the worker to exit. Idempotent; also happens on drop.
|
||||
pub fn stop(&self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GameDetector {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking worker loop: probe, decide, publish on change, sleep (cancellably).
|
||||
fn worker_loop(
|
||||
inputs: Arc<DetectorInputs>,
|
||||
tx: watch::Sender<Option<DetectedGame>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
let denylist = builtin_denylist();
|
||||
let mut steam = SteamProbe::new();
|
||||
let mut debouncer = Debouncer::default();
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let override_ = inputs.override_.lock().unwrap().clone();
|
||||
let process_map = inputs.process_map.lock().unwrap().clone();
|
||||
|
||||
let steam_game = steam.detect();
|
||||
let processes = scan::running_executables();
|
||||
|
||||
if let Some(new_current) =
|
||||
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist)
|
||||
{
|
||||
// A closed receiver means core shut down; stop quietly.
|
||||
if tx.send(new_current).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancellable sleep: wake promptly on a stop request.
|
||||
let mut slept = Duration::ZERO;
|
||||
while slept < POLL_INTERVAL && !stop.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(SLEEP_TICK);
|
||||
slept += SLEEP_TICK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::GameSource;
|
||||
use super::*;
|
||||
|
||||
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
|
||||
DetectedGame { id: id.into(), name: Some(name.into()), source }
|
||||
}
|
||||
|
||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_once_debounces_steam_detection() {
|
||||
let deny = builtin_denylist();
|
||||
let mut d = Debouncer::default();
|
||||
let steam = game("steam:730", "CS2", GameSource::Steam);
|
||||
let empty = BTreeMap::new();
|
||||
|
||||
// First poll: detected but not yet published (needs two hits).
|
||||
assert_eq!(
|
||||
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||
None
|
||||
);
|
||||
// Second poll: published.
|
||||
assert_eq!(
|
||||
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||
Some(Some(steam))
|
||||
);
|
||||
// Third identical poll: no change event.
|
||||
assert_eq!(
|
||||
poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_once_matches_process_when_no_steam() {
|
||||
let deny = builtin_denylist();
|
||||
let mut d = Debouncer::default();
|
||||
let procs = vec!["/games/hl2_linux".to_string()];
|
||||
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||
|
||||
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||
let published = change.expect("should publish on second hit").expect("a game");
|
||||
assert_eq!(published.id, "exe:hl2_linux");
|
||||
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_once_manual_override_is_immediate() {
|
||||
let deny = builtin_denylist();
|
||||
let mut d = Debouncer::default();
|
||||
let forced = game("steam:220", "HL2", GameSource::Steam);
|
||||
// Even with a live Steam detection of something else, the override wins now.
|
||||
let other = game("steam:730", "CS2", GameSource::Steam);
|
||||
let change = poll_once(
|
||||
&mut d,
|
||||
&ManualOverride::Force(forced.clone()),
|
||||
Some(other),
|
||||
&[],
|
||||
&BTreeMap::new(),
|
||||
&deny,
|
||||
);
|
||||
assert_eq!(change, Some(Some(forced)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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());
|
||||
assert_eq!(*det.subscribe().borrow(), None);
|
||||
det.set_override(ManualOverride::ForceNone);
|
||||
det.set_process_map(map(&[("x", "X")]));
|
||||
det.stop();
|
||||
// Dropping also stops; no hang/panic.
|
||||
drop(det);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
|
||||
//! cancellable poll service ([`detector`]) wires them together.
|
||||
|
||||
pub mod detector;
|
||||
pub mod scan;
|
||||
pub mod steam;
|
||||
pub mod vdf;
|
||||
|
||||
Reference in New Issue
Block a user