fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying #32

Merged
enricobuehler merged 1 commits from worktree-haptics-m4-apple into main 2026-08-04 21:01:54 +00:00
Owner

Workstream M4 of the 2026-08-03 force-feedback sweep — closes B14, B15, B18, B19, B20.

B14 — two DualSenses, one device

DualSenseHID.open() took devices.first from an unordered Set, so the choice can differ between two calls in one process. With two pads attached, each renderer's pad→device binding was a coin flip: both could land on the same device — one pad's rumble coming out of the other, with their two per-instance lastHidWrite dedupes fighting over it — or split by luck. It also bites with a pin set, where a non-forwarded DualSense can be the one opened.

Each renderer now asks for the device its own controller is, correlating GameController's stable ordering with IOKit location ids. Without a preference the lowest id wins: still arbitrary, but stable, which Set.first was not.

The selection rule is extracted as a pure preferredIndex(among:preferring:)IOHIDDevice cannot be constructed, so the logic was otherwise untestable.

B18 — a failed write counted as a successful render

rumble() logged the IOReturn and returned void, so a write that never reached the device still counted as rendered. That matters most for a stop, which has nothing behind it: lastHidWrite was stamped even on failure, the keepalive only re-writes non-zero levels, updateTicker() cancels once the target is (0, 0), and on USB there is no firmware timeout. A swallowed stop left the motors running with nothing scheduled to retry.

The result now reaches the caller, which drops the handle (reverting to CoreHaptics) and reports health, instead of claiming success.

B19 — a half-open split reported HEALTHY

setup() treated only the all-nil case as failure, so one surviving handle passed silently — and what rendered was wrong in a direction depending on which handle died:

  • lose highrender() falls to the combined branch (selected purely by high != nil) and plays max(low, high) on the left handle at the combined sharpness;
  • lose low → the split branch's reconcile no-ops on the nil slot and the heavy motor is discarded.

In both cases ok stayed true and reportHealth(nil) actively announced healthy, resetting consecutiveFailures. A half-open split now tears the survivor down via the existing teardown() and takes the combined path.

B20 — the lightbar stayed lit after the stream

reset() cleared triggers and player index but not light?.color — which this same class writes. Every DS write is valid-flag-selective, so a game's last colour (a low-health red, say) stayed lit in firmware back at the launcher, or for a pad that merely left the forwarded set. DS4 was cleared incidentally because its player indicator is the lightbar; DualSense was not.

The cached slot.lastLight needed no separate fix — session-end stop() already does slots.removeAll().

B15 — stop() blocking the main actor

RumbleRenderer.stop() is a queue.sync whose body is a per-motor CHHapticEngine.stop() — an XPC round trip to gamecontrollerd that the renderer's own comments record as able to hang — plus DualSenseHID.close()'s blocking IOHIDDeviceSetReport to a device that has just departed, and it queues behind any in-flight setup(). It was called from the @MainActor reconcile on every unplug and pin change, and the main thread drives the presenter's CADisplayLink, so it hitched the picture mid-stream.

Now Task.detached. RumbleRenderer is @unchecked Sendable and is already removed from routing at that point, so nothing observes it afterwards. The documented off-main-actor call in GamepadFeedback.stop() is unchanged — that one already honours its own contract.

Verification

  • swift buildBuild complete, and the three changed files confirmed recompiled (a 2.4 s "success" was cached; forced a rebuild rather than trusting it)
  • swift test188 passed, 0 failures (185 before, +3 new)
  • Non-vacuous: reverting the deterministic fallback fails exactly the 3 new tests

Rebuilding here needs a workaround. The checked-in PunktfunkCore.xcframework is stale — it predates punktfunk_connection_report_phase, so swift test fails to compile against it — and scripts/build-xcframework.sh still dies on this Mac at its macOS-floor guard (objects built for macOS 26.0 > 14.0). A macos-arm64 slice assembled by hand from cargo build --release --target aarch64-apple-darwin plus xcodebuild -create-xcframework is enough to typecheck and test. That toolchain/guard problem is pre-existing and unrelated to this PR, but it will block anyone verifying it.

Not on glass. B14 in particular wants two physical DualSenses — the correlation between GameController's ordering and IOKit location ids is the one part that cannot be proven off-device.

🤖 Generated with Claude Code

Workstream **M4** of the [2026-08-03 force-feedback sweep](https://claude.ai/code/artifact/aa75b7f8-736a-4723-b20f-cef5155ed120) — closes **B14, B15, B18, B19, B20**. ## B14 — two DualSenses, one device `DualSenseHID.open()` took `devices.first` from an unordered `Set`, so the choice can differ between two calls in one process. With two pads attached, each renderer's pad→device binding was a coin flip: both could land on the same device — one pad's rumble coming out of the other, with their two per-instance `lastHidWrite` dedupes fighting over it — or split by luck. It also bites with a pin set, where a *non-forwarded* DualSense can be the one opened. Each renderer now asks for the device its own controller is, correlating GameController's stable ordering with IOKit location ids. Without a preference the **lowest** id wins: still arbitrary, but *stable*, which `Set.first` was not. The selection rule is extracted as a pure `preferredIndex(among:preferring:)` — `IOHIDDevice` cannot be constructed, so the logic was otherwise untestable. ## B18 — a failed write counted as a successful render `rumble()` logged the `IOReturn` and returned void, so a write that never reached the device still counted as rendered. That matters most for a **stop**, which has nothing behind it: `lastHidWrite` was stamped even on failure, the keepalive only re-writes non-zero levels, `updateTicker()` cancels once the target is `(0, 0)`, and on USB there is no firmware timeout. A swallowed stop left the motors running with nothing scheduled to retry. The result now reaches the caller, which drops the handle (reverting to CoreHaptics) and reports health, instead of claiming success. ## B19 — a half-open split reported HEALTHY `setup()` treated only the all-nil case as failure, so one surviving handle passed silently — and what rendered was wrong in a direction depending on which handle died: - lose `high` → `render()` falls to the combined branch (selected purely by `high != nil`) and plays `max(low, high)` on the **left** handle at the combined sharpness; - lose `low` → the split branch's `reconcile` no-ops on the nil slot and the heavy motor is **discarded**. In both cases `ok` stayed true and `reportHealth(nil)` actively announced healthy, resetting `consecutiveFailures`. A half-open split now tears the survivor down via the existing `teardown()` and takes the combined path. ## B20 — the lightbar stayed lit after the stream `reset()` cleared triggers and player index but not `light?.color` — which this same class writes. Every DS write is valid-flag-selective, so a game's last colour (a low-health red, say) stayed lit in firmware back at the launcher, or for a pad that merely left the forwarded set. DS4 was cleared incidentally because its player indicator *is* the lightbar; DualSense was not. The cached `slot.lastLight` needed no separate fix — session-end `stop()` already does `slots.removeAll()`. ## B15 — `stop()` blocking the main actor `RumbleRenderer.stop()` is a `queue.sync` whose body is a per-motor `CHHapticEngine.stop()` — an XPC round trip to gamecontrollerd that the renderer's own comments record as able to hang — plus `DualSenseHID.close()`'s blocking `IOHIDDeviceSetReport` to a device that has just departed, and it queues behind any in-flight `setup()`. It was called from the `@MainActor` reconcile on **every** unplug and pin change, and the main thread drives the presenter's CADisplayLink, so it hitched the picture mid-stream. Now `Task.detached`. `RumbleRenderer` is `@unchecked Sendable` and is already removed from routing at that point, so nothing observes it afterwards. The documented off-main-actor call in `GamepadFeedback.stop()` is unchanged — that one already honours its own contract. ## Verification - `swift build` → **Build complete**, and the three changed files confirmed recompiled (a 2.4 s "success" was cached; forced a rebuild rather than trusting it) - `swift test` → **188 passed, 0 failures** (185 before, +3 new) - **Non-vacuous**: reverting the deterministic fallback fails exactly the 3 new tests ⚠ **Rebuilding here needs a workaround.** The checked-in `PunktfunkCore.xcframework` is stale — it predates `punktfunk_connection_report_phase`, so `swift test` fails to compile against it — and `scripts/build-xcframework.sh` still dies on this Mac at its macOS-floor guard (objects built for macOS 26.0 > 14.0). A `macos-arm64` slice assembled by hand from `cargo build --release --target aarch64-apple-darwin` plus `xcodebuild -create-xcframework` is enough to typecheck and test. That toolchain/guard problem is pre-existing and unrelated to this PR, but it will block anyone verifying it. ⏳ Not on glass. B14 in particular wants two physical DualSenses — the correlation between GameController's ordering and IOKit location ids is the one part that cannot be proven off-device. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
enricobuehler added 1 commit 2026-08-04 06:20:49 +00:00
fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying
apple / swift (pull_request) Successful in 1m25s
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / rust (pull_request) Successful in 7m11s
76832a5b86
Five faults in the Apple client's feedback path.

With two DualSenses attached, each pad's renderer opened "the first connected
DualSense" — taken from an unordered Set, so the choice could differ between
two calls in one process. Both renderers could land on the same device, one
pad's rumble coming out of the other while their per-instance write dedupes
fought over it, or they could split by luck. Each renderer now asks for the
device its own controller is, correlating GameController's stable ordering with
IOKit's location ids; the selection rule is a pure function so it can be tested
without an IOHIDDevice, which cannot be constructed. Without a preference the
lowest location id wins — still arbitrary, but stable, which Set.first was not.

A failed HID write was logged and swallowed, so a write that never reached the
device still counted as a successful render. That matters most for a stop,
which has nothing behind it: the renderer stamped its write clock even on
failure, the keepalive only re-writes non-zero levels, the ticker is cancelled
once the target is zero, and on USB there is no firmware timeout. A swallowed
stop therefore left the motors running with nothing scheduled to try again.
The write result now reaches the caller, which drops the handle and falls back
to CoreHaptics rather than claiming success.

A half-failed split-handle setup reported HEALTHY. Only the all-nil case
counted as failure, so one surviving handle passed silently while rendering
something wrong in a direction that depended on which handle died: lose the
right one and render falls to the combined branch, playing max(low, high) on
the LEFT handle; lose the left and the split branch discards the heavy motor
outright. A half-open split now tears the survivor down and takes the combined
path, which at least renders both motors somewhere.

Session end never put the lightbar out. This class is what turned it on, and
every DS write is valid-flag-selective, so a game's last colour stayed lit in
firmware after the stream ended — a DS4 was cleared incidentally because its
player indicator IS the lightbar, a DualSense was not.

And the renderer's stop() ran on the main actor. It is a queue.sync whose body
is a per-motor CHHapticEngine.stop() — an XPC round trip the renderer's own
notes record as able to hang — plus a blocking HID write to a device that has
just departed, and it queues behind any in-flight setup(). It runs on every
unplug and every pin change, and the main thread drives the presenter's
CADisplayLink, so it hitched the picture mid-stream. It is detached now; the
renderer is already off routing by then, so nothing observes it.

Verified: swift build clean, 188 tests pass (185 before), and the three new
device-selection tests fail if the deterministic fallback is reverted.

Note for anyone rebuilding here: the checked-in xcframework was stale (it
predates punktfunk_connection_report_phase) and build-xcframework.sh still dies
on this Mac at its macOS-floor guard. A macos-arm64 slice assembled by hand
from `cargo build --target aarch64-apple-darwin` is enough to typecheck.

From the 2026-08-03 force-feedback sweep (B14, B15, B18, B19, B20).
enricobuehler marked the pull request as ready for review 2026-08-04 21:01:40 +00:00
enricobuehler merged commit 68353a5d57 into main 2026-08-04 21:01:53 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#32