Skip to content

Commit bd6bbea

Browse files
docs: update PR body with all 4 changed files
1 parent a81d3d7 commit bd6bbea

1 file changed

Lines changed: 162 additions & 0 deletions

File tree

pr-body-98960.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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

Comments
 (0)