fix(recent-projects): avoid stale scan diagnostics regressions

This commit is contained in:
777genius 2026-05-26 13:46:33 +03:00
parent c2bc20bebd
commit 8c86def84d
6 changed files with 175 additions and 61 deletions

View file

@ -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) {

View file

@ -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) {

View file

@ -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,
};
}

View file

@ -102,6 +102,11 @@ interface CodexSessionFileListingResult {
timedOut: boolean;
}
interface InFlightListRequest {
contextKey: string;
promise: Promise<RecentProjectsSourceResult>;
}
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<RecentProjectsSourceResult> | null = null;
#inFlightList: InFlightListRequest | null = null;
constructor(
private readonly deps: {
@ -447,20 +452,6 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
}
async list(): Promise<RecentProjectsSourceResult> {
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<RecentProjectsSourceResult> {
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<RecentProjectsSourceResult> {
try {
const snapshotResult = await this.#listRecentSessionSnapshots();
const candidates = await Promise.all(

View file

@ -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));
});
});

View file

@ -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<null>();
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');