fix: reduce codex recent project timeouts

This commit is contained in:
777genius 2026-04-14 17:39:39 +03:00
parent 9fe3343038
commit d8fce1b3a3
3 changed files with 197 additions and 76 deletions

View file

@ -6,6 +6,7 @@ import type { RecentProjectsSourcePort } from '@features/recent-projects/core/ap
import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
import type {
CodexAppServerClient,
CodexRecentThreadsResult,
CodexThreadSummary,
} from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
@ -14,9 +15,7 @@ import type { ServiceContext } from '@main/services';
const CODEX_THREAD_LIMIT = 40;
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
const CODEX_REQUEST_TIMEOUT_MS = 4_500;
const CODEX_SOURCE_TIMEOUT_MS = 5_200;
const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150;
function isInteractiveSource(source: unknown): boolean {
return source === 'vscode' || source === 'cli';
@ -58,18 +57,11 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
return [];
}
const liveThreads = await this.#listThreadsSegmentSafe(binaryPath, 'live', {
archived: false,
totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
});
const archivedPromise = this.#listThreadsSegmentSafe(binaryPath, 'archived', {
archived: true,
totalTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS,
});
const archivedThreads =
liveThreads.length > 0
? await this.#awaitWithTimeout(archivedPromise, FAST_ARCHIVED_MERGE_TIMEOUT_MS)
: await archivedPromise;
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
this.#logSegmentFailure(threadSegments, 'live');
this.#logSegmentFailure(threadSegments, 'archived');
const liveThreads = threadSegments.live.threads;
const archivedThreads = threadSegments.archived.threads;
const interactiveThreads = [...liveThreads, ...archivedThreads].filter(
(thread) => Boolean(thread.cwd) && isInteractiveSource(thread.source)
@ -86,67 +78,45 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
return candidates;
}
async #listThreadsSegment(
binaryPath: string,
segment: 'live' | 'archived',
options: {
archived: boolean;
totalTimeoutMs: number;
}
): Promise<CodexThreadSummary[]> {
const result = await this.deps.appServerClient.listThreads(binaryPath, {
archived: options.archived,
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
limit: CODEX_THREAD_LIMIT,
requestTimeoutMs: CODEX_REQUEST_TIMEOUT_MS,
totalTimeoutMs: options.totalTimeoutMs,
liveRequestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
archivedRequestTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS,
totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
});
this.deps.logger.info('codex recent-projects thread list loaded', {
segment,
count: result.length,
this.deps.logger.info('codex recent-projects thread lists loaded', {
liveCount: result.live.threads.length,
archivedCount: result.archived.threads.length,
});
return result;
}
async #awaitWithTimeout(
promise: Promise<CodexThreadSummary[]>,
timeoutMs: number
): Promise<CodexThreadSummary[]> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<CodexThreadSummary[]>((resolve) => {
timer = setTimeout(() => resolve([]), timeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
#logSegmentFailure(result: CodexRecentThreadsResult, segment: 'live' | 'archived'): void {
const error = result[segment].error;
if (!error) {
return;
}
}
#unwrapThreadListError(error: unknown, segment: 'live' | 'archived'): CodexThreadSummary[] {
this.deps.logger.warn('codex recent-projects thread list failed', {
segment,
error: error instanceof Error ? error.message : String(error),
error,
});
return [];
}
async #listThreadsSegmentSafe(
binaryPath: string,
segment: 'live' | 'archived',
options: {
archived: boolean;
totalTimeoutMs: number;
}
): Promise<CodexThreadSummary[]> {
async #listRecentThreadsSafe(binaryPath: string): Promise<CodexRecentThreadsResult> {
try {
return await this.#listThreadsSegment(binaryPath, segment, options);
return await this.#listRecentThreads(binaryPath);
} catch (error) {
return this.#unwrapThreadListError(error, segment);
const message = error instanceof Error ? error.message : String(error);
this.deps.logger.warn('codex recent-projects thread list session failed', {
error: message,
});
return {
live: { threads: [], error: message },
archived: { threads: [], error: message },
};
}
}

View file

@ -38,26 +38,38 @@ export interface CodexThreadSummary {
path?: string | null;
}
export interface CodexThreadSegmentResult {
threads: CodexThreadSummary[];
error?: string;
}
export interface CodexRecentThreadsResult {
live: CodexThreadSegmentResult;
archived: CodexThreadSegmentResult;
}
export class CodexAppServerClient {
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
async listThreads(
async listRecentThreads(
binaryPath: string,
options: {
archived: boolean;
limit: number;
requestTimeoutMs?: number;
liveRequestTimeoutMs?: number;
archivedRequestTimeoutMs?: number;
totalTimeoutMs?: number;
}
): Promise<CodexThreadSummary[]> {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
): Promise<CodexRecentThreadsResult> {
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
return this.rpcClient.withSession(
{
binaryPath,
args: ['app-server'],
requestTimeoutMs,
requestTimeoutMs: sessionRequestTimeoutMs,
totalTimeoutMs,
label: 'codex app-server thread/list',
},
@ -75,22 +87,51 @@ export class CodexAppServerClient {
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
},
},
requestTimeoutMs
sessionRequestTimeoutMs
);
await session.notify('initialized');
const response = await session.request<ThreadListResponse>(
'thread/list',
{
archived: options.archived,
limit: options.limit,
sortKey: 'updated_at',
},
requestTimeoutMs
);
const [live, archived] = await Promise.allSettled([
session.request<ThreadListResponse>(
'thread/list',
{
archived: false,
limit: options.limit,
sortKey: 'updated_at',
},
liveRequestTimeoutMs
),
session.request<ThreadListResponse>(
'thread/list',
{
archived: true,
limit: options.limit,
sortKey: 'updated_at',
},
archivedRequestTimeoutMs
),
]);
return response.data ?? [];
return {
live:
live.status === 'fulfilled'
? { threads: live.value.data ?? [] }
: {
threads: [],
error: live.reason instanceof Error ? live.reason.message : String(live.reason),
},
archived:
archived.status === 'fulfilled'
? { threads: archived.value.data ?? [] }
: {
threads: [],
error:
archived.reason instanceof Error
? archived.reason.message
: String(archived.reason),
},
};
}
);
}

View file

@ -0,0 +1,110 @@
import { describe, expect, it, vi } from 'vitest';
import { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
import type { JsonRpcSession, JsonRpcStdioClient } from '@features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient';
function createSession(
request: JsonRpcSession['request'],
notify: JsonRpcSession['notify'] = vi.fn().mockResolvedValue(undefined)
): JsonRpcSession {
return {
request,
notify,
};
}
describe('CodexAppServerClient', () => {
it('loads live and archived threads in a single app-server session', async () => {
const session = createSession(
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
if (method === 'initialize') {
return Promise.resolve({});
}
if (method === 'thread/list' && params?.archived === false) {
return Promise.resolve({
data: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
});
}
if (method === 'thread/list' && params?.archived === true) {
return Promise.resolve({
data: [{ id: 'archived-1', cwd: '/Users/test/archive-project', source: 'vscode' }],
});
}
return Promise.reject(new Error(`Unexpected method: ${method}`));
})
);
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
const result = await client.listRecentThreads('/usr/local/bin/codex', {
limit: 40,
liveRequestTimeoutMs: 4500,
archivedRequestTimeoutMs: 2500,
totalTimeoutMs: 4500,
});
expect(withSession).toHaveBeenCalledTimes(1);
expect(withSession).toHaveBeenCalledWith(
expect.objectContaining({
binaryPath: '/usr/local/bin/codex',
requestTimeoutMs: 4500,
totalTimeoutMs: 4500,
}),
expect.any(Function)
);
expect(session.notify).toHaveBeenCalledWith('initialized');
expect(result).toEqual({
live: {
threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
},
archived: {
threads: [{ id: 'archived-1', cwd: '/Users/test/archive-project', source: 'vscode' }],
},
});
});
it('keeps live results when archived thread loading fails', async () => {
const session = createSession(
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
if (method === 'initialize') {
return Promise.resolve({});
}
if (method === 'thread/list' && params?.archived === false) {
return Promise.resolve({
data: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
});
}
if (method === 'thread/list' && params?.archived === true) {
return Promise.reject(new Error('JSON-RPC request timed out: thread/list'));
}
return Promise.reject(new Error(`Unexpected method: ${method}`));
})
);
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
const result = await client.listRecentThreads('/usr/local/bin/codex', {
limit: 40,
liveRequestTimeoutMs: 4500,
archivedRequestTimeoutMs: 2500,
totalTimeoutMs: 4500,
});
expect(result.live.threads).toEqual([
{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' },
]);
expect(result.archived).toEqual({
threads: [],
error: 'JSON-RPC request timed out: thread/list',
});
});
});