fix(host/input): rumble comes back when a controller does #25

Merged
enricobuehler merged 3 commits from worktree-haptics-m1-rumble-seq into main 2026-08-03 19:17:54 +00:00
Owner

Workstream M1 of the 2026-08-03 force-feedback sweep — closes B1 and T5.

The bug

Unplug a controller mid-session and plug it back in, and roughly half the time it never rumbles again for the rest of the session.

GamepadRemove restarted the pad's rumble sequence counter (rumble_seq[idx] = 0). The client's reorder gate does not restart — rumble_last_seq in client/pump/datagram_task.rs:25 lives for the whole QUIC connection and has no reset path anywhere, so it still holds whatever the pad reached before the unplug.

Restarting the host counter therefore hands the client a seq it has already seen, and seq_newer's wrapping half-space compare drops every envelope until the counter climbs back past the stored value — up to 128 sends. The counter only advances on a level change or a ~120 ms renewal while a level is non-zero, so that window spans many separate rumble events. It reads as a flaky controller rather than a clean outage.

Whether it bites is decided by how much the pad rumbled beforehand, which is why it looks intermittent: a pad that never rumbled before the re-plug has None on the client side and always heals.

Reachable well beyond a physical re-plug — a BT drop, or any reconcile_slots close/reopen from the forward-controllers toggle or a pin change, recycles the same wire index.

The fix

The counter now survives the removal, matching the sibling pad-state gate — whose comment eleven lines above already spells out that a re-plug must arrive with a still-newer seq to be accepted. The file was contradicting itself five lines apart.

The three clears that actually end a stale lease (rumble_state, rumble_seen, rumble_stop_burst) move into clear_pad_feedback, whose signature deliberately takes no seq parameter, so the arm cannot regress by editing.

Test

rumble_seq_survives_a_removal_so_the_client_gate_accepts drives the real wire encoder (encode_rumble_datagram_v2) and the real client gate (seq_newer), so it breaks if either side's rule moves rather than re-implementing the logic it checks.

It ends with an explicit non-vacuity assertion that the pre-fix behaviour is rejected across the whole forward window — the bug, reproduced — so the test cannot silently pass for the wrong reason.

Verification

Run in the amd64 container (punktfunk-host does not build on macOS):

  • cargo clippy -p punktfunk-host --all-targets --locked -- -D warnings0
  • cargo test -p punktfunk-host --bin punktfunk-host native::input::tests3 passed, 0 failed
  • cargo fmt --all --check → clean
  • Cargo.lock untouched (no dependency change)

Confirmed the container compiled these bytes, not a stale copy: the reset string survives only inside a doc comment, and every live rumble_seq write is now wrapping_add(1).

Not yet exercised on glass — worth one re-plug on a real pad before merge.

🤖 Generated with Claude Code

Workstream **M1** of the [2026-08-03 force-feedback sweep](https://claude.ai/code/artifact/aa75b7f8-736a-4723-b20f-cef5155ed120) — closes **B1** and **T5**. ## The bug Unplug a controller mid-session and plug it back in, and roughly half the time it never rumbles again for the rest of the session. `GamepadRemove` restarted the pad's rumble sequence counter (`rumble_seq[idx] = 0`). The client's reorder gate does **not** restart — `rumble_last_seq` in `client/pump/datagram_task.rs:25` lives for the whole QUIC connection and has no reset path anywhere, so it still holds whatever the pad reached before the unplug. Restarting the host counter therefore hands the client a seq it has already seen, and `seq_newer`'s wrapping half-space compare drops every envelope until the counter climbs back past the stored value — up to 128 sends. The counter only advances on a level change or a ~120 ms renewal *while a level is non-zero*, so that window spans many separate rumble events. It reads as a flaky controller rather than a clean outage. Whether it bites is decided by how much the pad rumbled beforehand, which is why it looks intermittent: a pad that never rumbled before the re-plug has `None` on the client side and always heals. Reachable well beyond a physical re-plug — a BT drop, or any `reconcile_slots` close/reopen from the forward-controllers toggle or a pin change, recycles the same wire index. ## The fix The counter now survives the removal, matching the sibling pad-state gate — whose comment eleven lines above already spells out that a re-plug must arrive with a still-newer seq to be accepted. The file was contradicting itself five lines apart. The three clears that actually end a stale lease (`rumble_state`, `rumble_seen`, `rumble_stop_burst`) move into `clear_pad_feedback`, whose signature deliberately takes no seq parameter, so the arm cannot regress by editing. ## Test `rumble_seq_survives_a_removal_so_the_client_gate_accepts` drives the **real** wire encoder (`encode_rumble_datagram_v2`) and the **real** client gate (`seq_newer`), so it breaks if either side's rule moves rather than re-implementing the logic it checks. It ends with an explicit non-vacuity assertion that the pre-fix behaviour is rejected across the whole forward window — the bug, reproduced — so the test cannot silently pass for the wrong reason. ## Verification Run in the amd64 container (`punktfunk-host` does not build on macOS): - `cargo clippy -p punktfunk-host --all-targets --locked -- -D warnings` → **0** - `cargo test -p punktfunk-host --bin punktfunk-host native::input::tests` → **3 passed, 0 failed** - `cargo fmt --all --check` → clean - `Cargo.lock` untouched (no dependency change) Confirmed the container compiled these bytes, not a stale copy: the reset string survives only inside a doc comment, and every live `rumble_seq` write is now `wrapping_add(1)`. Not yet exercised on glass — worth one re-plug on a real pad before merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
enricobuehler added 1 commit 2026-08-03 14:57:48 +00:00
fix(host/input): rumble comes back when a controller does
android / android (pull_request) Failing after 18s
ci / docs-site (pull_request) Successful in 1m7s
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m45s
ci / rust (pull_request) Successful in 6m9s
14502769e0
Unplug a pad mid-session and plug it back in, and roughly half the time it
never rumbles again for the rest of the session.

The removal arm restarted the pad's rumble sequence counter. The client's
reorder gate does not restart: `rumble_last_seq` lives for the whole QUIC
connection and has no reset path, so it still holds whatever the pad reached
before the unplug. Restarting the host counter therefore hands the client a
seq it has already seen, and its wrapping half-space compare drops every
envelope until the counter climbs back past the stored value — up to 128
sends. Since the counter only advances on a level change or a ~120 ms renewal
while a level is non-zero, that spans many separate rumble events, so it reads
as a flaky controller rather than a clean outage.

Whether it bites is decided by how much the pad rumbled beforehand, which is
why it looks intermittent: a pad that never rumbled before the re-plug has
`None` on the client side and always heals.

The counter now survives, matching the sibling pad-state gate — whose comment
eleven lines above already explains that a re-plug must arrive with a still-
newer seq to be accepted. The three clears that actually end the stale lease
move into `clear_pad_feedback`, whose signature deliberately has no seq
parameter so the arm cannot regress by editing.

Covered by a regression test that drives the real wire encoder and the real
client gate, and asserts the pre-fix behaviour is genuinely rejected across
the whole forward window, so it cannot pass vacuously.

Found by the 2026-08-03 force-feedback sweep (B1/T5 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
enricobuehler added 1 commit 2026-08-03 17:49:54 +00:00
Merge remote-tracking branch 'origin/main' into worktree-haptics-m1-rumble-seq
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 3m22s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / rust (pull_request) Successful in 8m11s
3eab1e41df
enricobuehler marked the pull request as ready for review 2026-08-03 17:52:22 +00:00
enricobuehler added 1 commit 2026-08-03 19:17:07 +00:00
Merge branch 'main' into worktree-haptics-m1-rumble-seq
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m51s
ci / docs-site (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 2m22s
android / android (pull_request) Successful in 3m25s
ci / rust (pull_request) Successful in 8m4s
5582a6ea51
enricobuehler merged commit 7db83445b2 into main 2026-08-03 19:17:54 +00:00
enricobuehler deleted branch worktree-haptics-m1-rumble-seq 2026-08-03 19:18:03 +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#25