fix(host/dualsense): heartbeat virtual DualSense so it isn't dropped when idle
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 1s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 0s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 0s
android / android (push) Failing after 21s
ci / web (push) Failing after 11s
ci / docs-site (push) Failing after 0s
ci / bench (push) Failing after 1s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 0s
flatpak / build-publish (push) Failing after 0s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1s
apple / swift (push) Successful in 53s
ci / rust (push) Failing after 2m34s

"Controller disconnected every few seconds" (Forza Horizon, held steady): the
virtual UHID DualSense emitted HID report 0x01 ONLY on state change, but a real
DualSense streams it continuously (~250 Hz). When the player holds the
wheel/throttle steady the client sends no wire events, so the host wrote nothing
and /dev/uhid went silent for seconds — the kernel hid-playstation driver / Proton
/ SDL treat that as an unplugged controller. (The uinput X-Box pad is immune:
evdev holds last-known state with no periodic-report requirement.)

Add DualSenseManager::heartbeat(max_gap): re-emit each live pad's CURRENT report
when it's been silent for max_gap (idempotent — a stale-but-correct frame, never a
phantom input; write_state bumps seq+timestamp). write() resets the per-pad timer,
so an actively-used pad emits no extra reports — the heartbeat only fills genuine
silence. PadBackend::heartbeat() drives it at an 8 ms gap (~125 Hz) for DualSense
(no-op for X-Box), called every input-thread tick (the loop already runs ≤4 ms).

GET_REPORT feature replies + the pad lifecycle were ruled out by the investigation
(pad is created once, never torn down mid-session). Compiles, clippy/fmt clean, 78
host tests pass. Verify on the box: held-idle DualSense stays present in evtest /
no SDL CONTROLLERDEVICEREMOVED; Forza no longer toasts "controller disconnected".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 07:37:49 +00:00
parent 01305c67a7
commit 7d5dbd47b7
2 changed files with 41 additions and 0 deletions
+17
View File
@@ -1198,6 +1198,19 @@ impl PadBackend {
PadBackend::DualSense(m) => m.pump(rumble, hidout),
}
}
/// Keep a virtual DualSense alive during input silence: re-emit its current HID report if it's
/// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as
/// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev
/// holds last-known state with no periodic-report requirement). Called every input-thread tick;
/// the per-pad gap timer (not the tick rate) governs the actual emit cadence.
fn heartbeat(&mut self) {
match self {
PadBackend::Xbox360(_) => {}
#[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)),
}
}
}
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
@@ -1293,6 +1306,10 @@ fn input_thread(
let _ = conn.send_datagram(h.encode().into());
},
);
// Keep the virtual DualSense from going silent during steady input (no-op for X-Box): a
// held-steady pad sends no wire events, so without a periodic re-emit the kernel/SDL drop
// it as unplugged. The 8 ms gap inside heartbeat() governs the rate, not this ≤4 ms tick.
pads.heartbeat();
if last_refresh.elapsed() >= std::time::Duration::from_millis(500) {
last_refresh = std::time::Instant::now();
for (i, &(low, high)) in rumble_state.iter().enumerate() {