Skip to content

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

Open
Adarsh-Me wants to merge 2 commits into
apache:mainfrom
Adarsh-Me:fix/update-publish-config
Open

fix(desktop): retry a transient update-check failure once before surfacing it#4811
Adarsh-Me wants to merge 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

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
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
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.

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