diff --git a/src/app/editor/clipboard/figma-images.ts b/src/app/editor/clipboard/figma-images.ts index 308a24ff1..4f5416494 100644 --- a/src/app/editor/clipboard/figma-images.ts +++ b/src/app/editor/clipboard/figma-images.ts @@ -35,31 +35,6 @@ async function sha1Hex(bytes: Uint8Array): Promise { return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('') } -async function fetchWithTimeout( - fetcher: ClipboardImageFetch, - input: RequestInfo | URL, - init: RequestInit | undefined, - timeoutMs: number -) { - const controller = new AbortController() - let timeout: ReturnType | undefined - const timeoutPromise = new Promise((_resolve, reject) => { - timeout = setTimeout(() => { - controller.abort() - reject(new Error('Figma image request timed out')) - }, timeoutMs) - }) - - try { - return await Promise.race([ - fetcher(input, { ...init, signal: controller.signal }), - timeoutPromise - ]) - } finally { - clearTimeout(timeout) - } -} - export async function resolveFigmaClipboardImages( fileKey: string, hashes: string[], @@ -69,15 +44,14 @@ export async function resolveFigmaClipboardImages( const uniqueHashes = [...new Set(hashes)] if (uniqueHashes.length === 0) return new Map() - const batchResponse = await fetchWithTimeout( - fetcher, + const batchResponse = await fetcher( `https://www.figma.com/file/${encodeURIComponent(fileKey)}/image/batch`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ sha1s: uniqueHashes, needs_compressed_textures: false }) - }, - timeoutMs + body: JSON.stringify({ sha1s: uniqueHashes, needs_compressed_textures: false }), + signal: AbortSignal.timeout(timeoutMs) + } ) if (!batchResponse.ok) { throw new Error(`Figma image request failed with status ${batchResponse.status}`) @@ -94,7 +68,7 @@ export async function resolveFigmaClipboardImages( const url = urls[hash] if (!url) return try { - const response = await fetchWithTimeout(fetcher, url, undefined, timeoutMs) + const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) }) if (!response.ok) throw new Error(`status ${response.status}`) const bytes = new Uint8Array(await response.arrayBuffer()) if ((await sha1Hex(bytes)) !== hash.toLowerCase()) { diff --git a/src/app/tauri/http.ts b/src/app/tauri/http.ts index 922f33340..057548160 100644 --- a/src/app/tauri/http.ts +++ b/src/app/tauri/http.ts @@ -20,6 +20,39 @@ function headersToProxyHeaders(headers: Headers): ProxyHttpHeader[] { return [...headers.entries()].map(([name, value]) => ({ name, value })) } +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new DOMException('The operation was aborted', 'AbortError') +} + +export function withAbortSignal(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortReason(signal)) + + return new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + cleanup() + reject(abortReason(signal)) + } + signal.addEventListener('abort', onAbort, { once: true }) + void (async () => { + try { + const value = await promise + cleanup() + resolve(value) + } catch (error) { + cleanup() + reject( + error instanceof Error + ? error + : new Error('Desktop HTTP request failed', { cause: error }) + ) + } + })() + }) +} + async function bodyToBytes(body: BodyInit | null | undefined): Promise { if (body == null) return undefined if (typeof body === 'string') return [...new TextEncoder().encode(body)] @@ -36,6 +69,7 @@ async function bodyToBytes(body: BodyInit | null | undefined): Promise { const request = new Request(input, init) + request.signal.throwIfAborted() const { invoke } = await import('@tauri-apps/api/core') const payload: ProxyHttpRequest = { url: request.url, @@ -43,7 +77,11 @@ export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit): headers: headersToProxyHeaders(request.headers), body: await bodyToBytes(init?.body) } - const response = await invoke('proxy_http_request', { request: payload }) + request.signal.throwIfAborted() + const response = await withAbortSignal( + invoke('proxy_http_request', { request: payload }), + request.signal + ) return new Response(new Uint8Array(response.body), { status: response.status, headers: response.headers.map(({ name, value }): [string, string] => [name, value]) diff --git a/tests/engine/app/clipboard/figma-images.test.ts b/tests/engine/app/clipboard/figma-images.test.ts index ea4ca6014..69c968df6 100644 --- a/tests/engine/app/clipboard/figma-images.test.ts +++ b/tests/engine/app/clipboard/figma-images.test.ts @@ -9,6 +9,17 @@ async function sha1Hex(bytes: Uint8Array) { return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('') } +function pendingResponseUntilAbort(_input: RequestInfo | URL, init?: RequestInit) { + return new Promise((_resolve, reject) => { + const signal = init?.signal + if (!signal) { + reject(new Error('Expected request signal')) + return + } + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) +} + describe('resolveFigmaClipboardImages', () => { test('resolves signed URLs, verifies bytes, and deduplicates hashes', async () => { const bytes = new Uint8Array([1, 2, 3]) @@ -71,13 +82,8 @@ describe('resolveFigmaClipboardImages', () => { test('times out stalled batch requests', async () => { await expect( - resolveFigmaClipboardImages( - 'file-key', - ['hash'], - () => Promise.withResolvers().promise, - 5 - ) - ).rejects.toThrow('timed out') + resolveFigmaClipboardImages('file-key', ['hash'], pendingResponseUntilAbort, 5) + ).rejects.toMatchObject({ name: 'TimeoutError' }) }) test('drops images whose signed URL request times out', async () => { @@ -85,7 +91,7 @@ describe('resolveFigmaClipboardImages', () => { const images = await resolveFigmaClipboardImages( 'file-key', ['1111111111111111111111111111111111111111'], - (input) => { + (input, init) => { if (String(input).includes('/image/batch')) { return Promise.resolve( Response.json({ @@ -99,7 +105,7 @@ describe('resolveFigmaClipboardImages', () => { }) ) } - return Promise.withResolvers().promise + return pendingResponseUntilAbort(input, init) }, 5 ) diff --git a/tests/engine/app/tauri/http.test.ts b/tests/engine/app/tauri/http.test.ts new file mode 100644 index 000000000..db024da00 --- /dev/null +++ b/tests/engine/app/tauri/http.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test' + +import { withAbortSignal } from '@/app/tauri/http' + +describe('withAbortSignal', () => { + test('resolves with the wrapped promise', async () => { + const controller = new AbortController() + + await expect(withAbortSignal(Promise.resolve('ok'), controller.signal)).resolves.toBe('ok') + }) + + test('rejects immediately when the signal is already aborted', async () => { + const controller = new AbortController() + const reason = new Error('cancelled') + controller.abort(reason) + + await expect( + withAbortSignal(Promise.withResolvers().promise, controller.signal) + ).rejects.toBe(reason) + }) + + test('rejects a pending promise when the signal aborts', async () => { + const controller = new AbortController() + const pending = Promise.withResolvers() + const result = withAbortSignal(pending.promise, controller.signal) + const reason = new Error('cancelled') + + controller.abort(reason) + + await expect(result).rejects.toBe(reason) + pending.resolve('late result') + }) +})