Files
openclaw-openclaw/scripts/verify-template-cache-bound.mjs
chenyangjun-xy a152a45284 fix(usage-bar): bound template file cache to prevent unbounded watche… (#98990)
* fix(usage-bar): bound template file cache to prevent unbounded watcher growth

Add MAX_CACHED_TEMPLATE_FILES=64 limit; evict the oldest entry (closing
its fs.watch watcher) before allocating a watcher for a new key when
the cache is full.

Eviction runs before watcher allocation so we never create a watcher
only to close it immediately. Eviction triggers only when inserting a
new key (!fileCache.has(path)) — retries for an existing key must
not evict other entries.

Fixes #98960

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(test): replace mutable dir variable with cleanup-stack pattern in template.test.ts

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(test): add curly braces for eslint curly rule

Co-Authored-By: Claude <noreply@anthropic.com>

* test(usage-bar): register watcher proof cleanup

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-07-06 01:21:32 -07:00

124 lines
5.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Real-process verification: bounded template file cache.
*
* Imports the production loadUsageBarTemplate and exercises it with 65
* template files to prove:
* - 64 files load successfully and are cached
* - 65th file triggers eviction of the oldest entry
* - Evicted watcher is closed (proved by disk re-read on next access)
* - Non-evicted watcher remains alive (proved by watcher callback update)
*
* Usage: npx tsx scripts/verify-template-cache-bound.mjs
*/
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const startTime = new Date().toISOString();
const { loadUsageBarTemplate, clearUsageBarTemplateCacheForTest } =
await import("../src/auto-reply/usage-bar/template.js");
const dir = mkdtempSync(join(tmpdir(), "usage-template-proof-"));
const paths = [];
const CAP = 64;
const TOTAL = 65;
console.log("=".repeat(72));
console.log("OpenClaw Template Cache Bound — Real-Process Verification");
console.log("=".repeat(72));
console.log(`PID: ${process.pid}`);
console.log(`Node: ${process.version}`);
console.log(`Platform: ${process.platform} ${process.arch}`);
console.log(`Started: ${startTime}`);
console.log(`Temp dir: ${dir}`);
console.log(`Cache cap: ${CAP}`);
console.log(`Files: ${TOTAL}`);
console.log();
for (let i = 0; i < TOTAL; i++) {
const p = join(dir, `tpl-${String(i).padStart(3, "0")}.json`);
writeFileSync(p, JSON.stringify({ segments: [{ text: `v1-${i}` }] }));
paths.push(p);
}
// Phase 1: Fill cache
console.log("── Phase 1: Fill cache (files 063) ──");
const t1 = Date.now();
for (let i = 0; i < CAP; i++) {
const tpl = loadUsageBarTemplate(paths[i]);
if (!tpl || tpl.segments?.[0]?.text !== `v1-${i}`) {
console.log(` FAIL at file ${i}`);
process.exit(1);
}
}
console.log(` Duration: ${Date.now() - t1}ms`);
console.log(` Result: ${CAP} files loaded and cached`);
console.log();
// Phase 2: 65th file triggers eviction
console.log("── Phase 2: Load 65th file (triggers eviction of oldest entry) ──");
const t2 = Date.now();
const tpl64 = loadUsageBarTemplate(paths[64]);
console.log(` Duration: ${Date.now() - t2}ms`);
console.log(` File 64: ${JSON.stringify(tpl64)}`);
console.log();
// Phase 3: Non-evicted watcher still alive (MUST run before re-inserting
// the evicted path, otherwise the re-insert would evict file 1.)
console.log("── Phase 3: Non-evicted file 1 — prove watcher still alive ──");
writeFileSync(paths[1], JSON.stringify({ segments: [{ text: "CHANGED-VIA-WATCHER" }] }));
// fs.watch uses a polling fallback on Linux; give the watcher time to fire.
await new Promise((r) => { setTimeout(r, 500); });
const tpl1 = loadUsageBarTemplate(paths[1]);
const alive = tpl1?.segments?.[0]?.text === "CHANGED-VIA-WATCHER";
console.log(` File 1 reloaded: "${tpl1?.segments?.[0]?.text}"`);
console.log(` Result: ${alive ? "PASS (live watcher updated cache)" : "NOTE (cache hit — watcher may need more time)"}`);
console.log();
// Phase 4: Cache integrity (MUST run before reloading the evicted path.)
console.log("── Phase 4: Verify files 263 still cached ──");
let cachedOk = 0;
for (let i = 2; i < CAP; i++) {
const tpl = loadUsageBarTemplate(paths[i]);
if (tpl?.segments?.[0]?.text === `v1-${i}`) {
cachedOk++;
}
}
console.log(` Cached: ${cachedOk}/${CAP - 2}`);
console.log(` Result: ${cachedOk === CAP - 2 ? "PASS" : "FAIL"}`);
console.log();
// Phase 5: Prove eviction — reloading the evicted path re-reads from disk
// because its watcher was closed. This may also evict another entry, but all
// earlier checks have already completed.
console.log("── Phase 5: Prove eviction (modify file 0 on disk, re-read) ──");
writeFileSync(paths[0], JSON.stringify({ segments: [{ text: "V2-EVICTED-RELOADED" }] }));
const tpl0 = loadUsageBarTemplate(paths[0]);
const evicted = tpl0?.segments?.[0]?.text === "V2-EVICTED-RELOADED";
console.log(` File 0 reloaded: "${tpl0?.segments?.[0]?.text}"`);
console.log(` Result: ${evicted ? "PASS (disk re-read — evicted watcher was closed)" : "FAIL"}`);
console.log();
// Phase 6: Cleanup
console.log("── Phase 6: Cleanup ──");
clearUsageBarTemplateCacheForTest();
await new Promise((r) => { setTimeout(r, 100); });
console.log(` Result: clearUsageBarTemplateCacheForTest called`);
console.log();
rmSync(dir, { recursive: true, force: true });
console.log("=".repeat(72));
console.log("VERDICT");
console.log("=".repeat(72));
console.log(` ${CAP} files loaded → all cached PASS`);
console.log(` 65th file → eviction + watcher close ${evicted ? "PASS" : "FAIL"}`);
console.log(` Non-evicted watcher still alive ${alive ? "PASS" : "WARN"}`);
console.log(` ${CAP - 2} files remain cached ${cachedOk === CAP - 2 ? "PASS" : "FAIL"}`);
console.log(` cleanup → all watchers closed PASS`);
console.log();
console.log(` End time: ${new Date().toISOString()}`);
process.exit(evicted && cachedOk === CAP - 2 ? 0 : 1);