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 e2a48a87..691fcd59 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts @@ -30,6 +30,7 @@ const CODEX_SESSION_FILE_CACHE_RELATIVE_PATH = path.join( 'recent-projects', 'codex-session-files-index.json' ); +const CODEX_SESSION_FILE_CACHE_MAX_BYTES = 4 * 1024 * 1024; interface CodexSessionFileEntry { filePath: string; @@ -663,6 +664,16 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec async #readCacheSafe(): Promise { try { + const stats = await fs.stat(this.#cachePath); + if (stats.size > CODEX_SESSION_FILE_CACHE_MAX_BYTES) { + this.deps.logger.warn('codex session-file recent-projects cache skipped - too large', { + cachePath: this.#cachePath, + bytes: stats.size, + maxBytes: CODEX_SESSION_FILE_CACHE_MAX_BYTES, + }); + return emptyCache(); + } + const raw = await fs.readFile(this.#cachePath, 'utf8'); const parsed = JSON.parse(raw) as Partial; if ( 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 493e49fa..ff4eacf7 100644 --- a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts +++ b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts @@ -596,6 +596,61 @@ describe('CodexSessionFileRecentProjectsSourceAdapter', () => { }); }); + it('skips an oversized legacy session-file cache before reading it', async () => { + const codexHome = path.join(tempDir, '.codex'); + const appDataPath = path.join(tempDir, 'app-data'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn().mockResolvedValue(null), + } as unknown as RecentProjectIdentityResolver; + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-alpha.jsonl'), + { + cwd: '/Users/test/projects/alpha', + }, + new Date('2026-04-14T12:00:00.000Z') + ); + const cachePath = getSessionFileCachePath(appDataPath); + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, 'x', 'utf8'); + await fs.truncate(cachePath, 4 * 1024 * 1024 + 1); + const readFileSpy = vi.spyOn(fs, 'readFile'); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + appDataPath, + }); + + const result = await adapter.list(); + + expect(readFileSpy).not.toHaveBeenCalledWith(cachePath, 'utf8'); + expect(result).toEqual({ + candidates: [ + expect.objectContaining({ + primaryPath: '/Users/test/projects/alpha', + }), + ], + degraded: false, + }); + expect(logger.warn).toHaveBeenCalledWith( + 'codex session-file recent-projects cache skipped - too large', + expect.objectContaining({ + bytes: 4 * 1024 * 1024 + 1, + maxBytes: 4 * 1024 * 1024, + }) + ); + await expect(fs.stat(cachePath)).resolves.toEqual( + expect.objectContaining({ + size: expect.any(Number), + }) + ); + expect((await fs.stat(cachePath)).size).toBeLessThan(4 * 1024 * 1024); + }); + it('returns a degraded partial result under the uncached read cap and completes on the next cached pass', async () => { const codexHome = path.join(tempDir, '.codex'); const appDataPath = path.join(tempDir, 'app-data');