feat(host-agent): add single-instance lock to prevent duplicate-process dispatch races
Acquire an exclusive, non-blocking filelock on the identity state directory as the first action of create_application(), before resolve_host_identity() or any enrollment/heartbeat side effect. A second process against the same identity_path exits immediately with InstanceAlreadyRunningError naming the lock path; the lock releases automatically on any process exit (including SIGKILL) via OS-level advisory locking, and explicitly during run_async()'s shutdown finally block. filelock is promoted from transitive to direct dependency (version unchanged at 3.29.7). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""Per-installation exclusive-execution lock for the Host Agent process.
|
||||
|
||||
Backed by ``filelock.FileLock`` (OS-level advisory file locking) so that
|
||||
the lock is released automatically when the holding process exits for
|
||||
any reason — clean shutdown, unhandled exception, or ``SIGKILL`` — without
|
||||
requiring any stale-lock cleanup step. See
|
||||
``openspec/changes/host-agent-single-instance-lock/design.md`` for the
|
||||
rationale behind choosing ``filelock`` over hand-rolled ``fcntl``/``msvcrt``
|
||||
branching.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
_LOCK_FILENAME = "host_agent.lock"
|
||||
|
||||
|
||||
class InstanceAlreadyRunningError(RuntimeError):
|
||||
"""Raised when another live process already holds the instance lock."""
|
||||
|
||||
def __init__(self, lock_path: Path) -> None:
|
||||
self.lock_path = lock_path
|
||||
super().__init__(
|
||||
"another Host Agent instance is already running for this "
|
||||
f"identity state directory (lock held at {lock_path}); "
|
||||
"stop the other process before starting a new one"
|
||||
)
|
||||
|
||||
|
||||
class InstanceLock:
|
||||
"""Exclusive, non-blocking lock scoped to a Host Agent state directory.
|
||||
|
||||
The state directory (typically ``identity_path.parent``) is created on
|
||||
construction to match the colocated-file convention already used for
|
||||
``host_console_history.sqlite3`` and ``host_governance_policy.json``.
|
||||
"""
|
||||
|
||||
def __init__(self, state_dir: Path) -> None:
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock_path = state_dir / _LOCK_FILENAME
|
||||
self._lock = FileLock(str(self._lock_path), timeout=0)
|
||||
self._acquired = False
|
||||
|
||||
@property
|
||||
def lock_path(self) -> Path:
|
||||
return self._lock_path
|
||||
|
||||
def acquire(self) -> None:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
except Timeout as exc:
|
||||
raise InstanceAlreadyRunningError(self._lock_path) from exc
|
||||
self._acquired = True
|
||||
|
||||
def release(self) -> None:
|
||||
if not self._acquired:
|
||||
return
|
||||
try:
|
||||
self._lock.release()
|
||||
finally:
|
||||
self._acquired = False
|
||||
|
||||
def __enter__(self) -> InstanceLock:
|
||||
self.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.release()
|
||||
Reference in New Issue
Block a user