mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-02 04:14:38 +08:00
fix(sessionstart): use lstatSync for age-gated cache cleanup (#644)
The lazy cleanup added for #181 evaluated each cache entry with statSync, which transparently follows symlinks. When a self-heal hook (or any local workflow) re-creates breadcrumb symlinks for previous CLAUDE_PLUGIN_ROOT versions pointing at the current install, statSync returned the target directory's mtime — old by definition — so the >1h gate tripped on the links themselves. rmSync with { recursive: true, force: true } on a symlink unlinks the link (not the target's contents), so all the fresh breadcrumbs vanished in a single iteration and any session pinned to one of those versions lost its plugin root mid-flight with "Plugin directory does not exist" — the exact failure mode #181 was supposed to prevent. lstatSync evaluates the link's own mtime, so a freshly created symlink survives the gate regardless of how old its target is, while real stale version directories are still cleaned up as before. Surgical change: import lstatSync instead of statSync at the top of the hook and call lstatSync at the age check inside the cleanup loop. Empirically verified by reproducing the scenario in /tmp — pre-fix the links trip the gate against the target's stale mtime, post-fix the links report their own fresh mtime and are preserved. Reported by @dtmsyi with a complete repro, root cause, and proposed 4-character fix. Thank you. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3ad9442b5c
commit
114741113d
@@ -41,7 +41,7 @@ await runHook(async () => {
|
||||
const { createSessionLoaders } = await import("./session-loaders.mjs");
|
||||
const { join, dirname } = await import("node:path");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const { readFileSync, unlinkSync, readdirSync, rmSync, statSync } = await import("node:fs");
|
||||
const { readFileSync, unlinkSync, readdirSync, rmSync, lstatSync } = await import("node:fs");
|
||||
|
||||
const detectedPlatform = detectPlatformFromEnv();
|
||||
const toolNamer = createToolNamer(detectedPlatform);
|
||||
@@ -234,6 +234,12 @@ await runHook(async () => {
|
||||
|
||||
// Age-gated lazy cleanup of old plugin cache version dirs (#181).
|
||||
// Only delete dirs older than 1 hour to avoid breaking active sessions.
|
||||
// Use lstatSync (not statSync) so a fresh symlink whose target happens
|
||||
// to be old is evaluated against the symlink's own mtime, not the
|
||||
// target's — otherwise self-heal hooks that re-create breadcrumb
|
||||
// symlinks for previous cache versions would be wiped out and any
|
||||
// session pinned to one of those versions would lose its plugin root
|
||||
// mid-flight (#644).
|
||||
try {
|
||||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
if (pluginRoot) {
|
||||
@@ -246,7 +252,7 @@ await runHook(async () => {
|
||||
for (const d of readdirSync(cacheParent)) {
|
||||
if (d === myDir) continue;
|
||||
try {
|
||||
const st = statSync(join(cacheParent, d));
|
||||
const st = lstatSync(join(cacheParent, d));
|
||||
if (now - st.mtimeMs > ONE_HOUR) {
|
||||
rmSync(join(cacheParent, d), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1416,6 +1416,27 @@ describe("Cache dir safety (#181)", () => {
|
||||
expect(SESSION_SOURCE).toContain("lazy cleanup");
|
||||
expect(SESSION_SOURCE).toContain("3600000"); // 1 hour in ms
|
||||
});
|
||||
|
||||
// #644: statSync follows symlinks → fresh symlinks pointing at stale targets
|
||||
// were deleted, breaking sessions whose CLAUDE_PLUGIN_ROOT was pinned to one
|
||||
// of those linked versions. lstatSync evaluates the link's own mtime, so a
|
||||
// freshly-created symlink survives the gate even when its target is old.
|
||||
test("sessionstart.mjs cleanup uses lstatSync to age-check entries (#644)", () => {
|
||||
const SESSION_SOURCE = readFileSync(resolve(ROOT, "hooks/sessionstart.mjs"), "utf-8");
|
||||
|
||||
// Isolate the lazy-cleanup block so we don't get false-positives from
|
||||
// unrelated stat calls elsewhere in the file.
|
||||
const blockStart = SESSION_SOURCE.indexOf("Age-gated lazy cleanup");
|
||||
expect(blockStart, "lazy cleanup block must exist").toBeGreaterThan(-1);
|
||||
const block = SESSION_SOURCE.slice(blockStart, blockStart + 1500);
|
||||
|
||||
// The age check MUST use lstatSync (does not follow symlinks).
|
||||
expect(block).toMatch(/lstatSync\(\s*join\(\s*cacheParent\s*,\s*d\s*\)\s*\)/);
|
||||
|
||||
// The age check MUST NOT use statSync (follows symlinks → wrongly evaluates
|
||||
// the link target's mtime, causing fresh symlinks to be deleted).
|
||||
expect(block).not.toMatch(/[^l]statSync\(\s*join\(\s*cacheParent/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Issue #185: upgrade must not use execSync (shell) ──
|
||||
|
||||
Reference in New Issue
Block a user