Symptom
Testing the GameSir Super Nova (BT, spoofs Sony DS4 v2 054c:09cc) in-game via WebHID (petegordon/Tandemonium#305). It works great on first connect, but a disconnect→reconnect cycle lands in one of two bad states:
- State 1 — gyro not recognized: the motion toggle shows off and can't be turned on, but the Gamepad-API joystick/buttons work.
- State 2 — neither the joystick/buttons nor the gyro is recognized.
Easy to reproduce: the Super Nova auto-disconnects when placed on its charger and auto-reconnects over Bluetooth the instant it's picked up, so the reconnect path gets exercised constantly.
Root cause — State 1 (confirmed, fixed)
The GameSir is a dual-path device: buttons/sticks via the Gamepad API, gyro via WebHID (IMU-only DS4 report). The HID entry is bound to the slot by ControllerManager._attachMatchingPoolEntry, which only runs at claim time.
On reconnect, the Gamepad-API pad re-appears and re-claims the slot before the async WebHID connect → poolDevice finishes re-pooling the device. So the claim-time bind finds an empty pool and never retries. The HID-pool claim loop that could otherwise re-bind is gated on hasFreshButtonPress() — but the GameSir's HID interface is IMU-only (no buttons), so that gate is never satisfied. Net: claimed slot, working buttons, slot._hidEntry === null → slot.fusion/slot.driver null → gyro toggle off and un-enableable.
Fix
Add a per-frame reconciliation pass in ingestFrame that binds any pool entry to an already-claimed-but-unbound slot it matches (vid:pid OR productName), independent of button activity. Binding-only — it never creates a slot — so it's safe to run every frame. Extracted the slot-match into _findClaimedUnboundSlotForEntry (also DRY-ing the existing HID-pool dedupe). Regression test in test/manager-hid-reconcile.test.js.
State 2 — candidate causes (needs console logs to confirm)
Buttons also dead means no slot claimed the pad at all. Candidates:
- Orphan never recovers. On gamepad disconnect,
ingestFrame calls slot.orphan() (state claimed→orphan), but nothing ever transitions orphan→empty. The claim loops only target state === 'empty'. A reconnect that reuses the same slot can't reclaim it → permanently stuck.
- Reclaim cooldown (
reclaimCooldownMs 1500ms) / _recentlyReleasedByIndex keyed on a reused pad index.
- Ghost-pad suppression (
gamepadHasFreshActivity returns false for a pad with stuck axes and no prior frame) — possible on a BT reconnect where Chrome surfaces the pad with non-centered axes before the first real input.
- Browser/OS simply not re-enumerating the Gamepad-API pad on that cycle (not fixable here).
Ask: next repro of State 2, grab the DevTools console (the manager logs claim/defer/skip decisions) so we can pin which of the above it is. The orphan-never-recovers leak (#1) looks like the most likely real bug and is a candidate follow-up fix.
Found while verifying petegordon/Tandemonium#305.
Symptom
Testing the GameSir Super Nova (BT, spoofs Sony DS4 v2
054c:09cc) in-game via WebHID (petegordon/Tandemonium#305). It works great on first connect, but a disconnect→reconnect cycle lands in one of two bad states:Easy to reproduce: the Super Nova auto-disconnects when placed on its charger and auto-reconnects over Bluetooth the instant it's picked up, so the reconnect path gets exercised constantly.
Root cause — State 1 (confirmed, fixed)
The GameSir is a dual-path device: buttons/sticks via the Gamepad API, gyro via WebHID (IMU-only DS4 report). The HID entry is bound to the slot by
ControllerManager._attachMatchingPoolEntry, which only runs at claim time.On reconnect, the Gamepad-API pad re-appears and re-claims the slot before the async WebHID
connect→poolDevicefinishes re-pooling the device. So the claim-time bind finds an empty pool and never retries. The HID-pool claim loop that could otherwise re-bind is gated onhasFreshButtonPress()— but the GameSir's HID interface is IMU-only (no buttons), so that gate is never satisfied. Net: claimed slot, working buttons,slot._hidEntry === null→slot.fusion/slot.drivernull → gyro toggle off and un-enableable.Fix
Add a per-frame reconciliation pass in
ingestFramethat binds any pool entry to an already-claimed-but-unbound slot it matches (vid:pid OR productName), independent of button activity. Binding-only — it never creates a slot — so it's safe to run every frame. Extracted the slot-match into_findClaimedUnboundSlotForEntry(also DRY-ing the existing HID-pool dedupe). Regression test intest/manager-hid-reconcile.test.js.State 2 — candidate causes (needs console logs to confirm)
Buttons also dead means no slot claimed the pad at all. Candidates:
ingestFramecallsslot.orphan()(stateclaimed→orphan), but nothing ever transitionsorphan→empty. The claim loops only targetstate === 'empty'. A reconnect that reuses the same slot can't reclaim it → permanently stuck.reclaimCooldownMs1500ms) /_recentlyReleasedByIndexkeyed on a reused pad index.gamepadHasFreshActivityreturns false for a pad with stuck axes and no prior frame) — possible on a BT reconnect where Chrome surfaces the pad with non-centered axes before the first real input.Ask: next repro of State 2, grab the DevTools console (the manager logs claim/defer/skip decisions) so we can pin which of the above it is. The orphan-never-recovers leak (#1) looks like the most likely real bug and is a candidate follow-up fix.
Found while verifying petegordon/Tandemonium#305.