Files
EOS/tests/test_servercleanup.py
T
dr-dimitriandGitHub 3b9eecc52b fix: handle denied inspection in test server cleanup (#1301)
Track owned test processes and restrict fallback cleanup to verified EOS servers using the test configuration. Add regression coverage for denied inspection and cleanup failures.

Fixes #1297
2026-09-13 00:22:32 +02:00

306 lines
11 KiB
Python

import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import psutil
import pytest
import requests
from conftest import cleanup_eos_eosdash, server_base
from xprocess import ProcessStarter
@pytest.fixture
def cleanup_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> SimpleNamespace:
"""Isolate every process and network operation performed by server cleanup."""
processes: dict[int, Mock] = {}
process_type = psutil.Process
def make_process(pid: int, module: str = "akkudoktoreos.server.eos") -> Mock:
process = Mock(spec=process_type)
# Track either platform's termination method with the same mock.
process.terminate = process.kill
process.pid = pid
process.cmdline.return_value = ["python", "-m", module]
process.environ.return_value = {"EOS_CONFIG_DIR": str(tmp_path)}
process.children.return_value = []
process.status.return_value = psutil.STATUS_RUNNING
processes[pid] = process
return process
def get_process(pid: int) -> Mock:
if pid not in processes:
raise psutil.NoSuchProcess(pid)
return processes[pid]
connections = Mock(return_value=[])
health = Mock(side_effect=requests.ConnectionError)
wait = Mock(return_value=([], []))
monkeypatch.setattr("conftest.psutil.Process", get_process)
monkeypatch.setattr("conftest.psutil.net_connections", connections)
monkeypatch.setattr("conftest.psutil.wait_procs", wait)
monkeypatch.setattr("conftest.requests.get", health)
return SimpleNamespace(
make_process=make_process,
connections=connections,
health=health,
wait=wait,
config_dir=str(tmp_path),
)
def run_cleanup(environment: SimpleNamespace, *processes: psutil.Process) -> None:
cleanup_eos_eosdash(
"127.0.0.1",
8503,
"127.0.0.1",
8555,
owned_processes=processes,
config_dir=environment.config_dir,
)
def connection(pid: int | None, port: int) -> SimpleNamespace:
return SimpleNamespace(pid=pid, laddr=SimpleNamespace(port=port))
@pytest.mark.parametrize("error", [psutil.AccessDenied(1), PermissionError(1, "Denied")])
def test_cleanup_owned_tree_when_enumeration_is_denied(
cleanup_environment: SimpleNamespace, error: Exception
) -> None:
"""Protected system processes must not prevent termination of owned servers."""
env = cleanup_environment
parent = env.make_process(101)
child = env.make_process(102, "akkudoktoreos.server.eosdash")
parent.children.return_value = [child]
env.connections.side_effect = error
run_cleanup(env, parent)
parent.kill.assert_called_once_with()
child.kill.assert_called_once_with()
env.connections.assert_called_once_with(kind="inet")
assert env.wait.call_args.args[0] == [parent, child]
assert 0 <= env.wait.call_args.kwargs["timeout"] <= 10
def test_cleanup_uses_verified_health_pid_without_connection_inspection(
cleanup_environment: SimpleNamespace,
) -> None:
"""A restarted test server can be found without system-wide connections."""
env = cleanup_environment
restarted = env.make_process(103)
# EOS restarts using the script path rather than `python -m`.
script = Path(__file__).parent.parent / "src/akkudoktoreos/server/eos.py"
restarted.cmdline.return_value = ["python", str(script)]
response = Mock(status_code=200)
response.json.return_value = {"pid": restarted.pid}
env.health.side_effect = [response, requests.ConnectionError()]
env.connections.side_effect = psutil.AccessDenied(1)
run_cleanup(env)
restarted.kill.assert_called_once_with()
def test_cleanup_connection_fallback_is_limited_to_verified_test_servers(
cleanup_environment: SimpleNamespace,
) -> None:
"""Port matches alone must not kill other applications or another EOS instance."""
env = cleanup_environment
eos = env.make_process(101)
dashboard = env.make_process(102, "akkudoktoreos.server.eosdash")
other_application = env.make_process(103, "unrelated.application")
other_test = env.make_process(104)
other_test.environ.return_value = {"EOS_CONFIG_DIR": "/another/test"}
misleading_module = env.make_process(105, "akkudoktoreos.server.eos_extra")
wrong_port = env.make_process(106, "akkudoktoreos.server.eosdash")
env.connections.return_value = [
connection(101, 8503),
connection(101, 8503),
connection(102, 8555),
connection(103, 8503),
connection(104, 8503),
connection(105, 8503),
connection(106, 8504),
connection(None, 8503),
]
run_cleanup(env)
eos.kill.assert_called_once_with()
dashboard.kill.assert_called_once_with()
for process in (other_application, other_test, misleading_module, wrong_port):
process.kill.assert_not_called()
@pytest.mark.parametrize("pid", [103, True, "103", -1, None])
def test_cleanup_does_not_trust_health_pid(
cleanup_environment: SimpleNamespace, pid: object
) -> None:
"""A health response cannot authorize termination of an unrelated process."""
env = cleanup_environment
unrelated = env.make_process(103, "unrelated.application")
response = Mock(status_code=200)
response.json.return_value = {"pid": pid}
env.health.side_effect = [response, requests.ConnectionError()]
run_cleanup(env)
unrelated.kill.assert_not_called()
@pytest.mark.parametrize("operation", ["cmdline", "environ"])
def test_cleanup_skips_inaccessible_fallback_process(
cleanup_environment: SimpleNamespace, operation: str
) -> None:
"""One inaccessible process must not hide a subsequent verified server."""
env = cleanup_environment
protected = env.make_process(103)
getattr(protected, operation).side_effect = psutil.AccessDenied(103)
owned = env.make_process(104)
env.connections.return_value = [connection(103, 8503), connection(104, 8503)]
run_cleanup(env)
protected.kill.assert_not_called()
owned.kill.assert_called_once_with()
def test_cleanup_stops_owned_process_when_children_are_inaccessible(
cleanup_environment: SimpleNamespace,
) -> None:
env = cleanup_environment
owned = env.make_process(101)
owned.children.side_effect = psutil.AccessDenied(101)
run_cleanup(env, owned)
owned.kill.assert_called_once_with()
def test_cleanup_tolerates_process_exit_during_termination(
cleanup_environment: SimpleNamespace,
) -> None:
env = cleanup_environment
exited = env.make_process(101)
exited.kill.side_effect = psutil.NoSuchProcess(101)
remaining = env.make_process(102)
run_cleanup(env, exited, remaining)
remaining.kill.assert_called_once_with()
@pytest.mark.parametrize("status", [psutil.STATUS_ZOMBIE, psutil.STATUS_RUNNING])
def test_cleanup_reports_only_live_processes_after_bounded_wait(
cleanup_environment: SimpleNamespace, status: str
) -> None:
env = cleanup_environment
process = env.make_process(101)
process.status.return_value = status
env.wait.return_value = ([], [process])
if status == psutil.STATUS_RUNNING:
with pytest.raises(AssertionError, match="cleanup timed out.*101"):
run_cleanup(env, process)
else:
run_cleanup(env, process)
@pytest.mark.parametrize("startup_failure", [False, True])
def test_server_base_cleans_owned_process_and_directory_on_failure(
cleanup_environment: SimpleNamespace,
monkeypatch: pytest.MonkeyPatch,
startup_failure: bool,
) -> None:
"""Both startup failures and exceptions from the test body run teardown."""
env = cleanup_environment
process = env.make_process(101)
xprocess = Mock()
xprocess.getinfo.return_value.pid = process.pid
config_dirs: list[Path] = []
monkeypatch.setattr("conftest.subprocess.run", Mock())
monkeypatch.setattr("conftest.ProcessStarter.wait", Mock(return_value=True))
def ensure(name: str, starter_type: type[ProcessStarter]) -> tuple[int, str]:
config_dirs.append(Path(starter_type.env["EOS_CONFIG_DIR"]))
assert name == f"eos-{config_dirs[-1].name}"
starter = starter_type(None, xprocess)
starter.wait(Mock())
if startup_failure:
raise RuntimeError("startup failed")
return process.pid, "server.log"
xprocess.ensure.side_effect = ensure
with pytest.raises(RuntimeError, match="failed"):
with server_base(xprocess):
raise RuntimeError("test body failed")
process.kill.assert_called_once_with()
assert config_dirs and not config_dirs[0].exists()
@pytest.mark.parametrize("reported_pid", [101, 999])
def test_server_startup_check_rejects_another_server_on_the_same_port(
cleanup_environment: SimpleNamespace,
monkeypatch: pytest.MonkeyPatch,
reported_pid: int,
) -> None:
"""A pre-existing server must never be mistaken for this test's process."""
env = cleanup_environment
process = env.make_process(101)
xprocess = Mock()
xprocess.getinfo.return_value.pid = process.pid
monkeypatch.setattr("conftest.subprocess.run", Mock())
monkeypatch.setattr("conftest.ProcessStarter.wait", Mock(return_value=True))
response = Mock(status_code=200)
response.json.return_value = {"pid": reported_pid}
env.health.side_effect = None
env.health.return_value = response
def ensure(name: str, starter_type: type[ProcessStarter]) -> tuple[int, str]:
starter = starter_type(None, xprocess)
starter.wait(Mock())
assert starter.startup_check() is (reported_pid == process.pid)
return process.pid, "server.log"
xprocess.ensure.side_effect = ensure
with server_base(xprocess):
pass
@pytest.mark.parametrize("enumeration_denied", [False, True])
def test_cleanup_terminates_real_owned_subprocess(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, enumeration_denied: bool
) -> None:
"""Exercise real process termination with available and denied enumeration."""
connections = Mock(return_value=[])
if enumeration_denied:
connections.side_effect = psutil.AccessDenied(1)
monkeypatch.setattr("conftest.psutil.net_connections", connections)
monkeypatch.setattr("conftest.requests.get", Mock(side_effect=requests.ConnectionError))
process = subprocess.Popen(
[sys.executable, "-c", "import sys; sys.stdin.buffer.read()"],
stdin=subprocess.PIPE,
)
try:
owned = psutil.Process(process.pid)
cleanup_eos_eosdash(
"127.0.0.1",
8503,
"127.0.0.1",
8504,
owned_processes=[owned],
config_dir=str(tmp_path),
)
assert not owned.is_running()
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
if process.stdin is not None:
process.stdin.close()