fix(main): route all window fan-outs through guarded broadcastToAllWindows + lint guard - #1119
Merged
Merged
Conversation
…indows The inbox review and reminder desktop notifications took BrowserWindow.getAllWindows()[0] on click. getAllWindows() can still list a destroyed short-lived window (splash, quick capture, print/export), and any access to one throws "Object has been destroyed" — the click then focuses nothing and never sends its navigation event, even when a live window is right behind it in the list. Take the first live window instead, matching the existing idiom in index.ts and the guarded broadcastToAllWindows helper. These are window-selection sites, not fan-outs, so they are deliberately not routed through broadcastToAllWindows: the click must focus and navigate exactly one window.
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…ndows 38 hand-rolled BrowserWindow.getAllWindows() + webContents.send() loops remained across ipc/, vault/, sync/, import/ and test-hooks. getAllWindows() can still list a destroyed short-lived window (splash, quick capture, print/export); the send then throws "Object has been destroyed". In sync/item-handlers/settings-handler that throw escapes ctx.emit inside the item's DB transaction and rolls back an already-applied sync item — the exact regression broadcastToAllWindows was introduced to prevent. Every migrated site keeps its channel, payload and target set. Two behaviours the loops had are preserved deliberately: the global-capture shortcut still broadcasts with no payload (the helper forwards arity via a rest parameter rather than passing an explicit undefined), and the synced-settings broadcast still tolerates getAllWindows() throwing during teardown — now logged instead of silently swallowed. The helper also contains a per-window send failure: a window that dies between the isDestroyed() guard and the send no longer aborts delivery to the remaining windows, and no longer surfaces as a throw in a caller that is mid-transaction. An ESLint no-restricted-syntax rule over main/** rejects both loop shapes so the pattern cannot regress again. It is anchored on getAllWindows() so that loops over a deliberate subset of windows — crdt-provider's per-doc windowIds, which must skip the source window — keep working. Window-selection sites (find/filter/[0]/length) are not fan-outs and are left alone.
h4yfans
marked this pull request as ready for review
August 7, 2026 20:08
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1000
Complete fix. Every
BrowserWindow.getAllWindows()fan-out in the main process now goes through the guardedbroadcastToAllWindowshelper, and an ESLint rule stops the hand-rolled pattern coming back a fourth time.Verification (the issue was real on current
main)getAllWindows()can still list a destroyed short-lived window (splash, quick capture, print/export) — that is whybroadcastToAllWindows(apps/desktop/src/main/lib/window-broadcast.ts) exists. Any access to such a window throwsObject has been destroyed.The worst case is
sync/item-handlers/settings-handler.ts, which runs inside the sync item's DB transaction: a throw there escapesctx.emitand rolls back an item that was already applied.sync/token-manager.ts(SESSION_EXPIRED) is next — it fires during teardown, exactly when windows are dying.Sites
I re-derived the list with a fresh grep of
mainrather than replaying a patch, and classified every hit.38 fan-out loops migrated (all of them; the issue's "39" counted a slightly older tree):
ipc/settings-handlers.tsipc/sync-attachment-handlers.tsvault/index.tsipc/auth-device-handlers.ts,ipc/auth-oauth-handlers.tsimport/import-context.ts,ipc/ai-inline-handlers.ts,ipc/bookmarks-handlers.ts,ipc/calendar-handlers.ts,ipc/canvas-handlers.ts,ipc/journal-handlers.ts,ipc/locale-handler.ts,ipc/saved-filters-handlers.ts,ipc/sync-core-handlers.ts,ipc/tags-handlers.ts,sync/crdt-writeback.ts,sync/item-handlers/settings-handler.ts,sync/linking-service.ts,sync/token-manager.ts,test-hooks.ts,vault/notes-io.ts,vault/rename-tracker.ts,vault/watcher.tsSkipped as already fixed or not applicable (no dead code resurrected):
vault/templates.ts— listed in the issue, but already migrated onmainby an earlier commit. Untouched.ipc/agent-handlers.ts:462,ipc/agent-lazy-handlers.ts:194—resolveSenderWindowId, window selection, not a fan-out.agent/mcp/tools/canvas-write.ts:40,menu.ts:31,index.ts(×7) — selection sites (find/filter/[0]/length), most alreadyisDestroyed-guarded.sync/crdt-provider.ts:716— iterates a doc's ownwindowIdsand must skipsourceWindowId. Rewriting it as a fan-out would change the target set and reintroduce the IPC echo loop. Deliberately left alone, and the lint rule is written so it does not flag it.Also in this PR (from the first commit, the two sites the issue lists that are selection rather than fan-out):
inbox/review-notification.tsandlib/reminders.tstookgetAllWindows()[0]in aNotificationclick handler and threw if that window had died — focusing nothing even with a live window next in the list. Both now take the first live window.Relationship to #937
#937 covers overlapping ground with the same approach and is @h4yfans' to close, keep, or land as he prefers — nothing here touches that branch. Where the two differ:
settings-handler.tsin a barecatch {}. This PR keeps the tolerance but logs, so a real fault is not swallowed.dataoptional, which would send(channel, undefined)for the zero-payloadquick-capture:openbroadcast. This PR uses a rest parameter so the arity is byte-identical —settings-handlers.test.tsassertstoHaveBeenCalledWith('quick-capture:open')with exactly one argument.Semantics
Every migrated site keeps its channel, payload, and target set. Deliberate preservations:
broadcastToAllWindows(channel, ...args)forwards exactly what it was given.settings-handler.ts. The old code caughtgetAllWindows()throwing and returned silently. The helper does not catch that, so thetrystays — but now logs via the existinglograther than swallowing. This function must never throw; it is inside a DB transaction.test-hooks.tsordering. The original loop sent up to three events per window, with only the middle one conditional. Per-window ordering is preserved.settings-handlers.ts:1051already had a window-levelisDestroyed()check; it now also gets the webContents-level check. Same targets.New helper behaviour: a window that dies between the guard and the send is logged and skipped instead of aborting the fan-out and throwing into the caller. A fan-out that silently stops reaching windows is worse than the throw it replaced, so the failure is logged, never dropped.
Tests
Per your steer, I covered the helper's guarantees once rather than writing 38 near-duplicate tests, and extended the existing
window-broadcast.test.tsinstead of adding a new file. It went 3 → 6 cases:Plus the two notification-click regression tests from the first commit (
reminders.test.ts,review-notification.test.ts), which went red→green withObject has been destroyedand survived a mutation check.30 existing test files had electron mocks returning windows with no
isDestroyed, which the guard requires. Those mocks were updated (isDestroyed: () => false) — mock-shape only, no assertion was weakened or deleted.Checks
Lint rule proof — reintroducing a raw loop in
main/lib/synthetic-violation.ts:Both loop shapes (
for...ofand.forEach) are caught. The two-step form (const windows = getAllWindows()then iterating the variable) is not caught — it is not statically distinguishable from legitimate window selection, and forcing it would false-positive onagent-handlers.ts. That limitation is written into the rule's comment rather than left implied.Docs updated in
apps/docs/src/architecture/ipc.md: per-window fault containment, arity forwarding, the lint rule and why subset loops stay allowed, and the first-live-window idiom for single-window targeting.