fix(desktop): let progressing catalog downloads outlive the deadline

This commit is contained in:
funky-xamarin
2026-09-26 19:35:45 +08:00
committed by brooklyn!
parent f87a52edf2
commit 5832a6eb93
2 changed files with 186 additions and 10 deletions

View File

@@ -175,18 +175,65 @@ export function parseCatalog(kind: CatalogKind, data: unknown): CatalogEntry[] {
}
export async function fetchCatalog(kind: CatalogKind): Promise<CatalogEntry[]> {
// 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<typeof setTimeout> | 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) =>

View File

@@ -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<Uint8Array>
let signal!: AbortSignal
const encoder = new TextEncoder()
vi.stubGlobal(
'fetch',
vi.fn(async (_url, options) => {
signal = options.signal
return new Response(
new ReadableStream<Uint8Array>({
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)
})
})