gabriel / muse public
test_coord_write_remote_atomic.py python
406 lines 16.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """
2 Tests for the bug: _write_remote_records uses write_text() which truncates
3 the target file to 0 bytes before writing. Any concurrent reader between the
4 truncation and write completion sees either 0 bytes or partial/corrupt content.
5
6 Root cause (coord_sync.py::_write_remote_records, last line):
7
8 target.write_text(json.dumps(rec) + "\n", encoding="utf-8")
9
10 write_text() opens with mode 'w', which truncates to 0 bytes immediately.
11 The write then fills the file. Between truncation and final close() there is
12 a window where the file is partially written or empty.
13
14 The fix: use write_text_atomic() which writes to a mkstemp temp file and then
15 os.rename()s into place. rename() is atomic on POSIX — readers always see
16 either the old content or the complete new content, never 0 bytes.
17
18 write_text_atomic is already imported in coord_sync.py and used throughout
19 the codebase for exactly this purpose.
20
21 Coverage:
22 Unit — _write_remote_records produces valid files post-write
23 Concurrency — concurrent writers never leave 0-byte or invalid-JSON files
24 Data integrity — written content exactly matches the source record
25 Idempotency — overwriting same UUID produces correct final state
26 Security — path validation still enforced after atomicity fix
27 Performance — atomic write is not dramatically slower than write_text
28 Regression — all existing _write_remote_records security tests still pass
29 """
30 from __future__ import annotations
31
32 import json
33 import pathlib
34 import threading
35 import time
36 from unittest.mock import patch
37
38 import pytest
39
40 from muse.core._types import MsgpackDict
41
42 # ---------------------------------------------------------------------------
43 # Helpers
44 # ---------------------------------------------------------------------------
45
46 _ALL_KINDS = ("reservation", "intent", "release", "heartbeat", "dependency", "task", "claim")
47 _FUTURE_TS = "2099-12-31T23:59:59+00:00"
48
49
50 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
51 (tmp_path / ".muse").mkdir(parents=True, exist_ok=True)
52 return tmp_path
53
54
55 def _record(kind: str, uuid: str, payload_size: int = 64) -> MsgpackDict:
56 return {
57 "kind": kind,
58 "record_uuid": uuid,
59 "run_id": "run-torvalds",
60 "payload": {"data": "k" * payload_size},
61 "expires_at": _FUTURE_TS,
62 }
63
64
65 def _remote_path(root: pathlib.Path, kind: str, uuid: str) -> pathlib.Path:
66 return root / ".muse" / "coordination" / "remote" / kind / f"{uuid}.json"
67
68
69 def _write_remote(root: pathlib.Path, records: list[dict]) -> None:
70 from muse.cli.commands.coord_sync import _write_remote_records
71 _write_remote_records(root, records)
72
73
74 # =============================================================================
75 # 1. UNIT — basic correctness after write
76 # =============================================================================
77
78 class TestWriteRemoteUnit:
79
80 def test_written_file_is_valid_json(self, tmp_path: pathlib.Path) -> None:
81 root = _make_repo(tmp_path)
82 rec = _record("reservation", "res-000001")
83 _write_remote(root, [rec])
84 path = _remote_path(root, "reservation", "res-000001")
85 assert path.exists()
86 data = json.loads(path.read_text())
87 assert data["kind"] == "reservation"
88 assert data["record_uuid"] == "res-000001"
89
90 def test_written_file_content_exactly_matches_record(self, tmp_path: pathlib.Path) -> None:
91 root = _make_repo(tmp_path)
92 rec = _record("task", "task-abc123", payload_size=256)
93 _write_remote(root, [rec])
94 path = _remote_path(root, "task", "task-abc123")
95 data = json.loads(path.read_text())
96 assert data == rec
97
98 def test_file_is_never_empty_after_write(self, tmp_path: pathlib.Path) -> None:
99 root = _make_repo(tmp_path)
100 for i in range(20):
101 rec = _record("heartbeat", f"hb-{i:04d}")
102 _write_remote(root, [rec])
103 path = _remote_path(root, "heartbeat", f"hb-{i:04d}")
104 content = path.read_text()
105 assert content, f"file {path} is empty after write"
106
107 def test_overwrite_produces_new_content(self, tmp_path: pathlib.Path) -> None:
108 root = _make_repo(tmp_path)
109 rec_v1 = _record("claim", "claim-001")
110 rec_v1["payload"] = {"version": 1}
111 _write_remote(root, [rec_v1])
112
113 rec_v2 = _record("claim", "claim-001")
114 rec_v2["payload"] = {"version": 2}
115 _write_remote(root, [rec_v2])
116
117 path = _remote_path(root, "claim", "claim-001")
118 data = json.loads(path.read_text())
119 assert data["payload"]["version"] == 2
120
121 def test_all_seven_kinds_written_correctly(self, tmp_path: pathlib.Path) -> None:
122 root = _make_repo(tmp_path)
123 records = [_record(kind, f"{kind}-001") for kind in _ALL_KINDS]
124 _write_remote(root, records)
125 for kind in _ALL_KINDS:
126 path = _remote_path(root, kind, f"{kind}-001")
127 assert path.exists(), f"{kind} file not written"
128 data = json.loads(path.read_text())
129 assert data["kind"] == kind
130
131
132 # =============================================================================
133 # 2. CONCURRENCY — concurrent writers must never produce 0-byte or corrupt files
134 # =============================================================================
135
136 class TestWriteRemoteConcurrency:
137 """
138 These tests FAIL before the fix (write_text) and PASS after (write_text_atomic).
139
140 The bug: write_text opens with 'w' which truncates to 0 bytes before writing.
141 Between truncation and close(), concurrent readers see empty or partial files.
142 """
143
144 def test_concurrent_writes_same_uuid_no_zero_byte_reads(self, tmp_path: pathlib.Path) -> None:
145 """
146 Writer thread: overwrites the same file 500 times.
147 Reader thread: reads the file concurrently, checks for 0-byte or corrupt reads.
148 """
149 root = _make_repo(tmp_path)
150 rec = _record("reservation", "res-concurrent", payload_size=4096)
151 # Prime the file
152 _write_remote(root, [rec])
153
154 target = _remote_path(root, "reservation", "res-concurrent")
155 zero_byte_reads: list[int] = []
156 corrupt_reads: list[str] = []
157 stop = threading.Event()
158
159 def writer() -> None:
160 for _ in range(500):
161 _write_remote(root, [rec])
162
163 def reader() -> None:
164 iteration = 0
165 while not stop.is_set():
166 try:
167 content = target.read_text()
168 if not content.strip():
169 zero_byte_reads.append(iteration)
170 else:
171 json.loads(content)
172 except json.JSONDecodeError as exc:
173 corrupt_reads.append(str(exc))
174 except FileNotFoundError:
175 pass # transient — rename may briefly make old name disappear
176 iteration += 1
177
178 t_writer = threading.Thread(target=writer)
179 t_reader = threading.Thread(target=reader)
180 t_reader.start()
181 t_writer.start()
182 t_writer.join()
183 stop.set()
184 t_reader.join()
185
186 assert zero_byte_reads == [], (
187 f"Saw {len(zero_byte_reads)} zero-byte reads — write_text truncation window exposed.\n"
188 f"First occurrence at reader iteration {zero_byte_reads[0]}.\n"
189 f"Fix: use write_text_atomic() instead of write_text()."
190 )
191 assert corrupt_reads == [], (
192 f"Saw {len(corrupt_reads)} corrupt-JSON reads — torn write occurred.\n"
193 f"First error: {corrupt_reads[0]}"
194 )
195
196 def test_concurrent_writes_different_uuids_all_valid(self, tmp_path: pathlib.Path) -> None:
197 """
198 8 threads each writing their own UUID 100 times.
199 All files must be valid JSON after all threads complete.
200 """
201 root = _make_repo(tmp_path)
202 n_threads = 8
203 n_writes = 100
204
205 def worker(idx: int) -> None:
206 rec = _record("intent", f"intent-{idx:04d}", payload_size=2048)
207 for _ in range(n_writes):
208 _write_remote(root, [rec])
209
210 threads = [threading.Thread(target=worker, args=(i,)) for i in range(n_threads)]
211 for t in threads:
212 t.start()
213 for t in threads:
214 t.join()
215
216 # All files must be valid JSON
217 for i in range(n_threads):
218 path = _remote_path(root, "intent", f"intent-{i:04d}")
219 assert path.exists(), f"intent-{i:04d}.json missing"
220 content = path.read_text()
221 assert content, f"intent-{i:04d}.json is empty"
222 data = json.loads(content)
223 assert data["record_uuid"] == f"intent-{i:04d}"
224
225 def test_concurrent_pull_and_read_no_corruption(self, tmp_path: pathlib.Path) -> None:
226 """
227 Simulates two concurrent coord sync pull operations writing to the same remote dir.
228 Both pull the same record. Reader checks consistency throughout.
229 """
230 root = _make_repo(tmp_path)
231 rec = _record("release", "rel-kernel-6-14", payload_size=8192)
232 _write_remote(root, [rec]) # prime
233
234 target = _remote_path(root, "release", "rel-kernel-6-14")
235 errors: list[str] = []
236 stop = threading.Event()
237
238 def puller() -> None:
239 for _ in range(300):
240 _write_remote(root, [rec])
241
242 def reader() -> None:
243 while not stop.is_set():
244 try:
245 content = target.read_text()
246 if not content.strip():
247 errors.append("zero-byte file read")
248 else:
249 json.loads(content)
250 except json.JSONDecodeError as exc:
251 errors.append(f"corrupt JSON: {exc}")
252 except FileNotFoundError:
253 pass
254
255 t1 = threading.Thread(target=puller)
256 t2 = threading.Thread(target=puller)
257 t_reader = threading.Thread(target=reader)
258
259 t_reader.start()
260 t1.start()
261 t2.start()
262 t1.join()
263 t2.join()
264 stop.set()
265 t_reader.join()
266
267 assert errors == [], (
268 f"{len(errors)} corruption events during concurrent pull simulation.\n"
269 f"First: {errors[0]}"
270 )
271
272
273 # =============================================================================
274 # 3. DATA INTEGRITY — written content survives overwrite correctly
275 # =============================================================================
276
277 class TestWriteRemoteDataIntegrity:
278
279 def test_large_payload_written_completely(self, tmp_path: pathlib.Path) -> None:
280 """50KB payload must be written and read back completely."""
281 root = _make_repo(tmp_path)
282 rec = _record("dependency", "dep-kernel-mm", payload_size=50 * 1024)
283 _write_remote(root, [rec])
284
285 path = _remote_path(root, "dependency", "dep-kernel-mm")
286 data = json.loads(path.read_text())
287 assert len(data["payload"]["data"]) == 50 * 1024
288
289 def test_sequential_overwrites_produce_correct_final_state(self, tmp_path: pathlib.Path) -> None:
290 """100 sequential overwrites of the same UUID — final content is correct."""
291 root = _make_repo(tmp_path)
292 for version in range(100):
293 rec = _record("reservation", "res-overwrite")
294 rec["payload"]["version"] = version
295 _write_remote(root, [rec])
296
297 path = _remote_path(root, "reservation", "res-overwrite")
298 data = json.loads(path.read_text())
299 assert data["payload"]["version"] == 99
300
301 def test_batch_write_all_files_present(self, tmp_path: pathlib.Path) -> None:
302 """Writing 1000 records in one call — all files must be present and valid."""
303 root = _make_repo(tmp_path)
304 records = [
305 _record(_ALL_KINDS[i % len(_ALL_KINDS)], f"rec-{i:06d}")
306 for i in range(1000)
307 ]
308 _write_remote(root, records)
309
310 for i, rec in enumerate(records):
311 path = _remote_path(root, rec["kind"], f"rec-{i:06d}")
312 assert path.exists(), f"rec-{i:06d} missing"
313 data = json.loads(path.read_text())
314 assert data["record_uuid"] == f"rec-{i:06d}"
315
316 def test_file_never_partially_written_single_thread(self, tmp_path: pathlib.Path) -> None:
317 """Single-threaded: read immediately after each write must be complete."""
318 root = _make_repo(tmp_path)
319 for i in range(50):
320 rec = _record("task", "task-sequential", payload_size=1024 * (i % 10 + 1))
321 _write_remote(root, [rec])
322 path = _remote_path(root, "task", "task-sequential")
323 content = path.read_text()
324 assert content, f"empty file after write {i}"
325 data = json.loads(content)
326 assert data == rec, f"content mismatch at write {i}"
327
328
329 # =============================================================================
330 # 4. SECURITY — path validation still enforced (regression)
331 # =============================================================================
332
333 class TestWriteRemoteSecurity:
334 """Verify the fix doesn't break existing security validation."""
335
336 def test_unknown_kind_rejected(self, tmp_path: pathlib.Path) -> None:
337 root = _make_repo(tmp_path)
338 rec = {"kind": "../evil", "record_uuid": "safe-uuid", "payload": {}}
339 _write_remote(root, [rec])
340 evil_path = root / ".muse" / "coordination" / "remote" / ".." / "evil" / "safe-uuid.json"
341 assert not evil_path.resolve().exists(), "path traversal via kind succeeded"
342
343 def test_unsafe_uuid_rejected(self, tmp_path: pathlib.Path) -> None:
344 root = _make_repo(tmp_path)
345 rec = {"kind": "reservation", "record_uuid": "../../../etc/passwd", "payload": {}}
346 _write_remote(root, [rec])
347 evil = root / ".muse" / "coordination" / "remote" / "reservation" / "../../../etc/passwd.json"
348 assert not pathlib.Path("/etc/passwd.json").exists() or True # sanity
349 # The file must not be written outside the remote/ dir
350 remote_dir = root / ".muse" / "coordination" / "remote"
351 written = list(remote_dir.rglob("*.json")) if remote_dir.exists() else []
352 assert written == [], f"unsafe UUID produced files: {written}"
353
354 def test_null_byte_uuid_rejected(self, tmp_path: pathlib.Path) -> None:
355 root = _make_repo(tmp_path)
356 rec = {"kind": "reservation", "record_uuid": "valid\x00evil", "payload": {}}
357 _write_remote(root, [rec])
358 remote_dir = root / ".muse" / "coordination" / "remote"
359 written = list(remote_dir.rglob("*.json")) if remote_dir.exists() else []
360 assert written == [], f"null-byte UUID produced files: {written}"
361
362 def test_valid_records_still_written_after_invalid_ones_skipped(self, tmp_path: pathlib.Path) -> None:
363 root = _make_repo(tmp_path)
364 records = [
365 {"kind": "../evil", "record_uuid": "uuid-1", "payload": {}}, # bad kind
366 _record("reservation", "res-valid-001"), # good
367 {"kind": "reservation", "record_uuid": "../bad", "payload": {}}, # bad uuid
368 _record("task", "task-valid-001"), # good
369 ]
370 _write_remote(root, records)
371
372 assert _remote_path(root, "reservation", "res-valid-001").exists()
373 assert _remote_path(root, "task", "task-valid-001").exists()
374 # Only 2 valid files
375 remote_dir = root / ".muse" / "coordination" / "remote"
376 written = list(remote_dir.rglob("*.json"))
377 assert len(written) == 2
378
379
380 # =============================================================================
381 # 5. PERFORMANCE — atomic write is not dramatically slower than write_text
382 # =============================================================================
383
384 class TestWriteRemotePerformance:
385
386 def test_500_records_completes_under_3s(self, tmp_path: pathlib.Path) -> None:
387 root = _make_repo(tmp_path)
388 records = [
389 _record(_ALL_KINDS[i % len(_ALL_KINDS)], f"perf-{i:06d}", payload_size=512)
390 for i in range(500)
391 ]
392 t0 = time.monotonic()
393 _write_remote(root, records)
394 elapsed = time.monotonic() - t0
395 assert elapsed < 3.0, f"500 atomic writes took {elapsed:.3f}s (> 3s)"
396
397 def test_atomic_write_throughput_records_per_second(self, tmp_path: pathlib.Path) -> None:
398 root = _make_repo(tmp_path)
399 records = [_record("reservation", f"tput-{i:06d}") for i in range(200)]
400
401 t0 = time.monotonic()
402 _write_remote(root, records)
403 elapsed = time.monotonic() - t0
404
405 rps = 200 / elapsed if elapsed > 0 else float("inf")
406 assert rps >= 50, f"write_remote throughput {rps:.0f} rec/s is below minimum 50/s"
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago