fix: detect failed updates and stop retry-looping instead of silently continuing#155
Conversation
… continuing Addresses issue NexGenStudioDev#139. The updater plugin was registered but downloadAndInstall() failures were only ever logged to the console. There was no way to tell a clean "download failed, safe to retry" from "install ran but the app never actually came up on the new version", and a persistently broken release would just keep getting retried every interval forever. Tauri's updater plugin already validates the Ed25519 signature against the configured pubkey internally: check() will not return an update object at all unless the signature is valid, so signature validation was never actually missing. What was genuinely missing is everything after that: telling apart a safe failure from a dangerous one, and recovering instead of looping. - src/system/updater/autoUpdater.ts: - download() and install() are now called as two separate steps (previously a single downloadAndInstall()). A download failure (network blip, server hiccup) never reaches install at all, so the running app was never at risk in the first place, and it is not counted toward the failure threshold below since it is transient and self-heals on the next interval. - Right before install() runs, the current version and the version being installed are persisted to localStorage as a "pending install" record. On the next app boot, before doing anything else, that record is reconciled against the actual running version (via @tauri-apps/api/app's getVersion()): if they match, the update is confirmed and the failure counter resets to zero; if they do not match, the update silently failed to apply and this counts as a failure. This is the actual "did the update leave the app broken" signal, since Tauri's updater plugin has no API to truly roll back a binary, the honest thing this app can do is detect that an update did not take effect and stop before it causes a crash loop. - After 3 consecutive install failures (either install() itself throwing, or a pending update not being confirmed on the next boot), auto-install is paused for the rest of that session rather than retried every interval, so a broken release cannot trigger an endless check/download/install/crash/retry cycle. - Every transition (checking, up-to-date, downloading, installed, update-confirmed, check-failed, install-failed, update-did-not-apply, auto-update-paused) is exposed via an optional onStatusChange callback so a caller can surface it in the UI later, instead of only going to the console as before. Left unwired from App.tsx for this PR since App.tsx currently calls startAutoUpdater() with no options at all and I did not want to force new toast/notification UI into an otherwise backend reliability fix. - update.close() is now called in a finally block. The Update resource returned by check() was never explicitly closed before. - src/system/updater/autoUpdater.test.ts (new): 10 tests covering the behavior above — does nothing outside a Tauri runtime or in dev mode, reports up-to-date correctly, the full download-then-pending-then-install-then-relaunch happy path, silent mode skips relaunch, a download failure never reaches install and is not counted as a failure, an install failure is recorded and counted and does not relaunch, a confirmed update on next boot resets the failure counter, an update that silently did not apply is detected and counted, and auto-install pauses once the failure threshold is reached. Verified: `npx vitest run` — 55/55 passing across the full existing suite plus these 10 new tests. `npx tsc --noEmit` and `npx eslint src/system/updater/` both clean. Signed-off-by: Anshul Jain <anshul23102@iiitd.ac.in>
There was a problem hiding this comment.
Code Review
This pull request introduces a robust auto-updater system for a Tauri application, including state tracking in localStorage to handle restarts, separate download/install steps, and failure-loop protection. Comprehensive unit tests have also been added. The review feedback highlights a critical bug where a failed update check incorrectly falls through to report the app as up-to-date, along with several robustness improvements regarding potential errors in startup reconciliation, resource closing, and concurrent execution of update checks.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const update = await check({ timeout: DEFAULT_TIMEOUT_MS }).catch((error: unknown) => { | ||
| console.error("[updater] check failed", error); | ||
| onStatusChange?.({ type: "check-failed", error }); | ||
| return null; | ||
| }); | ||
|
|
||
| if (!update) { | ||
| console.info("[updater] no update available"); | ||
| onStatusChange?.({ type: "up-to-date" }); | ||
| return; | ||
| } |
There was a problem hiding this comment.
When check() throws an error, the .catch block triggers check-failed and returns null. This causes the subsequent if (!update) block to execute, which logs "[updater] no update available" and triggers up-to-date.
This is a bug because a failed check is not the same as "no update available" / "up-to-date", and it will incorrectly trigger both check-failed and up-to-date statuses in quick succession.
Using a try-catch block around check allows us to handle the error and return early, preventing the fall-through.
| const update = await check({ timeout: DEFAULT_TIMEOUT_MS }).catch((error: unknown) => { | |
| console.error("[updater] check failed", error); | |
| onStatusChange?.({ type: "check-failed", error }); | |
| return null; | |
| }); | |
| if (!update) { | |
| console.info("[updater] no update available"); | |
| onStatusChange?.({ type: "up-to-date" }); | |
| return; | |
| } | |
| let update; | |
| try { | |
| update = await check({ timeout: DEFAULT_TIMEOUT_MS }); | |
| } catch (error) { | |
| console.error("[updater] check failed", error); | |
| onStatusChange?.({ type: "check-failed", error }); | |
| return; | |
| } | |
| if (!update) { | |
| console.info("[updater] no update available"); | |
| onStatusChange?.({ type: "up-to-date" }); | |
| return; | |
| } |
| const currentVersion = await getVersion(); | ||
|
|
||
| if (currentVersion === state.toVersion) { | ||
| const resolved: UpdaterState = { status: "idle", consecutiveFailures: 0 }; | ||
| writeState(resolved); | ||
| onStatusChange?.({ type: "update-confirmed", version: currentVersion }); | ||
| return resolved; | ||
| } |
There was a problem hiding this comment.
This suggestion addresses two potential robustness issues:
- If
state.statusis"pending-install"butstate.toVersionis missing (e.g., due to corrupted or modified localStorage), the code will treat it as a failed update and incrementconsecutiveFailures. We should check ifstate.toVersionis defined and, if not, reset the status to"idle"without incrementingconsecutiveFailures. - If
getVersion()throws an error, it will propagate and crash the updater initialization. Wrapping it in atry-catchblock handles any unexpected errors gracefully.
if (!state.toVersion) {
const resolved: UpdaterState = { status: "idle", consecutiveFailures: state.consecutiveFailures };
writeState(resolved);
return resolved;
}
let currentVersion: string;
try {
currentVersion = await getVersion();
} catch (error) {
console.error("[updater] failed to get current version during reconciliation", error);
return state;
}
if (currentVersion === state.toVersion) {
const resolved: UpdaterState = { status: "idle", consecutiveFailures: 0 };
writeState(resolved);
onStatusChange?.({ type: "update-confirmed", version: currentVersion });
return resolved;
}| writeState({ status: "idle", consecutiveFailures: state.consecutiveFailures + 1 }); | ||
| onStatusChange?.({ type: "install-failed", error }); | ||
| } finally { | ||
| await update.close(); |
There was a problem hiding this comment.
If update.close() throws an error, it will propagate out of checkAndInstallUpdate and crash the updater loop. Wrapping it in a try-catch block inside the finally block ensures that any unexpected failures in closing the resource do not disrupt the updater loop.
try {
await update.close();
} catch (closeError) {
console.error("[updater] failed to close update resource", closeError);
}| const runCheck = async () => { | ||
| if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { | ||
| console.warn( | ||
| `[updater] paused after ${state.consecutiveFailures} consecutive failures; skipping auto-install until restart` | ||
| ); | ||
| options.onStatusChange?.({ | ||
| type: "auto-update-paused", | ||
| consecutiveFailures: state.consecutiveFailures, | ||
| }); | ||
| return; | ||
| } | ||
| await checkAndInstallUpdate(silent, state, options.onStatusChange); | ||
| state = readState(); | ||
| }; |
There was a problem hiding this comment.
If checkIntervalMs is configured to a small value (e.g., during testing or custom configurations) or if a check/download takes longer than the interval, runCheck can be executed concurrently. This can lead to multiple concurrent update checks/downloads, which can cause Tauri's updater plugin to throw errors or waste bandwidth.
Adding an isChecking boolean flag guards against concurrent executions of runCheck.
let isChecking = false;
const runCheck = async () => {
if (isChecking) return;
isChecking = true;
try {
if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.warn(
`[updater] paused after ${state.consecutiveFailures} consecutive failures; skipping auto-install until restart`
);
options.onStatusChange?.({
type: "auto-update-paused",
consecutiveFailures: state.consecutiveFailures,
});
return;
}
await checkAndInstallUpdate(silent, state, options.onStatusChange);
state = readState();
} finally {
isChecking = false;
}
};|
Ready for review — could a maintainer take a look and add the appropriate GSSoC 2026 label? |
Summary
Closes #139.
The updater plugin was registered in
lib.rsbutdownloadAndInstall()failures inautoUpdater.tswere only ever logged to the console. There was no way to tell a clean "download failed, safe to retry" apart from "install ran but the app never actually came up on the new version," and a persistently broken release would just keep getting retried every interval forever.One clarification before the details: Tauri's updater plugin already validates the Ed25519 signature against the configured
pubkeyinternally,check()will not return an update object at all unless the signature is valid, so signature validation was never actually missing. What was genuinely missing is everything after that: telling apart a safe failure from a dangerous one, and recovering instead of looping. Tauri also has no API to truly roll back an installed binary, so "rollback" here means detecting a failed update and stopping before it causes a crash loop, which is the honest scope of what's actually implementable against this plugin's API surface.What Was Changed
src/system/updater/autoUpdater.ts:download()andinstall()are now called as two separate steps (previously a singledownloadAndInstall()). A download failure (network blip, server hiccup) never reachesinstall()at all, so the running app was never at risk in the first place, and it is not counted toward the failure threshold below since it's transient and self-heals on the next interval.install()runs, the current version and the version being installed are persisted tolocalStorageas a "pending install" record. On the next app boot, before doing anything else, that record is reconciled against the actual running version (via@tauri-apps/api/app'sgetVersion()): if they match, the update is confirmed and the failure counter resets to zero; if they don't match, the update silently failed to apply and this counts as a failure.install()itself throwing, or a pending update not being confirmed on the next boot), auto-install is paused for the rest of that session rather than retried every interval, so a broken release can't trigger an endless check/download/install/crash/retry cycle.checking,up-to-date,downloading,installed,update-confirmed,check-failed,install-failed,update-did-not-apply,auto-update-paused) is exposed via an optionalonStatusChangecallback so a caller can surface it in the UI later, instead of only going to the console as before. Left unwired fromApp.tsxfor this PR sinceApp.tsxcurrently callsstartAutoUpdater()with no options at all, and I didn't want to force new toast/notification UI into what is otherwise a backend reliability fix.update.close()is now called in afinallyblock. TheUpdateresource returned bycheck()was never explicitly closed before.src/system/updater/autoUpdater.test.ts(new): 10 tests covering the behavior above, does nothing outside a Tauri runtime or in dev mode, reports up-to-date correctly, the full download-then-pending-then-install-then-relaunch happy path, silent mode skips relaunch, a download failure never reaches install and is not counted as a failure, an install failure is recorded and counted and does not relaunch, a confirmed update on next boot resets the failure counter, an update that silently did not apply is detected and counted, and auto-install pauses once the failure threshold is reached.Test Plan
npx vitest run— 55/55 passing across the full existing suite plus these 10 new testsnpx tsc --noEmit— cleannpx eslint src/system/updater/— cleanRelated Issues / PRs
Closes #139.