diff --git a/src/main/updater-release-api-token.test.ts b/src/main/updater-release-api-token.test.ts new file mode 100644 index 00000000000..6f0b08817f5 --- /dev/null +++ b/src/main/updater-release-api-token.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ghExecMock = vi.fn() +vi.mock('./git/runner', () => ({ + ghExecFileAsync: (...args: unknown[]) => ghExecMock(...args) +})) + +const { _resetReleaseApiTokenCache, rejectReleaseApiToken, resolveReleaseApiToken } = + await import('./updater-release-api-token') + +const T0 = 1_000_000 +const TTL_MS = 5 * 60_000 + +describe('resolveReleaseApiToken', () => { + beforeEach(() => { + ghExecMock.mockReset() + _resetReleaseApiTokenCache() + }) + + it('reads the github.com token through gh and trims it', async () => { + ghExecMock.mockResolvedValue({ stdout: 'gho_abc\n', stderr: '' }) + + await expect(resolveReleaseApiToken(T0)).resolves.toBe('gho_abc') + + expect(ghExecMock.mock.calls[0][0]).toEqual(['auth', 'token', '--hostname', 'github.com']) + expect(ghExecMock.mock.calls[0][1]).toMatchObject({ idempotent: false }) + }) + + it('serves the cached token within its TTL and re-reads after it', async () => { + ghExecMock.mockResolvedValue({ stdout: 'gho_abc', stderr: '' }) + + await resolveReleaseApiToken(T0) + await resolveReleaseApiToken(T0 + TTL_MS - 1) + expect(ghExecMock).toHaveBeenCalledTimes(1) + + await resolveReleaseApiToken(T0 + TTL_MS + 1) + expect(ghExecMock).toHaveBeenCalledTimes(2) + }) + + // Why: gh missing or logged out is the unauthenticated path the picker always + // had; it must not fail the list, and it must not spawn gh on every click. + it('returns null when gh is missing or logged out and does not re-spawn within the TTL', async () => { + ghExecMock.mockRejectedValueOnce(new Error('gh: not found')) + + await expect(resolveReleaseApiToken(T0)).resolves.toBeNull() + await expect(resolveReleaseApiToken(T0 + TTL_MS - 1)).resolves.toBeNull() + expect(ghExecMock).toHaveBeenCalledTimes(1) + + ghExecMock.mockResolvedValueOnce({ stdout: 'gho_new', stderr: '' }) + await expect(resolveReleaseApiToken(T0 + TTL_MS + 1)).resolves.toBe('gho_new') + }) + + it('treats empty output as no token', async () => { + ghExecMock.mockResolvedValue({ stdout: '\n', stderr: '' }) + + await expect(resolveReleaseApiToken(T0)).resolves.toBeNull() + }) + + it('shares one in-flight read between concurrent callers', async () => { + let finish: (value: { stdout: string; stderr: string }) => void = () => {} + ghExecMock.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + + const first = resolveReleaseApiToken(T0) + const second = resolveReleaseApiToken(T0) + finish({ stdout: 'gho_abc', stderr: '' }) + + await expect(Promise.all([first, second])).resolves.toEqual(['gho_abc', 'gho_abc']) + expect(ghExecMock).toHaveBeenCalledTimes(1) + }) + + // Why: a token GitHub rejected would otherwise be re-read from the keyring + // and re-sent on every load — one gh spawn and one wasted request each time. + it('goes unauthenticated for a TTL after the token is rejected', async () => { + ghExecMock.mockResolvedValue({ stdout: 'gho_stale', stderr: '' }) + await expect(resolveReleaseApiToken(T0)).resolves.toBe('gho_stale') + + rejectReleaseApiToken(T0) + + await expect(resolveReleaseApiToken(T0 + TTL_MS - 1)).resolves.toBeNull() + expect(ghExecMock).toHaveBeenCalledTimes(1) + await expect(resolveReleaseApiToken(T0 + TTL_MS + 1)).resolves.toBe('gho_stale') + expect(ghExecMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/updater-release-api-token.ts b/src/main/updater-release-api-token.ts new file mode 100644 index 00000000000..2fd2aaf1bdc --- /dev/null +++ b/src/main/updater-release-api-token.ts @@ -0,0 +1,71 @@ +import { ghExecFileAsync } from './git/runner' + +/** + * The github.com token the release picker attaches to its api.github.com calls. + * + * Why: an unauthenticated request draws from a 60/hour bucket shared by every + * caller behind the same public IP — Homebrew, other apps, agents running curl + * — so the picker can find it empty without ever having spent it. The user's + * own token has its own 5000/hour bucket. `gh auth token` is a local keyring + * read that never touches the API; a missing or logged-out gh just means the + * request goes out unauthenticated as before. The token is never logged. + * + * Why not resolveGhAccountToken: that resolves a per-project bound account and + * needs a binding plus a `gh auth token --user` capability probe. This is the + * ambient login, and "no token" is an ordinary outcome here, not an error. + */ + +const TOKEN_RESOLVE_TIMEOUT_MS = 5_000 +// Why one TTL for hits and misses: a miss re-spawns gh — through wsl.exe on a +// Windows box without a native gh — so a short miss TTL would make the picker +// slower for exactly the users who can never get a token. +const TOKEN_TTL_MS = 5 * 60_000 + +type TokenCacheEntry = { token: string | null; expiresAt: number } + +let cached: TokenCacheEntry | null = null +let inFlight: Promise | null = null + +async function readGhToken(): Promise { + try { + // Why no retry: a hung keyring would otherwise hold the picker through the + // runner's backoff, and unauthenticated is an acceptable fallback anyway. + const { stdout } = await ghExecFileAsync(['auth', 'token', '--hostname', 'github.com'], { + timeout: TOKEN_RESOLVE_TIMEOUT_MS, + idempotent: false + }) + const token = stdout.replace(/\r?\n/g, '').trim() + return token || null + } catch { + return null + } +} + +export async function resolveReleaseApiToken(now: number = Date.now()): Promise { + if (cached && cached.expiresAt > now) { + return cached.token + } + if (inFlight) { + return inFlight + } + inFlight = readGhToken() + .then((token) => { + cached = { token, expiresAt: now + TOKEN_TTL_MS } + return token + }) + .finally(() => { + inFlight = null + }) + return inFlight +} + +/** GitHub rejected the token: go unauthenticated for a TTL instead of re-reading the same stale keyring entry on every load. */ +export function rejectReleaseApiToken(now: number = Date.now()): void { + cached = { token: null, expiresAt: now + TOKEN_TTL_MS } +} + +/** @internal — test-only */ +export function _resetReleaseApiTokenCache(): void { + cached = null + inFlight = null +} diff --git a/src/main/updater-release-build-cache.test.ts b/src/main/updater-release-build-cache.test.ts new file mode 100644 index 00000000000..8ecbb88f674 --- /dev/null +++ b/src/main/updater-release-build-cache.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ReleaseBuild } from '../shared/release-channel' +import { ReleaseBuildListCache } from './updater-release-build-cache' + +const build = (tag: string): ReleaseBuild => ({ + tag, + version: tag.slice(1), + channel: 'hourly', + name: null, + publishedAt: null, + releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/${tag}`, + installerUrl: null +}) + +const TTL_MS = 5 * 60_000 + +function createCache(load = vi.fn().mockResolvedValue([build('v1.4.160-hourly.202607281400')])) { + let now = 1_000_000 + const cache = new ReleaseBuildListCache(load, TTL_MS, () => now) + return { cache, load, advance: (ms: number) => (now += ms) } +} + +describe('ReleaseBuildListCache', () => { + it('serves a repeat request within the TTL without loading again', async () => { + const { cache, load } = createCache() + + await cache.list('hourly') + await cache.list('hourly') + + expect(load).toHaveBeenCalledTimes(1) + }) + + it('reloads once the TTL has passed', async () => { + const { cache, load, advance } = createCache() + + await cache.list('hourly') + advance(TTL_MS + 1) + await cache.list('hourly') + + expect(load).toHaveBeenCalledTimes(2) + }) + + it('reloads on force even inside the TTL', async () => { + const { cache, load } = createCache() + + await cache.list('hourly') + await cache.list('hourly', { force: true }) + + expect(load).toHaveBeenCalledTimes(2) + }) + + it('shares one in-flight load between concurrent callers', async () => { + const { cache, load } = createCache() + + const [first, second] = await Promise.all([cache.list('hourly'), cache.list('hourly')]) + + expect(load).toHaveBeenCalledTimes(1) + expect(second).toBe(first) + }) + + it('keys by channel', async () => { + const { cache, load } = createCache() + + await cache.list('hourly') + await cache.list('daily') + await cache.list('hourly') + + expect(load.mock.calls).toEqual([['hourly'], ['daily']]) + }) + + it('does not cache a failed load', async () => { + const load = vi + .fn() + .mockRejectedValueOnce(new Error('GitHub rate limit reached.')) + .mockResolvedValueOnce([build('v1.4.160-hourly.202607281400')]) + const { cache } = createCache(load) + + await expect(cache.list('hourly')).rejects.toThrow(/rate limit/) + await expect(cache.list('hourly')).resolves.toHaveLength(1) + expect(load).toHaveBeenCalledTimes(2) + }) + + it('keeps a forced reload that replaced a failing entry', async () => { + let failFirst: (error: Error) => void = () => {} + const load = vi + .fn() + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + failFirst = reject + }) + ) + .mockResolvedValueOnce([build('v1.4.160-hourly.202607281400')]) + const { cache } = createCache(load) + + const failing = cache.list('hourly') + const forced = cache.list('hourly', { force: true }) + failFirst(new Error('timed out')) + await expect(failing).rejects.toThrow(/timed out/) + await forced + + await cache.list('hourly') + expect(load).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/updater-release-build-cache.ts b/src/main/updater-release-build-cache.ts new file mode 100644 index 00000000000..d9ea1c930e8 --- /dev/null +++ b/src/main/updater-release-build-cache.ts @@ -0,0 +1,48 @@ +import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' + +const DEFAULT_TTL_MS = 5 * 60_000 + +type LoadBuilds = (channel: ReleaseChannel) => Promise + +type CacheEntry = { builds: Promise; expiresAt: number } + +export type ReleaseBuildListOptions = { + /** Bypass the cache — the refresh button, so a build published a minute ago shows up on demand. */ + force?: boolean +} + +/** + * Per-channel cache of listed release builds. + * + * Why: the picker reloads on every settings mount and every channel click, and + * each load was one GitHub API request. Serving repeats from here keeps a few + * minutes of browsing at one request per channel, and sharing the in-flight + * promise collapses two concurrent loads of one channel into a single request. + */ +export class ReleaseBuildListCache { + private readonly entries = new Map() + + constructor( + private readonly load: LoadBuilds, + private readonly ttlMs: number = DEFAULT_TTL_MS, + private readonly now: () => number = Date.now + ) {} + + list(channel: ReleaseChannel, options: ReleaseBuildListOptions = {}): Promise { + const existing = this.entries.get(channel) + if (!options.force && existing && existing.expiresAt > this.now()) { + return existing.builds + } + const builds = this.load(channel) + const entry: CacheEntry = { builds, expiresAt: this.now() + this.ttlMs } + this.entries.set(channel, entry) + // Why: a failed load must not be served for the next five minutes; drop it so + // the next call retries. Only evict our own entry — a forced reload may have replaced it. + builds.catch(() => { + if (this.entries.get(channel) === entry) { + this.entries.delete(channel) + } + }) + return builds + } +} diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts index 30188d73de2..ad82b840c9b 100644 --- a/src/main/updater-release-builds.test.ts +++ b/src/main/updater-release-builds.test.ts @@ -1,18 +1,41 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const fetchMock = vi.fn() vi.mock('electron', () => ({ net: { fetch: (...args: unknown[]) => fetchMock(...args) } })) -const { listReleaseBuilds, resolveTargetBuild } = await import('./updater-release-builds') +const tokenMock = vi.fn<() => Promise>() +const rejectTokenMock = vi.fn() +vi.mock('./updater-release-api-token', () => ({ + resolveReleaseApiToken: () => tokenMock(), + rejectReleaseApiToken: () => rejectTokenMock() +})) -function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}) { +const blockedUntilMock = vi.fn<() => number | null>() +const recordRateLimitMock = vi.fn() +vi.mock('./git/gh-rate-limit-breaker', () => ({ + getGhRateLimitBlockedUntilMs: () => blockedUntilMock(), + recordGhPrimaryRateLimit: (...args: unknown[]) => recordRateLimitMock(...args) +})) + +const { describeRateLimitReset, listReleaseBuilds, rateLimitResetAtMs, resolveTargetBuild } = + await import('./updater-release-builds') + +function jsonResponse( + body: unknown, + init: { ok?: boolean; status?: number; headers?: Record } = {} +) { return { ok: init.ok ?? true, status: init.status ?? 200, + headers: new Headers(init.headers ?? {}), json: () => Promise.resolve(body) } } +function requestHeaders(call = 0): Record { + return fetchMock.mock.calls[call][1].headers +} + /** Every platform's manifest by default, so a case that is not about asset * filtering stays readable and stays green whatever platform is passed. */ const allPlatformAssets = [ @@ -36,6 +59,16 @@ const release = (tag: string, extra: Record = {}) => ({ describe('listReleaseBuilds', () => { beforeEach(() => { fetchMock.mockReset() + rejectTokenMock.mockReset() + recordRateLimitMock.mockReset() + tokenMock.mockReset() + tokenMock.mockResolvedValue(null) + blockedUntilMock.mockReset() + blockedUntilMock.mockReturnValue(null) + }) + + afterEach(() => { + vi.restoreAllMocks() }) it('lists hourly builds from the dedicated repo, newest first', async () => { @@ -209,9 +242,140 @@ describe('listReleaseBuilds', () => { await expect(listReleaseBuilds('hourly', 'win32')).resolves.toEqual([]) }) - it('surfaces a rate limit as an actionable message', async () => { + // Why: unauthenticated requests draw from a 60/hour bucket shared by every + // caller behind the same IP; the user's own token has a 5000/hour bucket. + it('sends the local gh token as a bearer header when one is available', async () => { + tokenMock.mockResolvedValue('gho_abc') + fetchMock.mockResolvedValue(jsonResponse([release('v1.4.159')])) + + await listReleaseBuilds('stable', 'darwin') + + expect(requestHeaders()).toEqual({ + Accept: 'application/vnd.github+json', + Authorization: 'Bearer gho_abc' + }) + }) + + it('sends no authorization header when gh has no token', async () => { + fetchMock.mockResolvedValue(jsonResponse([release('v1.4.159')])) + + await listReleaseBuilds('stable', 'darwin') + + expect(requestHeaders()).toEqual({ Accept: 'application/vnd.github+json' }) + }) + + // Why: a revoked keyring token must not take the picker down when the + // unauthenticated request still lists the public repo. + it('retries unauthenticated once when GitHub rejects the token', async () => { + tokenMock.mockResolvedValue('gho_stale') + fetchMock + .mockResolvedValueOnce(jsonResponse(null, { ok: false, status: 401 })) + .mockResolvedValueOnce(jsonResponse([release('v1.4.159')])) + + await expect( + listReleaseBuilds('stable', 'darwin').then((builds) => builds.map((build) => build.version)) + ).resolves.toEqual(['1.4.159']) + + expect(rejectTokenMock).toHaveBeenCalledTimes(1) + expect(requestHeaders(1)).toEqual({ Accept: 'application/vnd.github+json' }) + }) + + it('does not retry a 401 that was already unauthenticated', async () => { + fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 401 })) + + await expect(listReleaseBuilds('stable', 'darwin')).rejects.toThrow(/HTTP 401/) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(rejectTokenMock).not.toHaveBeenCalled() + }) + + // Why: the token's 5000/hour bucket and the per-IP bucket are independent, so + // a spent token — an agent running gh in a loop — must not take the picker down + // while the unauthenticated request would still succeed. + it('falls back to the per-IP bucket when the token is rate limited and tells the gh breaker', async () => { + tokenMock.mockResolvedValue('gho_abc') + fetchMock + .mockResolvedValueOnce( + jsonResponse(null, { + ok: false, + status: 403, + headers: { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1800000600' } + }) + ) + .mockResolvedValueOnce(jsonResponse([release('v1.4.159')])) + + await expect( + listReleaseBuilds('stable', 'darwin').then((builds) => builds.map((build) => build.version)) + ).resolves.toEqual(['1.4.159']) + + expect(recordRateLimitMock).toHaveBeenCalledWith('core', 1_800_000_600_000) + expect(rejectTokenMock).not.toHaveBeenCalled() + expect(requestHeaders(1)).toEqual({ Accept: 'application/vnd.github+json' }) + }) + + it('skips the token while the gh breaker has the core bucket blocked', async () => { + blockedUntilMock.mockReturnValue(Date.now() + 60_000) + tokenMock.mockResolvedValue('gho_abc') + fetchMock.mockResolvedValue( + jsonResponse(null, { ok: false, status: 403, headers: { 'x-ratelimit-remaining': '0' } }) + ) + + const failure = listReleaseBuilds('stable', 'darwin') + await expect(failure).rejects.toThrow(/rate limit reached/) + // Why: the user is signed in; the breaker, not a missing login, kept the token home. + await expect(failure).rejects.not.toThrow(/gh auth login/) + + expect(tokenMock).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(requestHeaders()).toEqual({ Accept: 'application/vnd.github+json' }) + }) + + it('surfaces a rate limit with its reset time and a sign-in hint when unauthenticated', async () => { + const nowMs = 1_800_000_000_000 + vi.spyOn(Date, 'now').mockReturnValue(nowMs) + fetchMock.mockResolvedValue( + jsonResponse(null, { + ok: false, + status: 403, + headers: { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': String(nowMs / 1000 + 28 * 60) + } + }) + ) + + await expect(listReleaseBuilds('hourly', 'darwin')).rejects.toThrow( + "GitHub rate limit reached. Try again in about 28 minutes, or run `gh auth login` so Orca can use your account's higher limit." + ) + }) + + it('omits the sign-in hint when the rate-limited request was authenticated', async () => { + tokenMock.mockResolvedValue('gho_abc') + fetchMock.mockResolvedValue( + jsonResponse(null, { ok: false, status: 403, headers: { 'x-ratelimit-remaining': '0' } }) + ) + + const failure = listReleaseBuilds('hourly', 'darwin') + await expect(failure).rejects.toThrow(/rate limit reached/) + await expect(failure).rejects.not.toThrow(/gh auth login/) + }) + + it('treats 429 as a rate limit', async () => { + fetchMock.mockResolvedValue( + jsonResponse(null, { ok: false, status: 429, headers: { 'retry-after': '90' } }) + ) + + await expect(listReleaseBuilds('hourly', 'darwin')).rejects.toThrow(/in about 2 minutes/) + }) + + // Why: a 403 without rate-limit headers is a permission or access problem, and + // telling the user to wait would send them waiting for a reset that never comes. + it('reports a 403 without rate-limit headers as a plain HTTP error', async () => { fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 403 })) - await expect(listReleaseBuilds('hourly', 'darwin')).rejects.toThrow(/rate limit/i) + + const failure = listReleaseBuilds('hourly', 'darwin') + await expect(failure).rejects.toThrow(/HTTP 403/) + await expect(failure).rejects.not.toThrow(/rate limit/) }) it('reports a missing hourly repo distinctly', async () => { @@ -220,6 +384,48 @@ describe('listReleaseBuilds', () => { }) }) +describe('rateLimitResetAtMs', () => { + const nowMs = 1_800_000_000_000 + + it('is null when GitHub sent no reset', () => { + expect(rateLimitResetAtMs(new Headers(), nowMs)).toBeNull() + }) + + it('prefers the primary reset epoch over retry-after', () => { + const headers = new Headers({ + 'x-ratelimit-reset': String(nowMs / 1000 + 10 * 60), + 'retry-after': '30' + }) + expect(rateLimitResetAtMs(headers, nowMs)).toBe(nowMs + 10 * 60_000) + }) + + it('reads retry-after as seconds', () => { + expect(rateLimitResetAtMs(new Headers({ 'retry-after': '90' }), nowMs)).toBe(nowMs + 90_000) + }) + + // Why: secondary limits may send Retry-After as an HTTP date (RFC 9110). + it('reads retry-after as an HTTP date', () => { + const headers = new Headers({ 'retry-after': new Date(nowMs + 5 * 60_000).toUTCString() }) + expect(rateLimitResetAtMs(headers, nowMs)).toBe(nowMs + 5 * 60_000) + }) +}) + +describe('describeRateLimitReset', () => { + const nowMs = 1_800_000_000_000 + + it('falls back to a vague wait when the reset is unknown', () => { + expect(describeRateLimitReset(null, nowMs)).toBe('in a few minutes') + }) + + it('rounds a sub-minute reset up to a minute', () => { + expect(describeRateLimitReset(nowMs + 20_000, nowMs)).toBe('in about a minute') + }) + + it('rounds a partial minute up', () => { + expect(describeRateLimitReset(nowMs + 9 * 60_000 + 1, nowMs)).toBe('in about 10 minutes') + }) +}) + describe('resolveTargetBuild', () => { it('pins an hourly tag at the hourly repo download path', () => { expect(resolveTargetBuild('hourly', 'v1.4.160-hourly.202607281400')).toEqual({ diff --git a/src/main/updater-release-builds.ts b/src/main/updater-release-builds.ts index 01478535018..af93a369806 100644 --- a/src/main/updater-release-builds.ts +++ b/src/main/updater-release-builds.ts @@ -9,15 +9,78 @@ import { type ReleaseBuild, type ReleaseChannel } from '../shared/release-channel' +import { parseRelayRetryAfterMs } from '../shared/relay-retry-after-header' +import { getGhRateLimitBlockedUntilMs, recordGhPrimaryRateLimit } from './git/gh-rate-limit-breaker' import { isValidVersion } from './updater-fallback' +import { rejectReleaseApiToken, resolveReleaseApiToken } from './updater-release-api-token' const FETCH_TIMEOUT_MS = 8000 const MAX_LISTED_BUILDS = 100 +const RETRY_AFTER_MAX_MS = 60 * 60_000 function getReleasesApiUrl(repo: string): string { return `https://api.github.com/repos/${repo}/releases?per_page=${MAX_LISTED_BUILDS}` } +function fetchReleases(repo: string, token: string | null): Promise { + const headers: Record = { Accept: 'application/vnd.github+json' } + if (token) { + headers.Authorization = `Bearer ${token}` + } + return net.fetch(getReleasesApiUrl(repo), { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) +} + +/** GitHub answers a spent primary bucket with 403 + `x-ratelimit-remaining: 0`; secondary limits carry Retry-After. */ +function isRateLimited(res: Response): boolean { + return ( + res.status === 429 || + (res.status === 403 && + (res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after'))) + ) +} + +/** Primary limits carry the reset epoch; secondary limits carry Retry-After as seconds or an HTTP date. */ +export function rateLimitResetAtMs(headers: Headers, nowMs: number): number | null { + const resetEpochSeconds = Number(headers.get('x-ratelimit-reset')) + if (resetEpochSeconds > 0) { + return resetEpochSeconds * 1000 + } + const retryAfterMs = parseRelayRetryAfterMs(headers.get('retry-after'), RETRY_AFTER_MAX_MS, nowMs) + return retryAfterMs === null ? null : nowMs + retryAfterMs +} + +export function describeRateLimitReset(resetAtMs: number | null, nowMs: number): string { + if (resetAtMs === null) { + return 'in a few minutes' + } + const minutes = Math.ceil((resetAtMs - nowMs) / 60_000) + return minutes <= 1 ? 'in about a minute' : `in about ${minutes} minutes` +} + +function releaseListError( + res: Response, + repo: string, + channel: ReleaseChannel, + signedIn: boolean +): Error { + if (res.status === 404) { + return new Error(`No releases repository found at ${repo}.`) + } + if (isRateLimited(res)) { + const nowMs = Date.now() + const retry = describeRateLimitReset(rateLimitResetAtMs(res.headers, nowMs), nowMs) + return new Error( + signedIn + ? `GitHub rate limit reached. Try again ${retry}.` + : `GitHub rate limit reached. Try again ${retry}, or run \`gh auth login\` so Orca can use your account's higher limit.` + ) + } + return new Error(`Could not list ${channel} builds (HTTP ${res.status}).`) +} + export function getReleaseDownloadUrlForRepo(repo: string, tag: string): string { return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}` } @@ -89,26 +152,39 @@ function parseReleaseEntry( * * Why the REST API rather than the atom feed the routine update path uses: the * feed caps at the 10 newest entries, which cannot express "jump back to - * yesterday's hourly". This runs only on explicit dev interaction, so its - * unauthenticated rate limit never touches background checks. + * yesterday's hourly". This runs only on explicit dev interaction, so it never + * touches background checks; it sends the local gh token when there is one so + * the request spends the user's own quota, not the per-IP bucket every + * unauthenticated caller on the network shares. */ export async function listReleaseBuilds( channel: ReleaseChannel, platform: NodeJS.Platform = process.platform ): Promise { const repo = getReleaseRepoForChannel(channel) - const res = await net.fetch(getReleasesApiUrl(repo), { - headers: { Accept: 'application/vnd.github+json' }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) - }) + // Why: while the gh breaker has the token's core bucket marked spent, an + // authenticated request is a guaranteed 403 — go straight to the per-IP bucket. + const tokenBucketBlocked = getGhRateLimitBlockedUntilMs('core') !== null + const token = tokenBucketBlocked ? null : await resolveReleaseApiToken() + let signedIn = tokenBucketBlocked || token !== null + let res = await fetchReleases(repo, token) + if (token && res.status === 401) { + // Why: a revoked or expired keyring token answers 401, and the unauthenticated + // request still lists a public repo — fall back instead of failing the picker. + rejectReleaseApiToken() + signedIn = false + res = await fetchReleases(repo, null) + } else if (token && isRateLimited(res)) { + // Why: the token's bucket and the per-IP bucket are separate, so the other one + // may still have quota. Tell the breaker first so gh calls fail fast until the reset. + const resetAtMs = rateLimitResetAtMs(res.headers, Date.now()) + if (resetAtMs !== null) { + recordGhPrimaryRateLimit('core', resetAtMs) + } + res = await fetchReleases(repo, null) + } if (!res.ok) { - if (res.status === 404) { - throw new Error(`No releases repository found at ${repo}.`) - } - if (res.status === 403 || res.status === 429) { - throw new Error('GitHub rate limit reached. Try again in a few minutes.') - } - throw new Error(`Could not list ${channel} builds (HTTP ${res.status}).`) + throw releaseListError(res, repo, channel, signedIn) } const payload: unknown = await res.json() if (!Array.isArray(payload)) { diff --git a/src/main/updater.ts b/src/main/updater.ts index e22e18e84ba..cc9f0fc2c98 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -10,6 +10,7 @@ import type { RemoteServerUpdateSupport } from '../shared/remote-server-update' import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' +import type { ReleaseBuildListOptions } from './updater-release-build-cache' import { UpdaterSetup, type UpdaterSetupOptions } from './updater/updater-setup' import type { UpdateInstallMode } from './updater/updater-state' @@ -77,8 +78,11 @@ export async function showLinuxPackage(): Promise { return updater.showLinuxPackage() } -export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { - return updater.listAvailableReleaseBuilds(channel) +export async function listAvailableReleaseBuilds( + channel: ReleaseChannel, + options?: ReleaseBuildListOptions +): Promise { + return updater.listAvailableReleaseBuilds(channel, options) } export function dismissNudge(): void { diff --git a/src/main/updater/updater-build-selection.ts b/src/main/updater/updater-build-selection.ts index a533b776a84..9f4940abbab 100644 --- a/src/main/updater/updater-build-selection.ts +++ b/src/main/updater/updater-build-selection.ts @@ -12,10 +12,15 @@ import { } from '../../shared/release-channel' import { compareVersions } from '../updater-fallback' import { listReleaseBuilds, resolveTargetBuild } from '../updater-release-builds' +import { ReleaseBuildListCache, type ReleaseBuildListOptions } from '../updater-release-build-cache' import { UpdaterMenuChecks } from './updater-menu-checks' /** Handles local-build selection and exact release-channel/tag jumps. */ export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { + private readonly releaseBuildCache = new ReleaseBuildListCache((channel) => + listReleaseBuilds(channel) + ) + protected async checkForLocalBuildFromMenu(): Promise { if (process.platform !== 'darwin') { this.sendLocalBuildErrorAndRestore( @@ -67,8 +72,11 @@ export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { } } - protected async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { - return listReleaseBuilds(channel) + protected async listAvailableReleaseBuilds( + channel: ReleaseChannel, + options?: ReleaseBuildListOptions + ): Promise { + return this.releaseBuildCache.list(channel, options) } /** Pins the updater at one exact release tag and checks it, so a dev can move to any published build on any channel — including an older one. */ diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts index d60444d449a..8ebbc49b169 100644 --- a/src/main/updater/updater-setup.ts +++ b/src/main/updater/updater-setup.ts @@ -2,6 +2,7 @@ import { app, powerMonitor } from 'electron' import type { BrowserWindow } from 'electron' import { is } from '@electron-toolkit/utils' import type { ReleaseBuild, ReleaseChannel } from '../../shared/release-channel' +import type { ReleaseBuildListOptions } from '../updater-release-build-cache' import type { LinuxPackageInstallInstructions, UpdateCheckOptions, @@ -94,8 +95,11 @@ export class UpdaterSetup extends UpdaterDownloadInstall { return super.showLinuxPackage() } - async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { - return super.listAvailableReleaseBuilds(channel) + async listAvailableReleaseBuilds( + channel: ReleaseChannel, + options?: ReleaseBuildListOptions + ): Promise { + return super.listAvailableReleaseBuilds(channel, options) } dismissNudge(): void { diff --git a/src/main/window/main-window-updater.ts b/src/main/window/main-window-updater.ts index 51860db8dc5..f0298ed115d 100644 --- a/src/main/window/main-window-updater.ts +++ b/src/main/window/main-window-updater.ts @@ -113,12 +113,20 @@ export function registerUpdaterHandlers(_store: Store): void { }) ipcMain.handle( 'updater:listBuilds', - async (_event, channel: ReleaseChannel): Promise => { + async ( + _event, + channel: ReleaseChannel, + options?: { force?: boolean } + ): Promise => { if (!RELEASE_CHANNELS.includes(channel)) { return { ok: false, channel, message: `Unknown release channel "${channel}".` } } try { - return { ok: true, channel, builds: await listAvailableReleaseBuilds(channel) } + return { + ok: true, + channel, + builds: await listAvailableReleaseBuilds(channel, { force: options?.force === true }) + } } catch (error) { // Why: a network/rate-limit failure is expected here; return it as data so // the picker can render the reason instead of rejecting the invoke. diff --git a/src/preload/api/updater-api.ts b/src/preload/api/updater-api.ts index 0a3590e8035..f768509e647 100644 --- a/src/preload/api/updater-api.ts +++ b/src/preload/api/updater-api.ts @@ -18,7 +18,11 @@ export type UpdaterApi = { getLinuxPackageInstallInstructions: () => Promise /** Desktop-only. Reveals the revalidated cached package in the native file manager. */ showLinuxPackage: () => Promise - listBuilds: (channel: ReleaseChannel) => Promise + /** `force` bypasses the main-process list cache — the refresh button, not mount or channel switches. */ + listBuilds: ( + channel: ReleaseChannel, + options?: { force?: boolean } + ) => Promise onStatus: (callback: (status: UpdateStatus) => void) => () => void onClearDismissal: (callback: () => void) => () => void diff --git a/src/preload/api/updater-bridge.ts b/src/preload/api/updater-bridge.ts index 2b40e42b8be..325d5ace205 100644 --- a/src/preload/api/updater-bridge.ts +++ b/src/preload/api/updater-bridge.ts @@ -14,7 +14,7 @@ export const updaterApi = { getLinuxPackageInstallInstructions: () => ipcRenderer.invoke('updater:getLinuxPackageInstallInstructions'), showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), - listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), + listBuilds: (channel, options) => ipcRenderer.invoke('updater:listBuilds', channel, options), quitAndInstall: (): Promise => prepareAndInvokeUpdaterInstall( window, diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index cd0a8484cfe..afa2c03687b 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -116,46 +116,54 @@ export function ReleaseChannelSection(): React.JSX.Element { // from a channel the picker is no longer showing. const latestRequestRef = useRef(0) - const loadBuilds = useCallback(async (channel: ReleaseChannel): Promise => { - const requestId = latestRequestRef.current + 1 - latestRequestRef.current = requestId - const isStale = (): boolean => latestRequestRef.current !== requestId - setLoading(true) - setLoadError(null) - try { - const result = await window.api.updater.listBuilds(channel) - if (isStale()) { - return - } - if (result.ok) { - setBuilds(result.builds) - setSelectedTag(result.builds[0]?.tag ?? null) - } else { + const loadBuilds = useCallback( + async (channel: ReleaseChannel, options?: { force?: boolean }): Promise => { + const requestId = latestRequestRef.current + 1 + latestRequestRef.current = requestId + const isStale = (): boolean => latestRequestRef.current !== requestId + setLoading(true) + setLoadError(null) + try { + const result = await window.api.updater.listBuilds(channel, options) + if (isStale()) { + return + } + if (result.ok) { + setBuilds(result.builds) + setSelectedTag(result.builds[0]?.tag ?? null) + } else { + setBuilds(null) + setLoadError(result.message) + } + } catch (error) { + if (isStale()) { + return + } setBuilds(null) - setLoadError(result.message) + setLoadError(String((error as Error)?.message ?? error)) + } finally { + // Why: only the newest request owns the spinner; a superseded one clearing + // it would show "no builds" while the current load is still running. + if (!isStale()) { + setLoading(false) + } } - } catch (error) { - if (isStale()) { - return - } - setBuilds(null) - setLoadError(String((error as Error)?.message ?? error)) - } finally { - // Why: only the newest request owns the spinner; a superseded one clearing - // it would show "no builds" while the current load is still running. - if (!isStale()) { - setLoading(false) - } - } - }, []) + }, + [] + ) // Why: reload whenever the channel changes so the picker never offers tags - // from the channel the user just switched away from. + // from the channel the user just switched away from. Not before the version + // resolves: the running channel is unknown until then, and a load for the + // 'stable' placeholder would be a GitHub request whose result is thrown away. useEffect(() => { + if (appVersion === null) { + return + } setBuilds(null) setSelectedTag(null) void loadBuilds(activeChannel) - }, [activeChannel, loadBuilds]) + }, [activeChannel, appVersion, loadBuilds]) const selectedBuild = useMemo( () => builds?.find((build) => build.tag === selectedTag) ?? null, @@ -276,7 +284,7 @@ export function ReleaseChannelSection(): React.JSX.Element { void loadBuilds(activeChannel)} + onClick={() => void loadBuilds(activeChannel, { force: true })} > {loading ? (