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.
222 lines
8.4 KiB
Python
222 lines
8.4 KiB
Python
"""Cross-profile kill refusal regression tests (#89315).
|
|
|
|
A poisoned/contaminated ``gateway.pid`` inside one profile's HERMES_HOME can
|
|
truthfully name ANOTHER profile's live gateway (its ``hermes_home`` stamp
|
|
records the real owner). ``gateway stop`` / the restart force-kill escalation
|
|
/ ``profile delete`` must refuse to signal such a PID instead of starting the
|
|
mutual cross-profile SIGTERM restart loop from the issue report.
|
|
|
|
These tests exercise the REAL code paths against real PID files, a real
|
|
flock-held gateway lock, and a real dummy child process — no mocks of the
|
|
code under test.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from gateway.status import recorded_gateway_home_conflicts
|
|
|
|
|
|
def _spawn_gateway_lookalike(bin_dir: Path, lock_path: Path) -> subprocess.Popen:
|
|
"""Real child process whose argv matches the gateway runtime matcher."""
|
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
script = bin_dir / "hermes"
|
|
if sys.platform == "win32":
|
|
body = "import time\ntime.sleep(120)\n"
|
|
else:
|
|
body = (
|
|
"import fcntl, time\n"
|
|
f"fh = open({str(lock_path)!r}, 'a+')\n"
|
|
"fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)\n"
|
|
"time.sleep(120)\n"
|
|
)
|
|
script.write_text(f"#!{sys.executable}\n{body}", encoding="utf-8")
|
|
if sys.platform != "win32":
|
|
script.chmod(0o755)
|
|
cmd = [str(script), "gateway", "run"]
|
|
else:
|
|
cmd = [sys.executable, str(script), "gateway", "run"]
|
|
proc = subprocess.Popen(
|
|
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
)
|
|
deadline = time.monotonic() + 10.0
|
|
while time.monotonic() < deadline and not lock_path.exists():
|
|
if proc.poll() is not None:
|
|
raise RuntimeError("gateway lookalike died at startup")
|
|
time.sleep(0.05)
|
|
return proc
|
|
|
|
|
|
def _pid_record(proc: subprocess.Popen, script: Path, owner_home: Path) -> dict:
|
|
from gateway.status import get_process_start_time
|
|
|
|
return {
|
|
"pid": proc.pid,
|
|
"kind": "hermes-gateway",
|
|
"argv": [str(script), "gateway", "run"],
|
|
"start_time": get_process_start_time(proc.pid),
|
|
"hermes_home": str(owner_home),
|
|
}
|
|
|
|
|
|
class TestRecordedGatewayHomeConflicts:
|
|
def test_conflicting_home_detected(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "tim"))
|
|
record = {"pid": 1, "hermes_home": str(tmp_path)}
|
|
assert recorded_gateway_home_conflicts(record) is True
|
|
|
|
def test_same_home_accepted(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "profiles" / "tim"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
record = {"pid": 1, "hermes_home": str(home)}
|
|
assert recorded_gateway_home_conflicts(record) is False
|
|
|
|
def test_legacy_record_without_home_proves_nothing(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
assert recorded_gateway_home_conflicts({"pid": 1}) is False
|
|
assert recorded_gateway_home_conflicts(None) is False
|
|
assert recorded_gateway_home_conflicts({"pid": 1, "hermes_home": " "}) is False
|
|
|
|
def test_expected_home_override(self, tmp_path):
|
|
target = tmp_path / "profiles" / "tim"
|
|
record = {"pid": 1, "hermes_home": str(tmp_path)}
|
|
assert (
|
|
recorded_gateway_home_conflicts(record, expected_home=target) is True
|
|
)
|
|
assert (
|
|
recorded_gateway_home_conflicts(record, expected_home=tmp_path) is False
|
|
)
|
|
|
|
|
|
# The lookalike's argv ("<stub> gateway run") is what the guard's real-gateway spawn check matches;
|
|
# the child is a sleep stub the test kills in ``finally``, never a runtime.
|
|
@pytest.mark.spawns_gateway_lookalike
|
|
@pytest.mark.platforms("posix") # POSIX flock harness
|
|
class TestCrossProfileStopRefusal:
|
|
def test_stop_profile_gateway_refuses_other_profiles_pid(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""Profile B's ``gateway stop`` must not SIGTERM profile A's gateway.
|
|
|
|
On main this path is already safe upstream of any guard:
|
|
``get_running_pid()`` filters a pid record owned by another profile
|
|
(and unlinks the poisoned pid file) before ``stop_profile_gateway``
|
|
ever sees a pid — so the contract here is "returns False, other
|
|
profile's process untouched, poisoned pid file gone", not a printed
|
|
refusal.
|
|
"""
|
|
root_home = tmp_path / "root-home"
|
|
tim_home = tmp_path / "root-home" / "profiles" / "tim"
|
|
tim_home.mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(tim_home))
|
|
|
|
proc = _spawn_gateway_lookalike(
|
|
tmp_path / "bin", tim_home / "gateway.lock"
|
|
)
|
|
try:
|
|
record = _pid_record(proc, tmp_path / "bin" / "hermes", root_home)
|
|
(tim_home / "gateway.pid").write_text(json.dumps(record))
|
|
|
|
from hermes_cli import gateway as gateway_cli
|
|
|
|
assert gateway_cli.stop_profile_gateway() is False
|
|
assert not (tim_home / "gateway.pid").exists(), (
|
|
"poisoned cross-profile pid file should have been unlinked"
|
|
)
|
|
time.sleep(0.5)
|
|
assert proc.poll() is None, (
|
|
"cross-profile SIGTERM fired: profile A's gateway was killed"
|
|
)
|
|
finally:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
|
|
def test_stop_profile_gateway_still_stops_own_gateway(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""Same-home records keep stopping normally (no false refusal)."""
|
|
tim_home = tmp_path / "profiles" / "tim"
|
|
tim_home.mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(tim_home))
|
|
|
|
proc = _spawn_gateway_lookalike(
|
|
tmp_path / "bin", tim_home / "gateway.lock"
|
|
)
|
|
try:
|
|
record = _pid_record(proc, tmp_path / "bin" / "hermes", tim_home)
|
|
(tim_home / "gateway.pid").write_text(json.dumps(record))
|
|
|
|
from hermes_cli import gateway as gateway_cli
|
|
|
|
assert gateway_cli.stop_profile_gateway() is True
|
|
deadline = time.monotonic() + 15.0
|
|
while time.monotonic() < deadline and proc.poll() is None:
|
|
time.sleep(0.1)
|
|
assert proc.poll() is not None, "own gateway was not stopped"
|
|
finally:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
|
|
|
|
@pytest.mark.spawns_gateway_lookalike
|
|
@pytest.mark.platforms("posix") # POSIX flock harness
|
|
class TestProfileDeleteStopRefusal:
|
|
def test_stop_gateway_process_refuses_other_profiles_pid(
|
|
self, tmp_path, capsys
|
|
):
|
|
"""``profile delete`` must not kill a gateway owned by another home."""
|
|
root_home = tmp_path / "root-home"
|
|
tim_home = root_home / "profiles" / "tim"
|
|
tim_home.mkdir(parents=True)
|
|
|
|
proc = _spawn_gateway_lookalike(
|
|
tmp_path / "bin", tim_home / "gateway.lock"
|
|
)
|
|
try:
|
|
record = _pid_record(proc, tmp_path / "bin" / "hermes", root_home)
|
|
(tim_home / "gateway.pid").write_text(json.dumps(record))
|
|
|
|
from hermes_cli.profiles import _stop_gateway_process
|
|
|
|
_stop_gateway_process(tim_home)
|
|
out = capsys.readouterr().out
|
|
assert "Refusing to stop" in out
|
|
time.sleep(0.5)
|
|
assert proc.poll() is None, (
|
|
"profile delete killed another profile's gateway"
|
|
)
|
|
finally:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
|
|
def test_stop_gateway_process_still_stops_own_gateway(self, tmp_path):
|
|
tim_home = tmp_path / "profiles" / "tim"
|
|
tim_home.mkdir(parents=True)
|
|
|
|
proc = _spawn_gateway_lookalike(
|
|
tmp_path / "bin", tim_home / "gateway.lock"
|
|
)
|
|
try:
|
|
record = _pid_record(proc, tmp_path / "bin" / "hermes", tim_home)
|
|
(tim_home / "gateway.pid").write_text(json.dumps(record))
|
|
|
|
from hermes_cli.profiles import _stop_gateway_process
|
|
|
|
_stop_gateway_process(tim_home)
|
|
deadline = time.monotonic() + 15.0
|
|
while time.monotonic() < deadline and proc.poll() is None:
|
|
time.sleep(0.1)
|
|
assert proc.poll() is not None, "own gateway was not stopped"
|
|
finally:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|