--- title: 'The Safety Check That Made Our App Impossible to Quit' excerpt: '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.' date: '2026-07-06' author: 'Steven' authorAvatar: '/images/blog/authors/steven.jpg' coverImage: '/images/blog/covers/the-auto-update-that-made-our-app-unkillable.svg' tags: ['Engineering', 'Electron', 'Reliability', 'GeekBye Releases'] keywords: - 'electron auto update app wont quit' - 'quitAndInstall not working electron' - 'app cannot be quit after update mac' - 'electron autoUpdater unkillable process' - 'auto update failure recovery desktop app' - 'sentry noise filtering real bugs' lastModified: '2026-07-06' tldr: 'GeekBye v1.5.14 through v1.5.19 was six auto-update releases in four days. The headline bug: a well-meant "safety" guard wrapped quitAndInstall so the app would only fully quit if no error fired — but the error handler had already flipped the flag it checked, so on any failed update the app tore down its windows and then never quit. On macOS that left an unkillable, windowless process. The fix was to make teardown unconditional. Along the way: exact-signature telemetry filtering (so noise reduction stopped hiding real crashes) and a manual escape hatch behind every automatic path.' keyTakeaways: - 'Auto-update took six releases in four days to stabilize — it is genuinely one of the hardest features to ship in a desktop app' - 'A 500ms "safety" guard around quitAndInstall only called the final quit if an error flag was still unset — but the error handler cleared that flag, so a failed update left the app half-torn-down and unquittable' - 'On macOS a windowless app does not exit, so the bug produced a process with no window, no menu, and its quit listeners already removed — force-quit was the only escape' - 'The fix reverts to unconditional teardown: destroy every window (not close, which can be vetoed), then quit unconditionally' - 'Two supporting lessons shipped in the same arc: filter crash telemetry by exact signatures not broad keywords, and always pair the automatic update path with a manual one' --- 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](https://github.com/aiescu/geek-bye-releases/releases/tag/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](https://github.com/aiescu/geek-bye-releases/releases/tag/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](https://github.com/aiescu/geek-bye-releases/releases/tag/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: ```js 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 the next chapter — rebuilding the whole audio pipeline while users were mid-meeting — see [we deleted 5,000 lines of audio code and transcripts started showing up twice](/blog/rewriting-the-audio-pipeline-while-users-were-on-it) (v1.6). For where that road led, see [what a version 2 actually takes](/blog/what-a-version-2-actually-takes-206-commits) (v2.0.0) and the whole arc in [the anatomy of shipping software to perfection](/blog/anatomy-of-perfection).