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:
2026-07-14 15:49:30 +08:00
co-authored by Claude Opus 4.6
parent 99bde4febb
commit d00ada67a5
9 changed files with 444 additions and 102 deletions
@@ -0,0 +1,80 @@
from __future__ import annotations
import pytest
from host_agent.instance_lock import (
InstanceAlreadyRunningError,
InstanceLock,
)
def test_acquire_succeeds_when_free(tmp_path) -> None:
lock = InstanceLock(tmp_path)
lock.acquire()
assert lock.lock_path == tmp_path / "host_agent.lock"
lock.release()
def test_acquire_raises_when_already_held(tmp_path) -> None:
first = InstanceLock(tmp_path)
first.acquire()
try:
second = InstanceLock(tmp_path)
with pytest.raises(InstanceAlreadyRunningError) as info:
second.acquire()
assert info.value.lock_path == tmp_path / "host_agent.lock"
assert "another Host Agent instance" in str(info.value)
finally:
first.release()
def test_release_then_reacquire_succeeds(tmp_path) -> None:
lock = InstanceLock(tmp_path)
lock.acquire()
lock.release()
# Same handle can reacquire after release.
lock.acquire()
lock.release()
# A fresh handle against the same path also succeeds.
other = InstanceLock(tmp_path)
other.acquire()
other.release()
def test_release_is_idempotent_and_safe_before_acquire(tmp_path) -> None:
lock = InstanceLock(tmp_path)
# Releasing before acquire must be a no-op, not raise.
lock.release()
lock.acquire()
lock.release()
lock.release()
def test_independent_paths_never_contend(tmp_path) -> None:
dir_a = tmp_path / "identity-a"
dir_b = tmp_path / "identity-b"
lock_a = InstanceLock(dir_a)
lock_a.acquire()
try:
lock_b = InstanceLock(dir_b)
lock_b.acquire()
lock_b.release()
finally:
lock_a.release()
def test_context_manager_releases_on_exit(tmp_path) -> None:
with InstanceLock(tmp_path):
second = InstanceLock(tmp_path)
with pytest.raises(InstanceAlreadyRunningError):
second.acquire()
# After the context exits, a fresh handle can acquire.
InstanceLock(tmp_path).acquire()
def test_state_directory_is_created_if_missing(tmp_path) -> None:
nested = tmp_path / "nested" / "state"
lock = InstanceLock(nested)
assert nested.exists()
lock.acquire()
lock.release()