Skip to content

perf(ipc): cap the flush handshake at one ipcMain listener and free window-rpc on teardown - #1270

Merged
h4yfans merged 2 commits into
mainfrom
window-rpc-listener-early-cleanup
Aug 12, 2026
Merged

perf(ipc): cap the flush handshake at one ipcMain listener and free window-rpc on teardown#1270
h4yfans merged 2 commits into
mainfrom
window-rpc-listener-early-cleanup

Conversation

@h4yfans

@h4yfans h4yfans commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #1098. Part of epic #987 (memory/CPU audit 2026-08).

Prior work (#1021 / PR #1150)

#1021 already shipped: flushWindow removes its listener on the timeout path and scopes each reply with a request id and an event.sender check. This PR does not re-fix any of that — it only adds what #1098 asks for on top: the listener count, and the window-rpc cleanup path.

What was wrong

1. apps/desktop/src/main/index.ts:1919-1964 — one ipcMain listener per window on a shared channel.
app:flush-done is a single process-wide channel, but every flushWindow() call registered its own listener on it:

const handler = (event: IpcMainEvent, doneRequestId?: string): void => { ... }
ipcMain.on(channel, handler)          // once per window

flushAllWindows() runs flushWindow for every open window concurrently, so an 11-window quit put 11 listeners on app:flush-done and Node emitted MaxListenersExceededWarning on ipcMain. The request-id scoping from #1021 made those N listeners redundant: the id is what separates the replies, not the listener identity.

2. apps/desktop/src/main/lib/window-rpc.ts:38-56 — the per-call listener only ever came off on reply or timeout.
mainToRendererInvoke registers a listener on main:invoke:response:<uuid> and drops any reply whose sender is not the target window. When the target window goes away mid-call (closed, or reloaded out from under the request) no reply is ever coming, so the listener — and the caller's promise — sat on ipcMain for the full 2 s timeout. The handler also re-read win.webContents at reply time, which throws on a destroyed BrowserWindow.

What changed

  • index.ts: one module-level pendingFlushes map keyed by request id plus a single shared handleFlushDone listener. It attaches when the first flush starts and detaches when the last one settles (reply or timeout), so listener count on app:flush-done is 0 or 1 regardless of window count. The sender + request-id checks from [HIGH][shutdown] flushWindow leaks an ipcMain listener on timeout and lets any window satisfy any flush #1021 are preserved verbatim, just moved into the shared handler.
  • window-rpc.ts: capture win.webContents once as target (no more re-read that can throw), and settle null on target.once('destroyed') so the listener is released at teardown instead of at timeout. cleanup() removes both listeners.

Deliberately not done: the issue's item (1) says to "settle-or-cleanup on mismatch". Settling on a foreign sender's reply would hand any renderer a way to null out another window's in-flight RPC, so a mismatched reply is still dropped. The reachable cause of the lingering listener is the target going away, and that is what now triggers cleanup.

How it was proven

Test files: apps/desktop/src/main/index.phase2.test.ts, apps/desktop/src/main/lib/window-rpc.test.ts.

New/changed coverage:

  • keeps one flush-done listener when more than ten windows flush at once — 13 windows, asserts exactly 1 registration on app:flush-done, 13 distinct request ids still sent, and the listener detached once all settle.
  • waits for every window to answer its own flush before shutdown continues — assertion changed from toHaveLength(browserWindows.length) to toHaveLength(1).
  • drops the per-call listener as soon as the target window is destroyed — asserts the response listener is gone and no timer is left pending.

Before/after with the two source files reverted to origin/main (test files kept):

before:  Test Files  2 failed (2) | Tests  3 failed | 60 passed (63)
after:   Test Files  2 passed (2) | Tests  63 passed (63)

Verification

  • pnpm --filter @memry/desktop typecheck:node — clean.
  • vitest run --project main src/main/lib src/main/index.phase2.test.ts src/main/index.phase1.test.ts src/main/index.phase3.test.ts src/main/agent/mcp/tools/__tests__Test Files 30 passed (30) | Tests 334 passed (334).
  • pnpm exec eslint <changed files> — 0 errors (2 "file ignored" warnings for the two test files, pre-existing config behaviour).
  • git diff --check — clean.
  • pnpm docs:impact --base origin/main --strictcovered. pnpm docs:buildbuild complete in 4.54s.

Backward compatibility

Main-process-internal only: the app:request-flush / app:flush-done wire shape and the main:invoke payload are unchanged, so no preload, renderer, contract, schema, or settings change is involved.

@github-actions github-actions Bot added documentation Improvements or additions to documentation test labels Aug 12, 2026
@github-actions

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit a368667.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@h4yfans
h4yfans marked this pull request as ready for review August 12, 2026 14:21
@h4yfans
h4yfans merged commit a58f745 into main Aug 12, 2026
17 checks passed
@h4yfans
h4yfans deleted the window-rpc-listener-early-cleanup branch August 12, 2026 14:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[LOW][ipc] window-rpc listener lingers to timeout on sender mismatch; multi-window flush can trip the ipcMain listener cap

1 participant