Skip to content

Sleep/resume hardening, corner-crossing crash fix, Linux platform seam - #495

Merged
mgth merged 7 commits into
masterfrom
fix/492-span-wallpaper-crop-crash
Jul 9, 2026
Merged

Sleep/resume hardening, corner-crossing crash fix, Linux platform seam#495
mgth merged 7 commits into
masterfrom
fix/492-span-wallpaper-crop-crash

Conversation

@mgth

@mgth mgth commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Batch of work sitting on this branch after #493 merged (7 commits).

Sleep / resume

  • UI: settle-based display rebuild + idempotence guard + sleep gating (de98632) — rebuild once the OS display config has settled (trailing debounce + two matching signatures) instead of a fixed 5 s; skip the rebuild when the settled signature is unchanged (Memory Leak of ReactiveUI Observables via PhysicalSource #412 storm); gate all display handling while the display is off.
  • Daemon: detect sleep (screen off/on) and gate the UI (234e1b8) — register for GUID_CONSOLE_DISPLAY_STATE; on screen-off unhook + broadcast Suspended, on screen-on broadcast Resumed.
  • UI: re-hook the engine after wake — "stays blue" fix (04b7f97) — after a deep sleep the display re-enumeration storm unhooked the freshly re-hooked engine and the idempotence guard skipped the re-Start, leaving the engine stopped (blue) until a manual Start. The guard now reconciles the hook without rebuilding, and Resumed drives a bounded watchdog that re-asserts Start through the storm. Validated on a real sleep/wake cycle.
  • UI: remove the #displaychange-timing instrumentation (ee2736f) — drop the temporary DcLog/lbm-dc.log tracing now that the fix is validated; DisplayStabilityMaxSteps 40 → 8 (production value).

Daemon stability

  • Daemon: fix the corner-crossing crash (9b4b8dc) — i32 overflow that poisoned the engine lock.

Other

  • Fix ViewModel memory leak (9a766b8) — mark transient VMs/Views ExternallyOwned.
  • Platform seam for the Linux port (Phase B) (5f784ed) — ILayoutFactory / IDisplayController.

🤖 Generated with Claude Code

mathieu and others added 7 commits July 9, 2026 12:20
…Controller

Extract Windows-specific layout building and topology control behind
ILayoutFactory / IDisplayController, implemented in a new
LittleBigMouse.Platform.Windows project. The UI now depends only on the neutral
MonitorsLayout model and these seams — a Linux head plugs in its own factory.

- Move the former UI LayoutFactory, RegistryExt and Persistency into the platform
  project; drop the intermediate data-layer indirection.
- DpiAwarenessKind replaces the Win32 WinDef.DpiAwareness in the neutral model.
- Wallpaper drawn in place behind each monitor (WindowsWallpaperWatcher +
  in-place UpdateWallpaper), plus the detached-monitor span/crop guards.
- run-lbm.ps1: dev launcher (build Rust hook + UI, stage, relaunch elevated).
Grace tracks every transient IDisposable it creates in its root disposal scope
and holds it until the app closes. Monitor ViewModels/Views (ReactiveModel,
IDisposable) are rebuilt on every display-change; the container kept every
generation alive — gigabytes after a display-event storm (the UI reached ~10 GB
after a wake-from-sleep storm), even though HLab.Mvvm disposes them on
DetachedFromLogicalTree. Their lifetime is owned by the view tree, not the
container, so mark IView/IViewModel exports ExternallyOwned (same fix as
MonitorsLayout, #484 — this is the ViewModel follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework how the UI reacts to daemon display/power events:

- Rebuild once the OS display config has SETTLED (trailing debounce + two
  matching DisplaySignature reads) instead of a fixed 5 s delay.
- Idempotence guard: skip the rebuild when the settled signature equals the one
  already built. A spurious WM_DISPLAYCHANGE (DPMS on/off, mode re-apply) used to
  drop a fresh MonitorsLayout generation for nothing — the storm that filled GBs.
- Sleep gating: the daemon now emits Suspended/Resumed (screen off/on). While
  suspended the UI does nothing (no debounce, no rebuild); on Resumed it
  reconciles only if the config changed and re-hooks the daemon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon owns hook lifecycle, so it must be the one that reacts to sleep:
register for GUID_CONSOLE_DISPLAY_STATE on the display-listener window and, on
screen-off, unhook itself (release the clip — the cursor is never left confined
without a UI) and broadcast Suspended; on screen-on, broadcast Resumed and let
the UI re-Start us once the config settles. Deduplicated via a `suspended` flag
(the notification re-pushes the current state each hook/unhook cycle).

Display-state, not APM suspend: this machine's "veille" is a session-state
transition, so PBT_APMSUSPEND is never sent; display on/off does fire and also
covers lock/idle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e lock)

Touching a monitor corner permanently killed crossing until a full restart. The
straight-crossing path mapped the coordinate through the catch-all no-target
link (i32::MIN/MAX sentinel bounds) BEFORE checking the target, and
ZoneLink::to_target_pixel did its subtractions in i32 (the i64 cast came too
late). In a debug build that overflow panics; the panic drops the MutexGuard
held by the WH_MOUSE_LL callback, poisoning the engine lock for the whole
process. catch_unwind hides the crash, so the hook keeps running but every
lock/try_lock then fails -> the engine never acts -> crossing dead, and Stop/Start
can't recover (Load's lock is poisoned too); only a fresh process does. C++ never
hit it (it maps only inside `if (Target && ...)`, and its long overflow wraps).

Fixes:
- ZoneLink::to_target_pixel: widen operands to i64 BEFORE subtracting (+ guard s_len==0).
- strait_cross: resolve Some(target) BEFORE mapping the coordinate.
- Poison recovery in the callback, Load and unhook (lock().unwrap_or_else(into_inner))
  so a panic can never again leave crossing permanently dead.

Also add a watchdog that reinstalls the low-level hook if Windows silently drops
it (LowLevelHooksTimeout): cursor moving while our hook counts nothing -> reinstall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After a deep sleep, LBM would come back with the engine stopped (blue tray
icon) and needed a manual Start. Two things combined:

- The daemon unhooks itself over ANY display change (so the cursor is never
  confined while the desktop reshapes). A wake from a long sleep re-enumerates
  the GPU/monitors and fires a WM_DISPLAYCHANGE burst; one arrives ~1-2s AFTER
  we re-hook and unhooks us again (a late Stopped after Resumed).
- The idempotence guard (de98632) returned early when the settled signature
  equalled the built one — skipping not just the rebuild but the StartAsync
  re-hook. So once the daemon unhooked on an unchanged config, nothing ever
  re-hooked it. Confirmed in daemon-events.log: Resumed -> Running -> Stopped,
  then only "UNCHANGED sig — skip rebuild" until a manual Start.

Fix (UI only; the daemon keeps hook-lifecycle ownership):
- Idempotence guard now calls EnsureEngineHookedAsync instead of returning
  bare: StartAsync when Enabled && !suspended && State != Running, WITHOUT a
  rebuild (so #412 is not reintroduced).
- Resumed drives EnsureRunningAfterResumeAsync, a bounded watchdog that
  re-asserts Start until the engine is Running for a short quiet window
  (~1.5s), capped at 6 restarts / 30s so a legitimately-paused engine (an
  excluded game focused at wake) can't loop. _resumeWatchdogActive silences
  the settle-path reconcile while the watchdog is the active driver.

Never reconcile on a bare Stopped: an excluded app taking focus broadcasts
Stopped (not a proactive Paused), so that would re-hook over a game. The
display-settle / resume paths are the safe triggers, and the daemon's Run path
no-ops when paused, so re-asserting Start during a game is harmless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The resume re-hook fix is validated on a real sleep/wake cycle, so drop the
temporary DcLog / C:\dev\lbm-dc.log tracing added to measure display-settle
timing:

- Delete DcLog + its lock and every call site (DisplayChangedAsync, the resume
  watchdog, the idempotence-guard reconcile). The permanent daemon-events.log
  (TraceDaemonEvent) still records Running/Stopped/Resumed.
- Drop the settle diagnostics (steps/stable counters, the EnsureEngineHookedAsync
  reason arg) that only fed the log.
- Set DisplayStabilityMaxSteps to its production value (40 -> 8, ~1.1s cap).

Behaviour is unchanged; this only removes tracing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mgth
mgth merged commit 63f5ad0 into master Jul 9, 2026
1 check passed
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.

1 participant