Skip to content

fix: never hook a layout the desktop no longer matches (#607) - #609

Merged
mgth merged 3 commits into
masterfrom
fix/607-stale-layout-hook
Sep 16, 2026
Merged

mgth merged 3 commits into
masterfrom
fix/607-stale-layout-hook

Conversation

@mgth

@mgth mgth commented Sep 10, 2026

Copy link
Copy Markdown
Owner

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.xml still 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 with ClipCursor before 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:

  1. The daemon hooks whatever it is given. It unhooks itself on WM_DISPLAYCHANGE and relies on the UI to send fresh zones, but nothing checked that the zones it then receives match the desktop. The startup replay of Current.xml has the same exposure: boot undocked after being docked, and the daemon hooks the docked layout until the UI connects.
  2. The UI's Starts are not ordered. Around a wake three paths ask for a Start — the reconcile after Resumed, the resume watchdog, the rebuild after DisplayChanged — 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, and Current.xml records the last one sent — which is what the report shows. And since Enabled is stored per layout, a rebuild into a layout the user turned off sent nothing at all, leaving the daemon with whatever it last had.
  3. A Run behind a Load that 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 at Run time. Windows enumerates them with EnumDisplayMonitors in 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::run declines 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 new RunRefused event with the reason (no display under Dock), and takes the hook down if a Load+Run frame had kept it up for the swap. The virtual-layout refusal goes through the same path.
  • receive_message no longer honours a Run behind a failed Load, and load_layout takes 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 (was StartIfEnabledAsync): a rebuilt layout the user turned off now sends an explicit Stop instead of nothing — which also outranks any Start still in flight.
  • RunRefused is parsed, shown by the location control, and traced in daemon-events.log like every other event; older UIs reject it as unknown, per the contract. Golden corpus and wire-contract/README.md updated.

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: Running arrives 5.3 s after Resumed and no frame reached the daemon before the DisplayChanged at 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, where monitors_now is unknown; the Windows side was cross-checked with cargo check --target x86_64-pc-windows-gnu.

Ported onto the rust/ workspace (2026-09-16)

Master moved the hook to rust/crates/lbm-hook (with Rect in lbm-geom and the protocol in lbm-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 through lbm_ipc::client, and its reconciler already had a HookEvent::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, with Stopped remaining what it reasons on.

Tests

  • Whole rust/ workspace green, cargo fmt --check and cargo clippy --all-targets -- -D warnings clean, the latter also cross-checked for x86_64-pc-windows-gnu on lbm-hook and lbm-ipc.
  • Rust, hook (110 lib + 27 integration): a layout for an unplugged display is not hooked while the layout itself loads; a matching layout is; an unknown desktop leaves it unjudged; a stale Load+Run over a hooked engine takes the hook down; phantom_zones on overlap, reshaped desktops, and shared borders; a Run behind a failed Load neither re-hooks nor leaves the hook up. Goldens regenerated with the new event.
  • C# (343 DisplayLayout, 355 Ui.Avalonia): 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; ReconcileFreshLayoutAsync stops a disabled layout and leaves a missing one alone; RunRefused parsed, required in the golden corpus, and shown with its reason.

🤖 Generated with Claude Code

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>
mgth and others added 3 commits September 16, 2026 21:03
…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
mgth force-pushed the fix/607-stale-layout-hook branch from d766491 to ec911d0 Compare September 16, 2026 19:06
@mgth
mgth merged commit 2b8946a into master Sep 16, 2026
3 checks passed
@mgth
mgth deleted the fix/607-stale-layout-hook branch September 16, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cursor gets trapped at screen edges after disconnecting and waking from sleep

1 participant