Skip to content

fix(tray): drop icons whose owner window no longer exists - #1841

Merged
eythaann merged 3 commits into
eythaann:masterfrom
z1pp090:fix/tray-prune-dead-icons
Sep 23, 2026
Merged

eythaann merged 3 commits into
eythaann:masterfrom
z1pp090:fix/tray-prune-dead-icons

Conversation

@z1pp090

@z1pp090 z1pp090 commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Tray icons of processes that exit without calling NIM_DELETE stay in Seelen's tray list forever. This happens when an app crashes, is killed, or updates itself in place. I had three "Claude" entries with only one Claude process alive, because the Claude desktop app had updated twice that day.

The cause: Seelen mirrors Explorer's tray through the Shell_TrayWnd hook, but Explorer removes dead icons internally, without sending a message. The hook therefore never sees an IconRemove for them. Hovering such an icon logs:

[ERROR][@seelen/system-tray]  Unhandled Rejection App("Window handle is invalid")

send_action already detects that the window is gone, but it keeps the icon.

Fix

SystemTrayManager::prune_dead_icons() drops icons whose owner window is gone. It runs:

  • when a window that owns an icon is destroyed. The manager subscribes to EVENT_OBJECT_DESTROY through HookManager, so the icon disappears as soon as its process dies. The subscription is made in a Once inside instance(), after the manager is fully created, because the callback itself calls instance(); subscribing from create(), inside the LazyLock initializer, hung the background thread in my first attempt;
  • after every tray event from the hook (a safety net if a destroy event is ever missed);
  • in get_system_tray_icons, emitting Changed when it removed anything so every tray webview stays in sync;
  • in send_action when the target is dead: the icon is removed, Changed is emitted and it returns Ok(()) instead of the error.

An icon counts as alive only if IsWindow passes and the window still belongs to the process that registered the icon. The owning pid is recorded when the icon is added or its handle changes, so a recycled HWND neither keeps a stale icon nor receives its callback messages. If the pid could not be read, it falls back to IsWindow alone.

Testing

  • Repro on 2.8.6: a PowerShell NotifyIcon ("PruebaFantasma") in a separate process, then Stop-Process -Force. The icon stays in the popup indefinitely.
  • Dev build (npm run dev) of this change on top of v2.8.6. The tray logic is identical to master, which only changed the handler wrapper. Results:
    • First commit: the ghost icon is listed while alive and removed after the kill (Tray icon removed, its window no longer exists: 1105f0_1). There were no more Window handle is invalid errors.
    • After the review changes (owner pid check, notify on prune): every real icon (FanControl, Claude, Steam, WhatsApp, DSX, …) stayed listed, including while hovering over the whole list, so the pid check produced no false positives.
    • With destroy-event pruning, in two rounds: the ghost was gone from the popup 1.5 s after Stop-Process -Force. Each removal is logged in the same second as the apps module's Removing: Window(...) for that window, with no tray event in between, so the destroy event is what triggers it. All real icons stayed, and the background thread stayed responsive.
    • Before the destroy-event commit, a popup that was already mounted kept showing the ghost until another icon updated or the ghost was clicked, because the popup does not send actions on hover.
  • cargo fmt --check, cargo clippy -D warnings, and cargo test pass locally, and the full CI passes on the fork with all three commits.

🤖 Generated with Claude Code

When a process exits without calling NIM_DELETE (crash, forced kill,
in-place update), Explorer drops its icons internally, so the tray hook
never sees an IconRemove and the icon stays in Seelen's list forever.
Hovering it then fails with "Window handle is invalid".

Prune icons with a dead window handle after every tray event, when the
icon list is requested, and when an action targets a dead icon (the
same thing Explorer does on hover).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StkifHpSLWH4J5TvuP56ur
@greptile-apps

greptile-apps Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The PR should not merge until command-triggered pruning notifies all subscribed tray consumers; HWND-reuse hardening is also recommended.

Summary

This PR removes stale system-tray icons when their recorded owner window no longer exists and suppresses actions targeting dead windows.

  • Prunes dead icons after hook events and before returning the tray snapshot.
  • Removes an invalid action target and broadcasts the resulting change.
  • The command-side prune currently fails to notify other tray consumers, and HWND-only validation remains vulnerable to handle reuse.

Reviews (1) · Last reviewed commit: "fix(tray): drop icons whose owner window..."

z1pp090 and others added 2 commits September 22, 2026 22:22
Emit `Changed` when `get_system_tray_icons` prunes icons, so every tray
webview drops them, not only the caller.

Remember the process that owned each icon's window when it was
registered and treat the icon as dead if the handle now belongs to a
different process, so a recycled HWND can neither keep a stale icon nor
receive its callback messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StkifHpSLWH4J5TvuP56ur
Pruning only on tray events left a dead icon listed until some other
icon happened to update, since the tray popup does not send actions on
hover. Subscribe to EVENT_OBJECT_DESTROY and prune when the destroyed
window owns an icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StkifHpSLWH4J5TvuP56ur
@eythaann
eythaann merged commit c2b2e81 into eythaann:master Sep 23, 2026
4 checks passed
@z1pp090
z1pp090 deleted the fix/tray-prune-dead-icons branch September 24, 2026 15:36
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.

2 participants