diff --git a/src/tauri/main/bootstrap.js b/src/tauri/main/bootstrap.js index 1b6829f9..b281cfeb 100644 --- a/src/tauri/main/bootstrap.js +++ b/src/tauri/main/bootstrap.js @@ -17,6 +17,7 @@ import { installMobileWindowOpenCompat } from './compat/mobile/mobile-window-ope import { installDialogPolyfillCoverage } from './compat/dialog/dialog-polyfill-coverage.js'; import { createTraceIdFactory, DEFAULT_TRACE_HEADER } from './kernel/tracing/trace.js'; import { extractErrorText, resolveHostErrorResponse } from './kernel/host-error-response.js'; +import { isAbortError } from './kernel/abort-error.js'; import { installMainApiOptionParking } from './adapters/st/main-api-selector-option-parking.js'; import { installWorldInfoGlobalSelectorSelect2Enforcer } from './adapters/st/world-info-global-selector-select2-enforcer.js'; import { installChatApi } from './api/chat.js'; @@ -318,6 +319,10 @@ export function bootstrapTauriMain() { const durationMs = (globalThis.performance?.now?.() ?? Date.now()) - startedAt; return finalResponse; } catch (error) { + if (isAbortError(error)) { + throw error; + } + const message = extractErrorText(error); const resolved = resolveHostErrorResponse(message); const finalResponse = textResponse(resolved.body, resolved.status); diff --git a/src/tauri/main/interceptors.js b/src/tauri/main/interceptors.js index a50a2d18..f7018b75 100644 --- a/src/tauri/main/interceptors.js +++ b/src/tauri/main/interceptors.js @@ -1,3 +1,5 @@ +import { createAbortError } from './kernel/abort-error.js'; + export function createInterceptors({ isTauri, originalFetch, @@ -11,6 +13,46 @@ export function createInterceptors({ const ajaxPatchState = new WeakMap(); const OPAQUE_BASE_PROTOCOLS = new Set(['about:', 'blob:', 'data:', 'javascript:']); + function getAbortSignal(input, init) { + if (init && typeof init === 'object' && init.signal) { + return init.signal; + } + + if (input && typeof input === 'object' && 'signal' in input) { + return input.signal; + } + + return null; + } + + function createAbortRace(signal) { + if (!signal || typeof signal.addEventListener !== 'function') { + return null; + } + + if (signal.aborted) { + return { + promise: Promise.reject(createAbortError()), + cleanup: () => {}, + }; + } + + let abortHandler = null; + const abortPromise = new Promise((_, reject) => { + abortHandler = () => reject(createAbortError()); + signal.addEventListener('abort', abortHandler, { once: true }); + }); + + return { + promise: abortPromise, + cleanup: () => { + if (abortHandler) { + signal.removeEventListener('abort', abortHandler); + } + }, + }; + } + function resolveWindowBaseUrl(targetWindow) { try { const href = String(targetWindow?.location?.href || ''); @@ -80,6 +122,11 @@ export function createInterceptors({ } const patchedFetch = async function patchedFetch(input, init = {}) { + const signal = getAbortSignal(input, init); + if (signal?.aborted) { + throw createAbortError(); + } + if (!isTauri) { return delegateFetch(input, init); } @@ -89,8 +136,15 @@ export function createInterceptors({ return delegateFetch(input, init); } - const response = await routeRequest(requestUrl, input, init, targetWindow); - return response || jsonResponse({ error: `Unsupported endpoint: ${requestUrl.pathname}` }, 404); + const abortRace = createAbortRace(signal); + try { + const response = abortRace + ? await Promise.race([routeRequest(requestUrl, input, init, targetWindow), abortRace.promise]) + : await routeRequest(requestUrl, input, init, targetWindow); + return response || jsonResponse({ error: `Unsupported endpoint: ${requestUrl.pathname}` }, 404); + } finally { + abortRace?.cleanup(); + } }; try { diff --git a/src/tauri/main/kernel/abort-error.js b/src/tauri/main/kernel/abort-error.js new file mode 100644 index 00000000..9244ba1d --- /dev/null +++ b/src/tauri/main/kernel/abort-error.js @@ -0,0 +1,19 @@ +export function createAbortError(message = 'The operation was aborted.') { + const text = typeof message === 'string' && message.trim() ? message : 'The operation was aborted.'; + + if (typeof DOMException === 'function') { + return new DOMException(text, 'AbortError'); + } + + const error = new Error(text); + error.name = 'AbortError'; + return error; +} + +export function isAbortError(error) { + if (!error || typeof error !== 'object') { + return false; + } + + return error.name === 'AbortError'; +} diff --git a/src/tauri/main/routes/ai-routes.js b/src/tauri/main/routes/ai-routes.js index f1f73986..ff4a7d75 100644 --- a/src/tauri/main/routes/ai-routes.js +++ b/src/tauri/main/routes/ai-routes.js @@ -8,6 +8,7 @@ import { createSystemNotificationService } from '../services/notifications/syste import { registerOpenAiTokenizerRoutes } from './openai-tokenizer-routes.js'; import { createChannel } from '../../../tauri-bridge.js'; import { stripCommandErrorPrefixes } from '../../../scripts/util/command-error-utils.js'; +import { createAbortError, isAbortError } from '../kernel/abort-error.js'; function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; @@ -25,25 +26,6 @@ function getErrorMessage(error) { return error.message || error.toString?.() || 'Unknown error'; } -function createAbortError() { - const message = 'The operation was aborted.'; - if (typeof DOMException === 'function') { - return new DOMException(message, 'AbortError'); - } - - const error = new Error(message); - error.name = 'AbortError'; - return error; -} - -function isAbortError(error) { - if (!error || typeof error !== 'object') { - return false; - } - - return error.name === 'AbortError'; -} - function encodeSseDataFrame(data) { const text = typeof data === 'string' ? data : String(data ?? ''); if (!text.includes('\n') && !text.includes('\r')) { diff --git a/src/tauri/main/routes/sd-routes.js b/src/tauri/main/routes/sd-routes.js index c26d5205..b16616b1 100644 --- a/src/tauri/main/routes/sd-routes.js +++ b/src/tauri/main/routes/sd-routes.js @@ -1,13 +1,4 @@ -function createAbortError() { - const message = 'The operation was aborted.'; - if (typeof DOMException === 'function') { - return new DOMException(message, 'AbortError'); - } - - const error = new Error(message); - error.name = 'AbortError'; - return error; -} +import { createAbortError } from '../kernel/abort-error.js'; function createRequestId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { @@ -97,4 +88,3 @@ export function registerSdRoutes(router, context, { jsonResponse, textResponse } } }); } - diff --git a/tests/fetch-abort-contract.test.mjs b/tests/fetch-abort-contract.test.mjs new file mode 100644 index 00000000..c0413902 --- /dev/null +++ b/tests/fetch-abort-contract.test.mjs @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { installFakeDom } from './helpers/fake-dom.mjs'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function importFresh(modulePath) { + const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`; + return import(url); +} + +test('fetch interceptor rejects immediately when AbortSignal is already aborted', async () => { + const { createInterceptors } = await importFresh( + path.join(REPO_ROOT, 'src/tauri/main/interceptors.js'), + ); + + const dom = installFakeDom(); + dom.window.location = { + href: 'https://example.com/', + origin: 'https://example.com', + }; + + let delegatedCalls = 0; + dom.window.fetch = async () => { + delegatedCalls += 1; + return new Response('delegated'); + }; + + let routedCalls = 0; + const interceptors = createInterceptors({ + isTauri: true, + originalFetch: dom.window.fetch.bind(dom.window), + canHandleRequest: () => true, + toUrl: (input, base) => new URL(String(input), base), + routeRequest: async () => { + routedCalls += 1; + return new Response('routed'); + }, + jsonResponse: (body, status) => new Response(JSON.stringify(body), { status }), + safeJson: async (response) => response.json(), + }); + + interceptors.patchFetch(dom.window); + + const controller = new AbortController(); + controller.abort(); + + await assert.rejects( + dom.window.fetch('/api/backends/chat-completions/generate', { signal: controller.signal }), + (error) => { + assert.equal(error?.name, 'AbortError'); + return true; + }, + ); + + assert.equal(delegatedCalls, 0); + assert.equal(routedCalls, 0); + dom.cleanup(); +}); + +test('fetch interceptor rejects when AbortSignal is aborted while routed request is pending', async () => { + const { createInterceptors } = await importFresh( + path.join(REPO_ROOT, 'src/tauri/main/interceptors.js'), + ); + + const dom = installFakeDom(); + dom.window.location = { + href: 'https://example.com/', + origin: 'https://example.com', + }; + + let delegatedCalls = 0; + dom.window.fetch = async () => { + delegatedCalls += 1; + return new Response('delegated'); + }; + + let resolveRoute = null; + const routePromise = new Promise((resolve) => { + resolveRoute = resolve; + }); + + let routedCalls = 0; + const interceptors = createInterceptors({ + isTauri: true, + originalFetch: dom.window.fetch.bind(dom.window), + canHandleRequest: () => true, + toUrl: (input, base) => new URL(String(input), base), + routeRequest: async () => { + routedCalls += 1; + return routePromise; + }, + jsonResponse: (body, status) => new Response(JSON.stringify(body), { status }), + safeJson: async (response) => response.json(), + }); + + interceptors.patchFetch(dom.window); + + const controller = new AbortController(); + const fetchPromise = dom.window.fetch('/api/backends/chat-completions/generate', { signal: controller.signal }); + controller.abort(); + + await assert.rejects( + fetchPromise, + (error) => { + assert.equal(error?.name, 'AbortError'); + return true; + }, + ); + + resolveRoute?.(new Response('routed')); + + assert.equal(delegatedCalls, 0); + assert.equal(routedCalls, 1); + dom.cleanup(); +});