fix: never hook a layout the desktop no longer matches (#607) - #609
Merged
Merged
Conversation
This was referenced Sep 11, 2026
Merged
pull Bot
pushed a commit
to Mu-L/LittleBigMouse
that referenced
this pull request
Sep 11, 2026
…hine What the C# UI decided in DisplayChangeCoordinator, EngineController and MainService's daemon-event routing — when a display change rebuilds the layout, when the engine is hooked, how a wake is re-hooked — as one state machine without I/O: inputs in, effects out, timers as effects that come back as inputs, the layout read and rebuilt through a trait. One input is handled at a time and a Start carries no zones (the runtime computes them when it sends), so the stale Start of mgth#607 cannot exist, and mgth#609's request gate is not needed. mgth#609's other half is kept: a fresh layout the user turned off sends Stop. One Start per event: a Start just sent is given one step before the resume watchdog re-asserts it (C# sends it twice). The C# tests of both classes are the specification: 29 ported one to one on a virtual clock, plus the hook routing and focus reports. docs/v6-agent.md records the design and the deviations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tached (#607) Undock a laptop, sleep, wake it alone: the layout the daemon ends up running can still be the docked one, and a crossing into a monitor that is not there clips the cursor to a segment of the laptop's edge. The UI shows the right layout; the daemon holds the wrong one, and had no way to tell. Daemon: `Run` now asks the desktop what it is made of (`Shared::monitors_now` — `EnumDisplayMonitors` on Windows, unknown on Linux) and declines when a main zone lies on no attached display, whichever way the layout arrived: a late Start, the startup replay of Current.xml, a UI that lost a race. A `Load` that did not parse no longer lets the `Run` behind it hook the previous layout either, and takes the hook down when the frame had kept it up for the swap. Both refusals are said on the wire as a new `RunRefused` event carrying the reason; the location control shows it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d rebuild stops it (#607) Around a display change three paths ask the daemon to Start — the reconcile after a wake, the resume watchdog, the rebuild — each with the zones of its own moment, and nothing ordered them: a Start claimed before the rebuild could reach the daemon after the one claimed after it. `LatestRequestGate` gives every Start, live frame, Stop and Quit a ticket; a Start whose ticket is no longer the newest is dropped when its turn comes, a Stop never is. Enabled is stored per layout, so a display change can rebuild into a layout the user turned off. The coordinator used to leave the daemon alone then, running whatever it last had. It now sends an explicit Stop, which also outranks any Start still in flight. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Since #609 was opened the Rust agent arrived on master and reads the hook's events itself, through `lbm_ipc::client`. Its reconciler already knew a `HookEvent::RunRefused`; the parser and the two mappings behind it did not, so the new event would have been dropped as unknown before reaching it. The client golden asks for every event the hook emits to read back — this is what it caught. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mgth
force-pushed
the
fix/607-stale-layout-hook
branch
from
September 16, 2026 19:06
d766491 to
ec911d0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #607.
Undock a laptop, put it to sleep, wake it alone: the LBM window shows the laptop screen, but the cursor gets stuck on part of the screen's edge until LBM is stopped.
Current.xmlstill describes the four docked monitors — the daemon is running the docked layout.What happens
The engine's
move_cursor(engine/mod.rs) confines the cursor to the target zone's rectangle withClipCursorbefore moving it there. When that zone is a monitor that is no longer attached, Windows clamps the clip to the nearest reachable pixels: the segment of the laptop's edge that bordered the phantom monitor. That is the "middle third" in the report.How the docked layout reaches the daemon after the desktop has changed has two roots, and the fix closes both, plus a third path found on the way:
WM_DISPLAYCHANGEand relies on the UI to send fresh zones, but nothing checked that the zones it then receives match the desktop. The startup replay ofCurrent.xmlhas the same exposure: boot undocked after being docked, and the daemon hooks the docked layout until the UI connects.Resumed, the resume watchdog, the rebuild afterDisplayChanged— each with the zones computed at its own moment, then each waits (topology prologue, recovery-file write, pipe) before sending. A Start asked for before the rebuild can reach the daemon after the one asked for after it, andCurrent.xmlrecords the last one sent — which is what the report shows. And sinceEnabledis stored per layout, a rebuild into a layout the user turned off sent nothing at all, leaving the daemon with whatever it last had.Runbehind aLoadthat did not parse hooked the previous layout, with the hook kept up for the swap. The file replay already refused that; the live frame did not.Change
Daemon
Shared::monitors_now— the attached monitor rectangles, asked atRuntime. Windows enumerates them withEnumDisplayMonitorsin the same per-monitor-DPI-aware coordinates the UI computes zone bounds in; Linux answers "unknown", which leaves the layout unjudged, exactly as before. A function pointer so tests can bring a desktop of their own.daemon::rundeclines when a main zone overlaps no attached display (phantom_zones). Overlap, not equality, so a mode change the UI has not rebuilt for yet is not a refusal — a refused Run is LBM not working. The refusal is spoken on the wire as a newRunRefusedevent with the reason (no display under Dock), and takes the hook down if aLoad+Runframe had kept it up for the swap. The virtual-layout refusal goes through the same path.receive_messageno longer honours aRunbehind a failedLoad, andload_layouttakes the hook down on a failed parse when the frame had kept it up.Rect::intersects.UI
LatestRequestGate: every Start, live-preview frame, Stop and Quit takes a ticket before anything is awaited; when its turn to send comes, a Start whose ticket is no longer the newest is dropped. A Stop always lands, and queues behind a send in progress rather than overtaking it.EngineController.ReconcileFreshLayoutAsync(wasStartIfEnabledAsync): a rebuilt layout the user turned off now sends an explicit Stop instead of nothing — which also outranks any Start still in flight.RunRefusedis parsed, shown by the location control, and traced indaemon-events.loglike every other event; older UIs reject it as unknown, per the contract. Golden corpus andwire-contract/README.mdupdated.What is and is not established
The clip-to-phantom-monitor mechanism and the three paths above are established from the code. The report's timeline is not fully explained:
Runningarrives 5.3 s afterResumedand no frame reached the daemon before theDisplayChangedat 43.66 s. The daemon-side check makes the outcome the same whichever path delivered the stale layout, which is why it is the primary fix; the UI ordering removes the race itself. Not reproduced on hardware — this machine is Linux, wheremonitors_nowis unknown; the Windows side was cross-checked withcargo check --target x86_64-pc-windows-gnu.Ported onto the
rust/workspace (2026-09-16)Master moved the hook to
rust/crates/lbm-hook(withRectinlbm-geomand the protocol inlbm-ipc) while this was open. The moved files were byte-identical to the ones this branch edited, so the two commits reapplied unchanged at their new paths. One commit was added: the Rust agent now reads the hook's events throughlbm_ipc::client, and its reconciler already had aHookEvent::RunRefused— the parser and the two mappings behind it did not, which the client golden caught. The reconciler treats it as the frontend's to show, withStoppedremaining what it reasons on.Tests
rust/workspace green,cargo fmt --checkandcargo clippy --all-targets -- -D warningsclean, the latter also cross-checked forx86_64-pc-windows-gnuonlbm-hookandlbm-ipc.Load+Runover a hooked engine takes the hook down;phantom_zoneson overlap, reshaped desktops, and shared borders; aRunbehind a failedLoadneither re-hooks nor leaves the hook up. Goldens regenerated with the new event.LatestRequestGate— only the newest lands, the old-layout Start claimed first but served second is dropped, a Stop supersedes a Start still on its way and always lands, a send in progress is followed never overtaken;ReconcileFreshLayoutAsyncstops a disabled layout and leaves a missing one alone;RunRefusedparsed, required in the golden corpus, and shown with its reason.🤖 Generated with Claude Code