diff --git a/apps/desktop/src/app/capabilities/catalog/catalog-data.ts b/apps/desktop/src/app/capabilities/catalog/catalog-data.ts index 1b3cc968b6..2482012e47 100644 --- a/apps/desktop/src/app/capabilities/catalog/catalog-data.ts +++ b/apps/desktop/src/app/capabilities/catalog/catalog-data.ts @@ -175,18 +175,65 @@ export function parseCatalog(kind: CatalogKind, data: unknown): CatalogEntry[] { } export async function fetchCatalog(kind: CatalogKind): Promise { - // These are the same published snapshots as the docs galleries. Never fan - // out to repositories, README previews, avatars, or live hub searches. - const response = await fetch(`${CATALOG_BASE}/${kind}.json`, { - credentials: 'omit', - signal: AbortSignal.timeout(60_000) - }) - - if (!response.ok) { - throw new Error(`Catalog HTTP ${response.status}`) + // Bound inactivity, not total transfer time: a large skills snapshot can + // take minutes on a healthy slow connection. Headers and each body chunk + // get the same deadline; an actually stalled download still terminates. + const controller = new AbortController() + let timer: ReturnType | undefined + const armDeadline = () => { + clearTimeout(timer) + timer = setTimeout(() => controller.abort(), 60_000) } - return parseCatalog(kind, await response.json()) + armDeadline() + + try { + // Same published snapshots as the docs galleries; no per-entry fan-out. + const response = await fetch(`${CATALOG_BASE}/${kind}.json`, { + credentials: 'omit', + signal: controller.signal + }) + + if (!response.ok) { + throw new Error(`Catalog HTTP ${response.status}`) + } + + if (!response.body) { + return parseCatalog(kind, await response.json()) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + const chunks: string[] = [] + armDeadline() + + try { + while (true) { + const { done, value } = await reader.read() + + if (done) break + if (value.byteLength > 0) armDeadline() + chunks.push(decoder.decode(value, { stream: true })) + } + } finally { + reader.releaseLock() + } + + clearTimeout(timer) + chunks.push(decoder.decode()) + + return parseCatalog(kind, JSON.parse(chunks.join(''))) + } catch (error) { + // Chromium can turn our abort into "The user aborted a request." The + // controller, not that browser-dependent exception name, owns the cause. + if (controller.signal.aborted) { + throw new Error('Catalog download timed out after 60 seconds without progress. Please try again.') + } + + throw error + } finally { + clearTimeout(timer) + } } const catalogQuery = (kind: CatalogKind) => diff --git a/apps/desktop/src/app/capabilities/catalog/catalog-download.test.ts b/apps/desktop/src/app/capabilities/catalog/catalog-download.test.ts new file mode 100644 index 0000000000..dfab21db06 --- /dev/null +++ b/apps/desktop/src/app/capabilities/catalog/catalog-download.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { fetchCatalog } from './catalog-data' + +beforeEach(() => { + vi.useFakeTimers() + // Node's native timeout uses internal timers; keep the baseline's browser + // timeout on the same deterministic clock as the response fixture. + vi.spyOn(AbortSignal, 'timeout').mockImplementation(ms => { + const controller = new AbortController() + setTimeout(() => controller.abort(new DOMException('Timed out', 'TimeoutError')), ms) + return controller.signal + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +function serveStream() { + let writer!: ReadableStreamDefaultController + let signal!: AbortSignal + const encoder = new TextEncoder() + vi.stubGlobal( + 'fetch', + vi.fn(async (_url, options) => { + signal = options.signal + return new Response( + new ReadableStream({ + start(controller) { + writer = controller + signal.addEventListener('abort', () => + controller.error(new DOMException('The user aborted a request.', 'AbortError')) + ) + } + }) + ) + }) + ) + return { + send: (text: string) => writer.enqueue(encoder.encode(text)), + close: () => writer.close(), + fail: (error: Error) => writer.error(error), + signal: () => signal + } +} + +describe('catalog download liveness', () => { + it.each(['skills', 'plugins'] as const)( + 'finishes a progressing %s download beyond the old total deadline', + async kind => { + const stream = serveStream() + const result = fetchCatalog(kind).then( + value => ({ value }), + error => ({ error }) + ) + await vi.advanceTimersByTimeAsync(40_000) + stream.send('[{"name":"slow-') + await vi.advanceTimersByTimeAsync(40_000) + expect(stream.signal().aborted).toBe(false) + stream.send('catalog","source":"official"}]') + stream.close() + expect(await result).toMatchObject({ value: [{ name: 'slow-catalog' }] }) + expect(vi.getTimerCount()).toBe(0) + } + ) + + it('reports a stalled body as a timeout, not user cancellation', async () => { + const stream = serveStream() + const result = fetchCatalog('skills').catch(error => error) + await vi.advanceTimersByTimeAsync(40_000) + stream.send('[') + await vi.advanceTimersByTimeAsync(60_001) + expect(stream.signal().aborted).toBe(true) + expect((await result).message).toMatch(/timed out/i) + expect(vi.getTimerCount()).toBe(0) + }) + + it('bounds waiting for response headers', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + (_url, options) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => + reject(new DOMException('The user aborted a request.', 'AbortError')) + ) + }) + ) + ) + const result = fetchCatalog('skills').catch(error => error) + await vi.advanceTimersByTimeAsync(60_001) + expect((await result).message).toMatch(/timed out/i) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['[]', 'not json'])('cleans its deadline after completion or malformed JSON: %s', async body => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(body)) + ) + const result = await fetchCatalog('skills').catch(error => error) + if (body === '[]') expect(result).toEqual([]) + else expect(result).toBeInstanceOf(SyntaxError) + expect(vi.getTimerCount()).toBe(0) + }) + + it('preserves transport errors and releases its deadline', async () => { + const stream = serveStream() + const error = new Error('connection reset') + const result = fetchCatalog('skills').catch(error => error) + await vi.advanceTimersByTimeAsync(1) + stream.fail(error) + expect(await result).toBe(error) + expect(vi.getTimerCount()).toBe(0) + }) + + it('preserves HTTP errors and releases its deadline', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 503 })) + ) + await expect(fetchCatalog('plugins')).rejects.toThrow('Catalog HTTP 503') + expect(vi.getTimerCount()).toBe(0) + }) +})