Skip to content

Fix lifecycle-lock acquire() race that allowed duplicate daemons#46

Open
danielhertz1999-bit wants to merge 1 commit into
CodeAbra:mainfrom
danielhertz1999-bit:fix/lifecycle-lock-acquire-race
Open

Fix lifecycle-lock acquire() race that allowed duplicate daemons#46
danielhertz1999-bit wants to merge 1 commit into
CodeAbra:mainfrom
danielhertz1999-bit:fix/lifecycle-lock-acquire-race

Conversation

@danielhertz1999-bit

Copy link
Copy Markdown
Contributor

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 passed acquire() without raising LifecycleLockConflict. The singleton guard in daemon.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:

  1. The iai-mcp-daemon scheduled task, which runs the repo .venv interpreter (<repo>\.venv\Scripts\pythonw.exe -m iai_mcp.daemon).
  2. The wrapper/doctor respawn pathdoctor._respawn_daemon() spawns [sys.executable, "-m", "iai_mcp.daemon"], and when triggered from a globally-installed iai-mcp, sys.executable resolves 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.token end up pointing at whichever process wrote last. Observed live: two iai_mcp.daemon processes 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 with os.open(..., O_CREAT | O_EXCL | O_WRONLY, 0o600). Exactly one racer can create the file; every other gets FileExistsError and concedes, so its daemon exits cleanly. Cross-platform (Windows + POSIX); preserves 0o600 (with an explicit chmod since O_EXCL's mode is umask-subject on POSIX).
  • Safe stale-eviction: a leftover lock (dead pid / corrupt / another host — the real dead-pid boot state) is evicted with an atomic os.replace that 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

  • All existing lock tests preserved: stale recovery, corrupt-file overwrite, cross-host acquire, mode-0600, live-holder conflict.
  • Adds 3 regression tests, including a 12-thread concurrent-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 old os.replace() path (the concurrent test even surfaces PermissionError [WinError 5] on Windows) and pass with this change.

No behavior change on the single-launcher path.

🤖 Generated with Claude Code

@danielhertz1999-bit danielhertz1999-bit force-pushed the fix/lifecycle-lock-acquire-race branch from 5f9e7e8 to e766f0c Compare July 4, 2026 23:34
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant