A pad whose Select is KEYCODE_BACK quit the session on one press #235

Closed
enricobuehler wants to merge 1 commits from worktree-shield-select-back-quit into main
Owner

Field report: pressing Select on the pad disconnected the stream, reported as "the session crashes".

It was neither a crash nor a drop. The host log said so itself — client datagram stream ended plus virtual display torn down (deliberate quit — keep-alive skipped) is a client that announced it was leaving.

Why this identifies the cause without knowing the controller

Only two client paths raise a deliberate quit (grep nativeDisconnectQuit): StreamScreen's BackHandler, and router.onExitChord.

The chord is excluded by construction — armExit posts a 1 s timer and releasing any member calls disarmExit, so a tap always cancels. That leaves the back stack, and from a SOURCE_GAMEPAD device KEYCODE_BACK is the only keycode that reaches it: a mapped button is consumed in the gamepad branch, anything with a VK is consumed on the keycode path, volume/power go to the system, and a FLAG_FALLBACK BACK is swallowed.

So a one-press quit proves the button's keycode on its own. Which pad was on the couch is not knowable from the logs and does not matter.

Root cause

Plenty of controllers deliver Select as the plain KEYCODE_BACK a remote's Back uses, with no BUTTON_SELECT scancode behind it — the Android-TV shape, where every input device is expected to offer Back, reached whether the vendor prints "Back" on the button (NVIDIA's SHIELD controller) or "Select"/"View" (pads in an Android mode).

Gamepad.buttonBit had no row for KEYCODE_BACK, so the press fell out of dispatchKeyEvent's streaming gamepad branch unconsumed and landed on StreamScreen's BackHandler — the deliberate-quit exit. One press, session over.

The same gap meant those pads could not produce BTN_BACK at all, so every shortcut built on Select was unreachable on exactly the devices whose users have no keyboard: the emergency exit chord this client's own start banner advertises ("Hold Select + Start + L1 + R1 to leave"), the mic mute, and the stats tier. The banner was promising a chord the hardware could not type.

The change

New Gamepad.padButtonBit(keyCode, flags)buttonBit plus that one row — resolves a gamepad-sourced BACK to BTN_BACK, and MainActivity's streaming branch asks it instead. buttonBit had exactly one call site, which is what made this containable.

  • Keys off the keycode, not the vendor, so it covers every pad with this behaviour. A pad that does carry BUTTON_SELECT is unaffected in both directions — it never had the bug.
  • FLAG_FALLBACK stays excluded. Those are the synthetic BACK the framework raises after an unconsumed BUTTON_* press; forwarding one would put a phantom Select on the wire, and one landing while Start + L1 + R1 were held would complete the exit chord out of nowhere.
  • A remote's or keyboard's BACK is neither mouse- nor gamepad-sourced, so it still leaves the stream — for a device with no pad on it that is the documented way out, and the start banner already says exactly that.
  • mouseSideButton moves above the gamepad branch so a device that can be a mouse keeps its X1/X2 semantics. It answers null for everything that cannot be a mouse, so nothing else changes route.

Behaviour change worth flagging

With a controller attached, Back no longer exits the stream — the 1 s chord does. That is the designed cross-client contract (SDL/Apple DISCONNECT_HOLD) and the reason the hold exists at all, but it will read as a regression to anyone who had been using Back to leave.

Verification

:kit:testDebugUnitTest + :app:testDebugUnitTest green, :app:compileDebugKotlin clean. New PadButtonBitTest (4/4) pins the mapping, the fallback exclusion, that all three Select chords are reachable from a BACK-only pad, and that no other keycode moved.

Not verified on glass — that needs a real pad: Select reaches the game, Back no longer quits, the chord still leaves.

Anyone can confirm the diagnosis on their own hardware with the shipped build: Controllers screen → input test → press Select. If every other button lights and the "Select" cell stays dark, that pad emits KEYCODE_BACK (the grid keys that cell strictly on KEYCODE_BUTTON_SELECT). Safe to try — the probe consumes all pad keys while the test runs, so it will not back you out of the screen.

Field report: pressing Select on the pad disconnected the stream, reported as "the session crashes". It was neither a crash nor a drop. The host log said so itself — `client datagram stream ended` plus `virtual display torn down (deliberate quit — keep-alive skipped)` is a client that announced it was leaving. ## Why this identifies the cause without knowing the controller Only two client paths raise a deliberate quit (`grep nativeDisconnectQuit`): `StreamScreen`'s `BackHandler`, and `router.onExitChord`. The chord is excluded by construction — `armExit` posts a 1 s timer and releasing any member calls `disarmExit`, so a tap always cancels. That leaves the back stack, and from a `SOURCE_GAMEPAD` device `KEYCODE_BACK` is the **only** keycode that reaches it: a mapped button is consumed in the gamepad branch, anything with a VK is consumed on the keycode path, volume/power go to the system, and a `FLAG_FALLBACK` BACK is swallowed. So a one-press quit proves the button's keycode on its own. Which pad was on the couch is not knowable from the logs and does not matter. ## Root cause Plenty of controllers deliver Select as the plain `KEYCODE_BACK` a *remote's* Back uses, with no `BUTTON_SELECT` scancode behind it — the Android-TV shape, where every input device is expected to offer Back, reached whether the vendor prints "Back" on the button (NVIDIA's SHIELD controller) or "Select"/"View" (pads in an Android mode). `Gamepad.buttonBit` had no row for `KEYCODE_BACK`, so the press fell out of `dispatchKeyEvent`'s streaming gamepad branch unconsumed and landed on `StreamScreen`'s `BackHandler` — the deliberate-quit exit. One press, session over. The same gap meant those pads could not produce `BTN_BACK` **at all**, so every shortcut built on Select was unreachable on exactly the devices whose users have no keyboard: the emergency exit chord this client's own start banner advertises ("Hold Select + Start + L1 + R1 to leave"), the mic mute, and the stats tier. The banner was promising a chord the hardware could not type. ## The change New `Gamepad.padButtonBit(keyCode, flags)` — `buttonBit` plus that one row — resolves a gamepad-sourced BACK to `BTN_BACK`, and MainActivity's streaming branch asks it instead. `buttonBit` had exactly one call site, which is what made this containable. - **Keys off the keycode, not the vendor**, so it covers every pad with this behaviour. A pad that does carry `BUTTON_SELECT` is unaffected in both directions — it never had the bug. - **`FLAG_FALLBACK` stays excluded.** Those are the synthetic BACK the framework raises after an unconsumed `BUTTON_*` press; forwarding one would put a phantom Select on the wire, and one landing while Start + L1 + R1 were held would complete the exit chord out of nowhere. - A remote's or keyboard's BACK is neither mouse- nor gamepad-sourced, so it **still leaves the stream** — for a device with no pad on it that is the documented way out, and the start banner already says exactly that. - `mouseSideButton` moves above the gamepad branch so a device that can be a mouse keeps its X1/X2 semantics. It answers null for everything that cannot be a mouse, so nothing else changes route. ## Behaviour change worth flagging With a controller attached, **Back no longer exits the stream** — the 1 s chord does. That is the designed cross-client contract (SDL/Apple `DISCONNECT_HOLD`) and the reason the hold exists at all, but it will read as a regression to anyone who had been using Back to leave. ## Verification `:kit:testDebugUnitTest` + `:app:testDebugUnitTest` green, `:app:compileDebugKotlin` clean. New `PadButtonBitTest` (4/4) pins the mapping, the fallback exclusion, that all three Select chords are reachable from a BACK-only pad, and that no other keycode moved. **Not verified on glass** — that needs a real pad: Select reaches the game, Back no longer quits, the chord still leaves. Anyone can confirm the diagnosis on their own hardware with the shipped build: Controllers screen → input test → press Select. If every other button lights and the "Select" cell stays dark, that pad emits `KEYCODE_BACK` (the grid keys that cell strictly on `KEYCODE_BUTTON_SELECT`). Safe to try — the probe consumes all pad keys while the test runs, so it will not back you out of the screen.
enricobuehler added 1 commit 2026-08-14 20:05:32 +00:00
fix(android): a pad whose Select is KEYCODE_BACK quit the session on one press
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / web (pull_request) Successful in 5m55s
ci / rust (pull_request) Canceled after 6m47s
android / android (pull_request) Successful in 5m38s
1ac6c9bf3d
Field report: pressing Select disconnected the stream. The host log was
unambiguous about what it was NOT — "client datagram stream ended" plus "virtual
display torn down (deliberate quit — keep-alive skipped)" is a client that said
it was leaving, not a drop and not a compositor crash.

Only two client paths raise that: StreamScreen's BackHandler, and the exit chord
(router.onExitChord). The chord is excluded by construction — `armExit` posts a
1 s timer and releasing any member calls `disarmExit`, so a tap always cancels.
That leaves the back stack, and from a SOURCE_GAMEPAD device KEYCODE_BACK is the
ONLY keycode that reaches it: a mapped button is consumed in the gamepad branch,
anything with a VK is consumed on the keycode path, volume/power go to the
system, and a FLAG_FALLBACK BACK is swallowed. So a one-press quit identifies the
button's keycode without knowing which controller was on the couch.

Plenty of pads deliver Select as the plain KEYCODE_BACK a remote's Back uses,
with no BUTTON_SELECT scancode behind it — the Android-TV shape, where every
input device is expected to offer Back, reached whether the vendor prints "Back"
on the button or "Select"/"View". `buttonBit` had no row for KEYCODE_BACK, so the
press fell through unconsumed into StreamScreen's BackHandler, which is the
deliberate-quit exit. One press, session over.

The same gap meant those pads could not produce BTN_BACK at all, so every
shortcut built on Select was unreachable on exactly the devices whose users have
no keyboard: the emergency exit chord StreamScreen's own start banner advertises
("Hold Select + Start + L1 + R1 to leave"), the mic mute, the stats tier.

New `Gamepad.padButtonBit(keyCode, flags)` — buttonBit plus that one row —
resolves a gamepad-sourced BACK to BTN_BACK, and MainActivity's streaming branch
asks it instead. It keys off the keycode, not the vendor, so it covers every pad
with this behaviour; a pad that does carry BUTTON_SELECT is unaffected in both
directions, having never had the bug. FLAG_FALLBACK events stay excluded: those
are the synthetic BACK the framework raises after an unconsumed BUTTON_* press,
and forwarding one would put a phantom Select on the wire (one landing while
Start + L1 + R1 were held would complete the exit chord out of nowhere). A
remote's or keyboard's BACK is neither mouse- nor gamepad-sourced, so it still
leaves the stream — for a device with no pad on it that is the documented way
out, and the banner says so.

The mouse-side-button hook moves above the gamepad branch so a device that can
be a mouse keeps its X1/X2 semantics; it answers null for everything that cannot
be a mouse, so nothing else changes route.

PadButtonBitTest pins the mapping, the fallback exclusion, that the three Select
chords are now reachable from a BACK-only pad, and that no other keycode moved.

Verified: :kit:testDebugUnitTest + :app:testDebugUnitTest green (PadButtonBitTest
4/4), :app:compileDebugKotlin clean. NOT yet verified on-glass — the behaviour
needs a real pad: Select reaches the game, and Back no longer quits.
enricobuehler added 1 commit 2026-08-14 20:12:22 +00:00
Merge branch 'main' into worktree-shield-select-back-quit
ci / rust-arm64 (pull_request) Successful in 1m30s
ci / web (pull_request) Successful in 2m0s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m52s
ci / rust (pull_request) Successful in 26m11s
1e243c6c93
enricobuehler closed this pull request 2026-08-14 20:12:35 +00:00

Pull request closed

Please reopen this pull request to perform a merge.
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#235