Переглянути джерело

fix(tui-state): invalidate memo on in-place writes via ctime

Greptile flagged that a metadata-preserving in-place write (same inode,
same size, restored mtime) could leave the memo trusting stale content.
ctime cannot be restored from userspace on POSIX, so it is now part of
the file identity; an in-place rewrite always bumps it and invalidates
the memo even when mtime and size match.
dhaern 1 тиждень тому
батько
коміт
547f047e62
2 змінених файлів з 44 додано та 2 видалено
  1. 29 0
      src/tui-state.test.ts
  2. 15 2
      src/tui-state.ts

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

@@ -414,6 +414,35 @@ describe('tui-state persistence', () => {
     expect(readTuiSnapshot(tempDir).agentModels.explorer).toBe('model-x');
   });
 
+  test('in-place rewrite preserving mtime is caught by ctime', () => {
+    recordTuiAgentModel({ agentName: 'explorer', model: 'model-x' }, tempDir);
+    const filePath = getTuiStatePath(tempDir);
+
+    // Pin mtime to a whole-millisecond date (utimes cannot restore
+    // sub-millisecond precision), then re-prime the memo so it holds a
+    // stat snapshot of this exact state.
+    const pinned = new Date('2000-01-01T00:00:00Z');
+    fs.utimesSync(filePath, pinned, pinned);
+    recordTuiAgentModel({ agentName: 'explorer', model: 'model-x' }, tempDir);
+    const statBefore = fs.statSync(filePath);
+
+    // In-place rewrite (same inode, same length): mtime restored via
+    // utimes. ctime cannot be restored by userspace, so the memo must
+    // invalidate and re-record the value from the real file.
+    const external = readTuiSnapshot(tempDir);
+    external.agentModels.explorer = 'model-y';
+    const fd = fs.openSync(filePath, 'w');
+    fs.writeSync(fd, `${JSON.stringify(external)}\n`);
+    fs.closeSync(fd);
+    fs.utimesSync(filePath, statBefore.atime, statBefore.mtime);
+    const statAfter = fs.statSync(filePath);
+    expect(statAfter.ino).toBe(statBefore.ino);
+    expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs);
+
+    recordTuiAgentModel({ agentName: 'explorer', model: 'model-x' }, tempDir);
+    expect(readTuiSnapshot(tempDir).agentModels.explorer).toBe('model-x');
+  });
+
   test('a transient read failure does not seed the memo with an empty snapshot', async () => {
     recordTuiAgentActivity(
       { sessionID: 's1', agentName: 'oracle', active: true },

+ 15 - 2
src/tui-state.ts

@@ -214,18 +214,30 @@ function releaseStateLock(lock: TuiStateLock): void {
 // seed the memo. An identity mismatch (external rename) invalidates it.
 const lastKnownSnapshots = new Map<
   string,
-  { snapshot: TuiSnapshot; ino: number; mtimeMs: number; size: number }
+  {
+    snapshot: TuiSnapshot;
+    ino: number;
+    mtimeMs: number;
+    ctimeMs: number;
+    size: number;
+  }
 >();
 const LAST_KNOWN_SNAPSHOTS_MAX = 32;
 
 function statSnapshotFile(statePath: string): {
   ino: number;
   mtimeMs: number;
+  ctimeMs: number;
   size: number;
 } | null {
   try {
     const stat = fs.statSync(statePath);
-    return { ino: stat.ino, mtimeMs: stat.mtimeMs, size: stat.size };
+    return {
+      ino: stat.ino,
+      mtimeMs: stat.mtimeMs,
+      ctimeMs: stat.ctimeMs,
+      size: stat.size,
+    };
   } catch {
     return null;
   }
@@ -273,6 +285,7 @@ function memoFor(statePath: string): TuiSnapshot | undefined {
     !stat ||
     stat.ino !== entry.ino ||
     stat.mtimeMs !== entry.mtimeMs ||
+    stat.ctimeMs !== entry.ctimeMs ||
     stat.size !== entry.size
   ) {
     lastKnownSnapshots.delete(statePath);