From 8c86def84dea244497e58b277899c954bb2b6446 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 26 May 2026 13:46:33 +0300 Subject: [PATCH] fix(recent-projects): avoid stale scan diagnostics regressions --- .../input/http/registerRecentProjectsHttp.ts | 30 +++-------- .../input/ipc/registerRecentProjectsIpc.ts | 30 +++-------- .../input/recentProjectsDiagnostics.ts | 51 +++++++++++++++++++ ...xSessionFileRecentProjectsSourceAdapter.ts | 36 +++++++------ .../input/recentProjectsDiagnostics.test.ts | 49 ++++++++++++++++++ ...ionFileRecentProjectsSourceAdapter.test.ts | 40 +++++++++++++++ 6 files changed, 175 insertions(+), 61 deletions(-) create mode 100644 src/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.ts create mode 100644 test/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.test.ts diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts index afac9c48..ec27002a 100644 --- a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -5,32 +5,16 @@ import { } from '@features/recent-projects/contracts'; import { createLogger } from '@shared/utils/logger'; +import { + estimateDashboardRecentProjectsPayloadBytes, + getRecentProjectsMemoryDiagnostics, +} from '../recentProjectsDiagnostics'; + import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature'; import type { FastifyInstance } from 'fastify'; const logger = createLogger('Feature:RecentProjects:HTTP'); -function getPayloadBytes(value: unknown): number { - try { - return Buffer.byteLength(JSON.stringify(value), 'utf8'); - } catch { - return -1; - } -} - -function getMemoryDiagnostics(): { - rssBytes: number; - heapUsedBytes: number; - heapTotalBytes: number; -} { - const memory = process.memoryUsage(); - return { - rssBytes: memory.rss, - heapUsedBytes: memory.heapUsed, - heapTotalBytes: memory.heapTotal, - }; -} - export function registerRecentProjectsHttp( app: FastifyInstance, feature: RecentProjectsFeatureFacade @@ -48,8 +32,8 @@ export function registerRecentProjectsHttp( count: payload.projects.length, degraded: payload.degraded, durationMs: Date.now() - startedAt, - payloadBytes: getPayloadBytes(payload), - ...getMemoryDiagnostics(), + estimatedPayloadBytes: estimateDashboardRecentProjectsPayloadBytes(payload), + ...getRecentProjectsMemoryDiagnostics(), }); return payload; } catch (error) { diff --git a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts index 00639cc5..cdfb1af7 100644 --- a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts +++ b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts @@ -4,32 +4,16 @@ import { } from '@features/recent-projects/contracts'; import { createLogger } from '@shared/utils/logger'; +import { + estimateDashboardRecentProjectsPayloadBytes, + getRecentProjectsMemoryDiagnostics, +} from '../recentProjectsDiagnostics'; + import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature'; import type { IpcMain } from 'electron'; const logger = createLogger('Feature:RecentProjects:IPC'); -function getPayloadBytes(value: unknown): number { - try { - return Buffer.byteLength(JSON.stringify(value), 'utf8'); - } catch { - return -1; - } -} - -function getMemoryDiagnostics(): { - rssBytes: number; - heapUsedBytes: number; - heapTotalBytes: number; -} { - const memory = process.memoryUsage(); - return { - rssBytes: memory.rss, - heapUsedBytes: memory.heapUsed, - heapTotalBytes: memory.heapTotal, - }; -} - export function registerRecentProjectsIpc( ipcMain: IpcMain, feature: RecentProjectsFeatureFacade @@ -47,8 +31,8 @@ export function registerRecentProjectsIpc( count: payload.projects.length, degraded: payload.degraded, durationMs: Date.now() - startedAt, - payloadBytes: getPayloadBytes(payload), - ...getMemoryDiagnostics(), + estimatedPayloadBytes: estimateDashboardRecentProjectsPayloadBytes(payload), + ...getRecentProjectsMemoryDiagnostics(), }); return payload; } catch (error) { diff --git a/src/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.ts b/src/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.ts new file mode 100644 index 00000000..f527af36 --- /dev/null +++ b/src/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.ts @@ -0,0 +1,51 @@ +import type { + DashboardRecentProject, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; + +function stringBytes(value: string | undefined): number { + return value ? Buffer.byteLength(value, 'utf8') : 0; +} + +function estimateOpenTargetBytes(openTarget: DashboardRecentProject['openTarget']): number { + if (openTarget.type === 'existing-worktree') { + return stringBytes(openTarget.repositoryId) + stringBytes(openTarget.worktreeId) + 48; + } + + return stringBytes(openTarget.path) + 32; +} + +export function estimateDashboardRecentProjectsPayloadBytes( + payload: DashboardRecentProjectsPayload +): number { + let bytes = 32; + for (const project of payload.projects) { + bytes += + 160 + + stringBytes(project.id) + + stringBytes(project.name) + + stringBytes(project.primaryPath) + + stringBytes(project.primaryBranch) + + estimateOpenTargetBytes(project.openTarget); + for (const associatedPath of project.associatedPaths) { + bytes += stringBytes(associatedPath) + 8; + } + for (const providerId of project.providerIds) { + bytes += stringBytes(providerId) + 8; + } + } + return bytes; +} + +export function getRecentProjectsMemoryDiagnostics(): { + rssBytes: number; + heapUsedBytes: number; + heapTotalBytes: number; +} { + const memory = process.memoryUsage(); + return { + rssBytes: memory.rss, + heapUsedBytes: memory.heapUsed, + heapTotalBytes: memory.heapTotal, + }; +} diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts index cb7c54bc..e2a48a87 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts @@ -102,6 +102,11 @@ interface CodexSessionFileListingResult { timedOut: boolean; } +interface InFlightListRequest { + contextKey: string; + promise: Promise; +} + function emptyCache(): CodexSessionFileCacheFile { return { schemaVersion: CODEX_SESSION_FILE_CACHE_SCHEMA_VERSION, @@ -427,7 +432,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec readonly timeoutMs = CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS; readonly #codexHome: string; readonly #cachePath: string; - #inFlightList: Promise | null = null; + #inFlightList: InFlightListRequest | null = null; constructor( private readonly deps: { @@ -447,20 +452,6 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec } async list(): Promise { - if (this.#inFlightList) { - return this.#inFlightList; - } - - const request = this.#listUncached().finally(() => { - if (this.#inFlightList === request) { - this.#inFlightList = null; - } - }); - this.#inFlightList = request; - return request; - } - - async #listUncached(): Promise { const activeContext = this.deps.getActiveContext(); const localContext = this.deps.getLocalContext(); @@ -471,6 +462,21 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec }; } + const contextKey = `${activeContext.type}:${activeContext.id}`; + if (this.#inFlightList?.contextKey === contextKey) { + return this.#inFlightList.promise; + } + + const request = this.#listLocal(activeContext).finally(() => { + if (this.#inFlightList?.promise === request) { + this.#inFlightList = null; + } + }); + this.#inFlightList = { contextKey, promise: request }; + return request; + } + + async #listLocal(activeContext: ServiceContext): Promise { try { const snapshotResult = await this.#listRecentSessionSnapshots(); const candidates = await Promise.all( diff --git a/test/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.test.ts b/test/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.test.ts new file mode 100644 index 00000000..4bc31f28 --- /dev/null +++ b/test/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.test.ts @@ -0,0 +1,49 @@ +import { + estimateDashboardRecentProjectsPayloadBytes, + getRecentProjectsMemoryDiagnostics, +} from '@features/recent-projects/main/adapters/input/recentProjectsDiagnostics'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; + +describe('recentProjectsDiagnostics', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('estimates payload size without stringifying the whole payload', () => { + const stringifySpy = vi.spyOn(JSON, 'stringify'); + const payload: DashboardRecentProjectsPayload = { + degraded: false, + projects: [ + { + id: 'repo:alpha', + name: 'alpha', + primaryPath: '/Users/test/projects/alpha', + associatedPaths: ['/Users/test/projects/alpha', '/Users/test/worktrees/alpha-feature'], + mostRecentActivity: 1_777_000_000_000, + providerIds: ['codex', 'anthropic'], + source: 'mixed', + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'worktree-alpha-main', + }, + primaryBranch: 'main', + filesystemState: 'available', + }, + ], + }; + + expect(estimateDashboardRecentProjectsPayloadBytes(payload)).toBeGreaterThan(0); + expect(stringifySpy).not.toHaveBeenCalled(); + }); + + it('returns bounded numeric memory diagnostics', () => { + const diagnostics = getRecentProjectsMemoryDiagnostics(); + + expect(diagnostics.rssBytes).toEqual(expect.any(Number)); + expect(diagnostics.heapUsedBytes).toEqual(expect.any(Number)); + expect(diagnostics.heapTotalBytes).toEqual(expect.any(Number)); + }); +}); diff --git a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts index 5c31443e..493e49fa 100644 --- a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts +++ b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts @@ -389,6 +389,46 @@ describe('CodexSessionFileRecentProjectsSourceAdapter', () => { expect(identityResolver.resolve).toHaveBeenCalledTimes(1); }); + it('does not reuse an in-flight local Codex session-file read for another active context', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const resolveResult = deferred(); + const identityResolver = { + resolve: vi.fn().mockReturnValue(resolveResult.promise), + } as unknown as RecentProjectIdentityResolver; + let activeContext: unknown = { type: 'local', id: 'local-1' }; + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-alpha.jsonl'), + { + cwd: '/Users/test/projects/alpha', + branch: 'main', + }, + new Date('2026-04-14T12:00:00.000Z') + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => activeContext as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + appDataPath: path.join(tempDir, 'app-data'), + }); + + const first = adapter.list(); + await vi.waitFor(() => expect(identityResolver.resolve).toHaveBeenCalledTimes(1)); + + activeContext = { type: 'ssh', id: 'ssh-1' }; + await expect(adapter.list()).resolves.toEqual({ + candidates: [], + degraded: false, + }); + + resolveResult.resolve(null); + await expect(first).resolves.toEqual(expect.objectContaining({ degraded: false })); + expect(identityResolver.resolve).toHaveBeenCalledTimes(1); + }); + it('invalidates cached session metadata when the jsonl fingerprint changes', async () => { const codexHome = path.join(tempDir, '.codex'); const appDataPath = path.join(tempDir, 'app-data');