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
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:
@@ -17,6 +17,7 @@ use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
|
||||
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
|
||||
@@ -496,6 +497,9 @@ pub struct DualSenseManager {
|
||||
state: Vec<DsState>,
|
||||
/// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it.
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// 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,
|
||||
}
|
||||
@@ -512,6 +516,7 @@ impl DualSenseManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
state: vec![DsState::neutral(); MAX_PADS],
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
@@ -604,6 +609,24 @@ impl DualSenseManager {
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
let _ = pad.write_state(&st);
|
||||
}
|
||||
// Reset the heartbeat timer on every write (real input or heartbeat), so an actively-used
|
||||
// pad emits no extra reports — the heartbeat only fills genuine input-silence gaps.
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap`. A real DualSense
|
||||
/// streams report `0x01` continuously (~250 Hz); the kernel `hid-playstation` driver / Proton /
|
||||
/// SDL treat a multi-second silence (a held-steady stick produces no wire events) as an
|
||||
/// unplugged controller — the "controller disconnected every few seconds" symptom. Re-sending
|
||||
/// the current state is idempotent (a stale-but-correct frame, never a phantom input);
|
||||
/// `write_state` bumps the report's seq + timestamp, so each is a fresh, well-formed report.
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..self.pads.len() {
|
||||
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
@@ -619,6 +642,7 @@ impl DualSenseManager {
|
||||
self.pads[idx] = Some(p);
|
||||
self.state[idx] = DsState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user