Fix lifecycle-lock acquire() race that allowed duplicate daemons#46
Open
danielhertz1999-bit wants to merge 1 commit into
Open
Fix lifecycle-lock acquire() race that allowed duplicate daemons#46danielhertz1999-bit wants to merge 1 commit into
danielhertz1999-bit wants to merge 1 commit into
Conversation
5f9e7e8 to
e766f0c
Compare
LifecycleLock.acquire() did read -> check-for-live-holder -> write, with no atomicity. Two daemons launched in the same instant both read "no live lock", both wrote, and both passed acquire() without raising LifecycleLockConflict. The singleton guard in daemon.main() that is meant to make the loser exit therefore never fired, so one daemon kept running as a store-contending orphan. This reproduces reliably on Windows, where two independent launchers fire together: the `iai-mcp-daemon` scheduled task (repo .venv interpreter) and the wrapper/doctor respawn path (doctor._respawn_daemon uses sys.executable, resolving to a global Python). Because the Windows daemon binds an ephemeral port (start_server on 127.0.0.1:0), there is no bind collision to catch the duplicate either -- both bind, and .locked/.daemon.port/.daemon.token point at whichever wrote last. A bare O_CREAT|O_EXCL create is not sufficient: reclaiming a *stale* dead-pid lock still needs check-then-remove, and there is no atomic "remove this file only if it is dead" primitive -- under contention a racer can evict a lock a peer just created and briefly reopen the window (an earlier O_EXCL-plus-eviction attempt failed a 12-thread stress test ~10% of runs). Serialise the whole read-check-write behind an OS advisory lock (fcntl.flock on POSIX, msvcrt LockFile on Windows) on a sidecar `.guard` file. Only one acquirer across all processes runs the critical section at a time, which makes the simple read-check-write correct; the guard is held by the open handle and released automatically if the holder dies, so it cannot wedge. Waiting on the guard is bounded (_GUARD_TIMEOUT_SEC) so a stuck peer surfaces as a TimeoutError rather than an indefinite hang. All existing lock tests preserved (stale recovery, corrupt overwrite, cross-host, mode-0600, live-holder conflict). Adds a 12-thread concurrent acquire() stress test asserting exactly one winner (20/20 runs green; the pre-fix path fails it), plus a guard release/re-acquire test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e766f0c to
395c563
Compare
17 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
LifecycleLock.acquire()used a read → check →os.replace()sequence with no atomicity (last-writer-wins). Two daemons launched in the same instant both read "no live lock", both wrote, and both passedacquire()without raisingLifecycleLockConflict. The singleton guard indaemon.main()that is meant to make the loser exit (return 1) therefore never fired, so one daemon kept running as a store-contending orphan.Why it reproduces on Windows
Two independent launchers fire together:
iai-mcp-daemonscheduled task, which runs the repo.venvinterpreter (<repo>\.venv\Scripts\pythonw.exe -m iai_mcp.daemon).doctor._respawn_daemon()spawns[sys.executable, "-m", "iai_mcp.daemon"], and when triggered from a globally-installediai-mcp,sys.executableresolves to a different (global) Python.Because the Windows daemon binds an ephemeral port (
asyncio.start_server(..., "127.0.0.1", 0)in_ipc.start_ipc_server), there is no bind collision to catch the duplicate either — both bind successfully, and.locked/.daemon.port/.daemon.tokenend up pointing at whichever process wrote last. Observed live: twoiai_mcp.daemonprocesses started at the identical timestamp, one holding the published port, the other a portless orphan holding the store open for 13+ hours.Fix
Make the claim atomic:
_write_exclusive()creates the lock withos.open(..., O_CREAT | O_EXCL | O_WRONLY, 0o600). Exactly one racer can create the file; every other getsFileExistsErrorand concedes, so its daemon exits cleanly. Cross-platform (Windows + POSIX); preserves0o600(with an explicitchmodsinceO_EXCL's mode is umask-subject on POSIX).os.replacethat only one racer can win; a post-steal liveness re-check restores the file and concedes if a live daemon created it in the race window, so a real daemon's lock is never stolen. Bounded retry via_ACQUIRE_MAX_ATTEMPTS.Tests
acquire()stress test asserting exactly one winner and N-1 conflicts, from a seeded dead-pid stale lock (the real dual-launch boot state). All three fail against the oldos.replace()path (the concurrent test even surfacesPermissionError [WinError 5]on Windows) and pass with this change.No behavior change on the single-launcher path.
🤖 Generated with Claude Code