diff --git a/crates/pf-inject/src/inject/pad_slots.rs b/crates/pf-inject/src/inject/pad_slots.rs index e84a74a9..2b69235d 100644 --- a/crates/pf-inject/src/inject/pad_slots.rs +++ b/crates/pf-inject/src/inject/pad_slots.rs @@ -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-` 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 { + err.downcast_ref::().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

PadSlots

{ 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

PadSlots

{ } } + /// 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(); diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 9ceb5a41..715dc9f2 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -274,6 +274,19 @@ impl UhidManager { } } + /// 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 { diff --git a/crates/pf-inject/src/inject/windows/gamepad_raii.rs b/crates/pf-inject/src/inject/windows/gamepad_raii.rs index dded23fc..8ae738cb 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_raii.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_raii.rs @@ -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 { // 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 { @@ -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::() + .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 diff --git a/crates/pf-inject/src/inject/windows/gamepad_windows.rs b/crates/pf-inject/src/inject/windows/gamepad_windows.rs index 22c54bbb..8b74de48 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_windows.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_windows.rs @@ -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!( diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 0bdfdd28..dd1b3eb0 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -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>` 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 diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 525bbb57..5b47a4ce 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -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." diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 578e6f51..6f205080 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -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-` + // 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