Files
hermes-agent/tests/gateway/test_control_socket.py
ethernet 59624b7ace tests: replace every bare host skipif with platforms()
The OS lanes are marker-driven: list_os_marked_tests.py picks the files
a lane imports from their platforms() specs and the lane selects with
-m platforms. A test gated with skipif(sys.platform != "win32") is
therefore never imported on the Windows lane and skipped everywhere else
— it runs on no host. skipif(sys.platform == "win32") tests were merely
invisible to the lane bookkeeping, but the rule the tree now follows is
one host marker, never a bare skipif.

Mechanical mapping, semantics preserved: skip-on-Windows → "posix",
skip-off-Windows → "windows", skip-off-Linux → "linux", skip-on-macOS →
"not macos". The former skip reasons stay as trailing comments. A
non-host condition (os.geteuid() == 0) stays a separate skipif beside
the marker, spelled getattr(os, "geteuid", ...) so the decorator still
imports on Windows.

Where the conversion would stack two platforms() marks on one test (the
conftest rejects that at collection) the narrower mark wins:
- test_update_wedged_gateway: the class is already platforms("linux");
  its per-test "needs UNIX sockets" marks were redundant and are gone.
- test_process_registry.TestSystemdCgroupIsolation: the class-level
  skip-on-Windows moves onto the 11 methods that had no host mark; the
  11 platforms("linux") methods keep theirs.
- test_file_ops_single_roundtrip: the two fifo tests drop their
  platforms("linux") in favour of the module's "posix" (mkfifo exists on
  macOS; both tests already skip when it does not).
- test_linux_desktop_entry / test_gateway_job_teardown_live: duplicate
  or wider marks removed.
2026-09-21 19:18:15 -04:00

455 lines
15 KiB
Python

"""Tests for the gateway control socket (#92091 migration step 1)."""
import asyncio
import json
import socket
import sys
from pathlib import Path
import pytest
from gateway.control_socket import (
CONTROL_PROTOCOL_VERSION,
GatewayControlServer,
identify_gateway,
query_gateway_control,
resolve_client_socket_path,
resolve_server_socket_path,
windows_pipe_name,
)
pytestmark = pytest.mark.platforms("posix") # Unix-socket transport; the named-pipe half is covered on the wine2e lane
def _run(coro):
return asyncio.run(coro)
@pytest.fixture()
def home(tmp_path: Path) -> Path:
d = tmp_path / "home" / ".hermes"
d.mkdir(parents=True)
return d
def _serve(home: Path, handlers=None):
"""Context helper: start a server in a fresh loop, yield inside coro."""
return GatewayControlServer(home, verb_handlers=handlers)
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
def test_short_home_binds_in_home(tmp_path: Path):
# A home short enough for sun_path binds in-home with no pointer.
# tmp_path can exceed the limit on CI runners, so build one in the
# system temp root directly.
import tempfile
try:
short_root = Path(tempfile.mkdtemp(prefix="hgw-", dir="/tmp"))
except OSError:
pytest.skip("/tmp not writable on this host")
try:
short_home = short_root / ".hermes"
short_home.mkdir()
assert len(str(short_home / "gateway.sock").encode()) <= 100
bind, pointer = resolve_server_socket_path(short_home)
assert bind == short_home / "gateway.sock"
assert pointer is None
finally:
import shutil
shutil.rmtree(short_root, ignore_errors=True)
def test_long_home_uses_pointer_fallback(tmp_path: Path):
deep = tmp_path / ("x" * 120) / ".hermes"
deep.mkdir(parents=True)
bind, pointer = resolve_server_socket_path(deep)
assert bind != deep / "gateway.sock"
assert len(str(bind).encode()) <= 100
assert pointer == deep / "gateway.sock.path"
def test_client_resolution_prefers_direct_then_pointer(home: Path, tmp_path: Path):
assert resolve_client_socket_path(home) is None
# pointer file to an existing socket-ish file
target = tmp_path / "elsewhere.sock"
target.touch()
(home / "gateway.sock.path").write_text(str(target))
assert resolve_client_socket_path(home) == target
# direct file wins over pointer
direct = home / "gateway.sock"
direct.touch()
assert resolve_client_socket_path(home) == direct
def test_windows_pipe_name_is_stable_and_home_scoped(tmp_path: Path):
a = windows_pipe_name(tmp_path / "a")
b = windows_pipe_name(tmp_path / "b")
assert a.startswith(r"\\.\pipe\hermes-gateway-")
assert a != b
assert a == windows_pipe_name(tmp_path / "a")
# ---------------------------------------------------------------------------
# Server lifecycle + verbs (real sockets, real event loop)
# ---------------------------------------------------------------------------
def test_server_answers_identify_and_status(home: Path):
async def scenario():
server = GatewayControlServer(
home,
verb_handlers={
"identify": lambda: {"pid": 4242, "code_sha": "abc123", "protocol": 1},
"status": lambda: {"gateway_state": "running"},
},
)
assert await server.start()
try:
loop = asyncio.get_running_loop()
ident = await loop.run_in_executor(
None, lambda: query_gateway_control(home, "identify")
)
status = await loop.run_in_executor(
None, lambda: query_gateway_control(home, "status")
)
return ident, status
finally:
await server.stop()
ident, status = _run(scenario())
assert ident == {"pid": 4242, "code_sha": "abc123", "protocol": 1}
assert status == {"gateway_state": "running"}
def test_unknown_verb_and_malformed_request(home: Path):
async def scenario():
server = GatewayControlServer(
home, verb_handlers={"identify": lambda: {"pid": 1}}
)
assert await server.start()
try:
loop = asyncio.get_running_loop()
unknown = await loop.run_in_executor(
None, lambda: query_gateway_control(home, "restart")
)
def raw_garbage():
path = resolve_client_socket_path(home)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(2)
s.connect(str(path))
s.sendall(b"this is not json\n")
return s.recv(65536)
garbage_reply = await loop.run_in_executor(None, raw_garbage)
return unknown, garbage_reply
finally:
await server.stop()
unknown, garbage_reply = _run(scenario())
# unknown verb → ok:false → client returns None (fallback signal)
assert unknown is None
payload = json.loads(garbage_reply.decode())
assert payload["ok"] is False
assert payload["protocol"] == CONTROL_PROTOCOL_VERSION
def test_verb_handler_receives_params(home: Path):
"""A handler declaring a ``params`` argument is called with the request's params dict; a bare
handler is still called with no args (backward compat for identify/status/rescan)."""
received = {}
def with_params(params):
received.update(params)
return {"echo": params}
def bare():
return {"ok": 1}
async def scenario():
server = GatewayControlServer(
home, verb_handlers={"with-params": with_params, "bare": bare})
assert await server.start()
try:
loop = asyncio.get_running_loop()
got = await loop.run_in_executor(
None, lambda: query_gateway_control(
home, "with-params", params={"old": "a", "new": "b"}))
bare_ok = await loop.run_in_executor(
None, lambda: query_gateway_control(home, "bare"))
return got, bare_ok
finally:
await server.stop()
got, bare_ok = _run(scenario())
assert got == {"echo": {"old": "a", "new": "b"}}
assert received == {"old": "a", "new": "b"}
assert bare_ok == {"ok": 1}
def test_stop_removes_socket_and_pointer(home: Path):
async def scenario():
server = GatewayControlServer(
home, verb_handlers={"identify": lambda: {"pid": 1}}
)
assert await server.start()
bind, _ = resolve_server_socket_path(home)
assert bind.exists()
await server.stop()
return bind
bind = _run(scenario())
assert not bind.exists()
assert resolve_client_socket_path(home) is None
# queries after stop cleanly return None
assert query_gateway_control(home, "identify") is None
def test_stale_socket_file_is_replaced_on_bind(home: Path):
# Plant the stale file at wherever the server will actually bind
# (in-home OR the temp-dir fallback, depending on path length).
bind, _ = resolve_server_socket_path(home)
bind.parent.mkdir(parents=True, exist_ok=True)
bind.touch() # crashed predecessor's leftover
async def scenario():
server = GatewayControlServer(
home, verb_handlers={"identify": lambda: {"pid": 7}}
)
assert await server.start()
try:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: identify_gateway(home))
finally:
await server.stop()
assert _run(scenario()) == {"pid": 7}
def test_long_home_end_to_end_via_pointer(tmp_path: Path):
deep = tmp_path / ("p" * 120) / ".hermes"
deep.mkdir(parents=True)
async def scenario():
server = GatewayControlServer(
deep, verb_handlers={"identify": lambda: {"pid": 9}}
)
assert await server.start()
try:
assert (deep / "gateway.sock.path").is_file()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: identify_gateway(deep))
finally:
await server.stop()
assert _run(scenario()) == {"pid": 9}
assert not (deep / "gateway.sock.path").exists()
def test_no_socket_returns_none_fast(home: Path):
assert identify_gateway(home) is None
assert query_gateway_control(home, "status") is None
def test_default_identify_payload_shape(home: Path, monkeypatch):
"""The real identify handler carries the fleet-consumer contract fields."""
monkeypatch.setenv("HERMES_HOME", str(home))
async def scenario():
server = GatewayControlServer(home) # default handlers
assert await server.start()
try:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: identify_gateway(home))
finally:
await server.stop()
ident = _run(scenario())
assert ident is not None
assert ident["protocol"] == CONTROL_PROTOCOL_VERSION
assert ident["pid"] == __import__("os").getpid()
# contract keys exist even when values are None/absent-degradable
for key in ("hermes_home", "supervisor", "kind", "start_time"):
assert key in ident
assert ident["supervisor"] in {
"systemd",
"launchd",
"desktop",
"external",
"manual",
}
# ---------------------------------------------------------------------------
# Consumer integration: fleet matrix + inventory prefer socket, fall back
# ---------------------------------------------------------------------------
def _fake_identity(pid: int, sha: str):
return {
"protocol": 1,
"pid": pid,
"code_sha": sha,
"code_version": "9.9.9",
"supervisor": "systemd",
"kind": "hermes-gateway",
}
def test_collect_fleet_versions_prefers_socket(tmp_path: Path, monkeypatch):
import hermes_cli.update_receipt as ur
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(
"hermes_cli.version_info.get_code_identity",
lambda refresh=False: {"sha": "HEADSHA", "version": "1.0"},
)
monkeypatch.setattr(
"hermes_cli.profiles._get_default_hermes_home", lambda: home
)
monkeypatch.setattr(
"hermes_cli.profiles._get_profiles_root", lambda: tmp_path / "no-profiles"
)
# stale state file that would report a WRONG pid — socket must win
(home / "gateway_state.json").write_text(
json.dumps({"pid": 1, "code_sha": "stalefile", "kind": "hermes-gateway"})
)
monkeypatch.setattr(
"gateway.control_socket.identify_gateway",
lambda h, **kw: _fake_identity(31337, "HEADSHA"),
)
fleet = ur.collect_fleet_versions()
assert len(fleet) == 1
entry = fleet[0]
assert entry["pid"] == 31337
assert entry["state"] == "current"
assert entry["source"] == "socket"
def test_collect_fleet_versions_falls_back_to_state_file(tmp_path: Path, monkeypatch):
"""Without a socket answer the state file is a fallback claim, not an identity.
A live PID that is not the home's verified gateway (here: this pytest
process wrote the record) stays visible as ``unknown`` with no
self-reported sha; only the verified gateway PID is classified
``current``/``stale`` from the file (#110420).
"""
import os
import hermes_cli.update_receipt as ur
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(
"hermes_cli.version_info.get_code_identity",
lambda refresh=False: {"sha": "HEADSHA", "version": "1.0"},
)
monkeypatch.setattr(
"hermes_cli.profiles._get_default_hermes_home", lambda: home
)
monkeypatch.setattr(
"hermes_cli.profiles._get_profiles_root", lambda: tmp_path / "no-profiles"
)
monkeypatch.setattr(
"gateway.control_socket.identify_gateway", lambda h, **kw: None
)
(home / "gateway_state.json").write_text(
json.dumps(
{
"pid": os.getpid(), # a live pid so _pid_exists passes
"code_sha": "OLDSHA",
"kind": "hermes-gateway",
}
)
)
fleet = ur.collect_fleet_versions()
assert len(fleet) == 1
assert fleet[0]["pid"] == os.getpid()
assert fleet[0]["state"] == "unknown"
assert fleet[0]["code_sha"] is None
assert "source" not in fleet[0]
# Same file, but the profile's identity resolver verifies this PID as the
# gateway: the fallback may now classify from the stamped sha.
monkeypatch.setattr(
"gateway.status.live_gateway_pid_for_home", lambda h: os.getpid()
)
fleet = ur.collect_fleet_versions()
assert len(fleet) == 1
assert fleet[0]["state"] == "stale"
assert fleet[0]["code_sha"] == "OLDSHA"
assert "source" not in fleet[0]
def test_runtime_inventory_dedupes_same_pid_across_homes(tmp_path: Path, monkeypatch):
"""One multiplex gateway answering identify for two profile homes must
yield exactly ONE runtime record (reviewer point on #92447)."""
import hermes_cli.update_inventory as ui
home = tmp_path / ".hermes"
home.mkdir()
profiles_root = tmp_path / "profiles"
(profiles_root / "coder").mkdir(parents=True)
monkeypatch.setattr(
"hermes_cli.profiles._get_default_hermes_home", lambda: home
)
monkeypatch.setattr(
"hermes_cli.profiles._get_profiles_root", lambda: profiles_root
)
monkeypatch.setattr(
"hermes_cli.gateway._get_service_pids", lambda all_profiles=False: set()
)
monkeypatch.setattr(
"hermes_cli.gateway.find_profile_gateway_processes", lambda: []
)
monkeypatch.setattr(
"gateway.control_socket.identify_gateway",
lambda h, **kw: _fake_identity(777, "SHA777"),
)
plan = ui.collect_runtime_inventory()
gws = [r for r in plan.runtimes if r.kind == "gateway"]
assert len(gws) == 1, [r.__dict__ for r in gws]
assert gws[0].pid == 777
def test_runtime_inventory_prefers_socket_supervisor(tmp_path: Path, monkeypatch):
import hermes_cli.update_inventory as ui
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(
"hermes_cli.profiles._get_default_hermes_home", lambda: home
)
monkeypatch.setattr(
"hermes_cli.profiles._get_profiles_root", lambda: tmp_path / "no-profiles"
)
monkeypatch.setattr(
"hermes_cli.gateway._get_service_pids", lambda all_profiles=False: set()
)
monkeypatch.setattr(
"hermes_cli.gateway.find_profile_gateway_processes", lambda: []
)
monkeypatch.setattr(
"gateway.control_socket.identify_gateway",
lambda h, **kw: _fake_identity(555, "SHA555"),
)
plan = ui.collect_runtime_inventory()
gws = [r for r in plan.runtimes if r.kind == "gateway"]
assert len(gws) == 1
assert gws[0].pid == 555
# supervisor comes from the gateway's own declaration, not a PID scan
assert gws[0].supervisor == "systemd"
assert gws[0].code_sha == "SHA555"