fix(config-facade): mtime-based staleness detection for cache

getCachedConfig() now checks config file mtime on each call.
If external code writes via unified-config-loader directly,
the facade detects the file change and re-reads from disk
automatically. Resolves PR-Agent "Stale Cache" finding.
This commit is contained in:
Tam Nhu Tran
2026-04-30 15:56:50 -04:00
parent 2290a1dc5a
commit 1e5580a30a
2 changed files with 41 additions and 10 deletions
@@ -208,5 +208,27 @@ describe('config-loader-facade', () => {
expect(config.profiles).toBeDefined();
expect(config.cliproxy).toBeDefined();
});
it('auto-invalidates when config file is modified externally', async () => {
const facade = await importFacade();
// Prime the cache
const first = facade.getCachedConfig();
expect(first.version).toBeDefined();
// Modify config file directly on disk (simulating external code
// bypassing the facade)
const configPath = path.join(tempHome, '.ccs', 'config.yaml');
// Touch the file to update mtime (wait briefly for mtime resolution)
const content = fs.readFileSync(configPath, 'utf8');
await new Promise((r) => setTimeout(r, 10));
fs.writeFileSync(configPath, content + '# touched\n', 'utf8');
// getCachedConfig should detect the mtime change and re-read
const second = facade.getCachedConfig();
expect(first).not.toBe(second);
// Content should still be valid (re-read from disk)
expect(second.version).toBeDefined();
});
});
});
+19 -10
View File
@@ -52,34 +52,42 @@ export type { GeminiWebSearchInfo } from './unified-config-loader';
export { loadSettings, loadConfigSafe, readConfig, getCcsDir } from '../utils/config-manager';
// Internal imports for memoization wrappers
import { statSync } from 'fs';
import type { UnifiedConfig } from './unified-config-types';
import {
loadOrCreateUnifiedConfig as _loadOrCreateUnifiedConfig,
saveUnifiedConfig as _saveUnifiedConfig,
mutateUnifiedConfig as _mutateUnifiedConfig,
updateUnifiedConfig as _updateUnifiedConfig,
getConfigYamlPath as _getConfigYamlPath,
} from './unified-config-loader';
// ---------------------------------------------------------------------------
// Memoization cache
// Memoization cache with mtime-based staleness detection
// ---------------------------------------------------------------------------
let _configCache: UnifiedConfig | null = null;
let _cacheMtimeMs: number = 0;
function getConfigFileMtime(): number {
try {
return statSync(_getConfigYamlPath()).mtimeMs;
} catch {
return 0;
}
}
/**
* Get the unified config with in-memory caching.
* First call reads from disk; subsequent calls return a deep copy.
* Returns a copy to prevent callers from silently mutating the cache.
*
* NOTE: `loadOrCreateUnifiedConfig` (re-exported above) is NOT cached.
* Use `getCachedConfig()` for cached reads, or the direct import for uncached.
*
* Call invalidateConfigCache() or use mutateConfig()/updateConfig()
* to force a re-read from disk.
* Checks file mtime on each call — if the config file was modified
* externally (e.g. by code importing unified-config-loader directly),
* the cache is automatically invalidated and re-read from disk.
*/
export function getCachedConfig(): UnifiedConfig {
if (!_configCache) {
const currentMtime = getConfigFileMtime();
if (!_configCache || currentMtime > _cacheMtimeMs) {
_configCache = _loadOrCreateUnifiedConfig();
_cacheMtimeMs = getConfigFileMtime();
}
return structuredClone(_configCache);
}
@@ -90,6 +98,7 @@ export function getCachedConfig(): UnifiedConfig {
*/
export function invalidateConfigCache(): void {
_configCache = null;
_cacheMtimeMs = 0;
}
/**