Plugin manager: `.in_use/<pid>` lock files leak when sessions crash
Plugin manager: .in_use/<pid> lock files leak when sessions crash
Summary
The plugin manager records active sessions holding a plugin version by writing empty files named after the session's PID into ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/.in_use/. When a Claude Code process exits ungracefully (crash, force-kill, OS reboot before clean shutdown), the corresponding .in_use/<pid> file is not removed. The plugin manager subsequently believes the orphaned version is still in use, so it cannot safely garbage-collect it.
Reproduction
Live evidence collected on 2026-05-13 from one machine that had been running Claude Code over a multi-week period:
$ ls ~/.claude/plugins/cache/community-access/accessibility-agents/3.2.0/.in_use/
18792 2093 2320 23515 29905 36512 57707 63327
$ for pid in 18792 2093 2320 23515 29905 36512 57707 63327; do
if kill -0 "$pid" 2>/dev/null; then
echo "PID $pid: alive — $(ps -p $pid -o comm= 2>/dev/null)"
else
echo "PID $pid: STALE"
fi
done
PID 18792: alive — claude
PID 2093: alive — claude
PID 2320: STALE
PID 23515: alive — claude
PID 29905: STALE
PID 36512: STALE
PID 57707: STALE
PID 63327: STALE
4 of 8 lock files (50%) were stale — referencing processes that had long since exited. This blocked the plugin manager from orphan-cleaning the v3.2.0 directory even after v4.0.0 was installed and orphan-flagged.
Proposed fix — two options
Option A: kernel-level advisory locks (flock(2))
Replace the .in_use/<pid> file convention with flock() against a single file per version. The kernel releases the lock on process exit regardless of how the process died.
- Pros: correct by construction. No stale state possible.
- Cons: requires a syscall path on Windows (or a Win32
LockFileExequivalent). Slightly more code than the current file-touch convention.
Option B: stale-PID sweep on every cache read
Keep the file convention but add a sweep step before reading .in_use/:
for pid_file in (cache_dir / ".in_use").iterdir():
try:
pid = int(pid_file.name)
except ValueError:
pid_file.unlink(missing_ok=True) # malformed
continue
if not _pid_alive(pid):
pid_file.unlink(missing_ok=True)
_pid_alive() uses kill -0 <pid> on POSIX, OpenProcess on Windows. Cost: a few syscalls per cache operation. Risk: a freshly-spawned PID could collide with a recycled-PID stale entry — rare in practice (PID space is large) but worth noting.
- Pros: minimal change. Drop-in replacement for the current convention.
- Cons: still leaves a small window for stale entries between sweeps.
Recommendation
Ship Option B in the short term (low risk, immediate improvement). Plan Option A as a long-term cleanup once the cross-platform implementation cost is budgeted.
Acceptance
- After force-killing a Claude Code process holding a plugin version, the next plugin-manager interaction (install/update/list) removes the stale
.in_use/<pid>file for the dead PID. - A pid file referencing a PID that has been recycled to a non-Claude-Code process is treated as stale (the file is removed). Acceptable trade-off given the rarity.
See also
- Companion issue:
.orphaned_atflag has no garbage collector — #58880 - Companion issue: plugin major-version updates that change agent count drastically should expose a
post-updatemigration hook — #58882
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗