Skip to content

Commit 339443f

Browse files
committed
fix(compress): use OS-native file locking instead of a hand-rolled marker file
The O_CREAT|O_EXCL marker-file lock had no way to distinguish a crashed holder from a slow-but-live one: a run exceeding the 10-minute staleness window could have its lock stolen mid-write by a waiter, reintroducing the exact interleaved-write race this feature exists to close. It also had a busy-loop bug: a failed unlink during staleness reclaim was silently treated as success, skipping the timeout check and sleep entirely. Replace it with fcntl.flock (POSIX) / msvcrt.locking (Windows), both stdlib. The OS releases the lock automatically when the holding process dies or its file descriptor closes, so there's no staleness heuristic left to get wrong. lock_path_for now resolves its own input and no longer mkdirs as a side effect of computing a path; that mkdir moved into file_lock, the only place that needs the directory to exist. Rewrote tests/test_compress_concurrency.py for the new semantics: the stale-lock-reclaim test is replaced by one that closes a locking fd to simulate a crash and confirms the OS releases it immediately, and the lock-path identity test now compares a relative and a resolved spelling of the same file instead of comparing a function's output to itself.
1 parent 3c0ad80 commit 339443f

2 files changed

Lines changed: 119 additions & 122 deletions

File tree

skills/caveman-compress/scripts/compress.py

Lines changed: 56 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
from pathlib import Path
2020
from typing import List
2121

22+
_IS_WINDOWS = os.name == "nt" or sys.platform == "win32"
23+
24+
if _IS_WINDOWS:
25+
import msvcrt
26+
else:
27+
import fcntl
28+
2229
OUTER_FENCE_REGEX = re.compile(
2330
r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
2431
)
@@ -72,14 +79,8 @@ def split_frontmatter(text: str):
7279

7380

7481
def _state_base_dir(kind: str) -> Path:
75-
"""Resolve the platform-aware base dir for caveman-compress's own state
76-
(backups, locks). Shared by every state kind so the platform-detection
77-
logic (Windows vs XDG) lives in one place.
78-
- Windows: %LOCALAPPDATA%\\caveman-compress\\<kind>
79-
- else: $XDG_DATA_HOME/caveman-compress/<kind> if set,
80-
else ~/.local/share/caveman-compress/<kind>
81-
"""
82-
if os.name == "nt" or sys.platform == "win32":
82+
"""Shared platform-aware base dir for caveman-compress state (backups, locks) — Windows uses %LOCALAPPDATA%, else $XDG_DATA_HOME or ~/.local/share."""
83+
if _IS_WINDOWS:
8384
local_appdata = os.environ.get("LOCALAPPDATA")
8485
base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
8586
else:
@@ -89,18 +90,10 @@ def _state_base_dir(kind: str) -> Path:
8990

9091

9192
def backup_dir_for(filepath: Path) -> Path:
92-
"""Resolve the out-of-tree backup directory for a given source file.
93-
94-
Backups must live OUTSIDE the source directory so skill auto-loaders
95-
(Claude Code rules/, opencode instructions/, etc.) stop re-ingesting the
96-
`.original.md` copies as live files. The source file's parent-dir name is
97-
mirrored under the base to reduce cross-project collisions (e.g. two
98-
`task.md` files in different repos).
99-
"""
93+
"""Out-of-tree backup dir for filepath, keyed by its parent dir name — kept outside the source tree so skill auto-loaders don't re-ingest `.original.md` backups as live files."""
10094
return _state_base_dir("backups") / filepath.parent.name
10195

10296

103-
LOCK_STALE_SECONDS = 10 * 60 # crashed/killed process: long enough to outlast a slow Claude call + one retry, short enough not to wedge a repo indefinitely
10497
LOCK_WAIT_SECONDS = 120 # how long to wait for another session's in-progress compress before giving up
10598
LOCK_POLL_INTERVAL = 1.0
10699

@@ -110,71 +103,62 @@ class LockTimeoutError(RuntimeError):
110103

111104

112105
def lock_path_for(filepath: Path) -> Path:
113-
"""Resolve the cross-session lock file path for a given (resolved) source file.
106+
"""Cross-session lock path for filepath's resolved form, keyed by a hash of the full path (not basename) so same-named files in different repos never contend for the same lock."""
107+
digest = hashlib.sha256(str(filepath.resolve()).encode("utf-8")).hexdigest()[:16]
108+
return _state_base_dir("locks") / f"{filepath.stem}-{digest}.lock"
114109

115-
Keyed by a hash of the full resolved path, not just the basename, so two
116-
different files that happen to share a name (e.g. two `task.md` in
117-
different repos) never contend for the same lock, while two concurrent
118-
compress runs against the *same* file always do.
119-
"""
120-
digest = hashlib.sha256(str(filepath).encode("utf-8")).hexdigest()[:16]
121-
lock_dir = _state_base_dir("locks")
122-
lock_dir.mkdir(parents=True, exist_ok=True)
123-
return lock_dir / f"{filepath.stem}-{digest}.lock"
124110

111+
def _try_lock_nonblocking(fd: int) -> None:
112+
"""Attempt the OS-native exclusive lock on fd; raises BlockingIOError if another process already holds it."""
113+
if _IS_WINDOWS:
114+
try:
115+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
116+
except OSError as e:
117+
raise BlockingIOError(str(e)) from e
118+
else:
119+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
125120

126-
def _steal_stale_lock(lock_path: Path) -> bool:
127-
"""Remove lock_path if it's older than LOCK_STALE_SECONDS (abandoned by a
128-
crashed or killed process). Returns True if the lock is now gone."""
121+
122+
def _unlock(fd: int) -> None:
123+
"""Release the OS-native lock on fd; swallows errors since callers use this in a finally block."""
129124
try:
130-
age = time.time() - lock_path.stat().st_mtime
125+
if _IS_WINDOWS:
126+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
127+
else:
128+
fcntl.flock(fd, fcntl.LOCK_UN)
131129
except OSError:
132-
return True # already gone (e.g. the lock holder released it between our failed open and this stat)
133-
if age > LOCK_STALE_SECONDS:
134-
try:
135-
lock_path.unlink()
136-
except OSError:
137-
pass
138-
return True
139-
return False
130+
pass
140131

141132

142133
@contextlib.contextmanager
143134
def file_lock(filepath: Path):
144-
"""Cross-session, cross-platform exclusive lock keyed on ``filepath``.
145-
146-
Two Claude Code sessions calling compress_file on the same target
147-
concurrently would otherwise interleave reads/writes of the same file —
148-
one session's finished edit can get silently clobbered by the other's
149-
compression pass, or the two writes can interleave into corrupt content.
150-
``os.O_CREAT | os.O_EXCL`` is an atomic "create only if absent" on both
151-
POSIX and Windows, so it works as a lock with no extra dependency.
152-
"""
135+
"""Cross-session exclusive lock on filepath's resolved path, backed by the OS's own file lock (fcntl.flock on POSIX, msvcrt.locking on Windows) — a crashed or killed holder releases it automatically, so unlike a hand-rolled marker file there's no staleness bookkeeping to get wrong."""
153136
lock_path = lock_path_for(filepath)
154-
deadline = time.monotonic() + LOCK_WAIT_SECONDS
155-
while True:
156-
try:
157-
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
158-
with os.fdopen(fd, "w") as f:
159-
f.write(f"{os.getpid()} {time.time()}")
160-
break
161-
except FileExistsError:
162-
if _steal_stale_lock(lock_path):
163-
continue # stale lock cleared, retry immediately
164-
if time.monotonic() >= deadline:
165-
raise LockTimeoutError(
166-
f"Another caveman-compress run appears to be compressing {filepath} "
167-
f"(lock: {lock_path}). Giving up after {LOCK_WAIT_SECONDS}s — retry once "
168-
"it finishes, or remove the lock file by hand if you know it's stale."
169-
)
170-
time.sleep(LOCK_POLL_INTERVAL)
137+
lock_path.parent.mkdir(parents=True, exist_ok=True)
138+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR)
171139
try:
172-
yield
173-
finally:
140+
if os.fstat(fd).st_size == 0:
141+
os.write(fd, b"\0") # msvcrt.locking needs at least one byte in the file to lock
142+
os.lseek(fd, 0, 0)
143+
deadline = time.monotonic() + LOCK_WAIT_SECONDS
144+
while True:
145+
try:
146+
_try_lock_nonblocking(fd)
147+
break
148+
except BlockingIOError:
149+
if time.monotonic() >= deadline:
150+
raise LockTimeoutError(
151+
f"Another caveman-compress run appears to be compressing {filepath} "
152+
f"(lock: {lock_path}). Giving up after {LOCK_WAIT_SECONDS}s — retry once "
153+
"it finishes."
154+
) from None
155+
time.sleep(LOCK_POLL_INTERVAL)
174156
try:
175-
lock_path.unlink()
176-
except OSError:
177-
pass
157+
yield
158+
finally:
159+
_unlock(fd)
160+
finally:
161+
os.close(fd)
178162

179163

180164
def is_sensitive_path(filepath: Path) -> bool:
@@ -360,17 +344,14 @@ def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str:
360344

361345

362346
def compress_file(filepath: Path) -> bool:
363-
# Resolve first so the lock and every check below key off the same
364-
# canonical path regardless of how the caller spelled it.
347+
# Resolve first so the lock and every check below key off the same canonical path regardless of how the caller spelled it.
365348
filepath = filepath.resolve()
366349
with file_lock(filepath):
367350
return _compress_file_locked(filepath)
368351

369352

370353
def _compress_file_locked(filepath: Path) -> bool:
371-
# Entire read-modify-write-validate-retry sequence below runs under
372-
# compress_file's file_lock — nothing here executes without holding the
373-
# lock for this exact resolved path.
354+
"""Body of compress_file; runs entirely under compress_file's file_lock for this resolved path."""
374355
MAX_FILE_SIZE = 500_000 # 500KB
375356
if not filepath.exists():
376357
raise FileNotFoundError(f"File not found: {filepath}")

tests/test_compress_concurrency.py

Lines changed: 63 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
"""Tests for the cross-session compress lock (concurrency corruption bug).
2-
3-
Two Claude Code sessions running caveman-compress against the same
4-
CLAUDE.md concurrently used to interleave reads/writes with no coordination:
5-
one session's finished edit could get silently clobbered by the other's
6-
in-flight compression pass. `file_lock` serializes access per resolved
7-
target path so only one compress run touches a given file at a time.
8-
"""
1+
"""Tests for the cross-session compress lock — without it, two concurrent caveman-compress runs on the same file interleave reads/writes and silently corrupt output; file_lock serializes access per resolved target path."""
92

103
import os
114
import sys
@@ -23,15 +16,23 @@
2316

2417

2518
class LockPathTests(unittest.TestCase):
26-
def test_same_resolved_path_yields_same_lock_path(self):
27-
with tempfile.TemporaryDirectory() as data_home:
19+
def test_relative_and_resolved_spellings_of_same_file_yield_same_lock_path(self):
20+
with tempfile.TemporaryDirectory() as data_home, tempfile.TemporaryDirectory() as tmp:
2821
with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
29-
p = Path("/tmp/some/dir/task.md")
30-
self.assertEqual(compress_mod.lock_path_for(p), compress_mod.lock_path_for(p))
22+
target_dir = Path(tmp) / "sub"
23+
target_dir.mkdir()
24+
(target_dir / "task.md").write_text("x")
25+
resolved = (target_dir / "task.md").resolve()
26+
relative_cwd = os.getcwd()
27+
try:
28+
os.chdir(tmp)
29+
via_relative = compress_mod.lock_path_for(Path("sub/task.md"))
30+
finally:
31+
os.chdir(relative_cwd)
32+
self.assertEqual(via_relative, compress_mod.lock_path_for(resolved))
3133

3234
def test_same_basename_different_dirs_yields_different_lock_paths(self):
33-
# Two repos each with their own CLAUDE.md must never contend for the
34-
# same lock — only two runs against the *same* file should.
35+
# Two repos each with their own CLAUDE.md must never contend for the same lock — only same-file runs should.
3536
with tempfile.TemporaryDirectory() as data_home:
3637
with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
3738
a = compress_mod.lock_path_for(Path("/repo-a/CLAUDE.md"))
@@ -64,74 +65,89 @@ def try_second():
6465
t2.start()
6566
t1.join(timeout=5)
6667
t2.join(timeout=5)
68+
self.assertFalse(t1.is_alive())
69+
self.assertFalse(t2.is_alive())
6770

6871
self.assertEqual(len(released_first_at), 1)
6972
self.assertEqual(len(acquired_second_at), 1)
70-
# The second lock must not be acquired before the first is released —
71-
# this is the exact race that let two sessions interleave writes.
73+
# The second lock must not be acquired before the first is released — this is the exact race that let two sessions interleave writes.
7274
self.assertGreaterEqual(acquired_second_at[0], released_first_at[0])
7375

74-
def test_lock_file_removed_after_release(self):
76+
def test_lock_file_persists_but_is_unlocked_after_release(self):
7577
with tempfile.TemporaryDirectory() as data_home:
7678
with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
7779
target = Path("/tmp/whatever/CLAUDE.md")
7880
lock_path = compress_mod.lock_path_for(target)
7981
with compress_mod.file_lock(target):
8082
self.assertTrue(lock_path.exists())
81-
self.assertFalse(lock_path.exists())
83+
# OS-native locks are held on the open file description, not the file's existence — the marker file itself is never deleted.
84+
self.assertTrue(lock_path.exists())
85+
start = time.monotonic()
86+
with compress_mod.file_lock(target):
87+
pass
88+
self.assertLess(time.monotonic() - start, 1)
8289

83-
def test_stale_lock_reclaimed_without_waiting_full_timeout(self):
90+
def test_crashed_holder_lock_released_by_os_on_close(self):
8491
with tempfile.TemporaryDirectory() as data_home:
8592
with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
8693
target = Path("/tmp/whatever/CLAUDE.md")
8794
lock_path = compress_mod.lock_path_for(target)
88-
lock_path.write_text("99999 0")
89-
stale_mtime = time.time() - (compress_mod.LOCK_STALE_SECONDS + 5)
90-
os.utime(lock_path, (stale_mtime, stale_mtime))
95+
lock_path.parent.mkdir(parents=True, exist_ok=True)
96+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR)
97+
os.write(fd, b"\0")
98+
os.lseek(fd, 0, 0)
99+
compress_mod._try_lock_nonblocking(fd)
100+
os.close(fd) # simulates the holder process crashing/being killed without a clean unlock
91101

92102
start = time.monotonic()
93103
with mock.patch.object(compress_mod, "LOCK_WAIT_SECONDS", 30):
94104
with compress_mod.file_lock(target):
95105
pass
96-
elapsed = time.monotonic() - start
97-
# Should reclaim near-instantly, not wait anywhere close to the
98-
# (mocked, still generous) 30s wait budget.
99-
self.assertLess(elapsed, 2)
106+
# The OS releases the lock the instant the holder's fd closes — must succeed near-instantly, not wait out the budget.
107+
self.assertLess(time.monotonic() - start, 2)
100108

101109
def test_fresh_lock_not_stolen_and_times_out(self):
102110
with tempfile.TemporaryDirectory() as data_home:
103111
with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
104112
target = Path("/tmp/whatever/CLAUDE.md")
105-
lock_path = compress_mod.lock_path_for(target)
106-
lock_path.write_text(f"{os.getpid()} {time.time()}")
113+
held = threading.Event()
114+
release = threading.Event()
107115

108-
with mock.patch.object(compress_mod, "LOCK_WAIT_SECONDS", 0.2), \
109-
mock.patch.object(compress_mod, "LOCK_POLL_INTERVAL", 0.02):
110-
with self.assertRaises(compress_mod.LockTimeoutError):
111-
with compress_mod.file_lock(target):
112-
pass # pragma: no cover - must never be reached
116+
def hold():
117+
with compress_mod.file_lock(target):
118+
held.set()
119+
release.wait(timeout=5)
120+
121+
holder = threading.Thread(target=hold)
122+
holder.start()
123+
held.wait(timeout=5)
124+
try:
125+
with mock.patch.object(compress_mod, "LOCK_WAIT_SECONDS", 0.2), \
126+
mock.patch.object(compress_mod, "LOCK_POLL_INTERVAL", 0.02):
127+
with self.assertRaises(compress_mod.LockTimeoutError):
128+
with compress_mod.file_lock(target):
129+
pass # pragma: no cover - must never be reached
130+
finally:
131+
release.set()
132+
holder.join(timeout=5)
133+
self.assertFalse(holder.is_alive())
113134

114135
def test_lock_released_on_exception_inside_block(self):
115136
with tempfile.TemporaryDirectory() as data_home:
116137
with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
117138
target = Path("/tmp/whatever/CLAUDE.md")
118-
lock_path = compress_mod.lock_path_for(target)
119139
with self.assertRaises(ValueError):
120140
with compress_mod.file_lock(target):
121141
raise ValueError("boom")
122-
self.assertFalse(lock_path.exists())
142+
start = time.monotonic()
143+
with compress_mod.file_lock(target):
144+
pass
145+
self.assertLess(time.monotonic() - start, 1)
123146

124147

125148
class CompressFileLockIntegrationTests(unittest.TestCase):
126149
def test_concurrent_compress_calls_serialize_instead_of_interleaving(self):
127-
# Two threads calling compress_file on the SAME file concurrently used
128-
# to interleave reads/writes with no coordination. With the lock, the
129-
# second call only starts once the first has fully finished (backup
130-
# written, target written, lock released) — so it deterministically
131-
# hits the existing "backup already exists" guard instead of racing
132-
# the first call's in-flight write. Neither outcome is corruption;
133-
# what matters is there's exactly one call_claude invocation (no
134-
# overlap) and the target ends up with the first call's clean output.
150+
# Two threads on the SAME file used to interleave; the lock instead serializes them so exactly one call_claude runs.
135151
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as data_home:
136152
with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
137153
original = "# Heading\n\nProse to compress, long enough to pass the identity check here.\n"
@@ -162,15 +178,15 @@ def run():
162178
t2.start()
163179
t1.join(timeout=10)
164180
t2.join(timeout=10)
181+
self.assertFalse(t1.is_alive())
182+
self.assertFalse(t2.is_alive())
165183

166-
# Exactly one compression ever ran — the lock prevented the
167-
# second thread from ever reading/writing the file while the
168-
# first was mid-flight. That's what closes the actual race.
184+
# Exactly one compression ran — the lock stopped the second thread from touching the file while the first was mid-flight.
169185
self.assertEqual(len(call_starts), 1)
170186
self.assertEqual(len(results), 2)
171187
self.assertEqual(sorted(results), [False, True])
172188
self.assertEqual(path.read_text(encoding="utf-8"), compressed)
173-
self.assertFalse(lock_path.exists())
189+
self.assertTrue(lock_path.exists())
174190

175191

176192
if __name__ == "__main__":

0 commit comments

Comments
 (0)