Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/desktop/src/main/connection-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ vi.mock('./electron-runtime', () => ({

import { createHash } from 'node:crypto';
import {
CONNECTION_FETCH_TIMEOUT_MS,
_clearModelsCache,
classifyHttpError,
extractIds,
extractModelIds,
fetchWithTimeout,
getCacheKey,
normalizeBaseUrl,
} from './connection-ipc';
Expand Down Expand Up @@ -542,3 +544,42 @@ describe('normalizeBaseUrl', () => {
);
});
});

// ---------------------------------------------------------------------------
// fetchWithTimeout — aborts when the host hangs past the deadline
// ---------------------------------------------------------------------------

describe('fetchWithTimeout', () => {
it('exports a finite default timeout', () => {
expect(Number.isFinite(CONNECTION_FETCH_TIMEOUT_MS)).toBe(true);
expect(CONNECTION_FETCH_TIMEOUT_MS).toBeGreaterThan(0);
});

it('aborts the underlying fetch when the timer fires', async () => {
vi.useRealTimers();
const seenSignals: AbortSignal[] = [];
const fakeFetch = vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
const signal = init.signal as AbortSignal;
seenSignals.push(signal);
signal.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}),
);
const originalFetch = globalThis.fetch;
(globalThis as { fetch: typeof fetch }).fetch = fakeFetch as unknown as typeof fetch;

try {
await expect(fetchWithTimeout('https://example.test', {}, 5)).rejects.toMatchObject({
name: 'AbortError',
});
expect(seenSignals[0]?.aborted).toBe(true);
} finally {
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
}
});
});
34 changes: 31 additions & 3 deletions apps/desktop/src/main/connection-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ export function classifyHttpError(status: number): {

function classifyNetworkError(err: unknown): { code: ConnectionTestError['code']; hint: string } {
const message = err instanceof Error ? err.message : String(err);
if (err instanceof Error && err.name === 'AbortError') {
return {
code: 'NETWORK',
hint: `请求超时(>${CONNECTION_FETCH_TIMEOUT_MS / 1000}s),检查 baseUrl 与网络可达性`,
};
}
if (message.includes('ECONNREFUSED') || message.includes('ENOTFOUND')) {
return {
code: 'ECONNREFUSED',
Expand All @@ -186,6 +192,25 @@ function classifyNetworkError(err: unknown): { code: ConnectionTestError['code']
};
}

// Provider /models endpoints normally return in <1s. Anything past 10s means the
// host is unreachable or stuck — better to surface a clear NETWORK error than to
// pin the renderer's "Test connection" spinner forever.
export const CONNECTION_FETCH_TIMEOUT_MS = 10_000;

export async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number = CONNECTION_FETCH_TIMEOUT_MS,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}

export function extractIds(items: unknown[]): string[] | null {
const ids: string[] = [];
for (const item of items) {
Expand Down Expand Up @@ -327,7 +352,7 @@ export function registerConnectionIpc(): void {

let res: Response;
try {
res = await fetch(ep.url, {
res = await fetchWithTimeout(ep.url, {
method: 'GET',
headers: { ...ep.headers, ...authHeaders },
});
Expand Down Expand Up @@ -378,7 +403,7 @@ export function registerConnectionIpc(): void {

let res: Response;
try {
res = await fetch(ep.url, {
res = await fetchWithTimeout(ep.url, {
method: 'GET',
headers: { ...ep.headers, ...authHeaders },
});
Expand Down Expand Up @@ -435,7 +460,10 @@ export function registerConnectionIpc(): void {

let res: Response;
try {
res = await fetch(ep.url, { method: 'GET', headers: { ...ep.headers, ...authHeaders } });
res = await fetchWithTimeout(ep.url, {
method: 'GET',
headers: { ...ep.headers, ...authHeaders },
});
} catch (err) {
const { code, hint } = classifyNetworkError(err);
return {
Expand Down
Loading