Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 141 additions & 4 deletions desktop/src/main/artifacts/artifact-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,135 @@ export async function readSidecar(projectRoot: string): Promise<ReadResult> {
}
}

// ---------------------------------------------------------------------------
// 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<string, SharedSidecar>();
const sharedInFlight = new Map<string, Promise<ReadResult>>();

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<ReadResult> {
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<void> {
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.
*
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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
Expand Down
41 changes: 37 additions & 4 deletions desktop/src/main/artifacts/cas-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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<CasResult> {
const lock = target + '.lock';
await fs.mkdir(dirname(target), { recursive: true });
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/main/artifacts/project-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/main/artifacts/project-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, string>();
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;
Expand Down
8 changes: 4 additions & 4 deletions desktop/src/main/artifacts/projects-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<number> {
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');
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 11 additions & 7 deletions desktop/src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3929,7 +3933,7 @@ export function registerIpcHandlers(
// includes from each root's sidecar — covers e.g. a temp-dir xlsx.
const tracked = new Set<string>();
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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions desktop/tests/artifacts/artifact-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading