From 56734e2e530d2d39e9b2d9bc7de38c3470196a9d Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Wed, 14 Oct 2026 17:50:47 -0700 Subject: [PATCH] fix: share one project-list build across callers; log slow builds The deck abandons a project list after 150s and asks again on its next refresh, but the walk in the main process keeps going, so each retry stacked another full walk (58 repos of git, ~15.7k Codex rollout heads) on the first. Right after a boot SIHYEONG-4080 logged five 150s timeouts in a row (2026-10-12). Late callers now join the build under way, and a build slower than 20s is logged with its time summed per stage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AwhRmzUt3nLnhqU1NJyzpW --- src/main/api/deckApi.ts | 39 +++++++++++++++++++++++++++++------- src/main/ipc.cockpit.test.ts | 17 ++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts index c55b02b..31ed16b 100644 --- a/src/main/api/deckApi.ts +++ b/src/main/api/deckApi.ts @@ -63,6 +63,8 @@ export interface DeckApiBundle { } const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects'); +/** A project list slower than this is logged with where its time went (see projects:list). */ +const SLOW_PROJECT_LIST_MS = 20_000; const CLAUDE_TASKS = join(homedir(), '.claude', 'tasks'); const CODEX_SESSIONS = join(homedir(), '.codex', 'sessions'); const ANTIGRAVITY_DIR = join(homedir(), '.gemini', 'antigravity'); @@ -203,17 +205,40 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { lastUserMessage: (p, session) => getProvider(session.agentId).lastUserMessage(p, session.id), entry: (p) => cfg.store.get(p), }); - invoke('projects:list', allow('observe'), async () => { + // ONE build at a time, shared by every caller. The deck gives up on an answer after 150s and asks + // again on its next refresh, but giving up does not stop the walk here — so on a machine that had + // just booted (cold disk, every repository's git and the whole Codex store still to read) each retry + // started a second and third walk on top of the first, and none finished: SIHYEONG-4080 logged five + // 150s timeouts in a row on 2026-10-12. A late caller now joins the walk already under way. + let projectListInFlight: ReturnType | null = null; + invoke('projects:list', allow('observe'), () => { + if (projectListInFlight) return projectListInFlight; const scan = makeDeckScan(); - return buildProjectList({ - nowMs: Date.now(), + // Where a slow build spent its time, summed per stage across the worker pool — the only way to + // tell git from the transcript stores after the fact on a machine nobody was watching. + const spent = { scan: 0, git: 0, sessions: 0, cue: 0 }; + const timed = async (stage: keyof typeof spent, work: () => Promise | T): Promise => { + const t = Date.now(); + try { return await work(); } finally { spent[stage] += Date.now() - t; } + }; + const started = Date.now(); + let count = 0; + projectListInFlight = buildProjectList({ + nowMs: started, thresholds: effThresholds(), - scan: memoScan, - git: (dir) => getGitInfo(dir), - sessions: (p) => scan.sessions(p), - resumeCue: (p, session) => getProvider(session.agentId).lastUserMessage(p, session.id), + scan: () => timed('scan', memoScan), + git: (dir) => timed('git', () => getGitInfo(dir)), + sessions: (p) => timed('sessions', () => scan.sessions(p)), + resumeCue: (p, session) => timed('cue', () => getProvider(session.agentId).lastUserMessage(p, session.id)), getEntry: (p) => cfg.store.get(p), + }).then((list) => { count = list.length; return list; }).finally(() => { + projectListInFlight = null; + const ms = Date.now() - started; + if (ms >= SLOW_PROJECT_LIST_MS) { + cfg.diagnostics?.write('warn', 'deck', `projects:list took ${ms}ms for ${count} projects (summed: scan=${spent.scan} git=${spent.git} sessions=${spent.sessions} cue=${spent.cue}ms)`); + } }); + return projectListInFlight; }); invoke('project:memory', allow('observe'), (projectPath: string, fresh?: boolean) => { const projectPathString = String(projectPath); diff --git a/src/main/ipc.cockpit.test.ts b/src/main/ipc.cockpit.test.ts index 140d07b..3e8019d 100644 --- a/src/main/ipc.cockpit.test.ts +++ b/src/main/ipc.cockpit.test.ts @@ -70,6 +70,7 @@ beforeAll(() => { store: { getFolders: () => [{ path: ALLOWED_ROOT, kind: 'root' }], getAgent: () => storedAgent, getSessionSummary: () => true, getAiSessionSummary: () => false, + getThresholds: () => null, }, sendError: vi.fn(), defaultLanguage: 'en', @@ -374,3 +375,19 @@ describe('cockpit:conversationsLastUsed', () => { expect(await lastUsed(null, null)).toEqual([]); }); }); + +// The deck gives up on a slow list after 150s and asks again, but giving up does not stop the walk in +// the main process. Each retry used to start ANOTHER full walk on top of the first — on a machine that +// had just booted, none of them ever finished. +describe('projects:list', () => { + it('shares the build already under way instead of starting another', async () => { + const list = handlers.get('projects:list')!; + const first = list(null); + const second = list(null); + expect(second).toBe(first); + await first; + const third = list(null); + expect(third).not.toBe(first); // once it has landed, the next ask builds fresh + await third; + }); +});