diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index b44a970b..9fc1696a 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -506,6 +506,11 @@ pub mod gamepad; #[cfg(target_os = "windows")] #[path = "inject/windows/gamepad_raii.rs"] mod gamepad_raii; +/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]) used by every backend manager on +/// both platforms — replaces the per-backend permanent `broken` latch with capped-backoff retry. +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[path = "inject/pad_gate.rs"] +pub mod pad_gate; /// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck. #[cfg(target_os = "linux")] #[path = "inject/linux/steam_controller.rs"] diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index d71fe414..1b9a94bc 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -18,6 +18,7 @@ use super::dualsense_proto::{ DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC, }; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::fs::{File, OpenOptions}; @@ -180,8 +181,9 @@ pub struct DualSenseManager { /// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which /// re-emits the current state during input silence so the kernel never sees the device go quiet. last_write: Vec, - /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. - broken: bool, + /// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, /// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button /// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. remap: crate::inject::steam_remap::RemapConfig, @@ -200,7 +202,7 @@ impl DualSenseManager { state: vec![DsState::neutral(); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -300,7 +302,7 @@ impl DualSenseManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match DualSensePad::open(idx as u8) { @@ -313,10 +315,11 @@ impl DualSenseManager { self.state[idx] = DsState::neutral(); self.last_rumble[idx] = (0, 0); self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/linux/dualshock4.rs b/crates/punktfunk-host/src/inject/linux/dualshock4.rs index f602aee8..53d24f58 100644 --- a/crates/punktfunk-host/src/inject/linux/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/linux/dualshock4.rs @@ -15,6 +15,7 @@ use super::dualsense_proto::{DsState, Touch}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::fs::{File, OpenOptions}; @@ -365,8 +366,9 @@ pub struct DualShock4Manager { last_led: Vec>, /// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat). last_write: Vec, - /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. - broken: bool, + /// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, /// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID /// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. remap: crate::inject::steam_remap::RemapConfig, @@ -386,7 +388,7 @@ impl DualShock4Manager { last_rumble: vec![(0, 0); MAX_PADS], last_led: vec![None; MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -522,7 +524,7 @@ impl DualShock4Manager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match DualShock4Pad::open(idx as u8) { @@ -536,10 +538,11 @@ impl DualShock4Manager { self.last_rumble[idx] = (0, 0); self.last_led[idx] = None; self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/linux/gamepad.rs b/crates/punktfunk-host/src/inject/linux/gamepad.rs index edf83d55..722e6803 100644 --- a/crates/punktfunk-host/src/inject/linux/gamepad.rs +++ b/crates/punktfunk-host/src/inject/linux/gamepad.rs @@ -19,6 +19,7 @@ #![deny(clippy::undocumented_unsafe_blocks)] use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{bail, Result}; use std::collections::HashMap; use std::os::fd::{AsRawFd, OwnedFd}; @@ -557,8 +558,9 @@ pub struct GamepadManager { /// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when /// the client asked for `XboxOne`). All pads in a session share one identity. identity: PadIdentity, - /// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events. - broken: bool, + /// Create-retry gate: a transient `/dev/uinput` failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, } impl GamepadManager { @@ -572,7 +574,7 @@ impl GamepadManager { GamepadManager { pads: (0..MAX_PADS).map(|_| None).collect(), identity, - broken: false, + gate: PadGate::new(), } } @@ -608,14 +610,17 @@ impl GamepadManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match VirtualPad::create(idx, self.identity) { - Ok(p) => self.pads[idx] = Some(p), + Ok(p) => { + self.pads[idx] = Some(p); + self.gate.on_success(); + } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — retrying with backoff"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index 29894970..8c7f7746 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -24,6 +24,7 @@ use super::steam_proto::{ STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, }; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::fs::{File, OpenOptions}; @@ -353,7 +354,9 @@ pub struct SteamControllerManager { state: Vec, last_rumble: Vec<(u16, u16)>, last_write: Vec, - broken: bool, + /// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, } impl Default for SteamControllerManager { @@ -369,7 +372,7 @@ impl SteamControllerManager { state: vec![SteamState::neutral(); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), } } @@ -465,7 +468,7 @@ impl SteamControllerManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match open_transport(idx as u8) { @@ -474,10 +477,11 @@ impl SteamControllerManager { self.state[idx] = SteamState::neutral(); self.last_rumble[idx] = (0, 0); self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — controller input disabled"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — retrying with backoff"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/pad_gate.rs b/crates/punktfunk-host/src/inject/pad_gate.rs new file mode 100644 index 00000000..d4813861 --- /dev/null +++ b/crates/punktfunk-host/src/inject/pad_gate.rs @@ -0,0 +1,122 @@ +//! Shared virtual-pad creation-retry policy, used by every backend manager (Linux uinput/uhid, +//! Windows XUSB/UMDF). See [`PadGate`]. + +use std::time::{Duration, Instant}; + +/// Backoff after the first failed pad creation… +const FIRST_BACKOFF: Duration = Duration::from_secs(1); +/// …doubling on each consecutive failure, capped here so a persistently-broken host retries at most +/// this often (a negligible cost) while still self-healing within one window of the fix. +const MAX_BACKOFF: Duration = Duration::from_secs(30); + +/// Create-retry gate shared by every virtual-pad manager. +/// +/// Each backend used to carry a `broken: bool` that latched permanently on the FIRST pad-creation +/// error, so a single transient failure — a startup race on `/dev/uinput`, a momentary `EBUSY`, the +/// Windows companion driver not yet ready — disabled EVERY controller for the rest of the session, +/// even after the underlying cause cleared. `PadGate` replaces that latch with capped exponential +/// backoff: +/// +/// * After a failure, creation is blocked only until the backoff elapses — so the manager does not +/// re-attempt (and re-log) on every one of the 60–240 input frames a second — then a single +/// retry is permitted. +/// * A success clears the backoff, so the next failure starts fresh from [`FIRST_BACKOFF`]. +/// * Consecutive failures widen the window, doubling up to [`MAX_BACKOFF`]. +/// +/// Even a genuinely broken setup (bad `/dev/uinput` permissions, missing Windows driver) therefore +/// self-heals within [`MAX_BACKOFF`] of the fix — a udev-rule reload, a driver install, the next +/// client connect — with no host restart, while costing at most one failed syscall plus one log +/// line per backoff window. The gate is manager-wide (not per slot), matching the old `broken` +/// flag: these failures are systemic (device-node permissions, absent driver), not per-controller. +#[derive(Debug, Default)] +pub struct PadGate { + /// When the current backoff ends. `None` = creation is allowed right now. + retry_at: Option, + /// Current backoff length: `ZERO` until the first failure, then [`FIRST_BACKOFF`] doubling + /// toward [`MAX_BACKOFF`]. + backoff: Duration, +} + +impl PadGate { + /// A gate that permits creation immediately (no failures recorded yet). + pub fn new() -> PadGate { + PadGate::default() + } + + /// May a pad be created at `now`? `true` unless a post-failure backoff is still in effect. + pub fn allow(&self, now: Instant) -> bool { + match self.retry_at { + None => true, + Some(t) => now >= t, + } + } + + /// Record a successful pad creation — clear the backoff so the next failure starts fresh. + pub fn on_success(&mut self) { + self.retry_at = None; + self.backoff = Duration::ZERO; + } + + /// Record a failed pad creation at `now` — arm the next retry a capped-exponential backoff out. + pub fn on_failure(&mut self, now: Instant) { + self.backoff = if self.backoff.is_zero() { + FIRST_BACKOFF + } else { + (self.backoff * 2).min(MAX_BACKOFF) + }; + self.retry_at = Some(now + self.backoff); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_gate_allows_creation() { + assert!(PadGate::new().allow(Instant::now())); + } + + #[test] + fn failure_blocks_until_backoff_elapses_then_allows_one_retry() { + let t0 = Instant::now(); + let mut g = PadGate::new(); + g.on_failure(t0); + // Blocked for the whole first-backoff window… + assert!(!g.allow(t0)); + assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1))); + // …then a single retry is permitted. + assert!(g.allow(t0 + FIRST_BACKOFF)); + } + + #[test] + fn consecutive_failures_double_the_backoff_up_to_the_cap() { + let t0 = Instant::now(); + let mut g = PadGate::new(); + g.on_failure(t0); // window = 1s + g.on_failure(t0); // window = 2s + assert!(!g.allow(t0 + FIRST_BACKOFF)); // still blocked at 1s — the window is now 2s + assert!(g.allow(t0 + 2 * FIRST_BACKOFF)); + // Drive well past the cap and confirm the window never exceeds MAX_BACKOFF. + for _ in 0..20 { + g.on_failure(t0); + } + assert!(!g.allow(t0 + MAX_BACKOFF - Duration::from_millis(1))); + assert!(g.allow(t0 + MAX_BACKOFF)); + } + + #[test] + fn success_resets_the_backoff() { + let t0 = Instant::now(); + let mut g = PadGate::new(); + g.on_failure(t0); + g.on_failure(t0); // window grown to 2s + g.on_success(); + // Success clears the backoff: creation is immediately allowed again. + assert!(g.allow(t0)); + // The next failure starts from FIRST_BACKOFF, not the grown value. + g.on_failure(t0); + assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1))); + assert!(g.allow(t0 + FIRST_BACKOFF)); + } +} diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index cbca4599..cc6ce9d9 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -23,6 +23,7 @@ use super::dualsense_proto::{ }; use super::gamepad_raii::PadChannel; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{anyhow, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::ffi::c_void; @@ -385,7 +386,9 @@ pub struct DualSenseWindowsManager { state: Vec, last_rumble: Vec<(u16, u16)>, last_write: Vec, - broken: bool, + /// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, } impl Default for DualSenseWindowsManager { @@ -401,7 +404,7 @@ impl DualSenseWindowsManager { state: vec![DsState::neutral(); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), } } @@ -486,7 +489,7 @@ impl DualSenseWindowsManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match DsWinPad::open(idx as u8) { @@ -499,10 +502,11 @@ impl DualSenseWindowsManager { self.state[idx] = DsState::neutral(); self.last_rumble[idx] = (0, 0); self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs index 6f24a994..fd91f308 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -17,6 +17,7 @@ use super::dualshock4_proto::{ }; use super::gamepad_raii::PadChannel; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::Result; use punktfunk_core::quic::{HidOutput, RichInput}; use std::time::{Duration, Instant}; @@ -149,7 +150,9 @@ pub struct DualShock4WindowsManager { last_rumble: Vec<(u16, u16)>, last_led: Vec>, last_write: Vec, - broken: bool, + /// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, } impl Default for DualShock4WindowsManager { @@ -166,7 +169,7 @@ impl DualShock4WindowsManager { last_rumble: vec![(0, 0); MAX_PADS], last_led: vec![None; MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), } } @@ -251,7 +254,7 @@ impl DualShock4WindowsManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match Ds4WinPad::open(idx as u8) { @@ -265,10 +268,11 @@ impl DualShock4WindowsManager { self.last_rumble[idx] = (0, 0); self.last_led[idx] = None; self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index dd245194..5d38c747 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -14,6 +14,7 @@ use super::gamepad_raii::PadChannel; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{anyhow, Result}; use std::ffi::c_void; use std::time::{Duration, Instant}; @@ -291,7 +292,9 @@ pub struct GamepadManager { /// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the /// const's docs. last_active: Vec, - broken: bool, + /// Create-retry gate: a transient XUSB-companion failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, } impl Default for GamepadManager { @@ -306,12 +309,12 @@ impl GamepadManager { pads: (0..MAX_PADS).map(|_| None).collect(), last_rumble: vec![(0, 0); MAX_PADS], last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(), - broken: false, + gate: PadGate::new(), } } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match XusbWinPad::open(idx as u8) { @@ -322,10 +325,11 @@ impl GamepadManager { ); self.pads[idx] = Some(p); self.last_rumble[idx] = (0, 0); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)"); + self.gate.on_failure(Instant::now()); } } }