Skip to content

The Guard Watcher

Mikhail Deynekin edited this page Oct 16, 2026 · 2 revisions

The Guard Watcher

The watcher — called the guard in the UI and in code — is the component that makes “show all tray icons” stick: it reconciles HKCU\Control Panel\NotifyIconSettings against the desired visibility whenever the store changes. This page documents its design, every timing knob with its exact range, the three visibility modes, the statistics it exposes, and the defect that made the event-driven path inert in every build from 8.3.0 to 8.6.2 — kept on record, together with the fix that shipped in 8.6.3 and how it was verified end-to-end on a real Windows machine.


Contents


Design as implemented

The guard is one worker thread plus a small state struct, both behind an SRWLOCK (src/core/guard.cpp):

  1. Arm — open HKCU\Control Panel\NotifyIconSettings read-only and call RegNotifyChangeKeyValue with bWatchSubtree = TRUE, REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET, an auto-reset event and asynchronous delivery. The notification is one-shot: the watch is re-armed after every pass and after every paused iteration.
  2. Wait — WaitForMultipleObjects on {stop event, registry event, wake event} with a timeout taken from settings.guard.pollMs (INFINITE when pollMs is 0). The registry event is what makes event-driven mode work; the wake event makes pause/resume take effect immediately instead of waiting for the next change. Before 8.6.3 only the stop event was in the wait set — see the defect history below.
  3. Debounce — after waking, wait settings.guard.debounceMs more (clamped 50–5000 on load, default 400) so that burst writes from an application registering several icons settle into one pass.
  4. Pass — rescan the store and reconcile per the visibility mode (below); write IsPromoted only where it differs; log an EVENT promoted N icon(s): … line; emit a UI event "N|name1, name2" (names truncated past 160 characters).
  5. Optional shell restart — if autoRestartExplorer is on, at least two consecutive passes changed something, and explorerCooldownSec (floor 10 s, default 60 s) elapsed since the last restart, restart Explorer and emit an ExplorerRestarted event.
  6. Loop — re-arm and wait again. Stop() signals the stop event and joins with a 4 s budget; the handles are closed only when that join succeeded (a timed-out join leaks them deliberately rather than leaving the thread waiting on closed handles).
flowchart TD
    A["ArmRegistryWatch<br/>RegNotifyChangeKeyValue"] --> B["wait: stop | registry | wake<br/>timeout = pollMs or INFINITE"]
    B -->|stopped| Z["exit thread"]
    B -->|timeout| C["debounce wait"]
    C -->|paused?| A
    C --> D["RunPass: rescan + reconcile"]
    D --> E{"changed and<br/>auto-restart armed?"}
    E -->|yes| F["Explorer restart<br/>with cooldown"]
    E -->|no| A
    F --> A
Loading

Important

Builds 8.3.0 – 8.6.2 waited on the stop event only: the registry event from step 1 was signaled by the operating system but was not part of any wait set, so with the default pollMs = 0 the loop never ran a pass at all. Fixed in 8.6.3 — see the defect history and the fix.

Visibility modes

Set from the tray menu radios, the Guard page, or --mode <value>; stored as guard.displayMode (full | custom | anything else normalizes to custom).

Mode Desired state per icon Use case
full promoted “show everything, always”
custom (default) promoted unless the executable matches an exclusion wildcard or the icon id is in icons.userHidden per-icon control
compact promoted only for TrayIconGuard's own registration (guard::OwnIconId, matched by module path) minimal taskbar

Changing the mode saves settings, logs an event, and runs an immediate reconcile pass so the effect is visible at once (guard::SetDisplayMode).

Exclusions are case-insensitive wildcards matched against both the file name and the full executable path (store::IsExcluded), e.g. onedrive.exe or *teams*.

Timing knobs and their exact ranges

Setting (settings.json) UI control Values Default Load-time clamp
guard.debounceMs Guard → Debounce segment 200 / 400 / 800 / 1500 ms 400 50–5000
guard.pollMs Guard → Polling fallback segment 0 (off) / 2000 / 5000 / 15000 ms 0 ≥ 0
guard.autoRestartExplorer Guard → toggle bool false —
guard.explorerCooldownSec Guard → Cooldown segment 30 / 60 / 120 / 300 s 60 ≥ 10
guard.enabled Guard → master toggle; first run forces true bool true —
guard.legacyBelt Guard → repairs toggle bool true —

With pollMs = 0 the intended semantics are “event-driven only”; with pollMs > 0 the loop wakes on the poll timer (this is the path that actually reconciles in the published builds — see the defect).

Statistics and events

guard::Stats (read under lock) powers the Guard page statistics card and the status bar: running, paused, promotionsSession, passes, lastChangeTick, lastEventTime (local SYSTEMTIME), startTick for uptime. Event kinds emitted to the UI sink: Started, Stopped, Paused, Resumed, Promoted (payload N|names), Discovered, ExplorerRestarted, Error. The UI marshals them with a registered window message (TrayIconGuard.GuardEvent.v8, WM_APP+1 fallback) and a heap-copied payload the UI thread deletes; the tray icon shows a balloon on Promoted when behavior.notifications is on, and its state dot turns green (guarding) or amber (paused).

Pause, resume and manual passes

  • Pause/Resume: tray menu item, Dashboard button, Guard master toggle, or --toggle-guard IPC to a running instance. A paused guard still re-arms its watch each iteration but skips passes.
  • Manual pass: --promote-all (CLI), Dashboard Promote all now, Icons page Promote all, tray menu Promote all now. All route through guard::PromoteAllNow, which runs one reconcile pass off-thread-safe and returns the change count; UI variants create an automatic snapshot first when behavior.autoBackup is on.
  • --restore-defaults deletes every IsPromoted value and removes the legacy belt, handing control back to Windows.

Defect history: the armed event was never waited on

(Present in 8.3.0 – 8.6.2; fixed in 8.6.3. Kept as the record of what was wrong and why it stayed invisible for so long.)

In src/core/guard.cpp (ThreadProc) the wait is:

const DWORD pollMs = static_cast<DWORD>(settings::Get().pollMs);
const DWORD timeout = pollMs > 0 ? pollMs : INFINITE;
const DWORD wait = WaitForSingleObject(g_stopEvent, timeout);

g_regEvent — the event handed to RegNotifyChangeKeyValue in ArmRegistryWatch — never appears in any wait. Consequences, traced:

  • With the default pollMs = 0, after arming, the thread blocks on the stop event indefinitely: no pass ever runs, no icon is ever re-promoted, statistics stay at zero passes. The guard reports “guarding” (green dot, running = true).
  • With pollMs > 0 the loop works, but as a pure poller: the event-driven behavior documented since v7 is not what executes.
  • --watch inherits the same loop, so headless deployments are affected identically.

The defect is invisible on unsupported stores (the thread logs guard cannot start: NotifyIconSettings store absent and never starts) and invisible in short smoke tests; it manifests exactly as the decay problem the project exists to solve — silently, days later.

A secondary robustness issue sat in Stop(): the stop and registry event handles were closed after a 4 s join budget even if the worker was still inside a pass, leaving a window where the thread waits on closed handles. Also fixed in 8.6.3.

Fix shipped in 8.6.3

src/core/guard.cpp now waits on all three events, coalesces bursts while still listening, and keeps Stop() from closing handles a running thread may be using:

HANDLE waits[3] = {g_stopEvent, g_regEvent, g_wakeEvent};
const DWORD wait = WaitForMultipleObjects(3, waits, FALSE, timeout);
if (wait == WAIT_OBJECT_0) break;      // stop requested

// a burst of writes keeps the debounce window open; a stop inside it breaks out
if (!DebounceBurst(static_cast<DWORD>(settings::Get().debounceMs))) break;
...
ArmRegistryWatch();                    // one-shot notification: re-arm after every pass

Because g_regEvent is auto-reset, one burst of registry activity collapses into a single wake; the poll timer remains a safety net (pollMs > 0 still bounds the maximum reconcile latency). Resume() signals g_wakeEvent, so resuming reconciles immediately.

Verification recipe actually executed on Windows 11 (build 26300) against the released binaries — a promoted icon whose application is not running, default guard.pollMs = 0:

$key = "HKCU:\Control Panel\NotifyIconSettings\<id of a promoted icon>"
Start-Process .\TrayIconGuard.exe -ArgumentList '--watch' -RedirectStandardOutput watch.log
Set-ItemProperty $key IsPromoted 0 -Type DWord     # simulate Explorer hiding the icon
Start-Sleep 6
(Get-ItemProperty $key).IsPromoted                 # 8.6.3: 1   |   8.6.2: 0
Get-Content watch.log                              # [event] promoted 3|PSExpressBroker.exe
Build IsPromoted 6 s after being set to 0 --watch log
8.6.2 (and every earlier 8.x) 0 — nothing happens only [event] watcher armed
8.6.3 1 — the guard re-promoted it [event] promoted 3|PSExpressBroker.exe

The app log records the same pass: [EVENT] promoted 3 icon(s): PSExpressBroker.exe, roughly 2.5 s after --watch started.


See also

Clone this wiki locally