Files
hermes-agent/tests/gateway/test_cgroup_cleanup.py
kshitijk4poor 99f1b9afe2 fix(gateway): reap cgroup PIDs from a fresh read taken after the live-gateway guard
The single cgroup.procs read now feeds only the empty-check and the
live-gateway guard. The guard can take seconds (gateway.status import,
per-PID cmdline with psutil/ps fallback), so killing from that stale list
could SIGKILL a PID that exited and was reused outside the cgroup, and
would miss orphans spawned in the gap. Re-read cgroup.procs for the kill
loop (one small sysfs read), restoring base's kill-set freshness while
keeping the skipped import on the common empty-cgroup stop.
2026-10-02 00:46:21 +05:30

114 lines
4.6 KiB
Python

"""Tests for the systemd ExecStopPost cgroup reaper (issue #37454)."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from gateway import cgroup_cleanup
class TestOwnCgroupPath:
def test_parses_v2_cgroup_path(self, tmp_path, monkeypatch):
proc_self = tmp_path / "cgroup"
proc_self.write_text("0::/user.slice/user-1000.slice/hermes-gateway.service\n")
monkeypatch.setattr(
cgroup_cleanup,
"Path",
lambda p: proc_self if p == "/proc/self/cgroup" else Path(p),
)
assert cgroup_cleanup._own_cgroup_path() == "/user.slice/user-1000.slice/hermes-gateway.service"
class TestReapCgroup:
def test_noop_when_procs_file_missing(self, tmp_path, monkeypatch):
cgroup_path = "/missing.slice/hermes-gateway.service"
monkeypatch.setattr(
cgroup_cleanup,
"Path",
lambda p: tmp_path / "does-not-exist" if "cgroup.procs" in p else Path(p),
)
def _explode(*_a, **_kw):
pytest.fail("os.kill must not be called when cgroup.procs is unreadable")
monkeypatch.setattr(cgroup_cleanup.os, "kill", _explode)
assert cgroup_cleanup.reap_cgroup(cgroup_path) == 0
class TestMain:
def test_main_refuses_when_pid1_parent_is_not_systemd(self, tmp_path, monkeypatch):
# Regression: a container where the gateway itself is PID 1 (or init
# is tini/launchd) must NOT be authorized by "ppid == 1". PID 1 has to
# present as systemd in /proc/1/comm like any other parent.
comm = tmp_path / "comm"
comm.write_text("tini\n")
monkeypatch.setattr(cgroup_cleanup.os, "getppid", lambda: 1)
monkeypatch.setattr(
cgroup_cleanup,
"Path",
lambda p: comm if p == "/proc/1/comm" else Path(p),
)
def _explode(*_a, **_kw):
pytest.fail("os.kill must not be called for a non-systemd PID 1 parent")
monkeypatch.setattr(cgroup_cleanup.os, "kill", _explode)
assert cgroup_cleanup.main() == 1
class TestLiveGatewayGuard:
@pytest.mark.parametrize("verb", ["run", "restart"])
def test_reap_refuses_when_live_gateway_in_cgroup(self, monkeypatch, verb):
# Regression: a live gateway PID still in cgroup.procs (gateway is PID 1
# in a plain container, a targeted reap of a running service, or an
# in-process `gateway restart` on a host without a service manager)
# must abort the reap before any signal — even under a systemd parent —
# and main() must report the refusal with a non-zero exit.
import gateway.status
cgroup_path = "/some.slice/some-gateway.service"
gateway_cmdline = f"/opt/hermes/venv/bin/python -m hermes_cli.main gateway {verb}"
monkeypatch.setattr(
cgroup_cleanup, "_read_cgroup_pids", lambda _p: [777, os.getpid()]
)
monkeypatch.setattr(
gateway.status,
"_read_process_cmdline",
lambda pid: gateway_cmdline if pid == 777 else None,
)
def _explode(*_a, **_kw):
pytest.fail("os.kill must not signal a cgroup holding a live gateway")
monkeypatch.setattr(cgroup_cleanup.os, "kill", _explode)
assert cgroup_cleanup.reap_cgroup(cgroup_path) is None
monkeypatch.setattr(cgroup_cleanup, "_parent_is_systemd", lambda: True)
monkeypatch.setattr(cgroup_cleanup, "_own_cgroup_path", lambda: cgroup_path)
assert cgroup_cleanup.main() == 1
# Allow-path: once the gateway is gone, a non-gateway orphan in the
# same cgroup must still be reaped — the guard must not destroy the
# feature it secures.
monkeypatch.setattr(
gateway.status,
"_read_process_cmdline",
lambda pid: "bash -c adb forward tcp:8888 tcp:8889" if pid == 777 else None,
)
killed: list[int] = []
monkeypatch.setattr(cgroup_cleanup.os, "kill", lambda pid, sig: killed.append(pid))
assert cgroup_cleanup.reap_cgroup(cgroup_path) == 1
assert killed == [777]
# The kill set comes from a fresh cgroup.procs read taken after the
# (slow) guard: a PID that exited meanwhile is not signalled (it may be
# reused outside the cgroup) and an orphan spawned meanwhile is reaped.
reads = iter([[777, os.getpid()], [888, os.getpid()]])
monkeypatch.setattr(cgroup_cleanup, "_read_cgroup_pids", lambda _p: next(reads))
killed.clear()
assert cgroup_cleanup.reap_cgroup(cgroup_path) == 1
assert killed == [888]