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:
Mert Koseoglu
2026-05-20 10:30:15 +03:00
co-authored by Claude Opus 4.7
parent 3ad9442b5c
commit 114741113d
2 changed files with 29 additions and 2 deletions
+8 -2
View File
@@ -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 });
}
+21
View File
@@ -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) ──