diff --git a/src/config/cache-utils.ts b/src/config/cache-utils.ts index df017876400..68c07003752 100644 --- a/src/config/cache-utils.ts +++ b/src/config/cache-utils.ts @@ -25,3 +25,11 @@ export function getFileMtimeMs(filePath: string): number | undefined { return undefined; } } + +export function getFileSizeBytes(filePath: string): number | undefined { + try { + return fs.statSync(filePath).size; + } catch { + return undefined; + } +} diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts index ae3f81d6455..48f306adb4d 100644 --- a/src/config/sessions.cache.test.ts +++ b/src/config/sessions.cache.test.ts @@ -198,4 +198,38 @@ describe("Session Store Cache", () => { const loaded = loadSessionStore(storePath); expect(loaded).toEqual({}); }); + + it("should refresh cache when file is rewritten within the same mtime tick", async () => { + // This reproduces the CI flake where fast test writes complete within the + // same mtime granularity (typically 1s on HFS+/ext4), so mtime-only + // invalidation returns stale cached data. + const store1: Record = { + "session:1": createSessionEntry({ sessionId: "id-1", displayName: "Original" }), + }; + + await saveSessionStore(storePath, store1); + + // Warm the cache + const loaded1 = loadSessionStore(storePath); + expect(loaded1["session:1"].displayName).toBe("Original"); + + // Rewrite the file directly (bypassing saveSessionStore's write-through + // cache) with different content but preserve the same mtime so only size + // changes. + const store2: Record = { + "session:1": createSessionEntry({ sessionId: "id-1", displayName: "Original" }), + "session:2": createSessionEntry({ sessionId: "id-2", displayName: "Added" }), + }; + const json2 = JSON.stringify(store2, null, 2); + fs.writeFileSync(storePath, json2); + + // Force mtime to match the cached value so only size differs + const stat = fs.statSync(storePath); + fs.utimesSync(storePath, stat.atime, stat.mtime); + + // The cache should detect the size change and reload from disk + const loaded2 = loadSessionStore(storePath); + expect(loaded2["session:2"]).toBeDefined(); + expect(loaded2["session:2"].displayName).toBe("Added"); + }); }); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 473f9a69d6e..7ade8462cd6 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -17,7 +17,12 @@ import { normalizeSessionDeliveryFields, type DeliveryContext, } from "../../utils/delivery-context.js"; -import { getFileMtimeMs, isCacheEnabled, resolveCacheTtlMs } from "../cache-utils.js"; +import { + getFileMtimeMs, + getFileSizeBytes, + isCacheEnabled, + resolveCacheTtlMs, +} from "../cache-utils.js"; import { loadConfig } from "../config.js"; import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js"; import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js"; @@ -39,6 +44,7 @@ type SessionStoreCacheEntry = { loadedAt: number; storePath: string; mtimeMs?: number; + sizeBytes?: number; serialized?: string; }; @@ -208,7 +214,8 @@ export function loadSessionStore( const cached = SESSION_STORE_CACHE.get(storePath); if (cached && isSessionStoreCacheValid(cached)) { const currentMtimeMs = getFileMtimeMs(storePath); - if (currentMtimeMs === cached.mtimeMs) { + const currentSizeBytes = getFileSizeBytes(storePath); + if (currentMtimeMs === cached.mtimeMs && currentSizeBytes === cached.sizeBytes) { // Return a deep copy to prevent external mutations affecting cache return structuredClone(cached.store); } @@ -288,6 +295,7 @@ export function loadSessionStore( loadedAt: Date.now(), storePath, mtimeMs, + sizeBytes: getFileSizeBytes(storePath), serialized: serializedFromDisk, }); } @@ -667,6 +675,7 @@ function updateSessionStoreWriteCaches(params: { loadedAt: Date.now(), storePath: params.storePath, mtimeMs, + sizeBytes: getFileSizeBytes(params.storePath), serialized: params.serialized, }); }