Files
hermes-agent/tests/gateway/test_host_rendezvous.py
kshitijk4poor 6111d347f0 test(gateway): drop redundant read_record assert from the drift test
`hr.read_record(hr.ROLE_GATEWAY)` re-reads the on-disk, undrifted record,
so it never exercises the `drifted` copy the test is about; the
record_is_stale/liveness_is_proven asserts on `drifted` already cover the
invariant. Removing it keeps the test focused on drift tolerance.
2026-10-02 01:17:03 +05:30

229 lines
9.0 KiB
Python

"""Host-wide singleton invariants (multiplex-only): one lock per host, staleness is proved."""
import dataclasses
import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
import pytest
from gateway import host_rendezvous as hr
_CHILD = """
import json, os, sys, time
sys.path.insert(0, {tree!r})
from gateway.status import acquire_gateway_runtime_lock
from gateway import host_rendezvous as hr
print(json.dumps({{
"per_home": acquire_gateway_runtime_lock(),
"host": hr.claim_host_lock(hr.ROLE_GATEWAY)[0] is hr.HostLockOutcome.ACQUIRED,
}}), flush=True)
time.sleep(60)
"""
# Publishes a serve record + its 0600 token, arms the exit cleanup, then waits to be killed.
_SIGTERM_CHILD = """
import sys, time
sys.path.insert(0, {tree!r})
from gateway import host_rendezvous as hr
assert hr.claim_host_lock(hr.ROLE_SERVE)[0] is hr.HostLockOutcome.ACQUIRED
hr.publish_record(hr.ROLE_SERVE, host="127.0.0.1", port=9119, token="live-session-token")
hr.cleanup_on_exit(hr.ROLE_SERVE)
print("published", flush=True)
time.sleep(120)
"""
@pytest.fixture
def host_dir(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
yield tmp_path
hr.release_host_lock(hr.ROLE_GATEWAY)
hr.release_host_lock(hr.ROLE_SERVE)
def _tree() -> str:
return str(Path(__file__).resolve().parents[2])
def test_two_homes_take_their_own_per_home_lock_but_only_one_host_lock(host_dir, monkeypatch):
"""The per-home lock is per HERMES_HOME (N profiles = N locks); the host lock is not.
This is the whole point of the host layer: before it, a second profile's gateway took its
own ``gateway.lock`` and nothing on the machine noticed.
"""
tree = _tree()
home_a, home_b = host_dir / "home_a", host_dir / "home_b"
for home in (home_a, home_b):
home.mkdir()
env = {**os.environ, "HERMES_HOME": str(home_a), "HERMES_GATEWAY_LOCK_DIR": str(host_dir / "locks")}
child = subprocess.Popen(
[sys.executable, "-c", _CHILD.format(tree=tree)], env=env, cwd=tree,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
assert child.stdout is not None
try:
deadline = time.time() + 60
line = ""
while not line.strip() and time.time() < deadline:
line = child.stdout.readline()
first = json.loads(line)
assert first == {"per_home": True, "host": True}
monkeypatch.setenv("HERMES_HOME", str(home_b))
from gateway import status
assert status.acquire_gateway_runtime_lock() is True, "second home must get its OWN lock"
assert hr.claim_host_lock(hr.ROLE_GATEWAY)[0] is hr.HostLockOutcome.HELD_BY_OTHER, \
"host lock is not per-home"
finally:
child.kill()
child.wait(timeout=10)
from gateway import status
status.release_gateway_runtime_lock()
@pytest.mark.parametrize(
"pid,create_time",
[(2**22 - 1, 1.0), (os.getpid(), 1.0)],
ids=["dead-pid", "creation-time-mismatch"],
)
def test_stale_record_is_never_attachable(host_dir, pid, create_time):
"""A dead PID and a live PID from another incarnation are both stale — attaching to either
dials whatever now owns that port."""
record = hr.HostRecord(
role=hr.ROLE_SERVE, pid=pid, create_time=create_time, host="127.0.0.1", port=9119,
protocol_version=hr.HOST_PROTOCOL_VERSION, token_fingerprint="", profiles=("default",),
updated_at="2026-01-01T00:00:00+00:00")
hr.record_path(hr.ROLE_SERVE).parent.mkdir(parents=True, exist_ok=True)
hr.record_path(hr.ROLE_SERVE).write_text(json.dumps(record.to_json()), encoding="utf-8")
assert hr.record_is_stale(record) is True
assert hr.read_record(hr.ROLE_SERVE) is None
assert hr.read_record(hr.ROLE_SERVE, include_stale=True) is not None
@pytest.mark.platforms("posix")
def test_live_host_record_survives_wall_clock_create_time_drift(host_dir, monkeypatch):
"""WSL can shift psutil.create_time() for the same live PID while /proc start ticks stay fixed.
The host record must keep recognizing that owner, but a changed stable start fingerprint must
still be rejected as PID reuse.
"""
record = hr.publish_record(hr.ROLE_GATEWAY, profiles=("default",), home=str(host_dir))
assert record is not None
# Model the exact WSL failure family: the recorded epoch create_time drifted away from the
# live process's, while the stable start fingerprint is unchanged.
drifted = dataclasses.replace(record, create_time=record.create_time - 5)
assert hr.record_is_stale(drifted) is False
assert hr.liveness_is_proven(drifted) is True
assert record.start_time is not None
from gateway import status
monkeypatch.setattr(
status, "get_process_start_time",
lambda _pid: record.start_time + status.START_TIME_DRIFT_TOLERANCE + 1,
)
assert hr.record_is_stale(record) is True
@pytest.mark.platforms("posix") # POSIX signal disposition
def test_sigterm_removes_the_record_and_its_live_session_token(host_dir):
"""SIGTERM is the NORMAL stop (systemd stop, docker stop, the update relaunch) and it does not
run ``atexit``: the record outlived its process and the 0600 token kept a LIVE session token
on disk indefinitely, so the next launch attached to something that was gone."""
env = {**os.environ, "HERMES_GATEWAY_LOCK_DIR": str(host_dir / "locks")}
child = subprocess.Popen(
[sys.executable, "-c", _SIGTERM_CHILD.format(tree=_tree())], env=env, cwd=_tree(),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
assert child.stdout is not None
try:
assert child.stdout.readline().strip() == "published"
assert hr.record_path(hr.ROLE_SERVE).exists()
assert hr.token_path(hr.ROLE_SERVE).exists()
child.send_signal(signal.SIGTERM)
child.wait(timeout=30)
finally:
if child.poll() is None:
child.kill()
child.wait(timeout=10)
assert not hr.record_path(hr.ROLE_SERVE).exists(), "record outlived its process"
assert not hr.token_path(hr.ROLE_SERVE).exists(), "live session token outlived its process"
assert hr.claim_host_lock(hr.ROLE_SERVE)[0] is hr.HostLockOutcome.ACQUIRED, "host lock not released"
def test_unopenable_lock_dir_is_not_reported_as_another_owner(host_dir, monkeypatch):
"""EROFS/EACCES/ENOSPC is a THIRD outcome: collapsing it into contention told operators
another gateway owned the host and sent them hunting a process that never existed."""
blocked = host_dir / "blocked"
blocked.write_text("not a directory", encoding="utf-8")
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(blocked / "locks"))
outcome, error = hr.claim_host_lock(hr.ROLE_SERVE)
assert outcome is hr.HostLockOutcome.COULD_NOT_OPEN
assert isinstance(error, OSError)
def test_lock_cache_follows_the_lock_directory(host_dir, monkeypatch, tmp_path):
"""The handle cache is keyed by (role, resolved path): keyed by role alone it reported
"already held" for a directory it had never created, so ``owns_host_lock()`` lied."""
assert hr.claim_host_lock(hr.ROLE_SERVE)[0] is hr.HostLockOutcome.ACQUIRED
moved = tmp_path / "other-locks"
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(moved))
assert hr.owns_host_lock(hr.ROLE_SERVE) is False
assert hr.claim_host_lock(hr.ROLE_SERVE)[0] is hr.HostLockOutcome.ACQUIRED
assert (moved / "host-serve.lock").exists()
hr.release_host_lock(hr.ROLE_SERVE)
def test_probe_owner_refuses_a_closed_port_and_a_foreign_listener(host_dir):
"""The two shapes the record cannot see: the owner's socket is already closed (the
graceful-shutdown window) and an unrelated process holds the port."""
import dataclasses
closed = socket.socket()
closed.bind(("127.0.0.1", 0))
dead_port = closed.getsockname()[1]
closed.close()
record = hr.HostRecord(
role=hr.ROLE_SERVE, pid=os.getpid(), create_time=hr.process_create_time(),
host="127.0.0.1", port=dead_port, protocol_version=hr.HOST_PROTOCOL_VERSION,
token_fingerprint="", profiles=(), updated_at="2026-01-01T00:00:00+00:00")
assert hr.probe_owner(record, timeout=1.0) is None
foreign = socket.socket()
foreign.bind(("127.0.0.1", 0))
foreign.listen(1)
try:
assert hr.probe_owner(
dataclasses.replace(record, port=foreign.getsockname()[1]), timeout=1.0) is None
finally:
foreign.close()
def test_relative_xdg_state_home_is_ignored(monkeypatch, tmp_path):
"""XDG spec: a relative ``$XDG_STATE_HOME`` is invalid and must be ignored. Honouring one made
the host lock dir CWD-relative, so two serves started from different directories would each
take their own "host" lock."""
from gateway import status
monkeypatch.delenv("HERMES_GATEWAY_LOCK_DIR", raising=False)
monkeypatch.setenv("XDG_STATE_HOME", "relative/state")
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
assert status._get_lock_dir().is_absolute()
assert status._get_lock_dir() == tmp_path / ".local" / "state" / "hermes" / status._LOCKS_DIRNAME