fix (frontend): Fix fetch AbortSignal semantics to prevent blank prompt-viewer message floor

This commit is contained in:
Darkatse 2026-04-21 21:57:56 -04:00
parent 8e95a5812c
commit 845a5be013
6 changed files with 201 additions and 32 deletions

View file

@ -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);

View file

@ -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 {

View file

@ -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';
}

View file

@ -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')) {

View file

@ -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 }
}
});
}

View file

@ -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();
});