mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-20 19:46:50 +00:00
fix (frontend): Fix fetch AbortSignal semantics to prevent blank prompt-viewer message floor
This commit is contained in:
parent
8e95a5812c
commit
845a5be013
6 changed files with 201 additions and 32 deletions
5
src/tauri/main/bootstrap.js
vendored
5
src/tauri/main/bootstrap.js
vendored
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
19
src/tauri/main/kernel/abort-error.js
Normal file
19
src/tauri/main/kernel/abort-error.js
Normal 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';
|
||||
}
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
119
tests/fetch-abort-contract.test.mjs
Normal file
119
tests/fetch-abort-contract.test.mjs
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue