fix(pads/windows): say WHY a pad index is taken, and stop the devtest lying when it is
Debugging the on-glass session, a devtest run died with
error=create gamepad bootstrap mailbox Global\pfds-boot-0: Zugriff verweigert (0x80070005)
(install/repair: punktfunk-host.exe driver install --gamepad)
and then — this is the part that cost real time — kept printing "virtual Xbox One S Controller up",
streamed frames into nothing, and let the operator measure the INCUMBENT pad on that index. The
XInput packet count sat frozen and read as "the pad is dead", which was a wrong conclusion drawn
from a harness that had already failed and not said so.
WHAT IT ACTUALLY WAS. Pad lifetime is deliberately tied to the SESSION (native/input.rs: "the
gamepads are created and torn down with the session"), and a live session's pad legitimately owns
`Global\pfds-boot-0`. The mailbox's SDDL is `D:P(A;;GA;;;SY)(A;;GA;;;LS)` — SYSTEM and LocalService
only — and the host service runs as LocalSystem while a hand-run devtest runs as an elevated
Administrator, which is in neither ACE. `CreateFileMappingW` over an existing name is really an
OPEN, access-checked against the incumbent's DACL, so it returned ACCESS_DENIED and bailed at the
`?` BEFORE reaching the `ERROR_ALREADY_EXISTS` branch that already had the right sentence. That
branch only ever fires when both processes run as the same account.
The name is per-index on purpose and stays that way: `Global\pfds-boot-{index}` is the rendezvous
the driver polls, and its existence doubles as host-liveness. Making it per-process would let two
hosts build two devices on one wire index — the "the game sees two controllers" bug. The collision
is correct; only the diagnosis was wrong.
* `gamepad_raii.rs` classifies the failure: on ACCESS_DENIED it probes with `OpenFileMappingW`,
which separates what the OS collapsed — object-manager lookup precedes the access check, so
absent gives FILE_NOT_FOUND and present-but-forbidden gives ACCESS_DENIED. It now says the
mailbox belongs to a live session's pad and that nothing is wrong with the drivers.
* `pad_slots.rs` carries that as a typed `PadCreateFault` through the anyhow chain, so `ensure`
prints the fault's remedy instead of the per-backend reinstall hint, plus the pad index.
* `devtest.rs` now BAILS when no pad was actually built, instead of announcing success. This is
the fix that matters: every probe an operator runs next will still find a device on that index.
* `native.rs` names what a detached input thread still holds, since that is one of the ways a pad
can outlive its session.
DELIBERATELY NOT CHANGED, with reasons: the session-scoped pad lifetime (intentional and
documented); the mailbox naming (load-bearing, above); the retry/backoff (latching would resurrect
the `broken` flag `PadGate` exists to kill); the 10 s thread-detach in `serve_session` and the
service's `TerminateProcess` shutdown — both are real ways a devnode can outlive its owner, but
neither is evidenced in the field case and inventing a fix for an unobserved path is how you get a
regression instead of a bugfix.
`pf-inject/lib.rs` drops the `cfg(any(linux, windows))` gate on `pad_gate`/`pad_slots`. Neither
touches an OS pad API, and the gate meant a classification whose entire subject is a `cfg(windows)`
failure could not be tested on a dev machine at all.
VERIFIED
* ON WINDOWS (.173): `cargo test -p pf-inject --lib` 109/109; `cargo build -p punktfunk-host`
clean. Both agents' Windows code was compile-UNVERIFIED before this run.
* macOS: 5 new tests, including one that pins the anyhow downcast through the exact three-layer
context chain the Windows code builds — the assumption that could not otherwise be checked.
* `cargo fmt --all --check` clean.
NOT VERIFIED
* That a LocalSystem-owned mailbox really answers `OpenFileMappingW` with ACCESS_DENIED rather
than FILE_NOT_FOUND from an Administrator token. That is reasoned from the object manager's
lookup-then-access-check order, not measured. Repro on .173: hold a session pad on index 0, run
the devtest from an elevated console, and check the new sentence appears.
This commit is contained in:
@@ -16,6 +16,78 @@ const _: () = assert!(MAX_PADS <= 16);
|
||||
/// quiet.
|
||||
const SWEEP_GRACE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// A create failure whose CAUSE the backend was able to identify, attached to the `anyhow` error
|
||||
/// it returns (`err.context(PadCreateFault::…)`) so [`PadSlots::ensure`] can print the matching
|
||||
/// remedy instead of the backend's default one.
|
||||
///
|
||||
/// Why this exists. The create-failure line's remedy is a per-backend constant (`PadSlots`'s
|
||||
/// `hint`, from [`PadSlots::new`]), and on Windows that constant says "install/repair: punktfunk-host.exe
|
||||
/// driver install --gamepad", because a pad create that fails there has nearly always failed for
|
||||
/// want of the UMDF driver package. Nearly. On 2026-08-09 a `.173` devtest hit a create that
|
||||
/// failed for the opposite reason — the drivers were fine and a LIVE SIBLING PROCESS already owned
|
||||
/// the pad index's OS-level name — and the line told the operator to repair a driver that was
|
||||
/// working. Worse, the run carried on: the retry could not succeed while the other process held
|
||||
/// the index, and everything measured afterwards was that other process's pad (a frozen XInput
|
||||
/// packet count read as a real measurement). A wrong remedy is worse than no remedy, so a backend
|
||||
/// that can name the cause now says so and the line follows it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PadCreateFault {
|
||||
/// The OS-level name this pad index needs — on Windows the `Global\pf…-boot-<index>` bootstrap
|
||||
/// mailbox — is already held by another LIVE process.
|
||||
///
|
||||
/// Retrying stays right and is deliberately left alone: the name frees itself the moment the
|
||||
/// owner releases it (a session ending, a service restart), and that is exactly how the field
|
||||
/// case recovered. What retrying can never do is *hurry* it, and no driver install affects it
|
||||
/// at all — which is the whole content of [`Self::hint`].
|
||||
IndexOwnedElsewhere,
|
||||
}
|
||||
|
||||
impl PadCreateFault {
|
||||
/// Short tag for the structured `fault` log field — greppable; the prose lives in
|
||||
/// [`Self::hint`].
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PadCreateFault::IndexOwnedElsewhere => "index-owned-elsewhere",
|
||||
}
|
||||
}
|
||||
|
||||
/// The remedy this fault gets INSTEAD of the backend's default hint.
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
PadCreateFault::IndexOwnedElsewhere => {
|
||||
" — this pad index is already owned by another LIVE process (on a Windows host \
|
||||
that is the LocalSystem PunktfunkHost service, whose session still holds the \
|
||||
pad). The drivers are not the problem and reinstalling them will not help: the \
|
||||
retry succeeds on its own once that process releases the index (end its session, \
|
||||
or Restart-Service PunktfunkHost), or run against a pad index it does not hold."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PadCreateFault {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PadCreateFault::IndexOwnedElsewhere => f.write_str(
|
||||
"the OS name this pad index needs is already owned by another live process",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fault a backend attached to a create error, if any.
|
||||
///
|
||||
/// An `anyhow` context downcast, which is what makes this usable from a backend: the fault is
|
||||
/// found however many further `.context()` layers were wrapped around it on the way up, so a
|
||||
/// backend can attach it at the exact call that failed and still describe the failure in its own
|
||||
/// words afterwards. Split out of [`PadSlots::ensure`] so the choice is testable without standing
|
||||
/// up a tracing subscriber — and so the downcast-through-context behaviour this depends on is
|
||||
/// pinned by a test rather than assumed (the attaching code is `cfg(windows)` and cannot be
|
||||
/// compiled, let alone run, on a developer machine).
|
||||
fn create_fault(err: &anyhow::Error) -> Option<PadCreateFault> {
|
||||
err.downcast_ref::<PadCreateFault>().copied()
|
||||
}
|
||||
|
||||
/// What one [`PadSlots::sweep`] changed, as bitmasks over the wire pad indices.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
|
||||
pub struct Sweep {
|
||||
@@ -172,11 +244,24 @@ impl<P> PadSlots<P> {
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// Which remedy to print. The backend's default `hint` assumes the failure is the
|
||||
// one that dominates the field — on Windows an absent or stale driver package —
|
||||
// and sends the operator to reinstall. For a create that failed because a live
|
||||
// sibling owns this index that advice is not merely useless, it is a wrong lead
|
||||
// that costs a debugging session (2026-08-09, `.173`), so a named fault overrides
|
||||
// it. Anonymous failures keep the previous wording byte for byte.
|
||||
//
|
||||
// `index` is new and unconditional: the line used to name the backend and the
|
||||
// device but never the SLOT, so a multi-pad session's failure could not be told
|
||||
// from any other pad's.
|
||||
let fault = create_fault(&e);
|
||||
tracing::error!(
|
||||
index = idx,
|
||||
error = %format!("{e:#}"),
|
||||
fault = fault.map_or("unclassified", PadCreateFault::as_str),
|
||||
"virtual {} creation failed — retrying with backoff{}",
|
||||
self.device,
|
||||
self.hint
|
||||
fault.map_or(self.hint, PadCreateFault::hint)
|
||||
);
|
||||
self.gate.on_failure(Instant::now());
|
||||
false
|
||||
@@ -184,6 +269,18 @@ impl<P> PadSlots<P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many pads this table currently holds.
|
||||
///
|
||||
/// The question a bring-up harness has to ask before it believes anything it measures: a
|
||||
/// create that failed leaves the slot empty and [`Self::ensure`] only logs, so a devtest that
|
||||
/// pushes frames regardless is measuring whatever OTHER process's pad is answering on that
|
||||
/// index — which is exactly how a stale pad's frozen packet count was once read as a result
|
||||
/// (2026-08-09). Not `len` (and so not paired with `is_empty`): it counts LIVE pads, not the
|
||||
/// fixed [`MAX_PADS`] slots the table always has.
|
||||
pub fn live(&self) -> usize {
|
||||
self.pads.iter().flatten().count()
|
||||
}
|
||||
|
||||
/// The live pad at `idx`, if any (out-of-range → `None`).
|
||||
pub fn get(&self, idx: usize) -> Option<&P> {
|
||||
self.pads.get(idx).and_then(|s| s.as_ref())
|
||||
@@ -350,6 +447,93 @@ mod tests {
|
||||
assert_eq!(s.get(1), Some(&7), "the glitch never reached the drop");
|
||||
}
|
||||
|
||||
/// The mechanism the Windows backend's diagnosis rests on, and the one thing about it that
|
||||
/// could quietly stop working: [`create_fault`] must find the fault through however many
|
||||
/// `.context()` layers wrapped it. The real chain is built in `gamepad_raii::create_named`
|
||||
/// (`cfg(windows)`, so neither compiled nor run here) and has exactly this shape — the OS
|
||||
/// error at the bottom, the fault, then the human sentence on top — so reproduce it verbatim.
|
||||
#[test]
|
||||
fn a_named_fault_survives_the_context_layers_wrapped_around_it() {
|
||||
let err = anyhow::Error::msg("Zugriff verweigert (0x80070005)")
|
||||
.context(PadCreateFault::IndexOwnedElsewhere)
|
||||
.context("bootstrap mailbox Global\\pfds-boot-0 already exists");
|
||||
assert_eq!(
|
||||
create_fault(&err),
|
||||
Some(PadCreateFault::IndexOwnedElsewhere)
|
||||
);
|
||||
// …and the operator-facing rendering still carries every layer, newest first, so the
|
||||
// underlying OS error is never traded away for the diagnosis.
|
||||
let shown = format!("{err:#}");
|
||||
assert!(shown.contains("Global\\pfds-boot-0"), "{shown}");
|
||||
assert!(
|
||||
shown.contains("already owned by another live process"),
|
||||
"{shown}"
|
||||
);
|
||||
assert!(shown.contains("0x80070005"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclassified_failure_carries_no_fault() {
|
||||
// Every other backend failure — a missing driver, a wedged PnP, an EBUSY on /dev/uinput —
|
||||
// must keep the backend's own hint, so the absence of a fault has to read as absence.
|
||||
assert_eq!(
|
||||
create_fault(&anyhow::Error::msg("SwDeviceCreate failed")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// THE regression this classification exists for: the contended remedy must not send an
|
||||
/// operator to reinstall a driver that is working fine, and must name what actually has to
|
||||
/// happen. Asserted on the text because the text is the whole deliverable.
|
||||
#[test]
|
||||
fn the_contended_hint_never_tells_the_operator_to_reinstall_drivers() {
|
||||
let hint = PadCreateFault::IndexOwnedElsewhere.hint();
|
||||
assert!(
|
||||
!hint.contains("driver install"),
|
||||
"the contended hint must not repeat the driver-repair advice: {hint}"
|
||||
);
|
||||
assert!(hint.contains("already owned"), "{hint}");
|
||||
assert!(hint.contains("Restart-Service"), "{hint}");
|
||||
}
|
||||
|
||||
/// A named fault must not turn the create into a permanent latch — that latch is the exact
|
||||
/// `broken: bool` behaviour [`PadGate`] was built to remove, and the field case healed by
|
||||
/// itself precisely because the retry was still running when the owning service restarted.
|
||||
#[test]
|
||||
fn a_contended_create_still_backs_off_and_retries_rather_than_latching() {
|
||||
let mut s = slots();
|
||||
let contended = || {
|
||||
Err(anyhow::Error::msg("Zugriff verweigert")
|
||||
.context(PadCreateFault::IndexOwnedElsewhere))
|
||||
};
|
||||
assert!(!s.ensure(0, |_| contended()));
|
||||
assert_eq!(s.live(), 0);
|
||||
// Backed off, not latched: once the window elapses the closure runs again. `ensure` reads
|
||||
// the wall clock, so clear the backoff directly rather than sleeping through it — the
|
||||
// window's own arithmetic is pinned by `pad_gate`'s tests.
|
||||
s.gate.on_success();
|
||||
let mut ran = false;
|
||||
assert!(s.ensure(0, |i| {
|
||||
ran = true;
|
||||
Ok(i as u32)
|
||||
}));
|
||||
assert!(ran, "the create was never re-attempted");
|
||||
assert_eq!(s.live(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_counts_built_pads_not_slots() {
|
||||
let mut s = slots();
|
||||
assert_eq!(s.live(), 0, "an empty table has no pads, only slots");
|
||||
assert!(s.ensure(0, |_| Ok(0)));
|
||||
assert!(s.ensure(4, |_| Ok(4)));
|
||||
assert_eq!(s.live(), 2);
|
||||
let t0 = Instant::now();
|
||||
s.sweep_at(0, t0);
|
||||
s.sweep_at(0, t0 + SWEEP_GRACE);
|
||||
assert_eq!(s.live(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_failure_arms_the_gate_and_success_heals_it() {
|
||||
let mut s = slots();
|
||||
|
||||
@@ -274,6 +274,19 @@ impl<B: PadProto> UhidManager<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many virtual pads this manager has actually BUILT
|
||||
/// ([`PadSlots::live`](crate::pad_slots::PadSlots::live)).
|
||||
///
|
||||
/// For bring-up harnesses, which are the only callers that can act on it: a create failure
|
||||
/// leaves the slot empty and only logs, so a harness that pushes frames regardless still
|
||||
/// "works" — it just drives nothing, while whatever OTHER process owns that pad index keeps
|
||||
/// answering every probe the operator then runs. That is how a stale pad's frozen XInput
|
||||
/// packet count was once read as a measurement (2026-08-09, `.173`). A session has no use for
|
||||
/// this: its pads come and go with the client's `active_mask` and zero is a normal state.
|
||||
pub fn live_pads(&self) -> usize {
|
||||
self.slots.live()
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
use super::channel_proof;
|
||||
/// Re-exported so a pad backend needs only one `use` to wire up its channel.
|
||||
pub(super) use super::channel_proof::ProofTransport;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use crate::pad_slots::PadCreateFault;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
|
||||
use std::ffi::c_void;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
@@ -67,16 +68,17 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||
use windows::Win32::Foundation::{
|
||||
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
|
||||
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WIN32_ERROR,
|
||||
CloseHandle, DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
|
||||
ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0,
|
||||
WIN32_ERROR,
|
||||
};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
CreateFileMappingW, MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
FILE_MAP_READ, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::System::Threading::{
|
||||
GetCurrentProcess, OpenProcess, SetEvent, WaitForSingleObject, PROCESS_DUP_HANDLE,
|
||||
@@ -171,6 +173,16 @@ impl Shm {
|
||||
/// don't control — we close and retry briefly (our own driver holds the name for microseconds per
|
||||
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
|
||||
/// another host instance's) mailbox.
|
||||
///
|
||||
/// ⚠️ That squat check only ever sees the collisions we are ALLOWED to see. `CreateFileMappingW`
|
||||
/// opens a pre-existing object with full access, so a caller the incumbent's DACL excludes is
|
||||
/// refused with `ERROR_ACCESS_DENIED` and never reaches the `ERROR_ALREADY_EXISTS` branch at
|
||||
/// all — and that is the collision the field actually produces, because this SDDL grants SYSTEM
|
||||
/// and LocalService only, while the host service runs as LocalSystem and a hand-run devtest
|
||||
/// runs as an elevated Administrator. So the "another punktfunk-host instance is serving this
|
||||
/// pad index" diagnosis below was unreachable for the one pairing that happens: on `.173`
|
||||
/// (2026-08-09) it surfaced as a bare `Zugriff verweigert (0x80070005)` under a line telling the
|
||||
/// operator to reinstall the drivers. [`classify_named_create_failure`] is what restores it.
|
||||
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
|
||||
// Build the descriptor ONCE and reuse it across the squat-retry loop — it (and the OS
|
||||
// allocation it owns) lives to the end of this fn, so it outlives every create below.
|
||||
@@ -183,8 +195,10 @@ impl Shm {
|
||||
}
|
||||
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
|
||||
unsafe { SetLastError(WIN32_ERROR(0)) };
|
||||
let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size)
|
||||
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
|
||||
let shm = match Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size) {
|
||||
Ok(shm) => shm,
|
||||
Err(e) => return Err(classify_named_create_failure(name, e)),
|
||||
};
|
||||
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
|
||||
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
|
||||
if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS {
|
||||
@@ -192,11 +206,16 @@ impl Shm {
|
||||
}
|
||||
// `shm` drops here → unmap + close our handle to the foreign object, then retry.
|
||||
}
|
||||
bail!(
|
||||
// Reached only when we COULD open the incumbent (same account — two hosts both as SYSTEM,
|
||||
// or a LocalService squatter). The cross-account case exits through
|
||||
// `classify_named_create_failure` above; both carry the same fault, because to everything
|
||||
// downstream they are the same event: this index is taken.
|
||||
Err(anyhow!(
|
||||
"bootstrap mailbox {name} already exists and stayed alive across retries — another \
|
||||
punktfunk-host instance is serving this pad index, or a local service is squatting the \
|
||||
name (gamepad DoS attempt?)"
|
||||
);
|
||||
)
|
||||
.context(PadCreateFault::IndexOwnedElsewhere))
|
||||
}
|
||||
|
||||
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
|
||||
@@ -250,6 +269,76 @@ impl Drop for Shm {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a failed NAMED-section create into an error that names the cause, because
|
||||
/// `CreateFileMappingW` collapses two OPPOSITE situations into one `ERROR_ACCESS_DENIED`
|
||||
/// (`0x80070005`, and on a German box the entirely unsearchable "Zugriff verweigert" the field
|
||||
/// report carried):
|
||||
///
|
||||
/// * **the name is TAKEN, by someone whose object we may not open.** Creating over an existing name
|
||||
/// is really an open, and an open is access-checked against the incumbent's DACL. The mailbox
|
||||
/// SDDL grants SYSTEM + LocalService only, so the exact pairing that occurs on a dev box — the
|
||||
/// LocalSystem host service holding pad 0 for a live session while an operator runs
|
||||
/// `punktfunk-host.exe dualsense-windows-test` from an elevated Administrator console — is
|
||||
/// refused here rather than reported as the squat it is.
|
||||
/// * **the name is FREE and we may not create it.** `Global\` names need `SeCreateGlobalPrivilege`,
|
||||
/// which SYSTEM and services hold and an ordinary (even elevated) user token does not.
|
||||
///
|
||||
/// `OpenFileMappingW` separates them, because the object-manager lookup happens BEFORE the access
|
||||
/// check: an absent name is `ERROR_FILE_NOT_FOUND`, a present one we are not in the DACL of is
|
||||
/// `ERROR_ACCESS_DENIED`. Everything else keeps the original wording.
|
||||
///
|
||||
/// The contended case additionally carries a [`PadCreateFault`], which is what stops the pad
|
||||
/// manager's failure line from telling the operator to reinstall a driver that is working
|
||||
/// perfectly (see [`crate::pad_slots::PadCreateFault`]).
|
||||
fn classify_named_create_failure(name: &HSTRING, e: anyhow::Error) -> anyhow::Error {
|
||||
let denied = e
|
||||
.downcast_ref::<windows::core::Error>()
|
||||
.is_some_and(|w| w.code() == HRESULT::from_win32(ERROR_ACCESS_DENIED.0));
|
||||
if !denied {
|
||||
return e.context(format!("create gamepad bootstrap mailbox {name}"));
|
||||
}
|
||||
if named_section_exists(name) {
|
||||
return e
|
||||
.context(PadCreateFault::IndexOwnedElsewhere)
|
||||
.context(format!(
|
||||
"bootstrap mailbox {name} exists and belongs to a process this one may not open — a \
|
||||
live session's pad, held by the LocalSystem host service (its mailboxes grant SYSTEM \
|
||||
+ LocalService only, so an Administrator console sees ACCESS_DENIED, not \
|
||||
ALREADY_EXISTS). Nothing is wrong with the drivers"
|
||||
));
|
||||
}
|
||||
e.context(format!(
|
||||
"create gamepad bootstrap mailbox {name}: access denied although the name is FREE — this \
|
||||
process may not create Global\\ objects at all (that needs SeCreateGlobalPrivilege, which \
|
||||
SYSTEM and services hold and a user token does not)"
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether a section with this name exists right now, as seen from THIS process — the
|
||||
/// disambiguation [`classify_named_create_failure`] runs on. `true` also when the object is there
|
||||
/// but closed to us, which is the case that matters: ACCESS_DENIED from an OPEN means the name
|
||||
/// resolved and only the access check failed.
|
||||
///
|
||||
/// Deliberately not a security decision — a hostile squatter can make this say either thing. It
|
||||
/// only ever chooses which sentence to print.
|
||||
fn named_section_exists(name: &HSTRING) -> bool {
|
||||
// SAFETY: `name` is a live NUL-terminated UTF-16 string for the duration of the call. Ask for
|
||||
// the least access there is (`FILE_MAP_READ`): the handle is closed immediately and never
|
||||
// mapped — we want the lookup's verdict, not the object.
|
||||
let opened = unsafe { OpenFileMappingW(FILE_MAP_READ.0, false, PCWSTR(name.as_ptr())) };
|
||||
match opened {
|
||||
Ok(h) => {
|
||||
// SAFETY: `h` is the handle just opened here and referenced nowhere else.
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
true
|
||||
}
|
||||
// ERROR_FILE_NOT_FOUND (and anything else) reads as absent; ACCESS_DENIED is presence.
|
||||
Err(e) => e.code() == HRESULT::from_win32(ERROR_ACCESS_DENIED.0),
|
||||
}
|
||||
}
|
||||
|
||||
// ── The sealed-channel bootstrap broker ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two
|
||||
|
||||
@@ -296,6 +296,13 @@ impl GamepadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many virtual pads this manager has actually BUILT — the bring-up harness's
|
||||
/// "did the create happen?" check; see [`crate::uhid_manager::UhidManager::live_pads`] for why
|
||||
/// only a harness should ask.
|
||||
pub fn live_pads(&self) -> usize {
|
||||
self.slots.live()
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if self.slots.ensure(idx, XusbWinPad::open) {
|
||||
tracing::info!(
|
||||
|
||||
@@ -389,13 +389,22 @@ pub mod mouse_windows;
|
||||
/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for
|
||||
/// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff
|
||||
/// retry.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
///
|
||||
/// Built on every target, not just the two that have pad backends: it is pure timing arithmetic
|
||||
/// over `std::time`, and gating it meant its tests — and [`pad_slots`]', which need it — could not
|
||||
/// run on a developer machine at all. See [`pad_slots`].
|
||||
#[path = "inject/pad_gate.rs"]
|
||||
pub mod pad_gate;
|
||||
/// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the
|
||||
/// `Vec<Option<Pad>>` table, `active_mask` unplug sweep, and gate-checked create every backend
|
||||
/// manager used to copy-paste (G12).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
///
|
||||
/// Built on every target for the same reason as [`pad_gate`]: nothing in it touches an OS pad API
|
||||
/// (the backend supplies the pad type and the `open` closure), so the platform gate bought
|
||||
/// nothing and cost the ability to run the table's tests off a host box. That matters most for
|
||||
/// [`pad_slots::PadCreateFault`], whose whole job is to describe a `cfg(windows)` failure that
|
||||
/// only a Windows box can produce — the classification either has tests that run everywhere, or
|
||||
/// it has none that anyone runs.
|
||||
#[path = "inject/pad_slots.rs"]
|
||||
pub mod pad_slots;
|
||||
/// The `sensor_timestamp` every virtual Sony pad stamps into its input reports
|
||||
|
||||
@@ -398,6 +398,23 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
// 🛑 Never announce a pad that was not built. The arrival above only ASKS for one; a
|
||||
// failed create logs an ERROR and leaves the slot empty, and this harness would then
|
||||
// cheerfully print "virtual X up" and stream frames into nothing for `secs` seconds.
|
||||
// Every probe the operator runs next (joy.cpl, XInputGetState, a WGI enumeration) still
|
||||
// finds a device on this index — the one the OTHER process owns — so the run produces a
|
||||
// plausible, wrong measurement instead of a failure. That happened on `.173`
|
||||
// (2026-08-09): the host service held pad 0, the create was denied, and a frozen XInput
|
||||
// packet count off the incumbent pad was read as a result. A harness that cannot build
|
||||
// its own device has nothing to measure, so stop.
|
||||
if mgr.live_pads() == 0 {
|
||||
anyhow::bail!(
|
||||
"no virtual {} was created at index {idx} — see the ERROR above for the \
|
||||
cause. NOT measuring: any device answering on this index belongs to another \
|
||||
process (a live session's pad), and reading it would look like a result.",
|
||||
$label
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
|
||||
it in joy.cpl / Steam / a game; any feedback the game sends prints below.",
|
||||
@@ -458,6 +475,13 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
// Same guard as the `drive!` macro's — see the long note there.
|
||||
if mgr.live_pads() == 0 {
|
||||
anyhow::bail!(
|
||||
"no virtual Xbox 360 (XUSB) was created at index {idx} — see the ERROR above. NOT \
|
||||
measuring: a device answering on this index belongs to another process."
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
|
||||
an XInput game or xinputtest.exe."
|
||||
|
||||
@@ -1824,9 +1824,20 @@ async fn serve_session(
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// Name what is still held, not just that a thread was let go. The input thread OWNS this
|
||||
// session's virtual gamepads (`input_thread`'s `Pads`, dropped only when that fn returns),
|
||||
// and on Windows each one holds a `SwDeviceCreate` devnode plus the `Global\pf…-boot-<idx>`
|
||||
// bootstrap mailbox for its pad index. Detaching therefore leaves the pads plugged in and
|
||||
// the index taken: the next session — or a bring-up run beside this host — is denied that
|
||||
// index until this thread finally returns, and *that* failure surfaces somewhere else
|
||||
// entirely (see `pf_inject::pad_slots::PadCreateFault::IndexOwnedElsewhere`). An operator
|
||||
// reading only the later error has no way back to this line unless it says so here.
|
||||
tracing::warn!(
|
||||
grace_s = SIDE_THREAD_JOIN_GRACE.as_secs(),
|
||||
"audio/input threads did not exit after the connection closed — detaching them"
|
||||
"audio/input threads did not exit after the connection closed — detaching them. This \
|
||||
session's virtual gamepads are STILL HELD by the detached input thread (devnode + \
|
||||
pad-index mailbox on Windows), so a pad create on the same index will be refused as \
|
||||
already-owned until it returns"
|
||||
);
|
||||
}
|
||||
// The capture (and our gamescope session's VirtualOutput) are gone by here. If this was the
|
||||
|
||||
Reference in New Issue
Block a user