|
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.""" |
9 | 2 |
|
10 | 3 | import os |
11 | 4 | import sys |
|
23 | 16 |
|
24 | 17 |
|
25 | 18 | 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: |
28 | 21 | 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)) |
31 | 33 |
|
32 | 34 | 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. |
35 | 36 | with tempfile.TemporaryDirectory() as data_home: |
36 | 37 | with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}): |
37 | 38 | a = compress_mod.lock_path_for(Path("/repo-a/CLAUDE.md")) |
@@ -64,74 +65,89 @@ def try_second(): |
64 | 65 | t2.start() |
65 | 66 | t1.join(timeout=5) |
66 | 67 | t2.join(timeout=5) |
| 68 | + self.assertFalse(t1.is_alive()) |
| 69 | + self.assertFalse(t2.is_alive()) |
67 | 70 |
|
68 | 71 | self.assertEqual(len(released_first_at), 1) |
69 | 72 | 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. |
72 | 74 | self.assertGreaterEqual(acquired_second_at[0], released_first_at[0]) |
73 | 75 |
|
74 | | - def test_lock_file_removed_after_release(self): |
| 76 | + def test_lock_file_persists_but_is_unlocked_after_release(self): |
75 | 77 | with tempfile.TemporaryDirectory() as data_home: |
76 | 78 | with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}): |
77 | 79 | target = Path("/tmp/whatever/CLAUDE.md") |
78 | 80 | lock_path = compress_mod.lock_path_for(target) |
79 | 81 | with compress_mod.file_lock(target): |
80 | 82 | 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) |
82 | 89 |
|
83 | | - def test_stale_lock_reclaimed_without_waiting_full_timeout(self): |
| 90 | + def test_crashed_holder_lock_released_by_os_on_close(self): |
84 | 91 | with tempfile.TemporaryDirectory() as data_home: |
85 | 92 | with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}): |
86 | 93 | target = Path("/tmp/whatever/CLAUDE.md") |
87 | 94 | 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 |
91 | 101 |
|
92 | 102 | start = time.monotonic() |
93 | 103 | with mock.patch.object(compress_mod, "LOCK_WAIT_SECONDS", 30): |
94 | 104 | with compress_mod.file_lock(target): |
95 | 105 | 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) |
100 | 108 |
|
101 | 109 | def test_fresh_lock_not_stolen_and_times_out(self): |
102 | 110 | with tempfile.TemporaryDirectory() as data_home: |
103 | 111 | with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}): |
104 | 112 | 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() |
107 | 115 |
|
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()) |
113 | 134 |
|
114 | 135 | def test_lock_released_on_exception_inside_block(self): |
115 | 136 | with tempfile.TemporaryDirectory() as data_home: |
116 | 137 | with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}): |
117 | 138 | target = Path("/tmp/whatever/CLAUDE.md") |
118 | | - lock_path = compress_mod.lock_path_for(target) |
119 | 139 | with self.assertRaises(ValueError): |
120 | 140 | with compress_mod.file_lock(target): |
121 | 141 | 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) |
123 | 146 |
|
124 | 147 |
|
125 | 148 | class CompressFileLockIntegrationTests(unittest.TestCase): |
126 | 149 | 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. |
135 | 151 | with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as data_home: |
136 | 152 | with mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}): |
137 | 153 | original = "# Heading\n\nProse to compress, long enough to pass the identity check here.\n" |
@@ -162,15 +178,15 @@ def run(): |
162 | 178 | t2.start() |
163 | 179 | t1.join(timeout=10) |
164 | 180 | t2.join(timeout=10) |
| 181 | + self.assertFalse(t1.is_alive()) |
| 182 | + self.assertFalse(t2.is_alive()) |
165 | 183 |
|
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. |
169 | 185 | self.assertEqual(len(call_starts), 1) |
170 | 186 | self.assertEqual(len(results), 2) |
171 | 187 | self.assertEqual(sorted(results), [False, True]) |
172 | 188 | self.assertEqual(path.read_text(encoding="utf-8"), compressed) |
173 | | - self.assertFalse(lock_path.exists()) |
| 189 | + self.assertTrue(lock_path.exists()) |
174 | 190 |
|
175 | 191 |
|
176 | 192 | if __name__ == "__main__": |
|
0 commit comments