Skip to content

Commit d07174f

Browse files
authored
fix(modes): keep the keyboard captured across a mode-to-mode switch (#1519)
1 parent 48eb973 commit d07174f

16 files changed

Lines changed: 820 additions & 21 deletions

internal/adapter/eventtap/linux/evdev_cgo.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,54 @@ const (
2424
waylandEvdevEventBufferSize = 128
2525
waylandEvdevModifierReleasePollPeriod = 5 * time.Millisecond
2626
waylandEvdevPreGrabHoldPollPeriod = 50 * time.Millisecond
27-
waylandEvdevPreGrabTimeout = 5 * time.Second
2827
waylandEvdevHotplugBufSize = 4096
2928
waylandEvdevHotplugSettleDelay = 100 * time.Millisecond
3029
waylandEvdevHotplugPollInterval = 500 * time.Millisecond
3130
)
3231

32+
// The two bounds on how long a mode activation waits for the keyboard to go
33+
// quiet before grabbing it. Waiting at all is #1087's first line of defense:
34+
// grabbing while a key is held routes that key's release to our fd alone, so
35+
// libinput never sees it, considers the key down forever and eats its next
36+
// press. The other two lines are the ones that make *these* bounds affordable —
37+
// initialKeys suppresses the kernel's replay of a key already held at grab
38+
// time, and shutdownEvdevSession injects a synthetic release for it on the way
39+
// out — so a wait that expires falls back onto a handled path rather than off a
40+
// cliff. That is what lets both of these be bounds instead of conditions.
41+
//
42+
// They differ because what is being waited for differs, and because the cost of
43+
// giving up differs with it.
44+
const (
45+
// waylandEvdevModifierReleaseTimeout bounds the wait for held *modifiers*.
46+
// It is the long one: a modifier whose release we swallow is stuck across
47+
// every application the user touches next, not just this mode, so it is
48+
// worth waiting out an activation chord that is being held. What it may not
49+
// be is unbounded, which is what it was — the loop had no deadline at all,
50+
// so a modifier the kernel reports as held (a chord the user rests on, a
51+
// key wedged by something else) left the mode active, the overlay drawn and
52+
// the keyboard never grabbed, with everything typed going to the focused
53+
// application and nothing said about it. `platform/linux/AGENTS.md` has the
54+
// rule this broke: never block on the eventtap goroutine.
55+
waylandEvdevModifierReleaseTimeout = 5 * time.Second
56+
57+
// waylandEvdevPreGrabHoldTimeout bounds the wait for ordinary keys, and is
58+
// short because by the time it runs the modifier wait above has already
59+
// finished — so what is still down is a plain key, whose worst case is one
60+
// key eaten rather than a modifier stuck under everything.
61+
//
62+
// Five seconds here was the reported bug (a user mashing keys as the mode
63+
// came up, seeing "the grid clearly not moving… when I stop for a second,
64+
// it seems to fix the issue"): someone typing through an activation holds a
65+
// key at almost every poll, so the wait runs to its deadline and the mode
66+
// takes no input until it does. Waiting is futile in exactly that case —
67+
// the next poll finds another key down, and the grab that eventually
68+
// happens is a grab with a key held either way. Half a second is long
69+
// enough for the release of a deliberate keypress, which is what waiting is
70+
// for, and short enough that typing into an activation costs a beat rather
71+
// than the five seconds it cost before.
72+
waylandEvdevPreGrabHoldTimeout = 500 * time.Millisecond
73+
)
74+
3375
// waylandEvdevKeyboardActive reports whether an evdev keyboard grab is currently
3476
// held, i.e. keys are captured directly from the input devices rather than via
3577
// the overlay layer-surface's keyboard grab. When true, the overlay must NOT
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//go:build linux && cgo
2+
3+
package linux
4+
5+
import (
6+
"testing"
7+
"time"
8+
)
9+
10+
// TestPreGrabBoundsStayBoundedAndOrdered pins the two pre-grab waits as bounds
11+
// rather than conditions, and pins which of them is the long one.
12+
//
13+
// waitForEvdevKeysReleased cannot be driven from a test — it reads EVIOCGKEY off
14+
// real /dev/input devices — so what is asserted here is the policy those
15+
// constants carry, which is the half that regressed twice and both times
16+
// silently:
17+
//
18+
// - The modifier wait had no deadline at all. A modifier the kernel reported
19+
// as held left the mode active, the overlay drawn and the keyboard never
20+
// grabbed, with every key going to the focused application and nothing
21+
// logged. Nothing failed; the daemon just stopped taking input.
22+
// - The hold wait had the same five seconds as the modifier one, which is
23+
// what a user typing through an activation actually waits out: they hold a
24+
// key at almost every poll, so the wait runs to its deadline and the mode
25+
// accepts nothing until it does.
26+
//
27+
// The ordering is the design and not an accident of the numbers: the modifier
28+
// wait is allowed to be the patient one because swallowing a modifier's release
29+
// leaves it stuck across every application the user touches next, while the
30+
// hold wait runs only once modifiers are clear, so the worst it can cost is one
31+
// suppressed press on a plain key — which initialKeys already handles.
32+
//
33+
// Both bounds must also outlast the poll that watches them, or the loop reaches
34+
// its deadline before it has looked at the keyboard even once and the wait
35+
// becomes a sleep.
36+
func TestPreGrabBoundsStayBoundedAndOrdered(t *testing.T) {
37+
t.Parallel()
38+
39+
if waylandEvdevModifierReleaseTimeout <= 0 {
40+
t.Errorf(
41+
"waylandEvdevModifierReleaseTimeout = %v; the modifier wait must carry a "+
42+
"deadline — without one a held modifier means the keyboard is never "+
43+
"grabbed and every key reaches the focused application instead "+
44+
"(internal/adapter/platform/linux/AGENTS.md: never block on the "+
45+
"eventtap goroutine)",
46+
waylandEvdevModifierReleaseTimeout,
47+
)
48+
}
49+
50+
if waylandEvdevPreGrabHoldTimeout <= 0 {
51+
t.Errorf(
52+
"waylandEvdevPreGrabHoldTimeout = %v; the hold wait must carry a deadline",
53+
waylandEvdevPreGrabHoldTimeout,
54+
)
55+
}
56+
57+
if waylandEvdevPreGrabHoldTimeout >= waylandEvdevModifierReleaseTimeout {
58+
t.Errorf(
59+
"hold wait %v is not shorter than the modifier wait %v; a plain key held "+
60+
"at grab time costs one suppressed press, a modifier costs every "+
61+
"application the user touches next, so the patient one is the modifier "+
62+
"wait — a hold wait grown to match it is the mashing-keys bug coming back",
63+
waylandEvdevPreGrabHoldTimeout, waylandEvdevModifierReleaseTimeout,
64+
)
65+
}
66+
67+
for _, bound := range []struct {
68+
name string
69+
timeout time.Duration
70+
poll time.Duration
71+
}{
72+
{"modifier", waylandEvdevModifierReleaseTimeout, waylandEvdevModifierReleasePollPeriod},
73+
{"hold", waylandEvdevPreGrabHoldTimeout, waylandEvdevPreGrabHoldPollPeriod},
74+
} {
75+
if bound.poll >= bound.timeout {
76+
t.Errorf(
77+
"the %s wait polls every %v against a %v deadline; a wait that expires "+
78+
"before it has read the keyboard twice is a sleep, not a wait",
79+
bound.name, bound.poll, bound.timeout,
80+
)
81+
}
82+
}
83+
}

internal/adapter/eventtap/linux/evdev_session_cgo.go

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -540,18 +540,49 @@ func (et *EventTap) injectSyntheticRelease(code uint16) {
540540
}
541541

542542
// waitForEvdevKeysReleased blocks until every physically held key is released,
543-
// or the pre-grab timeout passes. Grabbing while a key is held makes the kernel
544-
// route that key's release to our fd only — libinput never sees it, considers
545-
// the key pressed forever, and silently eats its next press. Returns true when
546-
// the tap was stopped while waiting.
543+
// or one of the two pre-grab bounds passes. Grabbing while a key is held makes
544+
// the kernel route that key's release to our fd only — libinput never sees it,
545+
// considers the key pressed forever, and silently eats its next press. Returns
546+
// true when the tap was stopped while waiting.
547+
//
548+
// It waits in two stages, and the constants say why they are bounded
549+
// differently. Modifiers first, because a swallowed modifier release outlives
550+
// this mode; then everything still down, briefly, because by then it cannot be
551+
// a modifier. Both stages end in the same place when their bound passes: the
552+
// grab happens anyway, onto the handled path #1087 built for it.
553+
//
554+
// The stages are not redundant even though the second one's question subsumes
555+
// the first — EVIOCGKEY reports modifiers like any other key, so "nothing is
556+
// pressed" already implies "no modifier is held". What separates them is the
557+
// bound each carries and how closely each watches: the modifier stage polls at
558+
// five milliseconds because releasing an activation chord is on the path of
559+
// every mode entry and the latency is paid there, while the hold stage can tick
560+
// slower and wake on evdev traffic instead.
547561
func (et *EventTap) waitForEvdevKeysReleased(
548562
capture *waylandEvdevCapture,
549563
overlayCapture overlaymanager.KeyboardCaptureController,
550564
) bool {
565+
modifierDeadline := time.After(waylandEvdevModifierReleaseTimeout)
566+
551567
for capture.modifierKeysHeld() {
552568
select {
553569
case <-et.stopCh:
554570
return true
571+
case <-modifierDeadline:
572+
// Grab with the modifier still down rather than never grabbing.
573+
// Warn, not debug: this is the keyboard being taken while the
574+
// kernel says a modifier is held, so the synthetic release on the
575+
// way out is the only thing standing between the user and a
576+
// modifier stuck under every application they use next.
577+
if et.logger != nil {
578+
et.logger.Warn(
579+
"Grabbing the keyboard with a modifier still held; its release will "+
580+
"not reach the compositor until this mode exits",
581+
zap.Duration("waited", waylandEvdevModifierReleaseTimeout),
582+
)
583+
}
584+
585+
return false
555586
case <-time.After(waylandEvdevModifierReleasePollPeriod):
556587
}
557588
}
@@ -567,7 +598,7 @@ func (et *EventTap) waitForEvdevKeysReleased(
567598
overlayCapture.SetKeyboardCaptureEnabled(true)
568599
}
569600

570-
deadline := time.After(waylandEvdevPreGrabTimeout)
601+
deadline := time.After(waylandEvdevPreGrabHoldTimeout)
571602
ticker := time.NewTicker(waylandEvdevPreGrabHoldPollPeriod)
572603

573604
defer func() {
@@ -590,6 +621,19 @@ func (et *EventTap) waitForEvdevKeysReleased(
590621
case <-et.stopCh:
591622
return true
592623
case <-deadline:
624+
// Debug rather than warn, unlike the modifier bound above: this is
625+
// the ordinary way a user typing into an activation gets their mode
626+
// promptly, and the key held through the grab costs them one
627+
// suppressed press. A count, never the keys — the keystream is not
628+
// something this may log (root AGENTS.md, Conventions).
629+
if et.logger != nil {
630+
et.logger.Debug(
631+
"Grabbing the keyboard with keys still held; their first press is suppressed",
632+
zap.Int("held", len(pressed)),
633+
zap.Duration("waited", waylandEvdevPreGrabHoldTimeout),
634+
)
635+
}
636+
593637
return false
594638
case <-ticker.C:
595639
case _, ok := <-capture.events:

internal/app/modes/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- **State a focus change moves is settled on a read; the event tap's copy of it is pushed.** ADR 0005 has the app watcher publish the focused app into a lock-free cell and the keymap settle from it on the next read, because a keystroke *is* that read. The event tap's passthrough lists are the exception, and the reason is that they decide whether there will be a next read at all: an unblacklisted chord goes to the focused application instead of to Neru, so waiting for a keystroke means waiting for the keystroke that already went elsewhere. So they are pushed — `RefreshPassthroughForFocusedAppChange` (`passthrough.go`), a locked entry point started **on a goroutine** by `handleAppActivation` (`app/lifecycle.go`), never called inline from the watcher callback, which on macOS is the main queue. What the goroutine buys is only that the handler is reached off the main queue: it still queues behind whatever holds `h.mu`, so the guarantee is "as soon as the handler is free", not "before the next key". Anything else a focus change has to move goes the same way — settled on a read, or pushed through a goroutine — and never straight from the callback. The order those pushes take is `h.mu` → the event tap adapter's `mu` (`adapter/eventtap/adapter.go`), which every handler caller of `syncModifierPassthrough` has always used; the rule that comes with it is that **nothing holding the adapter's `mu` may wait on anything that takes `h.mu`**. `Adapter.Destroy` was the one violation of it and since #1511 there is none: it marks itself destroyed and disabled under `mu`, releases it, and tears the tap down outside the hold. It has to, because the two backends that wait unboundedly spend that teardown waiting for the key dispatcher — `dispatchWg.Wait()` on Linux, `stopDispatcher` on macOS — and the dispatcher drains keys into `HandleKeyPress`, so holding `mu` across it deadlocked a shutdown racing any pusher, with neither side able to give way. Windows is the third and is not one of them: its hook join is bounded at 250ms and reaps in the background past that, for this hazard, stated where it is done (`platform/windows/keyboard_hook.go`). The destroyed flag is the other half of moving the wait off the lock: from that point every adapter method that drives the tap handle is a no-op, because nothing may reach a tap being freed once the lock has stopped serializing them — the two that take no lock, `PostModifierEvent` and `SetKeyboardLayout`, are outside it because neither reaches the capture handle on any backend — they post through the display server or set a process-wide input source — and each says so where it is written. `TestAdapter_Destroy_DoesNotHoldTheLockWhileTheTapDrainsItsDispatcher` (`internal/adapter/eventtap/adapter_test.go`) is the pin, and each of the two waiting backends keeps a `TestEventTap_Destroy_WaitsForAnInFlightKeyDispatch` for the wait that makes it matter. Do not add a second violation.
1212
- **No method releases a lock it did not take** — every lock is released, via `defer`, by the method that took it; never unlock mid-method to make a blocking call safe. Instead compute a plan under the lock and have the lock-taking method execute it after release (`planIndicatorTick`/`drawIndicators` in `indicator_polling.go`), or hand the blocking call to a goroutine that re-enters through the outer locked surface guarded by the mode-session token (`requestScreenCapturePermissionAndResume` in `hints.go`).
1313
- **Everything the handler holds is reconfigured through `Handler.UpdateConfig`**, never from the app's reconfigure path. The mode components (`h.grid`, `h.scroll`) carry domain state derived from configuration, and the grid manager — the one that matters — has no lock of its own: the handler assigns it on activation and reads it on every keystroke, both under `h.mu`. So `updateComponentConfigs` lives here (`handler.go`) and runs inside the same hold; a reload calling `GridComponent.UpdateConfig` from `app/config.go` was a plain data race on live mode state (#1277), the same shape #459 had for the mode indicator. `App.reconfigureRuntimeFromConfig` reaches the handler only through `a.modes.UpdateConfig`, and a new piece of handler-held state derived from config is reconfigured there or not at all. Its `-race` regression is the one test on this list that does not live here: driving a reload against an activation needs the whole app, so it is `TestSimulation_ConfigReloadRacingGridActivation` in `internal/app` and `go test -race ./internal/app/modes/` alone will not run it.
14+
- **A mode-to-mode transition hands the keyboard over; it does not give it back.** `exitMode` releases the capture because that is what returning to idle means, and an activation entering from another mode calls `exitModeForTransition` instead, which is the same teardown with the release left out (`cleanup.go`). Releasing it there opened a window with nobody holding the keyboard, spanning the whole of the next mode's activation — the screen query, the domain state, the overlay draw — and on Linux that is long enough to type into: the keys reach the focused application as text, and the re-grab that follows costs a keymap rebuild plus a wait for every physically-held key to be released, so a user still mashing keys keeps extending the window (scroll had carried a carve-out of its own for the macOS shape of it since long before). Keeping it up is also where buffering comes from, and only from the moment the activation owns `h.mu`: it holds the lock end to end and the tap's dispatcher delivers through `HandleKeyPress`, so a key the tap reads *after* that waits on the lock and lands in the mode coming up. A key that reaches the handler *before* it is still the old mode's and may be dropped there — scroll's generic key handler does nothing — and nothing orders the two, because the hotkey dispatch and the dispatcher are separate goroutines contending for `h.mu`. That window is open and is not what this closes. **The exit returns its release, and the only form that compiles past the guardrail is `defer h.exitModeForTransition()()`** — exit now, release at return. It is one expression because pairing two statements by hand is a thing a new call site can half-do, and the cost of half-doing it is idle holding the keyboard, where every key the user presses goes nowhere at all. On the abandon path those buffered keys are *discarded* rather than delivered to the focused app (`Disable` drains the dispatch queue), which is the deliberate half of a two-bad-options trade and is stated on `releaseKeyboardIfNoModeEntered`. `TestModeTransitionExitsDeferTheirRelease` and `TestModeTransitionReleaseIsReachedThroughTheExit` (`internal/architecture`) fail on any other shape; `TestExitModeForTransition_KeepsTheKeyboard` and `TestReleaseKeyboardIfNoModeEntered_ReleasesOnlyWhenNothingWasEntered` pin the pair here; `TestSimulation_ModeSwitchNeverDropsTheKeyboard`, `TestSimulation_KeyPressedDuringAModeSwitchLandsInTheNewMode` and `TestSimulation_AbandonedModeSwitchGivesTheKeyboardBack` (`internal/app`) pin capture, delivery and give-back as journeys.
1415
- **Mode-lifecycle dispatch takes `h.mu` once**, and the whole of it — reading the active mode, selecting the implementation, calling the method — happens inside that one hold. A mode cannot change between being chosen and being used, so **an implementation must not re-check the active mode**; a re-check reads reasonable in isolation and is exactly what was deleted, so add one only with a caller that genuinely selects unlocked. `refreshActiveModeForMonitorMove` (`monitor.go`) is the pattern, `RefreshActiveModeForThemeChange` and `RefreshActiveModeForScreenChange` (`handler_refresh.go`) follow it, and ADR 0004 (`docs/adr/0004-mode-lifecycle-dispatch-under-one-lock-hold.md`) is why. There is no exception left: the screen-change dispatch was the last caller that snapshotted the mode unlocked, and the three re-checks that guarded that window went with it. The screen-change dispatch answers its caller with whether the overlay still needs a resize of its own; the app layer performs that resize but no longer decides whose overlay it is, and idle is answered inside the same hold, because resizing is what brings the overlay up and a display change with nothing open has to leave it hidden.
1516

1617
The `Mode` interface is `Activate(modecmd.Activation)`, `HandleKey(string)`, `Exit()`, `ModeType()`, `RefreshForMonitorMove(context.Context, image.Rectangle)`. Each mode is its own type: embed `baseMode` (`base.go`), which supplies the handler reference and answers `ModeType`, then write the other four as methods on that type — there is no shared fallback, so what a mode does on each of them is answered by reading the mode. Add a `var _ Mode = (*XMode)(nil)` assertion and register in `newModes` (`handler.go`). `Handler.ActivateMode` is the only activation entry point. Monitor-move refresh is core rather than an optional extension because all five modes participate — even scroll, which draws nothing but still has to switch the overlay back to the mode its indicators name — and because a mode that quietly failed to implement it would leave an overlay stranded on the display the user left.

0 commit comments

Comments
 (0)