Steven
Steven6 min read

The Safety Check That Made Our App Impossible to Quit

Auto-update was the hardest feature we ever shipped — six releases in four days to stop it bricking the app. The worst bug was one we introduced trying to be careful: a 500-millisecond "safety" check that turned a failed update into a process you literally could not quit.

Engineering
Electron
Reliability
GeekBye Releases
The Safety Check That Made Our App Impossible to Quit

Every desktop app developer underestimates auto-update exactly once. It looks like a solved problem — a library downloads a new version and restarts your app. Then you ship it, and you learn that "restart your app" is one of the most dangerous things a program can be asked to do, because it happens at the exact moment your app is tearing itself down and has the least margin for error.

GeekBye's auto-update took six releases in four days — v1.5.14 through v1.5.19 — to stabilize. This is the story of the worst bug in that stretch, which we caused ourselves by trying to be careful.

Six releases, and the one that mattered

The arc started mundane. v1.5.14 fixed an embarrassing typo-class bug: the update feed pointed at a GitHub repo name that didn't exist, so the updater was checking a 404. v1.5.15 added a manual "Check for Updates" button and a real error message. Then the quitAndInstall bugs started, and the releases came fast — because when your update mechanism is broken, you can't ship the fix for it through the update mechanism. Every iteration is a manual-reinstall gamble.

The one that matters is v1.5.18. Its entire content was a single commit with a title that still makes me wince: restore original quitAndInstall behavior to prevent unkillable app.

How "being careful" bricked the app

Here's the setup. When an update is downloaded, Electron's quitAndInstall is supposed to close the app and swap in the new version. In an earlier release, someone — reasonably — worried that quitting unconditionally was risky. What if the install errored? Wouldn't it be safer to quit only if everything looked healthy?

So the code grew a guard that looked sensible:

autoUpdater.quitAndInstall(false, true)
setTimeout(() => {
  if (this.updateDownloaded)
    app.quit() // only quit if the update is still "good"
  else console.log('Error detected — keeping app open')
}, 500)

The logic: fire the install, wait half a second, and only force the final app.quit() if the updateDownloaded flag is still true — otherwise keep the app open so the user isn't stranded.

The trap is one line away, in the error handler. That handler set this.updateDownloaded = false. So picture a failed install: the error event fires and clears the flag. But quitAndInstall had already begun teardown — it had closed the windows and removed the app's quit listeners. Then the 500ms timer wakes up, checks the now-false flag, decides "error detected, keep the app open," and skips app.quit().

Now you have, on macOS specifically, the worst possible state. macOS doesn't quit an app just because its last window closed — that's the window-all-closed behavior every Mac app relies on. So the process is still alive, but it has no window, no menu bar path, and its quit listeners have been torn out. There is nothing to click. Cmd-Q has nothing to talk to. The only way out is Force Quit from Activity Monitor. The "safety" check had converted a failed update — a recoverable annoyance — into a zombie you couldn't kill.

The fix: teardown must be unconditional

The correction in v1.5.18 is almost aggressively boring, which is the point. It removes the cleverness:

  1. Remove the window-all-closed and before-quit listeners that could interfere.
  2. Destroy every window — window.destroy(), not window.close(). Close can be vetoed by a handler; destroy cannot. When you are committing to shutting down, you don't ask politely.
  3. Call quitAndInstall.
  4. Call app.quit() unconditionally.

No flag, no timer, no "keep it open just in case." Because the truth about a shutdown path is that a half-finished shutdown is worse than either outcome. Fully quitting is fine. Fully staying open is fine. The one state you must never reach is torn down but still running — and that's exactly the state a conditional quit can strand you in.

Two more lessons the same week taught

The unkillable bug is the headline, but the six-release scramble hardened two other habits worth stealing.

Filter your crash telemetry by exact signatures, not broad keywords. Mid-scramble we found our error reporting was configured to drop anything containing words like permission, token, or microphone — an attempt to cut noise that was quietly swallowing real crashes that happened to mention those words. We tore the blanket filters out and replaced them with exact denial strings (the specific message macOS emits when a permission is declined) and specific transient network codes like ERR_NETWORK_CHANGED. Noise reduction and bug-hiding are the same knob turned opposite ways; if you filter by vibe, you'll filter out the thing you needed to see.

Every automatic path needs a manual escape hatch. Auto-update is best-effort by nature — networks flake, installs fail. So each failure mode got a human fallback: the manual "Check for Updates" button, exponential-backoff retries, a re-check timer, and — reserved specifically for the case where a user's manual attempt failed — a plain-language "delete the app and reinstall from the website" message. The automatic path is a convenience; the manual path is the guarantee.

The takeaway

  1. A guard around an irreversible action is more dangerous than the action. The conditional quit tried to prevent a bad update from quitting the app, and instead created a state worse than either quitting or not. Shutdown and install paths should be unconditional and idempotent — never gated on a mutable flag a different handler can flip out from under you.
  2. On macOS, "no windows" is not "no app." Any teardown logic has to account for the platform where a windowless process keeps running. Test the failure path, on the actual OS, not just the happy path.
  3. The feature you ship through the update system can't be tested through the update system. That asymmetry is why auto-update deserves paranoid, unconditional, heavily-manually-verified code. You only get to fix it the easy way after it already works.

This is the earliest chapter of the reliability work that eventually became GeekBye v2. For where that road led, see what a version 2 actually takes (v2.0.0) and the whole arc in the anatomy of shipping software to perfection.