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):
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.
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)
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).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.
GamepadRemoverestarted the pad's rumble sequence counter (rumble_seq[idx] = 0). The client's reorder gate does not restart —rumble_last_seqinclient/pump/datagram_task.rs:25lives 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
Noneon the client side and always heals.Reachable well beyond a physical re-plug — a BT drop, or any
reconcile_slotsclose/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 intoclear_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_acceptsdrives 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-hostdoes not build on macOS):cargo clippy -p punktfunk-host --all-targets --locked -- -D warnings→ 0cargo test -p punktfunk-host --bin punktfunk-host native::input::tests→ 3 passed, 0 failedcargo fmt --all --check→ cleanCargo.lockuntouched (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_seqwrite is nowwrapping_add(1).Not yet exercised on glass — worth one re-plug on a real pad before merge.
🤖 Generated with Claude Code