mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-14 03:46:38 +00:00
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:
+137
-130
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user