Repository navigation
App closes unexpectedly after switching Windows accounts or display modes #175
Copy link
Copy link
Open
Description
On a PC with more than one display, the app (claude-code-usage-monitor.exe) quits without any error when Windows stops using one of the other displays, even briefly.
Two everyday ways to trigger it:
- Switching to another Windows account and back. While you're switched away, Windows gives your session a single virtual display.
- Pressing Win+P and choosing PC screen only.
What you see:
- The taskbar widget and the tray icons disappear.
- The app is no longer running, and it stays gone until you start it again by hand.
- It happens whichever display's taskbar the widget sits in.
- There's no crash report and nothing in the
--diagnoselog.
Environment
- App 2.19.4 (WinGet), Windows 11 Pro 26H2 build 26300.9711
- Two monitors, the second to the left of the primary, taskbar shown on both
- Widget docked in the taskbar (a theme surface with the Taskbar nest)
- Lock in taskbar on (not checked whether it matters)
- Two local Windows accounts (for the account-switch case)
Steps to reproduce
- Use more than one display, with Show my taskbar on all displays on.
- Dock the widget in the taskbar of any display (a theme surface with the Taskbar nest).
- This runs through
position_custom_theme_internal; I haven't checked whether the default Classic theme takes the same path.
- This runs through
- Do either of these, then check whether the app is still running:
- Press Win+P, choose PC screen only (or run
DisplaySwitch.exe /internal), then switch back to Extend. - Switch to another signed-in Windows account and back.
- Press Win+P, choose PC screen only (or run
What happens
Shell_TrayWnd is the main taskbar; Shell_SecondaryTrayWnd is the taskbar on each other display. When a display goes away:
- Explorer moves that display's
Shell_SecondaryTrayWndoff-screen, outside every remaining display. - A fraction of a second later, Explorer destroys it, and the app exits at that moment.
Shell_TrayWndsurvives with the same handle. A newShell_SecondaryTrayWndis created about 1 s after the display returns.
| Trigger | Widget docked in | Result |
|---|---|---|
| Fast user switching | Main taskbar | The widget moved from Shell_TrayWnd into the off-screen Shell_SecondaryTrayWnd. The app was gone 0.36 s after switching away. |
| PC screen only | Main taskbar | The app was gone about 0.65 s after the switch. The widget's move into the other taskbar was too quick to observe; Shell_TrayWnd survived. |
| PC screen only | Other display's taskbar | The widget stayed in Shell_SecondaryTrayWnd and was hidden there instead of moving to the main taskbar. The app was gone about 0.6 s after the switch. |
Likely triggers by the same mechanism, not tested:
- Unplugging a display, or one dropping off when it sleeps
- Disconnecting a dock or KVM switch
- Disabling a display in Settings
- Remote Desktop
- Turning off Show my taskbar on all displays with the widget in another display's taskbar
The Windows behavior behind the exit:
- When a host window is destroyed with
DestroyWindow, a child window that another process parented into it receivesWM_DESTROY. - When the host process is killed instead, the child disappears without
WM_DESTROY. That's the Explorer-crash case the watchdog already handles.
Root causes
A. The taskbar lookup matches an off-screen taskbar to the primary display
Six places map taskbars to displays with MonitorFromWindow(taskbar, MONITOR_DEFAULTTOPRIMARY):
window.rs#L297: settings migrationwindow.rs#L362: theme runtime host sizewindow.rs#L752: lock-in-taskbar restorepositioning.rs#L271: which taskbar the surface is embedded inmessage_loop.rs#L468: drop target after a draghost_geometry.rs#L27: taskbar size used for layout
Why it picks the wrong taskbar:
- That flag returns the primary display for a window that intersects no display, so the off-screen taskbar counts as the primary display's.
find_taskbarssorts by top edge. When the off-screen taskbar sits higher thanShell_TrayWnd, as it did here, it comes first, and a main-taskbar widget is embedded into it.
B. The main window is both the app's anchor and a surface Explorer can destroy
- Its
WM_DESTROYalways callsPostQuitMessage(message_loop.rs#L930-L951). - Exit (
#L761-L771) andWM_APP_QUIT(#L176-L179) use the same path, so the app can't tell "the user chose Exit" from "Explorer destroyed my host", and nothing logs it. - The watchdog only recovers a window that vanishes while the process keeps running (Tray icon / widget disappears after Explorer (taskbar) restarts - root cause + fix #35, fix: survive explorer.exe restarts (tray icon no longer disappears) #36).
- The window can't simply be recreated in place. About 15 timers are set on it, and the poll worker, the update threads and the dashboard request listener keep the window handle they started with.
Other problems with the same causes
- Full restarts (from the code; not reproduced)
- When a mirror or desktop surface loses its host, the watchdog restarts the whole app: the dashboard closes, the tray icons are removed and re-added, and every account is polled again.
- Desktop surfaces are also recreated in place in
sync_custom_mirrors, so recovery is split three ways.
- Missed broadcasts (from reading the code; not tested)
- While docked, the main window is a child window, and
HWND_BROADCASTmessages are "not sent to child windows" (SendMessage), so it missesTaskbarCreatedandWM_SETTINGCHANGE. - A light/dark or language change then only shows at the next poll, through
WM_APP_USAGE_UPDATED(message_loop.rs#L139-L140).
- While docked, the main window is a child window, and
- Six copies of the lookup can drift apart; open feat(themes): add Top Bar theme, hide-until-hover placement and 5% low-usage alarm #164 already edits one of them.
Proposed design
- Controller window: a hidden top-level window, never parented into Explorer.
- It owns the app's lifetime: only its
WM_DESTROYquits, and Exit andWM_APP_QUITdestroy it. - It owns the timers, the tray icons, the dashboard request target, worker-thread results and broadcasts.
- It must be top-level, not message-only: message-only windows "do not receive broadcast messages" (Window Features).
- It owns the app's lifetime: only its
- Interchangeable surfaces:
- The main widget, mirrors, desktop surfaces and the floating card are all surface windows that only draw and handle input.
- A surface
WM_DESTROYthe app didn't request notifies the controller and is logged.
- One
reconcile()pass:- It compares the surfaces the theme wants with the windows that exist: it creates missing ones, re-embeds misplaced ones, hides any without a host, and removes extras.
- It runs on display and settings changes,
TaskbarCreated, a lost surface, and the watchdog's existing 2 s tick, debounced like the tray reposition.
- One host rule, replacing the six lookups:
Shell_TrayWndbelongs to the primary display.- Any other taskbar belongs to the display it overlaps most.
- A taskbar that overlaps no display is used only when that display has no other candidate, since Windows 10 slides an auto-hidden taskbar off-screen (Lock in taskbar does not keep the default widget docked on Windows 10 #165).
- While a surface's display is missing, the main surface moves to the main taskbar and duplicates hide; both return with the display.
- It's plain rectangle logic, so it can be unit-tested.
- Process restart only for a hung UI thread (fix(widget): relaunch when the UI thread stops answering #168). Explorer restarts, account switches and display changes are all handled by
reconcile().
Why this is better
- Simpler as a whole
- One lifetime owner, one recovery path, one host rule.
- Shell events no longer need process restarts, the relaunch environment flags, the single-instance handoff wait or the relaunch throttle.
- Performance
- One hidden window and no new thread.
reconcile()enumerates a few windows and rectangles, only when something changes. - Recovering a surface creates one window instead of starting a process and polling every account (the startup poll alone took 661 ms here).
- One hidden window and no new thread.
- Usability
- The widget survives display changes, account switches and Explorer restarts without losing the dashboard, tray icons or usage state.
- It returns to its display when the display does, and broadcasts arrive immediately.
- Room to grow
- A new surface type or placement is a new host-rule case; lifetime and recovery stay unchanged.
- A stable top-level window is also what session and power notifications, global hotkeys and notification popups need.
Notes
- Tray icon / widget disappears after Explorer (taskbar) restarts - root cause + fix #35 and fix: survive explorer.exe restarts (tray icon no longer disappears) #36
- They chose a process restart because, after an Explorer crash, the UI thread has no window left and another thread can't create one for it.
- With a controller window, the UI thread's message loop survives and recreates surfaces itself. The restart stays for a hung UI thread (fix(widget): relaunch when the UI thread stops answering #168).
- Quick fix, if wanted first
- The host rule alone fixes the main-taskbar case, because the widget then stays in
Shell_TrayWnd, which survives. - A widget in another display's taskbar still exits until the controller exists.
- The host rule alone fixes the main-taskbar case, because the widget then stays in
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity