|
| 1 | +## What Problem This Solves |
| 2 | + |
| 3 | +Fixes #98960: The module-level `fileCache` Map in `src/auto-reply/usage-bar/template.ts` holds a live `fs.watch` watcher for each cached template file path. The cache grows without bound — entries are never evicted, and watchers are only closed in `clearUsageBarTemplateCacheForTest()`. Over time this leaks file descriptors. |
| 4 | + |
| 5 | +## Root Cause |
| 6 | + |
| 7 | +`cacheTemplateFile()` inserts without checking cache size. Each new file path creates a persistent `fs.watch` watcher that is never released in production. |
| 8 | + |
| 9 | +## Why This Change Was Made |
| 10 | + |
| 11 | +Add a maximum cache size (`MAX_CACHED_TEMPLATE_FILES = 64`). When inserting a **new** cache key that would exceed the limit, evict the oldest entry (closing its `fs.watch` watcher) **before** allocating a watcher for the new entry. This bounds watcher count and prevents unbounded FD growth. |
| 12 | + |
| 13 | +Key design decisions: |
| 14 | +- Eviction runs **before** watcher allocation so we never create a watcher only to close it immediately. |
| 15 | +- Eviction triggers only when inserting a **new** key (`!fileCache.has(path)`). Retries for an existing key (re-reading after a prior miss) must not evict other entries. |
| 16 | +- Eviction uses `Map.keys().next()` for oldest-entry ordering (Map maintains insertion order). |
| 17 | + |
| 18 | +## Changes |
| 19 | + |
| 20 | +- `src/auto-reply/usage-bar/template.ts`: +13 lines |
| 21 | + - Added `MAX_CACHED_TEMPLATE_FILES = 64` constant |
| 22 | + - Added oldest-entry eviction with watcher cleanup in `cacheTemplateFile()`, before watcher allocation, guarded by `!fileCache.has(path)` |
| 23 | +- `src/auto-reply/usage-bar/template.test.ts`: +79/-1 lines |
| 24 | + - Two new focused tests in a `cache eviction` describe block |
| 25 | +- `src/auto-reply/usage-bar/template.watcher-proof.test.ts`: +125 lines (new) |
| 26 | + - Direct watcher create/close measurement via `vi.mock` intercept |
| 27 | +- `scripts/verify-template-cache-bound.mjs`: +123 lines (new) |
| 28 | + - Real-process verification loading 65 template files through production code |
| 29 | + |
| 30 | +## Evidence |
| 31 | + |
| 32 | +### Verification environment |
| 33 | + |
| 34 | +| Item | Value | |
| 35 | +|------|-------| |
| 36 | +| OS | Linux 4.19.112 x86_64 | |
| 37 | +| Node.js | v22.19.0 | |
| 38 | +| Vitest | v4.1.8 | |
| 39 | + |
| 40 | +### Regression tests (12/12, 2 new) |
| 41 | + |
| 42 | +``` |
| 43 | + ✓ returns the built-in template when unset |
| 44 | + ✓ returns an inline template object when usable |
| 45 | + ✓ falls back to the built-in template for an unusable inline object |
| 46 | + ✓ falls back quietly for an empty inline template |
| 47 | + ✓ loads and parses a template file |
| 48 | + ✓ falls back to the built-in template for invalid JSON |
| 49 | + ✓ falls back to the built-in template for an empty template file |
| 50 | + ✓ reloads a path after an initial miss |
| 51 | + ✓ reloads a path after invalid JSON is fixed |
| 52 | + ✓ serves the cached template without re-reading the file |
| 53 | + ✓ evicts the oldest entry and closes its watcher when inserting a new key over the limit ← NEW |
| 54 | + ✓ does not evict when retrying the same key after a prior miss ← NEW |
| 55 | + Test Files 1 passed (1) | Tests 12 passed (12) |
| 56 | +``` |
| 57 | + |
| 58 | +The two new tests cover: |
| 59 | +- **Overflow eviction**: 65 paths loaded → oldest evicted → re-read from disk proves eviction + watcher closure. |
| 60 | +- **Watcher closure**: Evicted entry's watcher is closed; re-access triggers fresh `readFileSync` instead of serving stale data. Non-evicted entry remains cached. |
| 61 | +- **Same-key retry**: Cache full → invalid file loaded (slot consumed) → file fixed → retry same path → does NOT evict other entries. |
| 62 | + |
| 63 | +### Real-process proof: 65-template load (production code, no mocking) |
| 64 | + |
| 65 | +Standalone script (`scripts/verify-template-cache-bound.mjs`) imports the production `loadUsageBarTemplate` module via `tsx` and exercises it with 65 real template files. No mocking — this is a real Node process running the actual production code path. |
| 66 | + |
| 67 | +``` |
| 68 | +======================================================================== |
| 69 | +OpenClaw Template Cache Bound — Real-Process Verification |
| 70 | +======================================================================== |
| 71 | +PID: 681079 |
| 72 | +Node: v22.19.0 |
| 73 | +Platform: linux x64 |
| 74 | +Started: 2026-07-03T02:41:33.053Z |
| 75 | +Temp dir: /tmp/usage-template-proof-pxStOa |
| 76 | +Cache cap: 64 |
| 77 | +Files: 65 |
| 78 | +
|
| 79 | +── Phase 1: Fill cache (files 0–63) ── |
| 80 | + Duration: 6ms |
| 81 | + Result: 64 files loaded and cached |
| 82 | +
|
| 83 | +── Phase 2: Load 65th file (triggers eviction of oldest entry) ── |
| 84 | + Duration: 0ms |
| 85 | + File 64: {"segments":[{"text":"v1-64"}]} |
| 86 | +
|
| 87 | +── Phase 3: Non-evicted file 1 — prove watcher still alive ── |
| 88 | + File 1 reloaded: "CHANGED-VIA-WATCHER" |
| 89 | + Result: PASS (live watcher updated cache) |
| 90 | +
|
| 91 | +── Phase 4: Verify files 2–63 still cached ── |
| 92 | + Cached: 62/62 |
| 93 | + Result: PASS |
| 94 | +
|
| 95 | +── Phase 5: Prove eviction (modify file 0 on disk, re-read) ── |
| 96 | + File 0 reloaded: "V2-EVICTED-RELOADED" |
| 97 | + Result: PASS (disk re-read — evicted watcher was closed) |
| 98 | +
|
| 99 | +── Phase 6: Cleanup ── |
| 100 | + Result: clearUsageBarTemplateCacheForTest called |
| 101 | +
|
| 102 | +======================================================================== |
| 103 | +VERDICT |
| 104 | +======================================================================== |
| 105 | + 64 files loaded → all cached PASS |
| 106 | + 65th file → eviction + watcher close PASS |
| 107 | + Non-evicted watcher still alive PASS |
| 108 | + 62 files remain cached PASS |
| 109 | + cleanup → all watchers closed PASS |
| 110 | +``` |
| 111 | + |
| 112 | +### Direct watcher create/close measurement (Vitest) |
| 113 | + |
| 114 | +Verification test (`src/auto-reply/usage-bar/template.watcher-proof.test.ts`) uses `vi.mock` + `vi.hoisted` to intercept `fs.watch` before module resolution and directly count every `FSWatcher` creation and `close()` call. |
| 115 | + |
| 116 | +``` |
| 117 | +======================================================================== |
| 118 | +Template Cache Bound — Direct Watcher & Eviction Measurement |
| 119 | +======================================================================== |
| 120 | +Phase 1: Load 64 files → 64 watchers created, 0 closed |
| 121 | +Phase 2: Load 65th file → 1 created, 1 closed (eviction) |
| 122 | + Content: {"segments":[{"text":"v1-64"}]} |
| 123 | + Oldest watcher CLOSED, new watcher CREATED |
| 124 | +Phase 3: Re-read all 63 cached files → 0 created, 0 closed |
| 125 | +Phase 4: clearUsageBarTemplateCacheForTest → 64 watchers closed |
| 126 | + Remaining active: 0 |
| 127 | +
|
| 128 | +======================================================================== |
| 129 | +VERDICT — Direct watcher measurement |
| 130 | +======================================================================== |
| 131 | + Watchers created : 65 |
| 132 | + Watchers closed : 65 |
| 133 | + Leaked : 0 |
| 134 | +
|
| 135 | + ✅ 64 files → 64 watchers |
| 136 | + ✅ 65th file → 1 watcher CLOSED (eviction) + 1 CREATED |
| 137 | + ✅ Cache hits → 0 created/closed |
| 138 | + ✅ Cleanup → all remaining closed, 0 leaked |
| 139 | +``` |
| 140 | + |
| 141 | +**Measurement methodology**: |
| 142 | + |
| 143 | +| Intercept point | What it counts | Phase 2 result | |
| 144 | +|-----------------|---------------|----------------| |
| 145 | +| Mock `fs.watch` | `state.created++` per watcher allocated | +1 (65th file) | |
| 146 | +| Instance `w.close = fn` | `state.closed++` per watcher closed | +1 (oldest evicted) | |
| 147 | + |
| 148 | +| Claim | Measurement | Result | |
| 149 | +|-------|------------|--------| |
| 150 | +| Cache bound at 64 | 64 watchers created in Phase 1, eviction triggers on 65th | PASS | |
| 151 | +| Oldest watcher CLOSED on eviction | `state.closed` increments by 1 in Phase 2 | PASS | |
| 152 | +| New watcher CREATED for 65th file | `state.created` increments by 1 in Phase 2 | PASS | |
| 153 | +| Cache hits: no new watchers | 0 created, 0 closed in Phase 3 | PASS | |
| 154 | +| Cleanup closes all (no leaks) | 64 closed in Phase 4, `created - closed = 0` | PASS | |
| 155 | + |
| 156 | +## User Impact |
| 157 | + |
| 158 | +- Watcher/FD usage bounded at 64 entries instead of growing indefinitely |
| 159 | +- Transparent to users; no config changes required |
| 160 | +- Evicted template files are automatically reloaded (with a fresh watcher) on next access |
| 161 | + |
| 162 | +🤖 Generated with [Claude Code](https://claude.com/claude-code) |
0 commit comments