fix(mobile): retry a failed Android release refresh in minutes, not hours

A cold refresh failure cached `null` for the full 6h success TTL, so an
Orca that autostarts before Wi-Fi/VPN associates advertised no Android
recommendation for the rest of the session. Mobile could not recover on
its own: once the refresh settled, `recommendedMobileAppVersionsPending`
was undefined, so it scheduled no follow-up read. The cache is
per-main-process, so this recurred on every desktop relaunch.

Track failure in the cache state and give failed refreshes a 10-minute
TTL while successes keep 6h. The inFlight dedupe guard still collapses
concurrent status.get calls, so the retry floor is ~6 calls/hour against
GitHub's 60/hr unauthenticated budget. A failure still retains the
last-known-good version; only a cold failure yields null.

Also drop the dead `appVersion` line from getStatus(): status.get spreads
getStatus() and then re-assigns appVersion from the remote-server-updater
snapshot, so the spread always lost. That snapshot falls back to
'0.0.0-dev' rather than undefined. Its duplicate RuntimeStatus
declaration broke `tsc` (TS2300) — appVersion was already declared.
This commit is contained in:
Brennan Benson
2026-07-27 16:23:37 -07:00
parent 0176d1f98b
commit dfa0133f45
5 changed files with 74 additions and 21 deletions
@@ -103,6 +103,64 @@ describe('extractAndroidVersionCandidates', () => {
expect(getRecommendedAndroidVersion(1_000)).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('retries a cold failure well before the success TTL', async () => {
let networkUp = false
const fetchMock = vi.fn(async (url: string) =>
url.includes('/git/matching-refs/')
? networkUp
? jsonResponse([{ ref: 'refs/tags/mobile-android-v0.0.50' }])
: jsonResponse({ message: 'offline' }, 503)
: jsonResponse({
tag_name: 'mobile-android-v0.0.50',
draft: false,
assets: [{ name: 'app-release.apk' }]
})
)
__setAndroidReleaseFeedFetcherForTests(fetchMock)
// Cold failure: no prior known version, so the host advertises nothing.
expect(getRecommendedAndroidVersion(1_000)).toBeNull()
await vi.waitFor(() => expect(isAndroidReleaseFeedRefreshPending()).toBe(false))
expect(fetchMock).toHaveBeenCalledOnce()
// Still inside the short failure TTL — no retry yet.
expect(getRecommendedAndroidVersion(9 * 60_000)).toBeNull()
expect(fetchMock).toHaveBeenCalledOnce()
// Past the failure TTL but far inside the 6h success TTL: retry anyway.
networkUp = true
expect(getRecommendedAndroidVersion(11 * 60_000)).toBeNull()
await vi.waitFor(() => expect(isAndroidReleaseFeedRefreshPending()).toBe(false))
expect(getRecommendedAndroidVersion(11 * 60_000)).toBe('0.0.50')
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('does not re-fetch a successful refresh before the 6h TTL', async () => {
const fetchMock = vi.fn(async (url: string) =>
url.includes('/git/matching-refs/')
? jsonResponse([{ ref: 'refs/tags/mobile-android-v0.0.51' }])
: jsonResponse({
tag_name: 'mobile-android-v0.0.51',
draft: false,
assets: [{ name: 'app-release.apk' }]
})
)
__setAndroidReleaseFeedFetcherForTests(fetchMock)
expect(getRecommendedAndroidVersion(1_000)).toBeNull()
await vi.waitFor(() => expect(isAndroidReleaseFeedRefreshPending()).toBe(false))
expect(fetchMock).toHaveBeenCalledTimes(2)
// Well past the failure TTL, still inside the success TTL — no refresh.
expect(getRecommendedAndroidVersion(60 * 60_000)).toBe('0.0.51')
expect(fetchMock).toHaveBeenCalledTimes(2)
// Past 6h: a success does eventually revalidate.
expect(getRecommendedAndroidVersion(7 * 60 * 60_000)).toBe('0.0.51')
expect(isAndroidReleaseFeedRefreshPending()).toBe(true)
})
})
function jsonResponse(body: unknown, status = 200): Response {
@@ -12,6 +12,11 @@ const FETCH_TIMEOUT_MS = 5000
// Why: a mobile build ships ~monthly, so a long TTL keeps this to a few
// unauthenticated GitHub calls per day per host while staying fresh enough.
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
// Why: a cold failure (autostart before Wi-Fi/VPN associates) would otherwise black
// out the nudge for a full TTL, and mobile schedules no follow-up read once the
// refresh settles. Retry soon instead; a 10-minute floor is ~6 calls/hour against
// GitHub's 60/hr unauthenticated budget.
const FAILED_CACHE_TTL_MS = 10 * 60 * 1000
// Why: only exact Android release tags count, so suffixed RC builds never nudge stable users.
const ANDROID_TAG_REF_RE = /^refs\/tags\/mobile-android-v(\d+\.\d+\.\d+)$/
// Why: malformed/orphaned tags must not turn one refresh into unbounded API fanout.
@@ -30,6 +35,7 @@ type AndroidRelease = {
type CacheState = {
version: string | null
fetchedAt: number
failed: boolean
}
type GitHubResponse = Pick<Response, 'ok' | 'status' | 'json'>
@@ -132,12 +138,15 @@ function refreshInBackground(now: number): void {
inFlight = fetchLatestAndroidVersion()
.then((version) => {
// Why: keep the last-known version on a failed refresh (null result) so a
// transient outage doesn't drop an already-correct nudge; only the
// timestamp advances, deferring the next retry by one TTL.
cache = { version: version ?? cache?.version ?? null, fetchedAt: now }
// transient outage doesn't drop an already-correct nudge.
cache = {
version: version ?? cache?.version ?? null,
fetchedAt: now,
failed: version === null
}
})
.catch(() => {
cache = { version: cache?.version ?? null, fetchedAt: now }
cache = { version: cache?.version ?? null, fetchedAt: now, failed: true }
})
.finally(() => {
inFlight = null
@@ -149,7 +158,8 @@ function refreshInBackground(now: number): void {
// (stale-while-revalidate). First call returns null (no banner) until the first
// fetch lands, which is the correct fail-open default.
export function getRecommendedAndroidVersion(nowMs: number): string | null {
if (cache === null || nowMs - cache.fetchedAt > CACHE_TTL_MS) {
const ttl = cache?.failed === true ? FAILED_CACHE_TTL_MS : CACHE_TTL_MS
if (cache === null || nowMs - cache.fetchedAt > ttl) {
refreshInBackground(nowMs)
}
return cache?.version ?? null
@@ -169,7 +179,7 @@ export function __resetAndroidReleaseFeedCacheForTests(): void {
// Test-only: seed the cache with a fresh value so status.get reads it without
// hitting the network.
export function __setAndroidReleaseFeedCacheForTests(version: string | null): void {
cache = { version, fetchedAt: Date.now() }
cache = { version, fetchedAt: Date.now(), failed: false }
inFlight = null
}
-10
View File
@@ -1763,16 +1763,6 @@ describe('OrcaRuntimeService', () => {
expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0)
})
it('reports app version on status', () => {
vi.stubEnv('ORCA_APP_VERSION', '9.9.9-test')
try {
const status = createRuntime().getStatus()
expect(status.appVersion).toBe('9.9.9-test')
} finally {
vi.unstubAllEnvs()
}
})
it('omits an unknown mobile recommendation, then reports cached Android', () => {
__setAndroidReleaseFeedCacheForTests(null)
try {
-3
View File
@@ -3687,9 +3687,6 @@ export class OrcaRuntimeService {
hostPlatform: process.platform,
terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null,
floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false,
// Why: the main process stamps its version into the env at startup;
// omit rather than fabricate when running outside that bootstrap (tests).
appVersion: process.env.ORCA_APP_VERSION,
// Why: derived live from GitHub Releases (cached, fail-open) instead of a
// hand-bumped constant, so releases need no recommendation bookkeeping; omitted
// entirely when unknown. iOS defers to the App Store (no soft nudge).
-2
View File
@@ -90,8 +90,6 @@ export type RuntimeStatus = {
// Why: mobile gates its Floating Workspace entry on this; absent on older
// hosts, false when the user disabled the feature in desktop settings.
floatingWorkspaceEnabled?: boolean
// Why: this host's own app version (semver); informational only, never gating.
appVersion?: string
// Why: per-platform release trains can diverge; absent on older hosts and
// mobile fails open (no update nudge).
recommendedMobileAppVersions?: RecommendedMobileAppVersions