Skip to content

Harden the dirty-editor quit guard#853

Merged
binaricat merged 2 commits into
mainfrom
followup/quit-guard-hardening
Apr 28, 2026
Merged

Harden the dirty-editor quit guard#853
binaricat merged 2 commits into
mainfrom
followup/quit-guard-hardening

Conversation

@binaricat
Copy link
Copy Markdown
Owner

Summary

Follow-up to #840 / #851 review pass. Three concrete failure modes that a multi-agent round-2 review turned up in electron/main.cjs:1268-1343:

  1. webContents.send is unguarded. If the renderer is destroyed between the reachability check and the send (dying GPU process, etc.), the throw escapes the before-quit handler with quitGuardChannelBusy = true already set and no timeout scheduled yet — the app becomes un-quittable until restart.
  2. Timeout vs. response race silently commits quit on hasDirty=true. Once setTimeout has already enqueued its callback for the next tick, clearTimeout is a no-op and the timeout still runs commitQuit(), overriding the user's "save first" intent.
  3. Reply listener accepted any sender. A rogue or future-buggy webContents could decide the quit by sending the channel name first.

Fix

  • Funnel both paths through a settle() helper that only acts the first time it's called (handles race Bug: 图标太大了 #2).
  • Wrap the send in try/catch; on failure, tear the listener/timer down and commitQuit() synchronously (handles Welcome to Netcatty Discussions! #1).
  • Switch from .once to .on + explicit removeListener and validate evt.sender === wc, so a rogue early reply doesn't consume the slot or decide the quit (handles [BUG]右上角点同步会报错,设置里同步正常 #3).
  • Add wc.isCrashed?.() to the reachability gate so a known-dead renderer skips the round-trip and quits instantly.
  • Renderer-side: wrap editorTabStore.getTabs() in try/catch so an unexpected throw reports hasDirty=false immediately instead of stranding the main process on the 5 s timeout.

Test plan

  • npm run lint clean
  • npm test 293/293 ✔
  • Manually verify: CMD+Q with dirty editor → toast appears, app stays open
  • Manually verify: CMD+Q with no dirty editors → app quits instantly
  • Manually verify: tray "Quit" with main window hidden → app quits instantly (preserves fix(quit): target main window for dirty-editor check on quit #840 behaviour)
  • Manually verify: rapid CMD+Q mash → app quits cleanly without ghost listeners

🤖 Generated with Claude Code

binaricat and others added 2 commits April 28, 2026 16:09
Follow-up to #840. Three concrete failure modes that round-2 review
turned up:

1. `webContents.send` is unguarded. If the renderer is destroyed
   between the reachability check and the send (e.g. a dying GPU
   process), the throw escapes the `before-quit` handler with
   `quitGuardChannelBusy = true` already set and no timeout scheduled
   yet — the app becomes un-quittable until restart. Wrap the send,
   and tear the listener/timer down on failure.

2. The timeout vs. response race silently commits a quit on
   `hasDirty=true`. Once `setTimeout` has already enqueued its
   callback for the next tick, `clearTimeout` is a no-op and the
   timeout callback runs even after the response arrived — which
   unconditionally calls `commitQuit()`, overriding the user's
   "save first" intent. Funnel both paths through a `settle()` helper
   that only acts the first time it's called.

3. The reply listener accepted any sender. A rogue or future-buggy
   `webContents` could decide the quit by sending the channel name
   first. Validate `evt.sender === wc` and ignore non-matches; switch
   from `.once` to `.on` + explicit `removeListener` so a rogue early
   reply doesn't consume the listener slot.

Also wrap the renderer-side handler in try/catch so an unexpected
throw inside `editorTabStore.getTabs()` reports `hasDirty=false`
immediately instead of stranding the main process for 5 s on a
silent timeout.

Verify `webContents.isCrashed()` before sending so a known-dead
renderer skips the round-trip and quits instantly instead of waiting
on the timeout fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex round-2-2 review suggested two small follow-ons:

1. Sender check should reject missing/falsy `evt.sender` outright. In
   real Electron IPC the sender is always populated; a falsy sender
   is anomalous and treating it as legit defeats the rogue-reply
   defence we just added.
2. Wrap `bridge.reportDirtyEditorsResult` in try/catch on the
   renderer side. If the IPC bridge is in a bad state and the call
   throws, the rest of the listener body is fine but the React
   useEffect callback would propagate the error — and an uncaught
   error in the listener would silently disable the quit guard for
   the rest of the session.

Both are pure tightening; no behaviour change on the happy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@binaricat binaricat merged commit 1fcf77e into main Apr 28, 2026
@binaricat binaricat deleted the followup/quit-guard-hardening branch April 28, 2026 08:13
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.

1 participant