fix(recent-projects): avoid stale scan diagnostics regressions
This commit is contained in:
parent
c2bc20bebd
commit
8c86def84d
6 changed files with 175 additions and 61 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue