`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.
229 lines
9.0 KiB
Python
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
|