You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: internal/app/modes/AGENTS.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -11,6 +11,7 @@
11
11
- **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.
12
12
-**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`).
13
13
- **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.
14
15
- **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.
15
16
16
17
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