Skip to content

Duplicate toasts on every extra monitor: the follower's watcher restarts itself ungated - #85

Merged
nixfred merged 1 commit into
nixfred:mainfrom
ianswope:fix/follower-watch-restart-ignores-leader
Sep 15, 2026
Merged

nixfred merged 1 commit into
nixfred:mainfrom
ianswope:fix/follower-watch-restart-ignores-leader

Conversation

@ianswope

Copy link
Copy Markdown
Contributor

Docking a laptop to two external monitors makes every allowlisted message
fire three desktop toasts — one per screen. It stays that way until the
shell is restarted; undocking clears it.

The leader election (war room #0) is doing its job. Two things defeat it.

1. Everyone is the leader for the first few hundred milliseconds

QsWindow.window is still null when a freshly built bar completes its
widgets, so on a hotplug every screen's widget satisfies !ownScreen at
Component.onCompleted and crowns itself. A sandboxed Quickshell running
the current expression verbatim, on a three-monitor setup:

PROBE init panel=eDP-1 qswindow=NULL ownScreen=NULL screens0=eDP-1 nscreens=3 LEADER=true
PROBE init panel=DP-1  qswindow=NULL ownScreen=NULL screens0=eDP-1 nscreens=3 LEADER=true
PROBE init panel=DP-2  qswindow=NULL ownScreen=NULL screens0=eDP-1 nscreens=3 LEADER=true
PROBE late panel=eDP-1 qswindow=ok   ownScreen=eDP-1 screens0=eDP-1 nscreens=3 LEADER=true
PROBE late panel=DP-1  qswindow=ok   ownScreen=DP-1  screens0=eDP-1 nscreens=3 LEADER=false
PROBE late panel=DP-2  qswindow=ok   ownScreen=DP-2  screens0=eDP-1 nscreens=3 LEADER=false

By the time it settles, running: root.leader has already started an
imsg watch on all three.

2. The demotion kills the follower's watcher, and its own backoff brings it back ungated

Settling flips leader false, the binding sets running = false, the ssh
session exits. onExited reads that as a failure — watchFails goes to 1,
watchRestart arms — and one backoff later:

onTriggered: watchProc.running = true

A bare assignment replaces the running: root.leader binding permanently.
From that moment the follower watches, refreshes and toasts forever, with no
gate left to stop it.

Evidence on real hardware

Three monitors, one shell process, after docking:

172332  ssh ... mini imsg 'watch'   started 13:59:41 - 16s   (bar rebuilt 13:59:25)
2907705 ssh ... mini imsg 'watch'   started 13:59:41
2907706 ssh ... mini imsg 'watch'   started 13:59:41

16 s is exactly 8000 * Math.pow(2, 1) — watchRestart's first backoff. The
shell log shows two rejected nixfred.blip IpcHandler registrations at
13:59:25; that handler is enabled: root.leader, so all three widgets
genuinely believed they led:

13:59:25.494 WARN scene: QML IpcHandler at .../BarWidget.qml[838:3]:
  Handler was registered but will not be used because another handler is
  registered for target nixfred.blip
13:59:25.741 WARN scene: ... (same, third widget)

The 6 s poll Timer { running: root.leader } has no imperative write and
stayed correctly single — which is what pointed at the binding clobber.

The fix

  • watchRestart restores the binding (Qt.binding(...)) instead of assigning
    a bare true.
  • An unresolved widget waits instead of racing when more than one screen
    exists. One screen or none still defaults to leader — that is what keeps a
    widget outside any window working, so the single-monitor path is unchanged.

watchLiveness's running = false also drops the binding, but the restart
path re-establishes it immediately, so it self-heals. Left alone.

Verification

  • 486/486 bun test pass.
  • Each new test fails when its own fix is reverted, and only that one
    — checked both ways, individually.
  • Root cause reproduced in a sandboxed Quickshell instance (output above),
    not inferred from reading.
  • Timing of the duplicate watchers matches the backoff arithmetic to the
    second.
  • Not verified: the post-fix behaviour on a live re-dock. The fix is
    installed here and the shell has been restarted, but the machine is
    currently undocked, so the three-monitor path has not been re-exercised
    end to end. Worth a second pair of eyes on a multi-monitor setup.

…rts itself

Omarchy builds one bar — and one of these widgets — per screen, and the
leader election exists to keep only the first screen's widget polling,
watching and toasting. Two defects let the followers do it anyway, so a
docked machine fired one desktop notification per monitor.

QsWindow.window is still null when a freshly built bar completes its
widgets, so on a hotplug every screen's widget satisfied `!ownScreen` and
crowned itself. A moment later the window resolved and the gate correctly
demoted them — but `running: root.leader` had already started their
`imsg watch`, and the demotion killed it. That exit is counted as a
failure, so one backoff later watchRestart ran `watchProc.running = true`,
which REPLACES the binding permanently. From then on the follower watched,
refreshed and toasted forever, ungated, until the shell restarted.

Restore the binding on restart instead of assigning a bare true, and let an
unresolved widget wait rather than race when more than one screen exists.
One screen (or none) still defaults to leader — that is what keeps a widget
outside any window working.

Reproduced on a three-monitor dock: three `imsg watch` ssh sessions under
one shell, and two rejected `nixfred.blip` IpcHandler registrations (the
handler is `enabled: root.leader`) proving all three widgets believed they
led. The two extra watchers started 16 s after the bar rebuild — exactly
watchRestart's 8000 * 2^1 first backoff. A sandboxed Quickshell running
the same expression logs `qswindow=NULL ownScreen=NULL LEADER=true` on all
three panels at Component.onCompleted, then the correct true/false/false
once the windows resolve.

Both tests fail when their own fix is reverted, and only that one.
@ianswope
ianswope requested a review from nixfred as a code owner September 14, 2026 20:06
@nixfred
nixfred merged commit aba4c75 into nixfred:main Sep 15, 2026
1 check passed
@nixfred

nixfred commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Merged, thanks @ianswope! duplicate toasts on extra monitors are gone. Tracing it to the watcher restart overwriting the leader-only rule, and matching the 16 s backoff to the process start times, was excellent diagnosis. It's in main as of 186b536 and deployed on my machine. I merged it together with nine other PRs, and all 526 tests pass on the combined tree.

nixfred added a commit that referenced this pull request Sep 16, 2026
WHAT: CHANGE.log is a running, newest-first log of every change since the last
X post, each entry naming the person with real name, GitHub login and their
X account as a https://x.com/<handle> link. X-POSTS.md is the queue of posts
for @nixfred: Post 1 (CONTRIBUTORS-X-POST.md, 2026-09-11) marked posted,
Post 2 drafted covering the 19 merged PRs since (#54 #71-#77 #80-#82 #84 #85
#87 #88 #92-#95) plus 4bad82d and 481e555.
WHY: Fred's standing rule 2026-09-16. Handles were missed before; a thank-you
without the @ never reaches the person, and a wrong @ tags a stranger.
HANDLES: verified @jondkinney (GitHub profile), @zachwilke_1 (pinefall.dev),
@bbishdotdev (bbish.dev + name), @mwhuss (name + Omarchy posts replying to
@nixfred). @jefehoser likely (same login, Omarchy posts), flagged for Fred.
Ian Swope and cjoh: none verified, recorded as checked.
.gitignore: *.log swallowed CHANGE.log, so it is re-included explicitly.

LR-T: blip, changelog, x-posts, contributors, handles
LR-D: blip, docs, community
LR-K: change-log-verified-x-handles, x-post-queue

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ainpzHJ5F7nsV4hB6HpZy
@ianswope
ianswope deleted the fix/follower-watch-restart-ignores-leader branch September 18, 2026 14:45
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