diff --git a/crates/pf-inject/src/inject/linux/gamepad.rs b/crates/pf-inject/src/inject/linux/gamepad.rs index cac39a69..435cf87d 100644 --- a/crates/pf-inject/src/inject/linux/gamepad.rs +++ b/crates/pf-inject/src/inject/linux/gamepad.rs @@ -669,6 +669,11 @@ impl GamepadManager { /// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose /// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered). pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { + // Finish any unplug whose removal frame only armed the grace — the producer sends that + // frame once, so without this the uinput node would outlive the controller. The swept + // mask is discarded because this manager keeps no per-index sibling state (the pads mix + // rumble internally); if that ever changes, consume it like the other two backends do. + self.slots.reap(); for (i, pad) in self.slots.iter_mut() { if let Some((low, high)) = pad.pump_ff() { send(i as u16, low, high); diff --git a/crates/pf-inject/src/inject/pad_slots.rs b/crates/pf-inject/src/inject/pad_slots.rs index 6f595ff6..8538fab7 100644 --- a/crates/pf-inject/src/inject/pad_slots.rs +++ b/crates/pf-inject/src/inject/pad_slots.rs @@ -62,15 +62,30 @@ impl

PadSlots

{ self.label } - /// Drop every allocated pad whose `active_mask` bit has stayed clear for [`SWEEP_GRACE`] (the - /// unplug sweep run on each state frame), logging each. Returns the swept indices as a bitmask - /// so the caller resets its per-index sibling state; an index another manager owns is `None` - /// here, so it is never swept. The grace is the devnode-churn debounce: a mask that glitches - /// clear for a few frames and returns re-arms nothing. + /// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out + /// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its + /// per-index sibling state; an index another manager owns is `None` here, so it is never + /// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few + /// frames and returns re-arms nothing. + /// + /// A frame can only ARM the grace, never complete it — no time has passed at the instant the + /// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the + /// manager's periodic pump is what actually finishes the unplug; a backend that only ever + /// called `sweep` would keep the detached pad alive for the rest of the session. pub fn sweep(&mut self, active_mask: u16) -> u16 { self.sweep_at(active_mask, Instant::now()) } + /// Drop every allocated pad whose grace has run out, logging each — the half of the unplug + /// that needs no state frame. Returns the dropped indices as a bitmask, same as [`Self::sweep`]. + /// + /// This can only ever *complete* an unplug some frame already started: it never arms a clock, + /// so however often it runs it cannot drop a pad whose `active_mask` bit never went clear. + /// That is what makes it safe to call from a hot pump loop. + pub fn reap(&mut self) -> u16 { + self.reap_at(Instant::now()) + } + /// Backdate every armed grace clock by [`SWEEP_GRACE`], so the NEXT sweep drops the pads /// whose bits are still clear — consumer tests (the managers') drive the debounce without /// wall-clock sleeps. Test-only: production code has no business expiring the grace. @@ -81,26 +96,37 @@ impl

PadSlots

{ } } - /// [`Self::sweep`] with an injectable clock (unit tests drive the grace window). + /// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm + /// each slot's clock from the mask, then reap whatever has already run out. fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 { - let mut swept = 0u16; - for (i, slot) in self.pads.iter_mut().enumerate() { + for i in 0..MAX_PADS { if active_mask & (1 << i) != 0 { self.inactive_since[i] = None; // active (again): a glitch never reaches the drop + } else if self.pads[i].is_some() && self.inactive_since[i].is_none() { + self.inactive_since[i] = Some(now); // newly inactive — start the grace + } + } + self.reap_at(now) + } + + /// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads + /// `inactive_since` and clears it, so a pad whose bit never went clear has no clock to run out + /// and cannot be dropped here. + fn reap_at(&mut self, now: Instant) -> u16 { + let mut swept = 0u16; + for i in 0..MAX_PADS { + let Some(since) = self.inactive_since[i] else { + continue; // active, or never went clear — nothing to complete + }; + if self.pads[i].is_none() { + self.inactive_since[i] = None; // the slot went away by some other route continue; } - if slot.is_none() { - continue; - } - match self.inactive_since[i] { - None => self.inactive_since[i] = Some(now), // newly inactive — start the grace - Some(since) if now.duration_since(since) >= SWEEP_GRACE => { - tracing::info!(index = i, "controller unplugged ({})", self.label); - *slot = None; - self.inactive_since[i] = None; - swept |= 1 << i; - } - Some(_) => {} // inside the grace — hold + if now.duration_since(since) >= SWEEP_GRACE { + tracing::info!(index = i, "controller unplugged ({})", self.label); + self.pads[i] = None; + self.inactive_since[i] = None; + swept |= 1 << i; } } swept @@ -161,6 +187,56 @@ mod tests { PadSlots::new("Test", "test pad", "") } + #[test] + fn a_single_frame_plus_a_reap_completes_the_unplug() { + // The shape production actually produces: ONE cleared-mask frame, then time, then a reap + // with no further frame. Before the arm/reap split the pad survived here forever. + let mut s = slots(); + assert!(s.ensure(2, |i| Ok(i as u32))); + assert_eq!( + s.sweep(0b0), + 0, + "a frame arms the grace but cannot itself drop" + ); + assert!(s.get(2).is_some()); + s.expire_grace(); + assert_eq!(s.reap(), 1 << 2, "the reap did not complete the unplug"); + assert!(s.get(2).is_none()); + assert_eq!(s.reap(), 0, "nothing left to reap"); + } + + #[test] + fn reap_never_drops_a_pad_no_frame_ever_deactivated() { + // Reaping COMPLETES an unplug; it must never invent one. A pad whose bit never went clear + // has no armed clock, so any number of reaps — even with the clock backdated — leaves it. + let mut s = slots(); + assert!(s.ensure(0, |i| Ok(i as u32))); + for _ in 0..10 { + assert_eq!(s.reap(), 0); + s.expire_grace(); + } + assert!( + s.get(0).is_some(), + "reap dropped a pad that never went inactive" + ); + } + + #[test] + fn a_glitch_that_returns_inside_the_grace_never_drops_the_pad() { + // The anti-flap guarantee, now that reaps are frequent: a client mask that blips clear and + // comes back must not churn a PnP devnode. + let mut s = slots(); + assert!(s.ensure(0, |i| Ok(i as u32))); + assert_eq!(s.sweep(0b0), 0); // bit clears — arms only + for _ in 0..5 { + assert_eq!(s.reap(), 0, "dropped a pad inside its grace"); + } + assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms + s.expire_grace(); + assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed"); + assert!(s.get(0).is_some()); + } + #[test] fn ensure_creates_once_and_reports_freshness() { let mut s = slots(); diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index cdb95fd4..8f91f0a9 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -217,13 +217,10 @@ impl UhidManager { if idx >= MAX_PADS { return; } - // Unplugs: drop any allocated pad whose mask bit cleared, resetting its state. + // Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands + // on a later `pump` tick — this frame is the only one the producer sends). let swept = self.slots.sweep(f.active_mask); - for i in 0..MAX_PADS { - if swept & (1 << i) != 0 { - self.reset_pad(i); - } - } + self.reset_swept(swept); if f.active_mask & (1 << idx) == 0 { return; // this event WAS the unplug } @@ -282,6 +279,12 @@ impl UhidManager { mut hidout: impl FnMut(HidOutput), ) { let now = Instant::now(); + // Finish any unplug whose removal frame only armed the grace. The producer emits that + // frame exactly once, so without this a detached pad — the single-pad session being the + // common case — would never be destroyed. Runs BEFORE the loop so a reaped index is + // already gone for `get_mut` here and for `heartbeat`'s `get` later in the same tick. + let swept = self.slots.reap(); + self.reset_swept(swept); for i in 0..MAX_PADS { let Some(pad) = self.slots.get_mut(i) else { continue; @@ -360,6 +363,18 @@ impl UhidManager { } } + /// Reset the sibling state of every index a sweep or reap just dropped. Both halves of the + /// unplug land here, so a pad torn down on the pump tick clears exactly what one torn down on + /// a state frame would — in particular `hidout_dedup`, which has no watchdog to re-arm it and + /// would otherwise swallow an identical lightbar/trigger re-assert after a re-plug. + fn reset_swept(&mut self, swept: u16) { + for i in 0..MAX_PADS { + if swept & (1 << i) != 0 { + self.reset_pad(i); + } + } + } + /// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a /// (re)connect starts from scratch and is always forwarded. fn reset_pad(&mut self, idx: usize) { @@ -494,18 +509,36 @@ mod tests { } #[test] - fn removal_frame_never_recreates_the_pad_it_swept() { + fn one_removal_frame_plus_a_pump_tick_completes_the_unplug() { + // The producer emits the cleared-mask frame exactly ONCE — `native/input.rs` guards it on + // the bit still being set — so the teardown has to finish on the periodic pump. The + // previous version of this test hand-fed a SECOND removal frame, which is what let the + // never-reaped pad hide: with one frame and no pump, the device outlived the session. let mut m = mgr(); m.handle(&frame(1, 0b10, 0)); assert!(m.slots.get(1).is_some()); - // Bit 1 cleared: the first sweep only ARMS the devnode-churn grace — the pad holds (a - // mask glitch must not flap PnP devices; see pad_slots::SWEEP_GRACE). + // The one removal frame: arms the devnode-churn grace, drops nothing. m.handle(&frame(1, 0b00, 0)); assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept"); - // Grace elapsed: the frame IS pad 1's removal — sweep, then early-return (no ensure). + // A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE). + m.pump(|_, _, _| {}, |_| {}); + assert!( + m.slots.get(1).is_some(), + "a tick inside the grace dropped it" + ); + // Grace elapsed: the next tick completes the unplug, with no further frame. m.slots.expire_grace(); + m.pump(|_, _, _| {}, |_| {}); + assert!( + m.slots.get(1).is_none(), + "the pump tick never completed the unplug" + ); + // …and a further cleared-mask frame must not resurrect it (the arm branch early-returns). m.handle(&frame(1, 0b00, 0)); - assert!(m.slots.get(1).is_none()); + assert!( + m.slots.get(1).is_none(), + "a cleared-mask frame recreated the pad" + ); } #[test] @@ -551,10 +584,15 @@ mod tests { assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards assert_eq!(collect(&mut m), vec![]); // exact repeat deduped assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards - // Unplug + recreate re-arms the dedup: the same level forwards again. - m.handle(&frame(0, 0b0, 0)); // arms the sweep grace + // Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes + // on a PUMP tick, not on a second frame — that is all production ever sends. + m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace m.slots.expire_grace(); - m.handle(&frame(0, 0b0, 0)); // grace elapsed — actually swept + assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward + assert!( + m.slots.get(0).is_none(), + "the pump tick completed the unplug" + ); m.handle(&frame(0, 0b1, 0)); *m.backend.feedback.borrow_mut() = vec![rumble((7, 7))]; assert_eq!(collect(&mut m), vec![(0, 7, 7)]); diff --git a/crates/pf-inject/src/inject/windows/gamepad_windows.rs b/crates/pf-inject/src/inject/windows/gamepad_windows.rs index 3cfe7803..d0191c72 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_windows.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_windows.rs @@ -318,14 +318,10 @@ impl GamepadManager { if idx >= MAX_PADS { return; } - // Unplugs: drop any allocated pad whose mask bit cleared. + // Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands + // on a later `pump_rumble` tick — this frame is the only one the producer sends). let swept = self.slots.sweep(f.active_mask); - for i in 0..MAX_PADS { - if swept & (1 << i) != 0 { - self.last_rumble[i] = (0, 0); - self.last_active[i] = Instant::now(); - } - } + self.reset_swept(swept); if f.active_mask & (1 << idx) == 0 { return; } @@ -345,10 +341,25 @@ impl GamepadManager { } } + /// Reset the sibling state of every index a sweep or reap just dropped, so both halves of the + /// unplug clear the same things. + fn reset_swept(&mut self, swept: u16) { + for i in 0..MAX_PADS { + if swept & (1 << i) != 0 { + self.last_rumble[i] = (0, 0); + self.last_active[i] = Instant::now(); + } + } + } + /// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries /// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small` /// (high-frequency) → `high` — matching the other backends. pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { + // Finish any unplug whose removal frame only armed the grace — the producer sends that + // frame once, so without this the XUSB devnode would outlive the controller. + let swept = self.slots.reap(); + self.reset_swept(swept); for (i, pad) in self.slots.iter_mut() { if let Some((large, small)) = pad.service() { // The game drove the pad this poll (SET_STATE bumped the seq) — refresh the diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index a71cde1c..29e773b5 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -629,6 +629,20 @@ const RUMBLE_RENEW_FLOOR_MS: u64 = 60; /// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends. const RUMBLE_STOP_BURST: u8 = 2; +/// Clear a removed pad's rumble bookkeeping — the level, the "we have seen a level" flag, and any +/// stop re-sends still owed. Together these end the pad's lease, so a re-plug on the same wire +/// index inherits nothing that could buzz the new device. +/// +/// The per-pad rumble **sequence is deliberately not a parameter**: it must stay monotonic for the +/// life of the connection because the client gates on it with a wrapping half-space compare and +/// never resets its side (`punktfunk-core/src/client/pump/datagram_task.rs`). Resetting it here is +/// the bug pinned by [`tests::rumble_seq_survives_a_removal_so_the_client_gate_accepts`]. +fn clear_pad_feedback(state: &mut (u16, u16), seen: &mut bool, stop_burst: &mut u8) { + *state = (0, 0); + *seen = false; + *stop_burst = 0; +} + /// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating /// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the /// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram. @@ -824,11 +838,22 @@ pub(super) fn input_thread( tracing::info!(pad = idx, "gamepad unplugged (native detach)"); } // Fresh feedback bookkeeping so a later re-plug on this index inherits no - // stale rumble lease/seq (a lease still ticking would buzz the new pad). - rumble_state[idx] = (0, 0); - rumble_seen[idx] = false; - rumble_seq[idx] = 0; - rumble_stop_burst[idx] = 0; + // stale rumble lease (a lease still ticking would buzz the new pad). + // + // `rumble_seq` deliberately SURVIVES — do not reset it here. The client's + // rumble reorder gate (`client/pump/datagram_task.rs`) is per-CONNECTION + // and has no reset path, so restarting this counter strands every later + // envelope for the re-plugged pad until the host climbs back past the + // value the client already stored (up to 128 sends ≈ 15 s of continuous + // rumble, or dozens of separate rumble events). The three clears below are + // what actually kill a stale lease; the sibling `pad_seq` gate keeps its + // value across a removal for exactly the same reason (see the comment at + // the top of this arm). + clear_pad_feedback( + &mut rumble_state[idx], + &mut rumble_seen[idx], + &mut rumble_stop_burst[idx], + ); } } InputKind::GamepadArrival => { @@ -1071,6 +1096,72 @@ mod tests { } } + /// A pad re-plug must not strand the client's rumble reorder gate. + /// + /// The client's `rumble_last_seq` lives for the whole QUIC connection and has no reset path + /// (`punktfunk-core/src/client/pump/datagram_task.rs`), so this host's per-pad rumble counter + /// has to stay monotonic across a `GamepadRemove`. Regression: the removal arm used to do + /// `rumble_seq[idx] = 0`, which made every envelope after a re-plug fail `seq_newer` until the + /// counter climbed back past the value the client had already stored — up to 128 sends. + /// + /// Drives the real wire encoder and the real gate, so it fails if either side's rule moves. + #[test] + fn rumble_seq_survives_a_removal_so_the_client_gate_accepts() { + use punktfunk_core::input::GamepadSnapshot; + use punktfunk_core::quic::{decode_rumble_envelope, encode_rumble_datagram_v2}; + + // The client half: one per-pad slot, per connection, never reset. + let deliver = |seq: u8, gate: &mut Option| { + let d = encode_rumble_datagram_v2(0, 0x4000, 0x8000, seq, 400); + let env = decode_rumble_envelope(&d) + .expect("v2 envelope decodes") + .envelope + .expect("v2 tail present"); + if GamepadSnapshot::seq_newer(env.seq, *gate) { + *gate = Some(env.seq); + true + } else { + false + } + }; + + // The host half: one wrapping counter, bumped on every change and every renewal. + let mut gate: Option = None; + let mut seq = 0u8; + + // A long rumble before the unplug pushes the client's stored seq well past zero. + for _ in 0..100 { + seq = seq.wrapping_add(1); + assert!(deliver(seq, &mut gate)); + } + assert_eq!(gate, Some(100)); + + // The pad is unplugged mid-buzz: the lease is cleared, the counter is not. + let (mut state, mut seen, mut burst) = ((0x1234u16, 0x5678u16), true, RUMBLE_STOP_BURST); + clear_pad_feedback(&mut state, &mut seen, &mut burst); + assert_eq!( + (state, seen, burst), + ((0, 0), false, 0), + "lease not cleared" + ); + + // It returns on the same wire index and the game rumbles again: the very first envelope + // has to reach the actuator. + seq = seq.wrapping_add(1); + assert!( + deliver(seq, &mut gate), + "first envelope after a re-plug was dropped by the client's reorder gate" + ); + + // Non-vacuity: the pre-fix behaviour (counter restarted at 0) really is rejected, and + // stays rejected for the whole forward window — this is the bug, reproduced. + let mut stranded = Some(100u8); + assert!( + (1..=100).all(|s| !deliver(s, &mut stranded)), + "test is vacuous — a restarted counter should have been gated out" + ); + } + /// Incremental wire events accumulate into the full pad frame the virtual xpad applies. #[test] fn gamepad_accumulator() {