refactor(inject/host): extract the shared PadGate create-retry policy + fix the permanent broken latch (G3/G12)

All seven virtual-pad managers (Linux uinput/uhid: gamepad, dualsense,
dualshock4, steam_controller; Windows XUSB/UMDF: gamepad, dualsense,
dualshock4) carried an identical copy-pasted `broken: bool` latch that
was set on the FIRST pad-creation error and never cleared — so a single
transient failure (a startup race on /dev/uinput, a momentary EBUSY, the
Windows companion driver not yet ready) permanently disabled EVERY
controller for the rest of the session, even after the cause cleared.

Extract that latch into one shared, unit-tested `PadGate`
(inject/pad_gate.rs) with the fix baked in: capped exponential backoff
(1s doubling to 30s) instead of a permanent kill. After a failure,
creation is blocked only until the backoff elapses — so the manager no
longer re-attempts (and re-logs) on every one of the 60–240 input
frames/sec — then a single retry is allowed; a success resets the
backoff. A genuinely broken setup therefore self-heals within one
backoff window of the fix (udev reload / driver install / next client
connect) with no host restart. The gate is manager-wide, matching the
old flag's semantics (these failures are systemic, not per-slot).

This folds G3 (broken latch) into G12 (dedup the manager skeleton): the
latch now lives in one place across all seven backends.

Verified on the Linux host build (.21): cargo clippy -D warnings clean,
full punktfunk-host suite 277 passed / 0 failed, 4 new PadGate tests
green. Windows managers verified separately on the x64 box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 12:33:49 +02:00
parent 43e52437c0
commit 5109a4c80a
9 changed files with 193 additions and 39 deletions
+5
View File
@@ -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"]
@@ -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<Instant>,
/// 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());
}
}
}
@@ -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<Option<(u8, u8, u8)>>,
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
last_write: Vec<Instant>,
/// 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());
}
}
}
@@ -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());
}
}
}
@@ -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<SteamState>,
last_rumble: Vec<(u16, u16)>,
last_write: Vec<Instant>,
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());
}
}
}
@@ -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 60240 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<Instant>,
/// 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));
}
}
@@ -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<DsState>,
last_rumble: Vec<(u16, u16)>,
last_write: Vec<Instant>,
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());
}
}
}
@@ -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<Option<(u8, u8, u8)>>,
last_write: Vec<Instant>,
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());
}
}
}
@@ -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<Instant>,
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());
}
}
}