fix(updater): surface user-initiated check failures when GitHub is down (#1181)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-04-27 14:50:39 -07:00 committed by GitHub
parent 7f6d42134d
commit 3edb2f3ea2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 112 additions and 31 deletions

View File

@ -107,7 +107,7 @@ describe('updater check failure handling', () => {
vi.unstubAllGlobals()
})
it('treats GitHub release transition errors as idle for user-initiated checks', async () => {
it('surfaces GitHub release transition errors to user-initiated checks', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
@ -127,18 +127,22 @@ describe('updater check failure handling', () => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
// Why: release transition failures should NOT pretend the user is up to
// date. Sending 'idle' lets the toast controller show an honest
// "currently rolling out" message instead of the misleading "you're on the
// latest version" that auto-dismisses.
expect(statuses).toContainEqual({ state: 'idle' })
// Why: a user-initiated benign failure must show a visible error. Silently
// sending 'idle' (or 'not-available') makes the button look broken.
expect(statuses).toContainEqual(
expect.objectContaining({
state: 'error',
userInitiated: true,
message: expect.stringContaining('GitHub may be temporarily unavailable')
})
)
expect(statuses).not.toContainEqual(
expect.objectContaining({ state: 'not-available', userInitiated: true })
)
})
})
it('treats missing latest-mac.yml during user-initiated checks as idle', async () => {
it('surfaces missing latest-mac.yml to user-initiated checks', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
@ -163,10 +167,43 @@ describe('updater check failure handling', () => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
expect(statuses).toContainEqual(
expect.objectContaining({
state: 'error',
userInitiated: true,
message: expect.stringContaining('GitHub may be temporarily unavailable')
})
)
expect(statuses).not.toContainEqual(
expect.objectContaining({ state: 'not-available', userInitiated: true })
)
})
})
it('silently drops background benign failures to idle', async () => {
// Why: background checks must stay quiet; only user-initiated clicks get
// an error card. This prevents noisy nag during a release transition.
autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub'))
})
return Promise.reject(new Error('Unable to find latest version on GitHub'))
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdates } = await import('./updater')
setupAutoUpdater(mainWindow as never)
checkForUpdates()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' }))
})
})
})

View File

@ -182,7 +182,7 @@ describe('updater', () => {
expect(errorStatuses).toEqual([{ state: 'error', message: 'boom', userInitiated: true }])
})
it('treats net::ERR_FAILED during checks as a benign idle transition', async () => {
it('surfaces net::ERR_FAILED to user-initiated checks with a friendly message', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
@ -202,7 +202,13 @@ describe('updater', () => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
expect(statuses).toContainEqual(
expect.objectContaining({
state: 'error',
userInitiated: true,
message: expect.stringContaining('GitHub may be temporarily unavailable')
})
)
})
const statuses = sendMock.mock.calls
@ -210,7 +216,8 @@ describe('updater', () => {
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'checking', userInitiated: true })
expect(statuses).toContainEqual({ state: 'idle' })
// Why: the raw electron-updater message is replaced with a user-friendly
// one so we never surface "net::ERR_FAILED" directly to the UI.
expect(statuses).not.toContainEqual(
expect.objectContaining({ state: 'error', message: 'net::ERR_FAILED' })
)

View File

@ -202,25 +202,24 @@ async function sendCheckFailureStatus(message: string, userInitiated?: boolean):
const handleFailure = async (): Promise<void> => {
if (isBenignCheckFailure(message)) {
// Why: release transition failures (missing latest.yml while a new
// release is being published) and network blips are transient. The
// previous approach sent 'not-available' for user-initiated checks
// during a release transition, which falsely told the user "you're
// on the latest version" — the toast would flash and auto-dismiss,
// hiding the fact that a newer release is mid-publish. Now all
// benign failures go to 'idle' uniformly: the toast controller
// converts a user-initiated checking→idle transition into an honest
// "currently rolling out" message, and a background retry is
// always scheduled so the update notification arrives once the
// release finishes.
// release is being published) and network blips are transient. Schedule
// a background retry so the notification arrives once the release
// finishes, and intentionally skip persistLastUpdateCheckAt — the check
// didn't truly complete, and recording a timestamp would suppress the
// next startup check.
console.warn('[updater] benign check failure:', message)
clearAvailableUpdateContext()
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
// Why: we intentionally do NOT call persistLastUpdateCheckAt here.
// The check didn't truly complete (the manifest was unreachable due
// to a release transition or network blip), so recording a timestamp
// would suppress the next startup check and delay discovery of the
// new version.
sendStatus({ state: 'idle' })
if (userInitiated) {
// Why: a user-initiated click expects visible feedback — silently
// dropping to 'idle' makes the button look broken. The card already
// prefixes "Could not check for updates." and Settings prefixes
// "Update check failed.", so the message here only carries the
// actionable cause.
sendErrorStatus('GitHub may be temporarily unavailable. Try again in a minute.', true)
} else {
sendStatus({ state: 'idle' })
}
return
}

View File

@ -12,9 +12,13 @@ import type { ChangelogData } from '../../../shared/types'
// ── Helpers ──────────────────────────────────────────────────────────
function releaseUrlForVersion(version: string | null): string {
// Why: when no version is cached (typically a failed check), point at the
// plain releases listing rather than /releases/latest — /latest also breaks
// when GitHub's release API is degraded, and the listing is the most
// reliable manual fallback.
return version
? `https://github.com/stablyai/orca/releases/tag/v${version}`
: 'https://github.com/stablyai/orca/releases/latest'
: 'https://github.com/stablyai/orca/releases'
}
function isAnimatedGif(url: string | undefined): boolean {
@ -312,7 +316,9 @@ export function UpdateCard() {
const errorCard: ErrorCardModel | null =
status.state === 'error'
? {
title: 'Update Error',
// Why: title is scoped to the operation that failed so check-time
// failures (commonly GitHub-side) don't read as a bug in Orca.
title: cachedVersion ? 'Update Error' : 'Update Check Failed',
summary: cachedVersion
? 'Could not complete the update.'
: 'Could not check for updates.',

View File

@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: GeneralPane is the single owner of all general settings UI;
splitting individual settings into separate files would scatter related controls without a
meaningful abstraction boundary. */
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type {
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState,
@ -121,6 +121,30 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const updateStatus = useAppStore((s) => s.updateStatus)
const fetchSettings = useAppStore((s) => s.fetchSettings)
// Why: the 'error' variant of UpdateStatus does not carry a `version` field.
// The main process emits `{ state: 'error' }` for both check failures (no
// version known yet) and download/install failures (version was known from
// the preceding 'available'/'downloading'/'downloaded' state). Cache the
// last-known version so the error copy below can distinguish the two cases
// without adding IPC. Mirrors `versionRef` in UpdateCard.tsx.
const updateVersionRef = useRef<string | null>(null)
if (
(updateStatus.state === 'available' ||
updateStatus.state === 'downloading' ||
updateStatus.state === 'downloaded') &&
updateStatus.version
) {
updateVersionRef.current = updateStatus.version
} else if (
updateStatus.state === 'checking' ||
updateStatus.state === 'idle' ||
updateStatus.state === 'not-available'
) {
// Why: a new check cycle has started or completed cleanly. Clear the
// cached version so a subsequent check failure cannot be mis-classified
// as a download failure based on a stale version from a prior cycle.
updateVersionRef.current = null
}
const [appVersion, setAppVersion] = useState<string | null>(null)
const [autoSaveDelayDraft, setAutoSaveDelayDraft] = useState(
String(settings.editorAutoSaveDelayMs)
@ -1089,7 +1113,15 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
</a>
</>
)}
{updateStatus.state === 'error' && `Update error: ${updateStatus.message}`}
{updateStatus.state === 'error' &&
// Why: `{ state: 'error' }` is emitted for both check-time
// failures (no version cached) and download/install failures
// (version cached from a prior 'available'/'downloading'/
// 'downloaded' state). Label accordingly so a download failure
// isn't mislabeled as a "check" failure. Mirrors UpdateCard.tsx.
(updateVersionRef.current
? `Update error. ${updateStatus.message}`
: `Update check failed. ${updateStatus.message}`)}
</p>
</SearchableSetting>
</section>