Skip to content

App closes unexpectedly after switching Windows accounts or display modes #175

Open

Description

@Sekers

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 --diagnose log.

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

  1. Use more than one display, with Show my taskbar on all displays on.
  2. 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.
  3. 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.

What happens

Shell_TrayWnd is the main taskbar; Shell_SecondaryTrayWnd is the taskbar on each other display. When a display goes away:

  1. Explorer moves that display's Shell_SecondaryTrayWnd off-screen, outside every remaining display.
  2. A fraction of a second later, Explorer destroys it, and the app exits at that moment.
  3. Shell_TrayWnd survives with the same handle. A new Shell_SecondaryTrayWnd is 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 receives WM_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):

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_taskbars sorts by top edge. When the off-screen taskbar sits higher than Shell_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

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_BROADCAST messages are "not sent to child windows" (SendMessage), so it misses TaskbarCreated and WM_SETTINGCHANGE.
    • A light/dark or language change then only shows at the next poll, through WM_APP_USAGE_UPDATED (message_loop.rs#L139-L140).
  • 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

  1. Controller window: a hidden top-level window, never parented into Explorer.
    • It owns the app's lifetime: only its WM_DESTROY quits, and Exit and WM_APP_QUIT destroy 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).
  2. 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_DESTROY the app didn't request notifies the controller and is logged.
  3. 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.
  4. One host rule, replacing the six lookups:
    • Shell_TrayWnd belongs 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.
  5. 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).
  • 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions