diff --git a/desktop/src/main/artifacts/artifact-store.ts b/desktop/src/main/artifacts/artifact-store.ts index 0f45c7f4..db9bf58b 100644 --- a/desktop/src/main/artifacts/artifact-store.ts +++ b/desktop/src/main/artifacts/artifact-store.ts @@ -31,6 +31,135 @@ export async function readSidecar(projectRoot: string): Promise { } } +// --------------------------------------------------------------------------- +// Shared parsed copy — the 2026-08-27 OOM fix (read side of PR #318) +// --------------------------------------------------------------------------- +// +// PR #318 queued the sidecar WRITER and left every READER unguarded. One Edit +// costs ~11 full parses of artifacts.json (LIST_SESSION, artifacts:get per +// visible tool card, check-existence, the watcher's id map, every open tab at +// startup…), and for youcoded-dev that file is 6.4 MB / 21,311 versions. Under a +// burst those parses pile up: the 2026-08-27 core dump held 477 parsed copies +// (~3.0 GB) against V8's 2.8 GB ceiling. Evidence: +// docs/active/investigations/2026-08-27-artifacts-sidecar-oom-crash.md. +// +// `readSidecarShared` bounds that to ONE parsed copy per project: +// - validated by the file's size + mtime (a stat, not a read) so an external +// write — another dev instance on the same folder — is always picked up; +// - concurrent callers share one in-flight parse; +// - a committed `writeSidecar` SEEDS the cache with the object it just wrote, +// so the reads that follow every edit cost zero parses; +// - an idle copy is dropped after SIDECAR_CACHE_IDLE_MS. +// +// The shared object is READ-ONLY by contract. Callers that mutate and write +// back (appendVersionsDirect, removeArtifactRecord, renameArtifact, +// runSidecarMigration, the manual include/exclude handlers, import-project) +// keep calling `readSidecar` for a private copy — a writer mutating the shared +// object would leak a never-committed state into every reader. +// +// Blind spot, accepted: a write by ANOTHER process that leaves size and mtime +// identical (same-tick, same-length) is not detected. Appends always change +// the size; the app's own writes seed the cache and never hit this. + +export const SIDECAR_CACHE_IDLE_MS = 60_000; + +interface SharedSidecar { + mtimeMs: number; + size: number; + value: ReadResult; + idleTimer: NodeJS.Timeout; +} +const sharedSidecars = new Map(); +const sharedInFlight = new Map>(); + +function sidecarPath(projectRoot: string): string { + return join(projectRoot, SIDECAR_RELATIVE); +} + +function retain(path: string, stat: { mtimeMs: number; size: number }, value: ReadResult): void { + const prev = sharedSidecars.get(path); + if (prev) clearTimeout(prev.idleTimer); + const idleTimer = setTimeout(() => { + // Only drop what this timer was armed for — a fresher entry re-armed its own. + if (sharedSidecars.get(path)?.idleTimer === idleTimer) sharedSidecars.delete(path); + }, SIDECAR_CACHE_IDLE_MS); + idleTimer.unref?.(); + sharedSidecars.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, value, idleTimer }); +} + +function touch(entry: SharedSidecar): void { + entry.idleTimer.refresh(); +} + +/** + * Read-only sidecar access that shares one parsed copy per project. See the + * block comment above for the contract; mutate-and-write callers use + * `readSidecar` instead. + */ +export async function readSidecarShared(projectRoot: string): Promise { + const path = sidecarPath(projectRoot); + let stat: { mtimeMs: number; size: number }; + try { + stat = await fs.stat(path); + } catch (e: any) { + if (e.code === 'ENOENT') { + const prev = sharedSidecars.get(path); + if (prev) { clearTimeout(prev.idleTimer); sharedSidecars.delete(path); } + return null; + } + throw e; + } + const cached = sharedSidecars.get(path); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + touch(cached); + return cached.value; + } + const inFlight = sharedInFlight.get(path); + if (inFlight) return inFlight; + const p = (async () => { + // Retain under the PRE-read stat: if the file changes between this stat + // and the read, the next caller's stat differs and forces a re-parse. + // Caching under a post-read stat could pin stale content under a fresh key. + const value = await readSidecar(projectRoot); + retain(path, stat, value); + return value; + })(); + sharedInFlight.set(path, p); + try { + return await p; + } finally { + sharedInFlight.delete(path); + } +} + +async function seedSharedSidecar(projectRoot: string, value: ProjectSidecar): Promise { + const path = sidecarPath(projectRoot); + try { + const stat = await fs.stat(path); + retain(path, stat, value); + } catch { + // Not fatal — the next reader parses from disk. + } +} + +/** Tests only: drop every shared copy and timer. */ +export function _resetSidecarCacheForTests(): void { + for (const e of sharedSidecars.values()) clearTimeout(e.idleTimer); + sharedSidecars.clear(); + sharedInFlight.clear(); +} + +/** + * The CAS comparand from raw sidecar text WITHOUT parsing it. `updatedAt` is a + * top-level-only key (shared/artifacts/types.ts — records carry `lastModified`, + * versions carry `ts`), so the first match is the right one wherever it sits. + * casWrite offers this the file HEAD first; undefined means "not in the head", + * and casWrite then retries with the whole text. + */ +export function extractUpdatedAt(json: string): string | undefined { + return /"updatedAt"\s*:\s*"([^"]*)"/.exec(json)?.[1]; +} + /** * Advance `updatedAt` past `expected` when the two would collide. * @@ -85,8 +214,13 @@ export async function writeSidecar( path, expectedUpdatedAt, json, - expectedUpdatedAt === null ? undefined : (raw) => JSON.parse(raw).updatedAt + // 2026-08-27: was `(raw) => JSON.parse(raw).updatedAt` — a full parse of a + // 6.4 MB file to read one timestamp, on every write. See extractUpdatedAt. + expectedUpdatedAt === null ? undefined : extractUpdatedAt ); + // The object we just wrote IS the on-disk state — hand it to every reader + // that follows (LIST_SESSION fires after every tracked write) for free. + if (result.committed) await seedSharedSidecar(projectRoot, next); return { committed: result.committed }; } @@ -332,9 +466,12 @@ function sleep(ms: number) { } // Project roots already checked in THIS process. The migration is safe to call -// from hot handlers (LIST_SESSION fires after every tracked write), and the -// pure pass over a 2,800-record array is cheap — but it is not free, and there -// is no reason to redo it every call. +// from hot handlers (LIST_SESSION fires after every tracked write), but the +// pure pass is over EVERY record — 3,917 artifacts / 21,311 versions in +// youcoded-dev on 2026-08-27, and growing ~600 versions a day — so there is no +// reason to redo it every call. (An earlier revision of this comment sized it +// at "2,800 records"; the 7.6x drift is part of why the 2026-08-27 OOM +// investigation exists — never assume this file is small.) // // Process-lifetime, deliberately: the sidecar (.youcoded/artifacts.json) is // per-device and NEVER synced — DEFAULT_IGNORES (sync-spaces/guards.ts) excludes diff --git a/desktop/src/main/artifacts/cas-write.ts b/desktop/src/main/artifacts/cas-write.ts index ecca37c6..95ed0149 100644 --- a/desktop/src/main/artifacts/cas-write.ts +++ b/desktop/src/main/artifacts/cas-write.ts @@ -159,11 +159,45 @@ export async function mutateFileUnderLock( } } +// 2026-08-27 OOM fix: the comparand is ONE timestamp, but this check used to +// hand the extractor the WHOLE file — for a 6.4 MB artifacts.json that was a +// full read AND (with the store's old `JSON.parse(raw).updatedAt` extractor) a +// full parse, on every write, just to compare 24 bytes. Offer the extractor a +// bounded head first; only when it has no answer there (returns undefined, or +// throws — JSON.parse on a truncated head does) fall back to the whole file. +// Every extractor that worked before still works: the fallback is the old path. +const HEAD_PROBE_BYTES = 4096; + +async function readComparand( + target: string, + extract: (json: string) => string | undefined +): Promise { + const handle = await fs.open(target, 'r'); + let head: string; + let complete: boolean; + try { + const buf = Buffer.alloc(HEAD_PROBE_BYTES); + const { bytesRead } = await handle.read(buf, 0, HEAD_PROBE_BYTES, 0); + head = buf.subarray(0, bytesRead).toString('utf8'); + complete = bytesRead < HEAD_PROBE_BYTES; + } finally { + await handle.close(); + } + if (complete) return extract(head); // the head IS the file — nothing to fall back to + try { + const fromHead = extract(head); + if (fromHead !== undefined) return fromHead; + } catch { + // A whole-file extractor (JSON.parse) chokes on a truncated head — expected. + } + return extract(await fs.readFile(target, 'utf8')); +} + export async function casWrite( target: string, expectedUpdatedAt: string | null, content: string, - extractUpdatedAt?: (json: string) => string + extractUpdatedAt?: (json: string) => string | undefined ): Promise { const lock = target + '.lock'; await fs.mkdir(dirname(target), { recursive: true }); @@ -176,10 +210,9 @@ export async function casWrite( // CAS check inside the lock — safe from races now if (extractUpdatedAt) { try { - const onDisk = await fs.readFile(target, 'utf8'); - const actual = extractUpdatedAt(onDisk); + const actual = await readComparand(target, extractUpdatedAt); if (actual !== expectedUpdatedAt) { - return { committed: false, actualUpdatedAt: actual }; + return { committed: false, actualUpdatedAt: actual ?? null }; } } catch (e: any) { if (e.code !== 'ENOENT') throw e; diff --git a/desktop/src/main/artifacts/project-manager.ts b/desktop/src/main/artifacts/project-manager.ts index f0a83e99..4d66fe8a 100644 --- a/desktop/src/main/artifacts/project-manager.ts +++ b/desktop/src/main/artifacts/project-manager.ts @@ -3,7 +3,7 @@ import { existsSync } from 'fs'; import { join, basename } from 'path'; import { canonicalize } from '../../shared/artifacts/canonicalize'; import { newProjectId } from '../../shared/artifacts/ulid'; -import { readSidecar } from './artifact-store'; +import { readSidecarShared } from './artifact-store'; import { sweepStaleTmp } from './cas-write'; import { readIndex, upsertProject } from './central-index'; import { CentralIndexProject } from '../../shared/artifacts/types'; @@ -30,7 +30,7 @@ export async function ensureProject( } // Check sidecar for auto-recovery - const sidecar = await readSidecar(projectRoot); + const sidecar = await readSidecarShared(projectRoot); let projectId: string; let name: string; if (sidecar && 'projectId' in sidecar) { diff --git a/desktop/src/main/artifacts/project-watcher.ts b/desktop/src/main/artifacts/project-watcher.ts index 973089a1..6cc8de92 100644 --- a/desktop/src/main/artifacts/project-watcher.ts +++ b/desktop/src/main/artifacts/project-watcher.ts @@ -18,7 +18,7 @@ import chokidar, { FSWatcher } from 'chokidar'; import path from 'path'; import { canonicalize } from '../../shared/artifacts/canonicalize'; -import { readSidecar } from './artifact-store'; +import { readSidecarShared } from './artifact-store'; export type ExternalChangeKind = 'edit' | 'add' | 'remove'; @@ -123,7 +123,7 @@ async function resolveArtifactId(projectRoot: string, absPath: string): Promise< if (!cached || Date.now() - cached.at > SIDECAR_CACHE_TTL_MS) { const ids = new Map(); try { - const sidecar = await readSidecar(projectRoot); + const sidecar = await readSidecarShared(projectRoot); if (sidecar && !('corrupted' in sidecar)) { for (const a of sidecar.artifacts) { const p = a.kind === 'internal' ? path.join(projectRoot, a.path) : a.absolutePath; diff --git a/desktop/src/main/artifacts/projects-index.ts b/desktop/src/main/artifacts/projects-index.ts index e14f269f..3d7c2732 100644 --- a/desktop/src/main/artifacts/projects-index.ts +++ b/desktop/src/main/artifacts/projects-index.ts @@ -13,7 +13,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; -import { readSidecar } from './artifact-store'; +import { readSidecarShared } from './artifact-store'; import { listProjects } from './central-index'; import { buildSavedFolderProjects } from './saved-folder-projects'; import { trackedArtifacts } from './visible-artifacts'; @@ -33,7 +33,7 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude'); // this was written for merged into Files on 2026-07-23. NO on-disk discovery is // mixed in here. export async function countArtifacts(projectRoot: string): Promise { - const sidecar = await readSidecar(projectRoot); + const sidecar = await readSidecarShared(projectRoot); if (!sidecar || 'corrupted' in sidecar) return 0; const visible = trackedArtifacts(sidecar.artifacts as any[], sidecar.manualIncludes, sidecar.manualExcludes, projectRoot) .filter((a: any) => a.status !== 'deleted'); @@ -66,7 +66,7 @@ export async function projectAllFiles(projectRoot: string): Promise<{ files: any try { scan = await discoverProjectFiles(projectRoot); } catch { scan = { files: [], truncated: false }; } const seen = new Set(scan.files.map((f: any) => f.path)); - const sidecar = await readSidecar(projectRoot); + const sidecar = await readSidecarShared(projectRoot); const extra: any[] = []; if (sidecar && !('corrupted' in sidecar)) { const candidates = sidecar.artifacts.filter((a) => { @@ -157,7 +157,7 @@ export async function listProjectsIndex(opts?: { withCounts?: boolean }): Promis } else { // Fast path for ChatView's frequent cwd-resolution calls: cheap sidecar- // only artifact count, no on-disk scan and no existence check. - const sidecar = await readSidecar(p.path); + const sidecar = await readSidecarShared(p.path); let trackedCount = 0; if (sidecar && !('corrupted' in sidecar)) { trackedCount = trackedArtifacts(sidecar.artifacts as any[], sidecar.manualIncludes, sidecar.manualExcludes, p.path) diff --git a/desktop/src/main/ipc-handlers.ts b/desktop/src/main/ipc-handlers.ts index d197a0bd..23c844aa 100644 --- a/desktop/src/main/ipc-handlers.ts +++ b/desktop/src/main/ipc-handlers.ts @@ -102,7 +102,11 @@ import { PROJECT_DESCRIPTION_MAX } from '../shared/artifacts/types'; import { loadConfigSync, writeConfig, getAppliedAtLaunch, getCachedGpu } from './performance-config'; import type { PerformanceConfigSnapshot } from '../shared/types'; import { ARTIFACT_IPC } from './artifacts/ipc-channels'; -import { appendVersion, readSidecar, writeSidecar, renameArtifact, removeArtifactRecord, runSidecarMigration } from './artifacts/artifact-store'; +// 2026-08-27 OOM fix: read-only handlers (list, get, save, check-existence, +// the binary-roots pass) go through readSidecarShared — one parsed copy per +// project however many callers ask at once. Only the manual include/exclude +// handlers, which mutate and write back, keep the private readSidecar. +import { appendVersion, readSidecar, readSidecarShared, writeSidecar, renameArtifact, removeArtifactRecord, runSidecarMigration } from './artifacts/artifact-store'; import { listProjects, removeProject } from './artifacts/central-index'; // Shared with remote-server.ts — see that module's header for why these left // this file (they were closures, so the remote transport could not reach them). @@ -3676,7 +3680,7 @@ export function registerIpcHandlers( // done here at each of the three call sites instead, and only when a write // actually happened. if (migration.migrated) invalidateSidecarIdCache(projectRoot); - const sidecar = await readSidecar(projectRoot); + const sidecar = await readSidecarShared(projectRoot); if (!sidecar || 'corrupted' in sidecar) return { ok: true, artifacts: [] }; // Filter to artifacts touched by this session const result = sidecar.artifacts.filter((a) => @@ -3747,7 +3751,7 @@ export function registerIpcHandlers( // project per process, so this costs one Set lookup after the first call. const migration = await runSidecarMigration(projectRoot); if (migration.migrated) invalidateSidecarIdCache(projectRoot); // see LIST_SESSION's WHY - const sidecar = await readSidecar(projectRoot); + const sidecar = await readSidecarShared(projectRoot); let tracked: any[] = []; if (sidecar && !('corrupted' in sidecar)) { @@ -3803,7 +3807,7 @@ export function registerIpcHandlers( // unbounded one. opts?: { full?: boolean }, ) => { - const sidecar = await readSidecar(projectRoot); + const sidecar = await readSidecarShared(projectRoot); const artifact = (sidecar && !('corrupted' in sidecar)) ? sidecar.artifacts.find((a) => a.id === artifactId) : undefined; @@ -3929,7 +3933,7 @@ export function registerIpcHandlers( // includes from each root's sidecar — covers e.g. a temp-dir xlsx. const tracked = new Set(); for (const root of roots) { - const sidecar = await readSidecar(root).catch(() => null); + const sidecar = await readSidecarShared(root).catch(() => null); if (!sidecar || 'corrupted' in sidecar) continue; for (const a of sidecar.artifacts) { if (a.kind === 'external' && a.absolutePath) tracked.add(canonicalize(a.absolutePath, null)); @@ -3967,7 +3971,7 @@ export function registerIpcHandlers( // caller that never showed the dialog (D5 — mistake-prevention tier). opts?: { baseMtimeMs?: number; confirmed?: boolean } ) => { - const sidecar = await readSidecar(projectRoot); + const sidecar = await readSidecarShared(projectRoot); const artifact = (sidecar && !('corrupted' in sidecar)) ? sidecar.artifacts.find((a) => a.id === artifactId) : undefined; @@ -4334,7 +4338,7 @@ export function registerIpcHandlers( if (!projectRoot || !Array.isArray(artifactIds) || artifactIds.length === 0) { return { ok: true, missingIds: [] }; } - const sidecar = await readSidecar(projectRoot); + const sidecar = await readSidecarShared(projectRoot); if (!sidecar || 'corrupted' in sidecar) return { ok: true, missingIds: [] }; const byId = new Map(sidecar.artifacts.map((a) => [a.id, a])); const results = await Promise.all( diff --git a/desktop/tests/artifacts/artifact-store.test.ts b/desktop/tests/artifacts/artifact-store.test.ts index 40a5e3b5..6e3502df 100644 --- a/desktop/tests/artifacts/artifact-store.test.ts +++ b/desktop/tests/artifacts/artifact-store.test.ts @@ -570,3 +570,30 @@ describe('renameArtifact — guards against a relative absolutePath (finding 2)' expect(sidecar.artifacts[0].absolutePath).toBe(join(projectRoot, 'renamed-outside.txt').replace(/\\/g, '/')); }); }); + +// 2026-08-27 OOM fix — see tests/artifacts/sidecar-cache.test.ts for the read +// side. This pins the write side: the CAS check must not unpack the on-disk +// file to read its one timestamp. +describe('writeSidecar — CAS check reads the timestamp without parsing the file', () => { + let projectRoot: string; + beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), 'as-cas-probe-')); + mkdirSync(join(projectRoot, '.youcoded')); + writeFileSync(join(projectRoot, '.youcoded', 'artifacts.json'), JSON.stringify(sample, null, 2)); + }); + afterEach(() => { vi.restoreAllMocks(); rmSync(projectRoot, { recursive: true, force: true }); }); + + it('commits a matching write with ZERO JSON.parse calls on the CAS path', async () => { + const cur = (await readSidecar(projectRoot)) as ProjectSidecar; + const parse = vi.spyOn(JSON, 'parse'); + const res = await writeSidecar(projectRoot, cur.updatedAt, cur); + expect(res.committed).toBe(true); + expect(parse).not.toHaveBeenCalled(); + }); + + it('still rejects a stale token', async () => { + const cur = (await readSidecar(projectRoot)) as ProjectSidecar; + const res = await writeSidecar(projectRoot, '2000-01-01T00:00:00.000Z', cur); + expect(res.committed).toBe(false); + }); +}); diff --git a/desktop/tests/artifacts/cas-write.test.ts b/desktop/tests/artifacts/cas-write.test.ts index 0b0df3bc..a6309f21 100644 --- a/desktop/tests/artifacts/cas-write.test.ts +++ b/desktop/tests/artifacts/cas-write.test.ts @@ -113,3 +113,40 @@ describe('casWrite', () => { } }); }); + +// 2026-08-27 OOM fix: the CAS comparand is ONE timestamp, but the check used +// to hand the extractor the WHOLE file — 6.4 MB of artifacts.json parsed to +// read 24 bytes, on every write. casWrite now offers the extractor a bounded +// head first and falls back to the whole file only when the head has no answer. +describe('casWrite — head probe before whole-file read', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cas-head-')); }); + afterEach(() => { vi.restoreAllMocks(); try { rmSync(dir, { recursive: true, force: true }); } catch {} }); + + const probe = (json: string) => /"updatedAt"\s*:\s*"([^"]*)"/.exec(json)?.[1]; + + it('never reads the whole file when the head probe answers', async () => { + const target = join(dir, 'big.json'); + // updatedAt near the front, then a body far larger than the probe window. + writeFileSync(target, `{"updatedAt":"2026-01-01T00:00:00Z","pad":"${'x'.repeat(200_000)}"}`); + const whole = vi.spyOn(fsp, 'readFile'); + const result = await casWrite(target, '2026-01-01T00:00:00Z', '{"updatedAt":"2026-01-02T00:00:00Z"}', probe); + expect(result.committed).toBe(true); + expect(whole.mock.calls.filter((c) => String(c[0]) === target)).toHaveLength(0); + }); + + it('falls back to the whole file when the head has no answer (updatedAt past the probe window)', async () => { + const target = join(dir, 'tail.json'); + writeFileSync(target, `{"pad":"${'y'.repeat(200_000)}","updatedAt":"2026-03-03T00:00:00Z"}`); + const ok = await casWrite(target, '2026-03-03T00:00:00Z', '{"updatedAt":"2026-03-04T00:00:00Z"}', probe); + expect(ok.committed).toBe(true); + }); + + it('an extractor that THROWS on the truncated head (JSON.parse) still gets the whole file', async () => { + const target = join(dir, 'parse.json'); + writeFileSync(target, `{"pad":"${'z'.repeat(200_000)}","updatedAt":"2026-05-05T00:00:00Z"}`); + const result = await casWrite(target, 'stale', '{"updatedAt":"x"}', (json) => JSON.parse(json).updatedAt); + expect(result.committed).toBe(false); + expect(result.actualUpdatedAt).toBe('2026-05-05T00:00:00Z'); + }); +}); diff --git a/desktop/tests/artifacts/sidecar-cache.test.ts b/desktop/tests/artifacts/sidecar-cache.test.ts new file mode 100644 index 00000000..eeede4d4 --- /dev/null +++ b/desktop/tests/artifacts/sidecar-cache.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readdirSync, utimesSync, promises as fsPromises } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { + readSidecar, readSidecarShared, writeSidecar, appendVersion, _resetSidecarCacheForTests, + SIDECAR_CACHE_IDLE_MS, +} from '../../src/main/artifacts/artifact-store'; +import type { ProjectSidecar } from '../../src/shared/artifacts/types'; +import sample from '../../../shared-fixtures/artifacts/sample-sidecar.json'; + +// 2026-08-27 — "YouCoded dies after 12 h of use". PR #318 queued the sidecar +// WRITER; every READER stayed unguarded, and one Edit now costs ~11 full parses +// of a 6.4 MB artifacts.json (LIST_SESSION, artifacts:get per visible card, +// check-existence, the watcher's id map, every open tab at startup…). Under a +// burst those parses pile up — the core dump held 477 copies ≈ 3.0 GB. The +// shared read below bounds that to ONE parsed copy per project, however many +// callers ask at once, and to ZERO extra parses after a write the app itself +// just made. Full evidence: docs/active/investigations/2026-08-27-artifacts-sidecar-oom-crash.md. + +const sidecarReads = (spy: ReturnType) => + spy.mock.calls.filter((c) => String(c[0]).endsWith('artifacts.json')).length; + +describe('readSidecarShared — one parsed copy per project', () => { + let projectRoot: string; + let sidecarPath: string; + beforeEach(() => { + _resetSidecarCacheForTests(); + projectRoot = mkdtempSync(join(tmpdir(), 'as-shared-')); + mkdirSync(join(projectRoot, '.youcoded')); + sidecarPath = join(projectRoot, '.youcoded', 'artifacts.json'); + writeFileSync(sidecarPath, JSON.stringify(sample)); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + _resetSidecarCacheForTests(); + rmSync(projectRoot, { recursive: true, force: true }); + }); + + it('a burst of N concurrent reads performs ONE parse and hands every caller the same object', async () => { + const N = 300; + const readSpy = vi.spyOn(fsPromises, 'readFile'); + const results = await Promise.all(Array.from({ length: N }, () => readSidecarShared(projectRoot))); + expect(sidecarReads(readSpy)).toBe(1); + expect(results.every((r) => r === results[0])).toBe(true); + expect((results[0] as ProjectSidecar).projectId).toBe(sample.projectId); + }); + + it('a second read of an unchanged file costs no parse at all', async () => { + const readSpy = vi.spyOn(fsPromises, 'readFile'); + const a = await readSidecarShared(projectRoot); + const b = await readSidecarShared(projectRoot); + expect(sidecarReads(readSpy)).toBe(1); + expect(b).toBe(a); + }); + + it('re-parses when the file changes on disk (another process wrote it)', async () => { + const a = (await readSidecarShared(projectRoot)) as ProjectSidecar; + const changed = { ...sample, name: 'renamed-elsewhere', updatedAt: '2030-01-01T00:00:00.000Z' }; + writeFileSync(sidecarPath, JSON.stringify(changed)); + // Same-size-same-mtime collisions are the only blind spot; force a + // distinct mtime so the test is about content, not filesystem tick size. + const t = new Date(Date.now() + 5_000); + utimesSync(sidecarPath, t, t); + const b = (await readSidecarShared(projectRoot)) as ProjectSidecar; + expect(b).not.toBe(a); + expect(b.name).toBe('renamed-elsewhere'); + }); + + it('a committed writeSidecar SEEDS the cache — the reads that follow every edit cost zero parses', async () => { + const cur = (await readSidecar(projectRoot)) as ProjectSidecar; + cur.name = 'written-here'; + const res = await writeSidecar(projectRoot, cur.updatedAt, cur); + expect(res.committed).toBe(true); + const readSpy = vi.spyOn(fsPromises, 'readFile'); + const shared = (await readSidecarShared(projectRoot)) as ProjectSidecar; + expect(sidecarReads(readSpy)).toBe(0); + expect(shared.name).toBe('written-here'); + expect(shared.updatedAt).toBe(cur.updatedAt); + }); + + it('a queued appendVersion burst leaves the cache holding the final state, with no parse for the reader', async () => { + await readSidecarShared(projectRoot); + await Promise.all(Array.from({ length: 50 }, (_, i) => appendVersion(projectRoot, sample.projectId, sample.name, { + path: `docs/burst-${i}.md`, kind: 'internal', absolutePath: null, + sessionId: 'sess-cache', type: 'create', author: 'agent', toolUseId: `toolu_c${i}`, + }))); + const readSpy = vi.spyOn(fsPromises, 'readFile'); + const shared = (await readSidecarShared(projectRoot)) as ProjectSidecar; + expect(sidecarReads(readSpy)).toBe(0); + expect(shared.artifacts.filter((a) => a.path.startsWith('docs/burst-'))).toHaveLength(50); + // And the shared copy is exactly what is on disk. + const disk = (await readSidecar(projectRoot)) as ProjectSidecar; + expect(disk.updatedAt).toBe(shared.updatedAt); + expect(disk.artifacts.length).toBe(shared.artifacts.length); + }); + + it('a missing sidecar reads as null and is picked up once it appears', async () => { + rmSync(sidecarPath); + expect(await readSidecarShared(projectRoot)).toBeNull(); + writeFileSync(sidecarPath, JSON.stringify(sample)); + expect((await readSidecarShared(projectRoot)) as ProjectSidecar).toMatchObject({ projectId: sample.projectId }); + }); + + it('a corrupted sidecar is backed up ONCE, not once per reader', async () => { + writeFileSync(sidecarPath, '{ not json'); + const results = await Promise.all(Array.from({ length: 20 }, () => readSidecarShared(projectRoot))); + expect(results.every((r) => r && 'corrupted' in r)).toBe(true); + await readSidecarShared(projectRoot); + const backups = readdirSync(join(projectRoot, '.youcoded')).filter((f) => f.includes('.bak.')); + expect(backups).toHaveLength(1); + }); + + it('an idle copy is dropped after SIDECAR_CACHE_IDLE_MS and re-parsed on the next read', async () => { + vi.useFakeTimers(); + await readSidecarShared(projectRoot); + const readSpy = vi.spyOn(fsPromises, 'readFile'); + await vi.advanceTimersByTimeAsync(SIDECAR_CACHE_IDLE_MS + 1); + await readSidecarShared(projectRoot); + expect(sidecarReads(readSpy)).toBe(1); + }); +});