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
This commit is contained in:
dr-dimitri
2026-09-13 00:22:32 +02:00
committed by GitHub
parent 5469ba836c
commit 3b9eecc52b
3 changed files with 485 additions and 197 deletions
+137 -130
View File
@@ -5,16 +5,16 @@ import json
import logging import logging
import os import os
import pickle import pickle
import signal
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time import time
from collections.abc import Sequence
from contextlib import contextmanager from contextlib import contextmanager
from fnmatch import fnmatch from fnmatch import fnmatch
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import Callable, Generator, Optional, Union, cast from typing import Callable, Generator, Optional, TextIO, Union, cast
from unittest.mock import PropertyMock, patch from unittest.mock import PropertyMock, patch
import pandas as pd import pandas as pd
@@ -409,127 +409,121 @@ def config_eos(config_eos_factory) -> ConfigEOS:
# ------------------------------------ # ------------------------------------
def _test_server_process(pid: int, module: str, config_dir: str) -> Optional[psutil.Process]:
"""Verify that a fallback PID belongs to this test's EOS configuration."""
if pid <= 0 or pid == os.getpid():
return None
try:
process = psutil.Process(pid)
cmdline = process.cmdline()
script = Path(__file__).parent.parent / "src" / Path(*module.split("."))
is_module = cmdline[1:3] == ["-m", module]
is_script = len(cmdline) > 1 and Path(cmdline[1]).resolve() == script.with_suffix(".py")
if not (is_module or is_script):
return None
process_config_dir = process.environ().get("EOS_CONFIG_DIR")
if process_config_dir and Path(process_config_dir).resolve() == Path(config_dir).resolve():
return process
except (psutil.Error, OSError):
# Protected or exited processes cannot be verified and must be left alone.
pass
return None
def cleanup_eos_eosdash( def cleanup_eos_eosdash(
host: str, host: str,
port: int, port: int,
eosdash_host: str, eosdash_host: str,
eosdash_port: int, eosdash_port: int,
server_timeout: float = 10.0, server_timeout: float = 10.0,
*,
owned_processes: Sequence[psutil.Process] = (),
config_dir: Optional[str] = None,
) -> None: ) -> None:
"""Clean up any running EOS and EOSdash processes. """Stop owned test processes and verified servers using the test configuration.
Process objects retain process identity across PID reuse. Health endpoints and
connection inspection are only fallbacks for restarted or orphaned servers;
neither a port match nor a reported PID alone authorizes termination.
Args: Args:
host (str): EOS server host (e.g., "127.0.0.1"). host: EOS server host.
port (int): Port number used by the EOS process. port: EOS server port.
eosdash_hostr (str): EOSdash server host. eosdash_host: EOSdash server host.
eosdash_port (int): Port used by EOSdash. eosdash_port: EOSdash server port.
server_timeout (float): Timeout in seconds before giving up. server_timeout: Maximum time allowed for HTTP probes and termination waits.
owned_processes: Process handles captured by the test that started them.
config_dir: Unique test configuration directory required for fallback cleanup.
""" """
server = f"http://{host}:{port}" deadline = time.monotonic() + server_timeout
eosdash_server = f"http://{eosdash_host}:{eosdash_port}" processes = list(owned_processes)
servers = (
(f"http://{host}:{port}/v1/health", port, "akkudoktoreos.server.eos"),
(
f"http://{eosdash_host}:{eosdash_port}/eosdash/health",
eosdash_port,
"akkudoktoreos.server.eosdash",
),
)
sigkill = signal.SIGTERM if os.name == "nt" else signal.SIGKILL if config_dir is not None:
for url, _, module in servers:
# Attempt to shut down EOS via health endpoint remaining = deadline - time.monotonic()
try: if remaining <= 0:
result = requests.get(f"{server}/v1/health", timeout=2) break
if result.status_code == HTTPStatus.OK:
pid = result.json()["pid"]
os.kill(pid, sigkill)
time.sleep(1)
result = requests.get(f"{server}/v1/health", timeout=2)
assert result.status_code != HTTPStatus.OK
except Exception:
pass
# Fallback: kill processes bound to the EOS port
pids: list[int] = []
for _ in range(int(server_timeout / 3)):
for conn in psutil.net_connections(kind="inet"):
if conn.laddr and conn.laddr.port == port and conn.pid is not None:
try:
process = psutil.Process(conn.pid)
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
if "akkudoktoreos.server.eos" in " ".join(cmdline):
pids.append(conn.pid)
except Exception:
pass
for pid in pids:
os.kill(pid, sigkill)
running = False
for pid in pids:
try: try:
proc = psutil.Process(pid) response = requests.get(url, timeout=min(2.0, remaining))
status = proc.status() if response.status_code == HTTPStatus.OK:
if status != psutil.STATUS_ZOMBIE: pid = response.json()["pid"]
running = True if type(pid) is int:
break process = _test_server_process(pid, module, config_dir)
except psutil.NoSuchProcess: if process is not None:
processes.append(process)
except (requests.RequestException, ValueError, KeyError, TypeError):
pass
# macOS may deny the entire enumeration because of an unrelated process.
# Inspect once; owned handles and verified health PIDs work without it.
try:
connections = psutil.net_connections(kind="inet")
except (psutil.AccessDenied, OSError):
connections = []
for conn in connections:
if not conn.laddr or conn.pid is None:
continue continue
if not running: for _, server_port, module in servers:
break if conn.laddr.port == server_port:
time.sleep(3) process = _test_server_process(conn.pid, module, config_dir)
if process is not None:
processes.append(process)
# Check for processes still running (maybe zombies). # Capture descendants before stopping parents, which may otherwise orphan them.
for pid in pids: roots = list(dict.fromkeys(processes))
for process in roots:
try: try:
proc = psutil.Process(pid) processes.extend(process.children(recursive=True))
status = proc.status() except (psutil.NoSuchProcess, psutil.AccessDenied):
assert status == psutil.STATUS_ZOMBIE, f"Cleanup EOS expected zombie, got {status} for PID {pid}" pass
processes = list(dict.fromkeys(processes))
for process in processes:
try:
# Stop supervisors first so they cannot respawn their children.
if os.name == "nt":
process.terminate()
else:
process.kill()
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
# Process already reaped (possibly by init/systemd)
continue
# Attempt to shut down EOSdash via health endpoint
for srv in (eosdash_server, "http://127.0.0.1:8504", "http://127.0.0.1:8555"):
try:
result = requests.get(f"{srv}/eosdash/health", timeout=2)
if result.status_code == HTTPStatus.OK:
pid = result.json()["pid"]
os.kill(pid, sigkill)
time.sleep(1)
result = requests.get(f"{srv}/eosdash/health", timeout=2)
assert result.status_code != HTTPStatus.OK
except Exception:
pass pass
# Fallback: kill EOSdash processes bound to known ports _, alive = psutil.wait_procs(processes, timeout=max(0.0, deadline - time.monotonic()))
pids = [] running = []
for _ in range(int(server_timeout / 3)): for process in alive:
for conn in psutil.net_connections(kind="inet"):
if conn.laddr and conn.laddr.port in (eosdash_port, 8504, 8555) and conn.pid is not None:
try:
process = psutil.Process(conn.pid)
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
if "akkudoktoreos.server.eosdash" in " ".join(cmdline):
pids.append(conn.pid)
except Exception:
pass
for pid in pids:
os.kill(pid, sigkill)
running = False
for pid in pids:
try:
proc = psutil.Process(pid)
status = proc.status()
if status != psutil.STATUS_ZOMBIE:
running = True
break
except psutil.NoSuchProcess:
continue
if not running:
break
time.sleep(3)
# Check for processes still running (maybe zombies).
for pid in pids:
try: try:
proc = psutil.Process(pid) if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
status = proc.status() running.append(process.pid)
assert status == psutil.STATUS_ZOMBIE, f"Cleanup EOSdash expected zombie, got {status} for PID {pid}"
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
# Process already reaped (possibly by init/systemd) pass
continue assert not running, f"Test server cleanup timed out for PIDs {running}"
@contextmanager @contextmanager
@@ -573,6 +567,8 @@ def server_base(
eos_tmp_dir = tempfile.TemporaryDirectory() eos_tmp_dir = tempfile.TemporaryDirectory()
eos_dir = str(eos_tmp_dir.name) eos_dir = str(eos_tmp_dir.name)
eos_general_data_folder_path = str(Path(eos_dir) / "data") eos_general_data_folder_path = str(Path(eos_dir) / "data")
process_name = f"eos-{Path(eos_dir).name}"
owned_processes: list[psutil.Process] = []
class Starter(ProcessStarter): class Starter(ProcessStarter):
# Set environment for server run # Set environment for server run
@@ -636,12 +632,21 @@ def server_base(
# xprocess will now attempt to clean up upon interruptions # xprocess will now attempt to clean up upon interruptions
terminate_on_interrupt = True terminate_on_interrupt = True
def wait(self, log_file: TextIO) -> bool:
"""Capture the process identity even if the startup check fails."""
pid = self.process.getinfo(process_name).pid
owned_processes.append(psutil.Process(pid))
return super().wait(log_file)
# checks if our server is ready # checks if our server is ready
def startup_check(self): def startup_check(self):
try: try:
response = requests.get(f"{server}/v1/health", timeout=10) response = requests.get(f"{server}/v1/health", timeout=10)
logger.debug(f"[xprocess] Health check: {response.status_code}") logger.debug(f"[xprocess] Health check: {response.status_code}")
if response.status_code == 200: if (
response.status_code == 200
and response.json().get("pid") == owned_processes[0].pid
):
return True return True
logger.debug(f"[xprocess] Health check: {response}") logger.debug(f"[xprocess] Health check: {response}")
except Exception as e: except Exception as e:
@@ -659,7 +664,7 @@ def server_base(
if self.startup_check(): if self.startup_check():
return True return True
if datetime.now() > self._max_time: if datetime.now() > self._max_time:
info = self.process.getinfo("eos") info = self.process.getinfo(process_name)
error_msg = ( error_msg = (
f"The provided startup check could not assert process responsiveness\n" f"The provided startup check could not assert process responsiveness\n"
f"within the specified time interval of {self.timeout} seconds.\n" f"within the specified time interval of {self.timeout} seconds.\n"
@@ -667,38 +672,40 @@ def server_base(
) )
raise TimeoutError(error_msg) raise TimeoutError(error_msg)
# Kill all running eos and eosdash process - just to be sure
cleanup_eos_eosdash(host, port, eosdash_host, eosdash_port, server_timeout)
# Ensure there is an empty config file in the temporary EOS directory # Ensure there is an empty config file in the temporary EOS directory
config_file_path = Path(eos_dir).joinpath(ConfigEOS.CONFIG_FILE_NAME) config_file_path = Path(eos_dir).joinpath(ConfigEOS.CONFIG_FILE_NAME)
with config_file_path.open(mode="w", encoding="utf-8", newline="\n") as fd: with config_file_path.open(mode="w", encoding="utf-8", newline="\n") as fd:
json.dump({}, fd) json.dump({}, fd)
logger.info(f"Created empty config file in {config_file_path}.") logger.info(f"Created empty config file in {config_file_path}.")
# ensure process is running and return its logfile try:
pid, logfile = xprocess.ensure("eos", Starter) # A unique name prevents xprocess from reusing a different test's server.
logger.info(f"Started EOS ({pid}). This may take very long (up to {server_timeout} seconds).") pid, logfile = xprocess.ensure(process_name, Starter)
logger.info(f"EOS_DIR: {Starter.env["EOS_DIR"]}, EOS_CONFIG_DIR: {Starter.env["EOS_CONFIG_DIR"]}") logger.info(f"Started EOS ({pid}). This may take up to {server_timeout} seconds.")
logger.info(f"View xprocess logfile at: {logfile}") logger.info(f"EOS_DIR: {eos_dir}, EOS_CONFIG_DIR: {eos_dir}")
logger.info(f"View xprocess logfile at: {logfile}")
yield { yield {
"server": server, "server": server,
"port": port, "port": port,
"eosdash_server": eosdash_server, "eosdash_server": eosdash_server,
"eosdash_port": eosdash_port, "eosdash_port": eosdash_port,
"eos_dir": eos_dir, "eos_dir": eos_dir,
"timeout": server_timeout, "timeout": server_timeout,
} }
finally:
# clean up whole process tree afterwards try:
xprocess.getinfo("eos").terminate() cleanup_eos_eosdash(
host,
# Cleanup any EOS process left. port,
cleanup_eos_eosdash(host, port, eosdash_host, eosdash_port, server_timeout) eosdash_host,
eosdash_port,
# Remove temporary EOS_DIR server_timeout,
eos_tmp_dir.cleanup() owned_processes=owned_processes,
config_dir=eos_dir,
)
finally:
eos_tmp_dir.cleanup()
@pytest.fixture(scope="class") @pytest.fixture(scope="class")
+43 -67
View File
@@ -1,7 +1,6 @@
import asyncio import asyncio
import json import json
import os import os
import signal
import time import time
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
@@ -170,7 +169,7 @@ class TestServerStartStop:
monkeypatch.setenv("EOS_CONFIG_DIR", str(eos_dir)) monkeypatch.setenv("EOS_CONFIG_DIR", str(eos_dir))
# Import with environment vars set to prevent creation of EOS.config.json in wrong dir. # Import with environment vars set to prevent creation of EOS.config.json in wrong dir.
from akkudoktoreos.server.rest.starteosdash import supervise_eosdash from akkudoktoreos.server.rest import starteosdash
config_eos.server.host = get_default_host() config_eos.server.host = get_default_host()
config_eos.server.port = 8503 config_eos.server.port = 8503
@@ -180,79 +179,56 @@ class TestServerStartStop:
eosdash_server = f"http://{config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}" eosdash_server = f"http://{config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}"
# Cleanup any EOS and EOSdash process left.
cleanup_eos_eosdash(
host=config_eos.server.host,
port=config_eos.server.port,
eosdash_host=config_eos.server.eosdash_host,
eosdash_port=config_eos.server.eosdash_port,
server_timeout=timeout,
)
# Port may be blocked # Port may be blocked
assert wait_for_port_free(config_eos.server.eosdash_port, timeout=120, waiting_app_name="EOSdash") assert wait_for_port_free(config_eos.server.eosdash_port, timeout=120, waiting_app_name="EOSdash")
"""Start EOSdash.""" owned_processes: list[psutil.Process] = []
await supervise_eosdash() try:
await starteosdash.supervise_eosdash()
process = starteosdash.eosdash_proc
assert process is not None, "EOSdash supervisor did not start a process"
owned_processes.append(psutil.Process(process.pid))
# give EOSdash some time to startup startup = False
await asyncio.sleep(1) error = ""
for _ in range(int(timeout / 3)):
try:
resp = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
if resp.status_code == HTTPStatus.OK:
startup = True
break
error = f"{resp.status_code}, {str(resp.content)}"
except requests.RequestException as ex:
error = str(ex)
await asyncio.sleep(3)
# --------------------------------- assert startup, f"Connection to {eosdash_server}/eosdash/health failed: {error}"
# Wait for health endpoint to come up health = resp.json()
# --------------------------------- assert health.get("status") == "alive"
startup = False assert health.get("version") == __version__
error = "" assert health.get("pid") == process.pid
for retries in range(int(timeout / 3)): # Terminate the process started by this test, then reap it via asyncio.
process.terminate()
await asyncio.wait_for(process.wait(), timeout=timeout)
try: try:
resp = requests.get(f"{eosdash_server}/eosdash/health", timeout=2) resp = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
if resp.status_code == HTTPStatus.OK: except requests.RequestException:
startup = True pass
break else:
error = f"{resp.status_code}, {str(resp.content)}" assert resp.status_code != HTTPStatus.OK
except Exception as ex: finally:
error = str(ex) cleanup_eos_eosdash(
host=config_eos.server.host,
await asyncio.sleep(3) port=config_eos.server.port,
eosdash_host=config_eos.server.eosdash_host,
assert startup, f"Connection to {eosdash_server}/eosdash/health failed: {error}" eosdash_port=config_eos.server.eosdash_port,
server_timeout=timeout,
health = resp.json() owned_processes=owned_processes,
assert health.get("status") == "alive" config_dir=str(config_eos.general.config_folder_path),
assert health.get("version") == __version__ )
if starteosdash.eosdash_proc is not None:
# --------------------------------- await asyncio.wait_for(starteosdash.eosdash_proc.wait(), timeout=timeout)
# Shutdown EOSdash (as provided)
# ---------------------------------
try:
resp = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
if resp.status_code == HTTPStatus.OK:
pid = resp.json().get("pid")
assert pid is not None, "EOSdash did not report a PID"
os.kill(pid, signal.SIGTERM)
time.sleep(1)
# After shutdown, the server should not respond OK anymore
try:
resp2 = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
assert resp2.status_code != HTTPStatus.OK
except Exception:
pass # expected
except Exception:
pass # ignore shutdown errors for safety
# ---------------------------------
# Cleanup any leftover processes
# ---------------------------------
cleanup_eos_eosdash(
host=config_eos.server.host,
port=config_eos.server.port,
eosdash_host=config_eos.server.eosdash_host,
eosdash_port=config_eos.server.eosdash_port,
server_timeout=timeout,
)
@pytest.mark.skipif(os.name == "nt", reason="Server restart not supported on Windows") @pytest.mark.skipif(os.name == "nt", reason="Server restart not supported on Windows")
def test_server_restart(self, server_setup_for_function, is_system_test): def test_server_restart(self, server_setup_for_function, is_system_test):
+305
View File
@@ -0,0 +1,305 @@
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()