Kaynağa Gözat

fix(tui-state): bound the async snapshot cache (LRU, 8 entries)

Address the Greptile P2 on #1197: a long-lived daemon polling many
projects retained one snapshot per visited path. Insertion order now
doubles as LRU order, hits refresh it, and the cache is capped.
dhaern 1 gün önce
ebeveyn
işleme
da4405326b
2 değiştirilmiş dosya ile 43 ekleme ve 2 silme
  1. 18 0
      src/tui-state.test.ts
  2. 25 2
      src/tui-state.ts

+ 18 - 0
src/tui-state.test.ts

@@ -60,6 +60,24 @@ describe('tui-state persistence', () => {
     expect(third.agentModels.explorer).toBe(GPT.model);
     expect(third.agentModels.explorer).toBe(GPT.model);
   });
   });
 
 
+  test('readTuiSnapshotAsync cache is bounded (LRU eviction)', async () => {
+    recordTuiAgentModel(LUNA, tempDir);
+    const first = await readTuiSnapshotAsync(tempDir);
+    expect(first.agentModels.explorer).toBe(LUNA.model);
+
+    // Poll 8 other projects: the first entry must be evicted even though
+    // its file is unchanged (a fresh read returns a new object identity).
+    for (let i = 0; i < 8; i += 1) {
+      const dir = path.join(tempDir, `project-${i}`);
+      recordTuiAgentModel(GPT, dir);
+      await readTuiSnapshotAsync(dir);
+    }
+
+    const reRead = await readTuiSnapshotAsync(tempDir);
+    expect(reRead.agentModels.explorer).toBe(LUNA.model);
+    expect(reRead).not.toBe(first);
+  });
+
   test('persists enabled agent models', () => {
   test('persists enabled agent models', () => {
     recordTuiAgentModels(
     recordTuiAgentModels(
       {
       {

+ 25 - 2
src/tui-state.ts

@@ -170,6 +170,9 @@ function readTuiSnapshotStrict(statePath: string): TuiSnapshot | null {
 // dev/ino — mtimeMs+size alone would also be sufficient, but inode
 // dev/ino — mtimeMs+size alone would also be sufficient, but inode
 // identity rules out same-mtime rewrites. Cache misses fall through to
 // identity rules out same-mtime rewrites. Cache misses fall through to
 // a normal read; any stat/read failure bypasses the cache entirely.
 // a normal read; any stat/read failure bypasses the cache entirely.
+// Bounded LRU: a long-lived daemon polling many projects must not
+// retain a snapshot per visited path.
+const ASYNC_SNAPSHOT_CACHE_MAX = 8;
 const asyncSnapshotCache = new Map<
 const asyncSnapshotCache = new Map<
   string,
   string,
   { stat: string; snapshot: TuiSnapshot }
   { stat: string; snapshot: TuiSnapshot }
@@ -179,6 +182,21 @@ function snapshotStatKey(stat: fs.Stats): string {
   return `${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.size}`;
   return `${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.size}`;
 }
 }
 
 
+function rememberAsyncSnapshot(
+  statePath: string,
+  statKey: string,
+  snapshot: TuiSnapshot,
+): void {
+  // Map insertion order doubles as the LRU order: re-insert to refresh.
+  asyncSnapshotCache.delete(statePath);
+  asyncSnapshotCache.set(statePath, { stat: statKey, snapshot });
+  while (asyncSnapshotCache.size > ASYNC_SNAPSHOT_CACHE_MAX) {
+    const oldest = asyncSnapshotCache.keys().next().value;
+    if (oldest === undefined) break;
+    asyncSnapshotCache.delete(oldest);
+  }
+}
+
 export async function readTuiSnapshotAsync(
 export async function readTuiSnapshotAsync(
   projectDir: string,
   projectDir: string,
 ): Promise<TuiSnapshot> {
 ): Promise<TuiSnapshot> {
@@ -187,11 +205,16 @@ export async function readTuiSnapshotAsync(
     const stat = await fs.promises.stat(statePath);
     const stat = await fs.promises.stat(statePath);
     const statKey = snapshotStatKey(stat);
     const statKey = snapshotStatKey(stat);
     const cached = asyncSnapshotCache.get(statePath);
     const cached = asyncSnapshotCache.get(statePath);
-    if (cached && cached.stat === statKey) return cached.snapshot;
+    if (cached && cached.stat === statKey) {
+      // Refresh LRU position on hit.
+      asyncSnapshotCache.delete(statePath);
+      asyncSnapshotCache.set(statePath, cached);
+      return cached.snapshot;
+    }
     const snapshot = parseSnapshot(
     const snapshot = parseSnapshot(
       await fs.promises.readFile(statePath, 'utf8'),
       await fs.promises.readFile(statePath, 'utf8'),
     );
     );
-    asyncSnapshotCache.set(statePath, { stat: statKey, snapshot });
+    rememberAsyncSnapshot(statePath, statKey, snapshot);
     return snapshot;
     return snapshot;
   } catch {
   } catch {
     asyncSnapshotCache.delete(statePath);
     asyncSnapshotCache.delete(statePath);