From 04785dd9c0c2c4590d5b70bb0756a8edf13f16ae Mon Sep 17 00:00:00 2001 From: Ashish Huddar Date: Mon, 18 May 2026 12:25:32 +0530 Subject: [PATCH] fix(canvas): bound disk I/O in readCanvases when agents emit many files (greptile P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix: every .json file in .ao/canvases/ was lstat-ed, read, JSON-parsed, and Zod-validated before slice(0, 32) was applied. A buggy agent emitting 1000 canvas files at 256KB cap each would trigger 256MB of disk I/O on every 5-second poll. Fix: two-pass discovery + capped read loop: 1. Cheap pass — lstat every .json, filter non-regular + oversized files, collect {name, mtimeMs}. No reads, no JSON.parse, no Zod. 2. Sort survivors by mtime desc (newest first). Preserves the "newest 32 wins" property the cap was meant to enforce. 3. Capped pass — read + parse + validate in mtime order, break early once CANVAS_MAX_PER_SESSION valid canvases are collected. Hard ceiling at CANVAS_MAX_PER_SESSION * 4 file reads so a pile of invalid newer files can't starve the loop into reading everything anyway. Final sort still uses canvas-supplied updatedAt (the UI ordering), which may differ from mtime if an agent rewrites a file without bumping the canvas's updatedAt. Adds regression test: writes 200 canvas files with monotonic mtimes, asserts result is the 32 newest by mtime (not arbitrary OS order). 20 canvas-log tests pass (19 -> 20). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/src/__tests__/canvas-log.test.ts | 26 ++++++++++ packages/core/src/canvas-log.ts | 52 ++++++++++++++----- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/packages/core/src/__tests__/canvas-log.test.ts b/packages/core/src/__tests__/canvas-log.test.ts index b0b2aed2c..10eb9fb78 100644 --- a/packages/core/src/__tests__/canvas-log.test.ts +++ b/packages/core/src/__tests__/canvas-log.test.ts @@ -91,6 +91,32 @@ describe("readCanvases", () => { expect(result[0]?.id).toBe(`c-${CANVAS_MAX_PER_SESSION + 4}`); }); + it("bounds I/O when an agent emits hundreds of canvas files (greptile P1)", async () => { + // Pre-fix: readCanvases lstat-ed, read, parsed, validated EVERY file before + // capping. 200 files × 256 KB = 50+ MB of reads per 5s poll. Post-fix: + // I/O is capped at CANVAS_MAX_PER_SESSION * 4 reads and the newest mtime + // files are selected. + const N = 200; + for (let i = 0; i < N; i++) { + const c: CanvasArtifact = { + ...validMarkdown, + id: `c-${String(i).padStart(3, "0")}`, + }; + const filePath = join(canvasDir, `c-${String(i).padStart(3, "0")}.json`); + await writeFile(filePath, JSON.stringify(c)); + // mtime-order ascending by index so the highest indices are the newest + const ts = 1_700_000_000_000 + i * 1000; + await import("node:fs/promises").then((fs) => fs.utimes(filePath, ts / 1000, ts / 1000)); + } + const result = await readCanvases(dir); + expect(result).toHaveLength(CANVAS_MAX_PER_SESSION); + // The 32 returned must be the 32 highest-index (newest mtime) files. + const ids = new Set(result.map((c) => c.id)); + for (let i = N - CANVAS_MAX_PER_SESSION; i < N; i++) { + expect(ids.has(`c-${String(i).padStart(3, "0")}`)).toBe(true); + } + }); + it("drops file canvases that use the reserved core- prefix", async () => { const reserved: CanvasArtifact = { ...validMarkdown, id: "core-git-diff" }; await writeFile(join(canvasDir, "evil.json"), JSON.stringify(reserved)); diff --git a/packages/core/src/canvas-log.ts b/packages/core/src/canvas-log.ts index 699ecba3e..e9d09b45d 100644 --- a/packages/core/src/canvas-log.ts +++ b/packages/core/src/canvas-log.ts @@ -38,12 +38,19 @@ export async function readCanvases(workspacePath: string): Promise CANVAS_MAX_FILE_BYTES) { + console.warn(`canvas: dropping ${entry} (${st.size} bytes exceeds ${CANVAS_MAX_FILE_BYTES})`); + continue; + } + candidates.push({ name: entry, mtimeMs: st.mtimeMs }); } catch { - continue; + // file vanished between readdir and lstat, or unreadable — skip silently } - if (size > CANVAS_MAX_FILE_BYTES) { - console.warn(`canvas: dropping ${entry} (${size} bytes exceeds ${CANVAS_MAX_FILE_BYTES})`); - continue; + } + + // Newest-first so the read-loop can early-break at CANVAS_MAX_PER_SESSION + // and still keep the newest files (the design intent of the cap). + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + + const readBudget = CANVAS_MAX_PER_SESSION * 4; + const canvases: CanvasArtifact[] = []; + let reads = 0; + for (const { name } of candidates) { + if (canvases.length >= CANVAS_MAX_PER_SESSION) break; + if (reads >= readBudget) { + console.warn(`canvas: stopping at ${reads} reads (budget exhausted, ${candidates.length - reads} skipped)`); + break; } + reads++; + const filePath = join(dir, name); let raw: string; try { @@ -73,27 +97,31 @@ export async function readCanvases(workspacePath: string): Promise (a.updatedAt < b.updatedAt ? 1 : -1)); - return canvases.slice(0, CANVAS_MAX_PER_SESSION); + return canvases; } export async function synthesizeGitDiffCanvas(