From ddadd8bead330f24c5dd2edd6058196988c8c86b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 11:14:58 +0545 Subject: [PATCH] Offer a retry when an update check or download fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update dialog had no way out of a failure. A check that could not reach GitHub printed the raw transport error and stopped there, so the only recourse was to close the dialog and find the command palette entry again. Retry re-runs whatever actually failed: the download when an update is already in hand, otherwise a fresh check. Three things around it were wrong in the same dialog: - The header read "Checking for updates…" over an error body, because it tested `checking` before `status`, and `checking` is still true while the failed check unwinds. A settled outcome now outranks the spinner, and starting a new check clears the previous one so the header is never stale in either direction. - The endpoint URL is a single unbreakable token and ran past the dialog edge. - `check()` had no timeout, so an unreachable host held the dialog on "Checking for updates…" for whatever the HTTP stack's default is. Bounded to 15s. The download is deliberately left unbounded: a timeout there would break exactly the slow connections that need it most. Downloading was also redrawing far more than it painted. downloadAndInstall reports one Progress event per HTTP chunk, thousands of them for a build this size, and every one wrote $state. The running total is now kept out of reactivity and published once a frame. --- .changeset/updater-retry-and-speed.md | 7 ++ src/lib/components/UpdateDialog.svelte | 98 ++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 .changeset/updater-retry-and-speed.md diff --git a/.changeset/updater-retry-and-speed.md b/.changeset/updater-retry-and-speed.md new file mode 100644 index 00000000..22f8be38 --- /dev/null +++ b/.changeset/updater-retry-and-speed.md @@ -0,0 +1,7 @@ +### Bug Fixes +- The update dialog offers a Retry button when a check or a download fails, instead of leaving the error on screen with nothing to do about it. +- A failed check no longer says "Checking for updates…" in its header while showing the error underneath, and a long endpoint URL wraps inside the dialog rather than running past its edge. + +### Changes +- Update checks give up after 15 seconds. Without a bound they inherited the HTTP stack's default and could sit on "Checking for updates…" long after the request was dead. +- Downloading an update no longer redraws the dialog on every network chunk. Progress is published once a frame, which takes the redraw count for a 100MB build from thousands down to about one per 16ms. diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte index 5bce09f2..8616118f 100644 --- a/src/lib/components/UpdateDialog.svelte +++ b/src/lib/components/UpdateDialog.svelte @@ -31,11 +31,22 @@ let pendingUpdate = $state(null) let dismissed = $state(false) let checking = $state(false) + /** Which operation produced `errorMsg`, so Retry repeats that one. */ + let failedAction = $state(/** @type {'check'|'install'} */ ('check')) + + // A check that cannot reach GitHub should give up in seconds. Without an + // explicit bound it inherits the HTTP stack's default, which left the dialog + // spinning on "Checking for updates…" far longer than anyone waits. + const CHECK_TIMEOUT_MS = 15_000 /** Manually trigger an update check (e.g. from the command palette). */ export async function checkNow() { if (checking || status === 'downloading') return dismissed = false + // Drop the previous outcome: re-checking after a failure showed "Update + // failed" in the header while the new check was still running. + status = 'idle' + errorMsg = '' checking = true await checkForUpdate() if (status === 'idle') status = 'up-to-date' @@ -180,9 +191,30 @@ }) } + /** + * Record a failure and remember what caused it, so Retry repeats that step + * rather than always falling back to a fresh check. + * @param {'check'|'install'} action + * @param {unknown} e + */ + function fail(action, e) { + failedAction = action + errorMsg = String(e) + status = 'error' + } + + /** Re-run whatever failed: the download if we already have an update, else the check. */ + async function retry() { + if (failedAction === 'install' && pendingUpdate) { + await install() + return + } + await checkNow() + } + async function checkForUpdate() { try { - const update = await check() + const update = await check({ timeout: CHECK_TIMEOUT_MS }) if (!update) { console.info('[updater] no update available') return @@ -195,10 +227,7 @@ onupdatefound() } catch (e) { console.error('[updater] check failed:', e) - if (checking) { - errorMsg = String(e) - status = 'error' - } + if (checking) fail('check', e) } } @@ -209,21 +238,38 @@ progress = 0 downloadedBytes = 0 totalBytes = 0 + // downloadAndInstall reports one Progress event per HTTP chunk, which is + // thousands of them for a build this size. Writing $state on every event + // re-rendered the dialog at that same rate and was most of why downloading + // felt slow, so the running total is kept out of reactivity and published + // once a frame. + let received = 0 + let frame = 0 + const publish = () => { + frame = 0 + downloadedBytes = received + if (totalBytes > 0) progress = Math.round((received / totalBytes) * 100) + } + try { await pendingUpdate.downloadAndInstall((event) => { if (event.event === 'Started') { totalBytes = event.data.contentLength ?? 0 } else if (event.event === 'Progress') { - downloadedBytes += event.data.chunkLength - if (totalBytes > 0) progress = Math.round((downloadedBytes / totalBytes) * 100) + received += event.data.chunkLength + if (!frame) frame = requestAnimationFrame(publish) } else if (event.event === 'Finished') { + if (frame) cancelAnimationFrame(frame) + frame = 0 + downloadedBytes = received progress = 100 } }) status = 'done' } catch (e) { - errorMsg = String(e) - status = 'error' + if (frame) cancelAnimationFrame(frame) + console.error('[updater] download failed:', e) + fail('install', e) } } @@ -296,18 +342,18 @@ - {#if checking} - Checking for updates… + {#if status === 'error'} + {failedAction === 'install' ? 'Download failed' : "Couldn't check for updates"} {:else if status === 'available'} Stroke {updateVersion} available {:else if status === 'downloading'} Downloading update… {:else if status === 'done'} Ready to install - {:else if status === 'error'} - Update failed {:else if status === 'up-to-date'} Up to date + {:else if checking} + Checking for updates… {/if} @@ -390,7 +436,31 @@ {:else if status === 'error'} -

{errorMsg}

+

+ {failedAction === 'install' + ? 'The download did not finish.' + : 'Stroke could not reach the update server.'} +

+ +

{errorMsg}

+
+ + +
{:else if status === 'up-to-date' || checking}