feat(host/windows,drivers): gamepad driver attach/heartbeat health surfaced in logs
The gamepad drivers have no IOCTL plane (hidclass gates the stack), so until now the host had ZERO visibility into whether a driver ever bound: a pad could be "created" with no driver installed and nothing was logged. Two health fields are carved from reserved shm space (layout-compatible; pf-driver-proto pins the offsets): driver_proto — stamped by pf-xusb at device add + per serviced XInput IOCTL (movement = the game-visible path) and by pf-dualsense/DS4 from its ~125Hz timer — and driver_heartbeat. Host-side, every pad owns a DriverAttach watcher fed from the existing service() poll: INFO on attach (WARN on proto mismatch), and after 3s of silence ONE diagnosis WARN combining a cached pnputil /enum-drivers store check, the devnode's CM problem code (CM_Locate_DevNodeW/CM_Get_DevNode_Status on the instance id now captured from the create callback, with plain-language hints: 28 = not installed, 52 = signature/Memory Integrity, …) and the driver's debug log path. Also fixes a real bug both SwDeviceCreate wrappers shared: the 10s WaitForSingleObject result was ignored and the callback HRESULT zero-initialised, so a PnP timeout read as SUCCESS (now E_FAIL init + explicit timeout error). Failure-mode table: design/gamepad-driver-health.md. Linux workspace green; Windows host + drivers CI-compile only, on-box recipe at the bottom of the design doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,13 @@
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::os::windows::io::{FromRawHandle, OwnedHandle};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::core::{w, HSTRING, PCWSTR};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
CM_Get_DevNode_Status, CM_Locate_DevNodeW, CM_DEVNODE_STATUS_FLAGS, CM_LOCATE_DEVNODE_NORMAL,
|
||||
CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
|
||||
};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||
use windows::Win32::Security::Authorization::{
|
||||
@@ -121,3 +127,204 @@ impl Drop for SwDevice {
|
||||
unsafe { SwDeviceClose(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Driver health surfacing ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The gamepad drivers have no IOCTL plane (hidclass gates the stack), so the only cross-process
|
||||
// signal is the shared section itself. The drivers stamp `driver_proto` into their section once
|
||||
// attached (pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION); [`DriverAttach`] watches that field
|
||||
// from the host's regular pump and turns silence into actionable WARN/ERROR log lines — the piece
|
||||
// that used to be missing entirely: a pad could be "created" with no driver installed and nothing
|
||||
// was ever logged until the user gave up ("host doesn't see my controller" bug reports).
|
||||
|
||||
/// How long to give PnP to bind the driver + the driver to stamp the section before warning.
|
||||
const ATTACH_GRACE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Per-pad driver-attach watcher: feed it the section's `driver_proto` on every service tick; it
|
||||
/// logs the attach (INFO), a version mismatch (WARN), or — after [`ATTACH_GRACE`] of silence — one
|
||||
/// diagnosis WARN combining the driver-store check and the devnode problem code. States never
|
||||
/// repeat their log line, so the pump can call this at full rate.
|
||||
pub(super) struct DriverAttach {
|
||||
/// Driver label for log lines (`pf_xusb` / `pf_dualsense` / `pf_dualshock4`).
|
||||
driver: &'static str,
|
||||
/// The INF the driver store must hold for this driver (`pf_xusb.inf` / `pf_dualsense.inf`).
|
||||
inf: &'static str,
|
||||
/// The driver's own debug log, referenced in the diagnosis line.
|
||||
driver_log: &'static str,
|
||||
/// Section name, for log lines.
|
||||
shm_name: String,
|
||||
/// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path).
|
||||
instance_id: Option<String>,
|
||||
created: Instant,
|
||||
state: AttachState,
|
||||
}
|
||||
|
||||
enum AttachState {
|
||||
Waiting,
|
||||
/// Diagnosis logged; still watching so a late attach gets its INFO line.
|
||||
Warned,
|
||||
Attached,
|
||||
}
|
||||
|
||||
impl DriverAttach {
|
||||
pub(super) fn new(
|
||||
driver: &'static str,
|
||||
inf: &'static str,
|
||||
driver_log: &'static str,
|
||||
shm_name: String,
|
||||
instance_id: Option<String>,
|
||||
) -> DriverAttach {
|
||||
DriverAttach {
|
||||
driver,
|
||||
inf,
|
||||
driver_log,
|
||||
shm_name,
|
||||
instance_id,
|
||||
created: Instant::now(),
|
||||
state: AttachState::Waiting,
|
||||
}
|
||||
}
|
||||
|
||||
/// `driver_proto` is the section field the driver stamps once attached (0 = not attached).
|
||||
pub(super) fn observe(&mut self, driver_proto: u32) {
|
||||
match self.state {
|
||||
AttachState::Attached => {}
|
||||
AttachState::Waiting | AttachState::Warned if driver_proto != 0 => {
|
||||
let late = matches!(self.state, AttachState::Warned);
|
||||
tracing::info!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
proto = driver_proto,
|
||||
late,
|
||||
"gamepad driver attached to the shared section"
|
||||
);
|
||||
if driver_proto != pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION {
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
driver_proto,
|
||||
host_proto = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION,
|
||||
"gamepad driver/host protocol mismatch — update the drivers: punktfunk-host.exe driver install --gamepad"
|
||||
);
|
||||
}
|
||||
self.state = AttachState::Attached;
|
||||
}
|
||||
AttachState::Waiting if self.created.elapsed() >= ATTACH_GRACE => {
|
||||
self.diagnose();
|
||||
self.state = AttachState::Warned;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
||||
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
||||
fn diagnose(&self) {
|
||||
let store = match driver_store_has(self.inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed)",
|
||||
};
|
||||
let devnode = match &self.instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => {
|
||||
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log = self.driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-health) driver also reads as \
|
||||
not-attached: update with punktfunk-host.exe driver install --gamepad"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||
/// consulted on the failure path, so the one-off subprocess cost never hits a healthy session.
|
||||
fn driver_store_inventory() -> &'static str {
|
||||
static INV: OnceLock<String> = OnceLock::new();
|
||||
INV.get_or_init(|| {
|
||||
std::process::Command::new("pnputil")
|
||||
.arg("/enum-drivers")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_ascii_lowercase())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the driver store holds `inf` (e.g. `pf_xusb.inf`). `None` = pnputil unavailable/failed.
|
||||
fn driver_store_has(inf: &str) -> Option<bool> {
|
||||
let inv = driver_store_inventory();
|
||||
if inv.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(inv.contains(&inf.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// Human-readable PnP status of a devnode: driver bound/started or the CM problem code with a hint.
|
||||
fn devnode_status_line(instance_id: &str) -> String {
|
||||
let wide: Vec<u16> = instance_id
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut devinst = 0u32;
|
||||
// SAFETY: `wide` is a valid NUL-terminated UTF-16 instance id; `devinst` receives the handle.
|
||||
let cr = unsafe {
|
||||
CM_Locate_DevNodeW(
|
||||
&mut devinst,
|
||||
PCWSTR(wide.as_ptr()),
|
||||
CM_LOCATE_DEVNODE_NORMAL,
|
||||
)
|
||||
};
|
||||
if cr != CR_SUCCESS {
|
||||
return format!(
|
||||
"devnode {instance_id} not found (CM_Locate_DevNodeW CR={})",
|
||||
cr.0
|
||||
);
|
||||
}
|
||||
let mut status = CM_DEVNODE_STATUS_FLAGS(0);
|
||||
let mut problem = CM_PROB(0);
|
||||
// SAFETY: devinst is the devnode located above; the two out-params receive status + problem.
|
||||
let cr = unsafe { CM_Get_DevNode_Status(&mut status, &mut problem, devinst, 0) };
|
||||
if cr != CR_SUCCESS {
|
||||
return format!("devnode {instance_id}: status query failed (CR={})", cr.0);
|
||||
}
|
||||
if status.0 & DN_HAS_PROBLEM.0 != 0 {
|
||||
return format!(
|
||||
"devnode {instance_id} has PnP problem code {} ({}) [status 0x{:08x}]",
|
||||
problem.0,
|
||||
cm_problem_hint(problem.0),
|
||||
status.0
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"devnode {instance_id} status 0x{:08x} (driver_loaded={} started={})",
|
||||
status.0,
|
||||
status.0 & DN_DRIVER_LOADED.0 != 0,
|
||||
status.0 & DN_STARTED.0 != 0,
|
||||
)
|
||||
}
|
||||
|
||||
/// The CM_PROB_* codes a virtual-pad devnode realistically hits, with the operator-facing cause.
|
||||
fn cm_problem_hint(problem: u32) -> &'static str {
|
||||
match problem {
|
||||
1 => "not configured — no driver bound; install the drivers",
|
||||
10 => "device failed to start — driver bound but its start failed; check the driver log",
|
||||
18 => "reinstall required — re-run driver install",
|
||||
24 => "device not present/working — PnP could not start the virtual devnode",
|
||||
28 => "drivers not installed — the pf driver package is missing from the store or its certificate is not trusted",
|
||||
31 => "driver failed to load — binding found the package but loading it failed",
|
||||
39 => "driver corrupt or missing — reinstall the drivers",
|
||||
43 => "reported failure after start — check the driver log",
|
||||
52 => "driver signature rejected — certificate not in Root/TrustedPublisher, or blocked by Memory Integrity",
|
||||
_ => "see Device Manager for this code",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user