Skip to content

Throttle FG-window polling to fix race with shell hotkeys (fixes #166) - #201

Open
rossveitch wants to merge 1 commit into
dankrusi:mainfrom
rossveitch:fix/polling-race-with-shell-hotkeys
Open

rossveitch wants to merge 1 commit into
dankrusi:mainfrom
rossveitch:fix/polling-race-with-shell-hotkeys

Conversation

@rossveitch

Copy link
Copy Markdown

Summary

Fixes #166 (Helper fighting native Win+Ctrl+Left/Right desktop switching).

_MonitorFGWindowName polled the foreground window's name every 20 ms by calling Util.OS.GetForegroundWindowName(), which issues synchronous WM_GETTEXT to the foreground window's message pump. This thread ran unconditionally, even when feature.restorePreviousWindowFocus (the only feature that genuinely needs sub-second foreground-window data) was disabled.

During a Win+Ctrl+Left/Right desktop transition the shell briefly has its hotkey and message queues in flux. The helper's 50 Hz blocking SendMessage calls race with the shell's own WM_HOTKEY handling, intermittently delaying or dropping the shell's processing of the user's keystroke. Result: the native desktop-switch shortcut works most of the time but occasionally no-ops. Stopping the helper makes the shortcut 100% reliable — confirmed locally and matches the symptom report in #166.

Fix

Two-line conceptual change inside App.cs:

  • _MonitorFGWindowName — poll at 200 ms when restorePreviousWindowFocus is on, 1000 ms when off (was 20 ms unconditionally). The FGWindowHistory list feeds two consumers:
    1. restorePreviousWindowFocus, which needs ~200 ms.
    2. The tray-click "is Task View already open" heuristic in AppForm.cs — only cares about state at click time, 1 s is plenty.
  • _monitorFocusedWindow — skip work entirely when restorePreviousWindowFocus is off (5 s heartbeat sleep). This thread exists solely to feed that feature.

Impact

  • Users with restorePreviousWindowFocus = true (opt-in): polling load drops 10× (200 ms vs 20 ms). 200 ms is well within human-perceptible latency for the feature's purpose.
  • Users with the feature off (default): polling load drops 50×, and the race condition with Win+Ctrl+Arrow disappears.
  • No functional regression for any other feature. FGWindowHistory is still populated; just less aggressively.

Why this is the right layer to fix it

Yes, ideally desktop-change monitoring would use IVirtualDesktopNotification event sinks instead of any polling at all, and foreground-window tracking would use EVENT_SYSTEM_FOREGROUND WinEvent hooks. Those are bigger refactors. This PR is the smallest change that eliminates the user-visible bug without touching architecture.

Test plan

  • Build (Release | AnyCPU, .NET Framework 4.7.2)
  • Run with default config (useHotKey... = false, restorePreviousWindowFocus = false) — confirm Win+Ctrl+Left/Right works reliably across many invocations and across different foreground apps (Edge, terminal, Explorer)
  • Run with feature.restorePreviousWindowFocus = true — confirm focus restoration still works on desktop switch
  • Run with feature.showDesktopNumberInIconTray.clickToOpenTaskView = true — confirm tray-click open-task-view still suppresses the open when Task View is already foreground

Tested locally on Surface Pro 8 / Windows 11 25H2 (build 26200).

🤖 Generated with Claude Code

The _MonitorFGWindowName thread polled the foreground window's name every
20 ms (50 Hz) by calling Util.OS.GetForegroundWindowName(), which sends
synchronous WM_GETTEXT to the foreground window's message pump. The thread
ran unconditionally, even when feature.restorePreviousWindowFocus (the only
feature that needs sub-second freshness) was disabled.

During a Win+Ctrl+Left/Right desktop transition the shell briefly has its
hotkey and message queues in flux. The helper's 50 Hz blocking SendMessage
calls race with the shell's own WM_HOTKEY handling, intermittently delaying
or dropping the shell's processing of the user's keystroke. Symptom: the
native Win+Ctrl+Arrow desktop switch works most of the time but occasionally
no-ops. This matches the report in dankrusi#166.

Fix:
- _MonitorFGWindowName: poll at 200 ms when restorePreviousWindowFocus is
  on, 1000 ms when off (was 20 ms unconditionally). The history feeds two
  consumers: (a) restorePreviousWindowFocus, which needs ~200 ms; and
  (b) the tray-click "is Task View already open" heuristic in AppForm.cs,
  which only cares about state at click time -- 1 s is more than enough.
- _monitorFocusedWindow: skip work entirely when restorePreviousWindowFocus
  is disabled (5 s heartbeat). This thread's only purpose is to feed that
  feature; polling when the feature is off was pure waste.

No behavior change for users with restorePreviousWindowFocus=true beyond a
10x reduction in foreground polling rate (200 ms is still plenty for that
feature). For users with the feature off (default), the polling load drops
50x and the race condition disappears.

Fixes dankrusi#166
@koppor

koppor commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor
grafik

Thanks for digging into this @rossveitch. I opened #206 for the same issue and I think the two are complementary rather than competing — they fix different failure modes reported in #166:

  • Throttle FG-window polling to fix race with shell hotkeys (fixes #166) #201 (this PR) targets the case with restorePreviousWindowFocus off (the default): the 20 ms GetForegroundWindowName() storm racing the shell's hotkey handling, so the native switch occasionally no-ops. Throttling a 50 Hz blocking cross-process WM_GETTEXT is clearly worth doing regardless.
  • Fix #166: stop focus restore from fighting native desktop switching #206 targets the case with the feature on: _restorePrevWinFocus() calls SetForegroundWindow() on a window that the store-side read race filed under the wrong desktop, so Windows jumps back to that window's desktop. That matches the symptom in my comment — the switch snaps back and refocuses the wrong window, which is a focus write (the restore path firing), not just a dropped hotkey. This PR's throttling makes that snap-back rarer but doesn't remove it, since a switch animation outlasts a 200 ms tick.

They touch different methods (_MonitorFGWindowName / _monitorFocusedWindow here vs. _storeLastWinFocused / _restorePrevWinFocus in #206) and merge cleanly — I test-merged the two branches, no conflicts. There's even a nice synergy: this PR gates _monitorFocusedWindow to feature-on, so the desktop check #206 adds in _storeLastWinFocused only runs when the feature is actually enabled.

One honest caveat on the diagnosis: "stopping WVDH makes the shortcut 100% reliable" is consistent with both theories (quitting stops the polling storm and the restore path), so it doesn't by itself pin the no-op on the SendMessage storm — but the throttling stands on its own merits.

My suggestion is to land both, since they cover the two distinct symptoms in #166. Happy to fold the throttling into #206 (or rebase #206 on top of this) if a single PR is easier to review — whatever @dankrusi prefers.

🤖 Generated with Claude Code

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.

[Bug]: WindowsVirtualDesktopHelper fighting native desktop switching

2 participants