fix(artifacts): lock library.json's read-modify-write across processes - #4
fix(artifacts): lock library.json's read-modify-write across processes#4dudarenok-maker wants to merge 2 commits into
Conversation
update_artifact_library_live and append_artifact_library_version each read the whole file, mutate one key, and write it back with no cross-process coordination - atomic_write_json only makes the final write atomic, not the read-modify-write cycle around it. Two Ringer lanes finishing close together race: whichever holds a stale read writes last and silently reverts whatever the other lane just added. A high-frequency lane (claude, cline) self-heals within minutes; a quota-limited lane that runs rarely (cline-glm, cline-qwen-cloud, cline-muse-local, cline-qwen-local) loses its entry for good the next time a busier lane races past it with a stale read, because nothing re-adds it until that lane happens to run again. Confirmed on a real box: cline-glm has real run/report/version files on disk going back weeks with no matching library.json entry at all. Adds artifact_library_lock(), an exclusive-create lockfile mutex (Windows- safe, unlike this file's existing catalog_refresh_lock, which imports fcntl and silently no-ops on native Windows), wrapping both critical sections. Steals a stale lock past a timeout so a crashed holder can't wedge every other lane's library update forever. Two new regression tests: one reproduces the exact lost-update symptom (fails without the fix, passes with it - verified by neutralizing the lock and re-running), one pins the lock's mutual exclusion directly.
PR review — pass 1 (head 2a689b3, depth medium)Scope: Verdict up front: the lock is correctly placed at the two sites it wraps, and the read is correctly inside the critical section — but the bug the PR set out to fix is still reproducible after the fix, and on Windows the new lock actively drops writes under contention. Three blocking items. 🔴 Blocking — the reported symptom still reproduces: a third read-modify-write site was missed
So the most frequent writer in the system is still unlocked, and it writes back an entire library snapshot taken before it did its Executed repro (
Knock-on: reconcile's stale write-back can also resurrect Fix: wrap 🔴 Blocking — on Windows, contended acquisition raises an uncaught
|
Pass 1 review of #4 found the create/delete lockfile mutex was wrong in three ways, each only visible under real syscall contention rather than a slowed-down/gated repro: 1. Windows raises PermissionError, not FileExistsError, when a create races the delete-pending window left by the PREVIOUS holder's own unlink() - only FileExistsError was caught, so a real contended acquisition threw uncaught out of a best-effort caller. Measured ~7.5% of contended acquisitions on this box. 2. The stale-lock steal check (stat the mtime, then unlink) is two syscalls with a gap between them - two waiters could each judge the same lock stale and both proceed. Reproduced: two concurrent holders. 3. The 10s timeout was SHORTER than the 30s staleness window meant to let a waiter steal past a genuinely live holder, so a real holder's presence made the timeout fire first every time, silently. Replaces the whole create/delete design with an OS-level byte-range lock on a persistent file (msvcrt on Windows, flock on POSIX) - no create/delete race because the file is never deleted, no staleness question because the OS releases the lock the instant the holding process's handle closes (crash included), and no two processes can ever both hold it because the OS enforces that, not this code. Also wraps reconcile_artifact_library_dead_runs, a third read-modify-write site on library.json the first pass missed - it runs at every run start AND every HUD dashboard poll, making it the system's most frequent reader-and-writer of the file this whole fix concerns. Two more defects surfaced by stress-testing the redesign under real (not slowed-down) heavy contention, past what the review itself caught: 4. os.replace() (MoveFileEx) transiently fails with PermissionError when the destination has been momentarily touched by another handle (a virus scanner's real-time scan of the file just written) - self-resolving within milliseconds, but with no retry it escaped as data loss. Adds a bounded retry to atomic_write_text, used by every atomic writer in this file, not just the library. 5. The give-up-after-timeout fallback is silent by design - under contention that genuinely outlasts the timeout, it reproduces the ORIGINAL lost-update bug with zero exception raised. Since the lock is now crash-safe there is no longer a reason for a short timeout; widened it and made the give-up path print to stderr rather than stay silent. Test changes: threaded-test exceptions don't fail unittest on their own, which is why the first pass's own regression test stayed green through a silently-swallowed PermissionError - every threaded test now routes through a shared helper that fails on any thread exception. Added a heavy- contention stress test (20 threads x 25 writes, no pacing) and a test that forces the timeout path and asserts it is loud, not silent.
Summary
update_artifact_library_live/append_artifact_library_versionread-modify-write~/.ringer/artifacts/library.jsonwith no cross-process coordinationcline-glmhad real run/report/version files with no matchinglibrary.jsonentry - a quota-limited lane that runs rarely never gets a chance to self-heal the way high-frequency lanes (claude,cline) doartifact_library_lock(), a Windows-safe exclusive-create lockfile mutex (this file's existingcatalog_refresh_lockusesfcntl, which silently no-ops on native Windows), around both critical sections, with stale-lock stealing so a crashed holder can't wedge every lane's update foreverTest plan
pytest tests/test_artifact_library.py- all passpytest tests/) - same 37 pre-existing, unrelated failures before and after; no regressions