fix(shutdown): scope the window flush handshake per window and request - #1150
Merged
Conversation
`flushWindow` shared one `app:flush-done` channel across every in-flight flush with no sender and no request id, and its 2s timeout branch resolved without removing the listener. - Leak: a renderer that never answers left its listener on the shared `ipcMain` permanently. `window-close` runs this on every close, so the listener count grew for the life of the process. - Correctness: `flushAllWindows()` registers one listener per window on the same channel, so the first window to reply resolved all of them and quit proceeded while slower renderers still had unsaved edits pending. A late reply to an earlier request could likewise satisfy the next request for the same window. `app:request-flush` now carries a `randomUUID` request id that the renderer echoes back on `app:flush-done`; the main-side handler accepts a reply only when it comes from that window's `webContents` and matches that request id, and both the reply and the timeout path go through one `settle()` that always removes the listener. Closes #1021
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
h4yfans
marked this pull request as ready for review
August 7, 2026 22:26
This was referenced Aug 8, 2026
This was referenced Aug 12, 2026
Merged
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.
Closes #1021
Problem
flushWindow()inapps/desktop/src/main/index.tsis the last line of defence against losing unsaved edits at window close and at quit. Two defects on that path:resolve()withoutipcMain.removeListener. A busy or hung renderer that never answered left its listener on the sharedipcMainpermanently.ipcMain.on('window-close')callsflushWindowon every window close, so the listener count grew for the life of the process.app:flush-donehad no request id and noevent.sendercheck.flushAllWindows()registers one listener per window on that same channel, so the first window to reply resolved all of them — shutdown then continued while slower renderers still had saves in flight. A late reply to an earlier request (close flush timed out, renderer answered afterwards) could likewise satisfy the next request for the same window.Root cause
One shared, unscoped IPC channel used for N concurrent request/response pairs, plus a cleanup path that only ran on the success branch.
apps/desktop/src/main/lib/window-rpc.tsalready solves exactly this with a per-request id and a sender check;flushWindowpredated it and never adopted the pattern.Fix
app:request-flushnow carries arandomUUID()request id; the renderer echoes it back onapp:flush-done(preload/api/core.ts,renderer/src/hooks/use-flush-on-quit.ts).event.sender === win.webContentsand the echoed id matches this request.settle()that alwaysclearTimeouts andipcMain.removeListeners.Nothing was dropped or shortened. The failure mode is unchanged and still conservative: an unrecognised or absent reply simply falls through to the existing 2 s timeout, which resolves and lets shutdown continue exactly as it does today. No new way to lose data was introduced; two existing ways were removed.
Test evidence
New tests in
apps/desktop/src/main/index.phase2.test.ts(four), written before the fix.RED, on unmodified
origin/maincode:GREEN, after the fix:
Full main-process suite:
Mutation verification
Each fix line reverted individually; every mutant died.
settle()->resolve()(drops the listener cleanup)removes the flush-done listener when a window flush times outTests 1 failed | 56 passed (57)if (event.sender !== win.webContents) returnignores a flush-done from another window even when it carries the pending request idTests 1 failed | 56 passed (57)if (doneRequestId !== requestId) returnignores a stale flush-done from an earlier request on the same windowTests 1 failed | 56 passed (57)Note on the second row: on the first pass the sender mutant survived, because the multi-window test was already killed by the request-id check alone (a
randomUUIDno other window can produce). Rather than accept a near-equivalent mutant, a fourth test was added that pins the sender check on its own terms — a window replying with another window's live request id must be ignored. That test kills it. The sender check is defence in depth on top of the id; both are kept because both are cheap and the issue asked for both.Verification
Risk and backward compatibility
Low. No database, sync-protocol, vault-format or settings change — nothing persisted or sent over the wire is touched, so nothing here can be read by, or has to tolerate, another app version.
app:request-flush/app:flush-doneare purely intra-process channels between the main process and its own renderer; main, preload and renderer ship in the same asar, so there is no version skew across the new argument. The main-side handler still tolerates a missing request id (it just falls through to the pre-existing 2 s timeout), so a stale renderer surviving a hot reload degrades to today's behavior rather than hanging.The user-visible effect is only that a multi-window quit now waits for each window's own flush instead of the fastest one — strictly more saved work, at the cost of up to the same 2 s per-window bound that already existed.
No cache or latch was added.
Scope
flushWindow/flushAllWindowsonly.broadcastToAllWindowsand the window fan-out helpers touched by PRs #1119 and #937 are deliberately untouched; this change sits beside them inindex.tsbut shares no code with them, so it should rebase cleanly either way.Docs gate:
pnpm docs:impact --strictreportsmissing-docsforapps/desktop/src/main/index.ts,preload/api/core.ts,preload/index.d.tsanduse-flush-on-quit.ts. Pushed withMEMRY_DOCS_IMPACT_SKIP=1: this is an internal IPC-handshake correctness fix on the shutdown path with no user-facing surface, setting, or workflow change — there is no page underapps/docs/src/**that describes this behavior, and adding one would document an implementation detail.