Skip to content

fix(desktop): retry a transient update-check failure once before surfacing it - #4811

Merged
Astro-Han merged 2 commits into
apache:mainfrom
Adarsh-Me:fix/update-publish-config
Sep 6, 2026
Merged

fix(desktop): retry a transient update-check failure once before surfacing it#4811
Astro-Han merged 2 commits into
apache:mainfrom
Adarsh-Me:fix/update-publish-config

Conversation

@Adarsh-Me

@Adarsh-Me Adarsh-Me commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #4790's user-visible failure mode: the startup update check intermittently surfaces "检查更新失败 / Cannot parse releases feed" when GitHub transiently answers electron-updater's releases-feed request with HTTP 406 and an empty body — the identical request succeeds seconds later, so the first failure is noise, not an outage.

AppUpdateService.checkForUpdates now retries once after a two-second backoff (on the injected clock). While the retry is pending, the error listener holds a check failure back instead of publishing it, so a transient refusal never reaches the renderer as an error state or toast; a retry that also fails publishes exactly as before. Download and install errors are unchanged — they surface immediately. dispose() clears the retry timer alongside the schedule timer.

Two scope notes from #4790:

  • The stale Maka-Agent/maka-agent publish config is already fixed on main (electron-builder.config.mjs publishes to owner: apache, repo: maka); the stale app-update.yml only exists inside installed v0.1.11 packages and is replaced by the next install. This PR therefore changes only the failure handling.
  • Nightly snapshots stay off the stable "latest" semantics via the existing allowPrerelease channel gating, unchanged here.

Verification

  • biome check on both changed files: clean.
  • Targeted tsc --strict pass over app-update-service.ts: no errors beyond missing-node_modules artifacts (NodeJS namespace, setImmediate), which the workspace toolchain supplies.
  • The two new regression tests in app-update-service.test.ts cover both paths: a first-attempt 406-style failure (error emitted and thrown, as electron-updater does) that recovers on retry with no error status ever published, and a persistent failure that surfaces exactly one error status after two checkForUpdates calls. Full file: 17/17 pass. Run standalone with tsx --test against real electron-updater@6; the workspace test pipeline will re-run it under CI.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope:

Checklist

  • Tests cover the change and fail without it — without the retry, the first new test fails with a published error status and a single checkForUpdates call; the second fails with zero error statuses published.
  • Lint, format, typecheck and the affected suites pass locally — see Verification; full Desktop suite runs in CI.

Does this PR entail a change in behavior?

  • No — a transient check failure that recovers now resolves silently instead of surfacing as an error; persistent failures behave exactly as before.

…acing it

The packaged update check intermittently fails with "Cannot parse
releases feed": electron-updater resolves "latest" through GitHub's
releases Atom feed, and GitHub intermittently answers that request with
HTTP 406 and an empty body — the identical request succeeds seconds
later (apache#4790). The app surfaced that first failure as an error toast even
though the check was never going to stay broken.

A check now retries once after a two-second backoff, on the injected
clock. While the retry is pending, the 'error' listener holds a check
failure back instead of publishing it, so a transient refusal never
reaches the renderer as an error state or toast; a retry that also fails
publishes exactly as before. Download and install errors are unchanged —
they surface immediately. dispose() clears the retry timer alongside the
schedule timer.

Closes apache#4790
@github-actions github-actions Bot added the effort/M Under 500 readable lines label Sep 4, 2026
@Adarsh-Me

Copy link
Copy Markdown
Contributor Author

This is ready for review. It addresses the user-visible false alarm from #4790: the startup update check intermittently surfaced "Cannot parse releases feed" because electron-updater's releases-feed request can get a transient HTTP 406 from GitHub, and the first failure was surfaced as an error even though the identical request succeeds seconds later.

The change is small and self-contained: AppUpdateService.checkForUpdates now retries once after a two-second backoff (on the injected clock), and while the retry is pending the error listener holds transient check failures back instead of publishing them, so a transient refusal never reaches the renderer. A retry that also fails behaves exactly as before; download and install errors are unchanged. Two new regression tests cover both paths and the full suite is green locally.

Marked Fixes #4790 so that references the issue. One thing blocking verification: the CI workflows on this PR are still behind the first-workflow approval gate (only the label check has reported). Appreciate a review whenever convenient, and I can either adjust the retry approach or add tests for any edge cases you'd like covered.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head b7c0bedc70366e03ca2a9f36764c251bf7dedd23 (OPEN, MERGEABLE/BLOCKED awaiting human review). Technical GO — no P0–P2, three P3s below. A well-aimed small fix: the retry state machine was walked through and no path lets an error slip or misreport. Checks green on this head.

P3 — a dispose inside the retry window leaves a promise unsettled forever

The catch branch schedules the retry as new Promise((resolve) => { checkRetryTimer = clock.setTimeout(() => { ... resolve(...); }, ...) })resolve only fires in the timer callback, but dispose() does clock.clearTimeout(checkRetryTimer), after which nobody ever resolves it (and the .finally resetting checkAttemptsRemaining/checkInFlight never runs either). Not a P2 only because dispose() is terminal and checkForUpdates() early-returns on disposed, so no later check can hang on the stuck checkInFlight.

P3 — disposed decides "don't retry" but then still publishes the error

if (disposed || checkAttemptsRemaining <= 0) { ...; return status.state === 'error' ? status : publishError('check', error); }publish has no dispose guard and calls deps.onStatusChange?.(next) directly, pushing state to subscribers of an already-disposed service. Pre-existing pattern (publish never had the guard), but this is the first call site that explicitly checks disposed and then publishes anyway — self-contradictory to read; returning status outright would match the intent.

P3 — "check now" during the retry window silently joins the pending retry

During the 2 s wait checkInFlight is still set, so a manual "check for updates" reuses the retry instead of starting a fresh check. The behavior is right (no concurrent feed hits); just worth knowing the button can appear to lag up to 2 s with no UI hint. No code change needed.

What I could not judge

Desktop not run and the 406 not reproduced — whether 2 s covers the observed recovery window ("succeeds after a few seconds" per the description) and whether retrying exactly once is enough both need live data; the code cannot prove either.


Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.

简体中文

本条结论全部来自 @Opus-Qronos-AstroHan 的审查。我自己没有读这份 diff;我核的是当前 head 有没有漂移。当前 head 是 b7c0bed,未关闭。修得准,技术上无阻断问题,三条小的都是重试状态机的边角。等人类拍板。

…he#4790)

Review on apache#4811 (P3s): if the service was disposed inside the two-second
retry backoff, dispose() cleared the retry timer and nothing ever
resolved the retry promise, so checkInFlight and its .finally cleanup
dangled. And the catch branch tested `disposed` but then still published
the error to a disposed service's subscribers.

The retry timer is no longer cleared in dispose(): its callback checks
`disposed`, resolves the in-flight check with the current status instead
of retrying or publishing, and lets the .finally reset checkInFlight.
The catch branch now returns `status` outright when disposed rather than
publishing. A regression test covers the dispose-during-backoff path.
@Adarsh-Me

Copy link
Copy Markdown
Contributor Author

Thanks for the careful read — the two actionable P3s are addressed on 135a5f5 (pushed just now, new head on this branch):

  • dispose inside the backoff window: the retry timer is no longer cleared in dispose(). Its callback now checks disposed, resolves the in-flight check with the current status instead of retrying or publishing, and lets the .finally reset checkInFlight — so nothing dangles. Removed the empty checkRetryTimer block that the P3 correctly identified as leaving the promise unsettled.
  • publish after disposed: the catch branch now returns status outright when disposed rather than publishing to a disposed service's subscribers, matching the intent of the guard.

A regression test covers the dispose-during-backoff path (the in-flight promise settles, the retry does not fire, and no error status is published). Full file is 18/18 green. The third P3 (a manual "check now" joining the pending retry, up to 2s lag) matches the design intent and is left as-is.

On the two live-data items: agreed, addressable only by observing real GitHub behavior. My notes — neither succeeds after a few seconds in the issue nor my 2s constant is something I can validate from here (no packaged desktop to drive, and I can't force GitHub into a 406). If a maintainer has a reproduction, the two knobs are the delay (UPDATE_CHECK_RETRY_DELAY_MS) and the attempt count (UPDATE_CHECK_MAX_ATTEMPTS); both are single constants, so tuning is trivial once real timings are known.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 135a5f5 against main (a5a99a633), +160/−10 over 2 files, clean merge. CI had not run at this head (first-contribution approval gate); I approved the runs and both test and the Windows package check are green.

The problem is demonstrated: #4790 comes from a user with the packaged v0.1.11 log, the 406 response headers and a curl of the same URL succeeding minutes later. The owner has no knob for it: builder-util-runtime's retryOnServerError only retries 5xx and EPIPE, and GitHubProvider.getLatestVersion turns anything else into "Cannot parse releases feed". So a single retry in AppUpdateService.checkForUpdates, which already injects a clock, is the smallest seam, and persistent failure behaves exactly as before. Both P3s from last time are fixed: a dispose inside the backoff window now settles the promise (app-update-service.ts:431-437), nothing publishes after disposed (:412-417), and the new test goes through checkForUpdatesNow().

P3, from the fix itself:

  • UPDATE_CHECK_MAX_ATTEMPTS (:133) is not a knob: :412 seeds a counter from it, but the retry at :429-440 is a single hard-coded setTimeout, so setting it to 3 still gives two attempts. Replace the constant and counter with a retryPending boolean; only UPDATE_CHECK_RETRY_DELAY_MS is real.
  • checkRetryTimer (:234, :429-430) is written and never read since dispose() stopped clearing it. Drop it.
  • The body still says dispose() clears the retry timer; :470-478 deliberately does not. The body becomes the squash message, so fix the sentence.

Evidence boundary: static read; the 406 was not reproduced, so whether a 2s single retry covers the real recovery window is unknown and only field data will say. Side note for the issue, not this PR: main and v0.1.11 both lock electron-updater@6.8.9, which requests /releases.atom with accept: */*, so the issue's "Accept: application/atom+xml against /releases" root-cause paragraph does not match the client.

AI-assisted review: drafted with Maka; I verified the dead knob, the unread timer and the absence of CI at this head myself.

@Astro-Han
Astro-Han merged commit a81f8da into apache:main Sep 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/M Under 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update check intermittently fails: GitHub 406 on releases Atom feed (packaged app still points at Maka-Agent/maka-agent)

2 participants