ReMe/tests/unit/test_service_utils.py
jinliyl 4c54c2b650
Some checks failed
CI / Python packages / Build and verify distributions (push) Waiting to run
CI / Python quality / GitHub Actions (push) Waiting to run
CI / Python quality / Pre-commit (push) Waiting to run
CI / Python tests / Unit Tests - py3.11 (push) Waiting to run
CI / Python tests / Unit Tests - py3.12 (push) Waiting to run
CI / Python tests / Unit Tests - py3.13 (push) Waiting to run
CI / Python tests / Unit Tests - py3.14 (push) Waiting to run
CI / Windows / CLI smoke - py3.11 (push) Waiting to run
Deploy / Documentation / Build documentation (push) Waiting to run
Deploy / Documentation / deploy (push) Blocked by required conditions
CI and Release / Docker / Build and test / amd64 (push) Waiting to run
CI and Release / Docker / Build and test / arm64 (push) Waiting to run
CI and Release / Docker / Publish multi-platform tags (push) Blocked by required conditions
Security / CodeQL / Analyze javascript-typescript (push) Waiting to run
Security / CodeQL / Analyze python (push) Waiting to run
CI / ReMe Studio / Studio checks (push) Has been cancelled
feat(docker): add container deployment and multi-platform release workflow (#582)
* feat(docker): add container deployment and multi-platform release workflow

* fix(llm): initialize providers on model access and preserve runtime injection

* test(llm): keep rejected model updates independent of credentials
2026-10-03 23:54:58 +08:00

247 lines
8.8 KiB
Python

"""Tests for the psutil-based service discovery helpers in ``service_utils``.
Covers the cross-platform replacements for the old ``lsof`` / ``pgrep``
shell-outs:
* ``_pid_on_port`` — real integration (open a listening socket, find its
PID) plus a mock test proving inaccessible processes are skipped, not
crashed on.
* ``_scan_reme_procs`` — mock ``psutil.process_iter`` to assert cmdline
parsing, defaulting, filtering, and AccessDenied tolerance.
"""
# pylint: disable=protected-access,missing-function-docstring,unused-argument
import os
import json
import socket
from types import SimpleNamespace
import psutil
import pytest
from reme.constants import normalize_connect_host
from reme.utils import service_utils as su
# ----------------------------------------------------------------------
# Fakes for mocking psutil.process_iter
# ----------------------------------------------------------------------
def _conn(port: int, status=psutil.CONN_LISTEN, has_laddr: bool = True):
return SimpleNamespace(status=status, laddr=SimpleNamespace(port=port) if has_laddr else None)
class _FakeProc:
"""Stand-in for a psutil.Process yielded by process_iter."""
def __init__(self, pid: int, *, cmdline=None, conns=None, conn_exc=None):
self.info = {"pid": pid, "cmdline": cmdline if cmdline is not None else []}
self._conns = conns or []
self._conn_exc = conn_exc
def net_connections(self, kind="tcp"):
if self._conn_exc is not None:
raise self._conn_exc
return self._conns
class _RaisingInfoProc:
"""Process whose `.info` access raises (simulates AccessDenied in iteration)."""
def __init__(self, exc):
self._exc = exc
@property
def info(self):
raise self._exc
def _patch_iter(monkeypatch, procs):
monkeypatch.setattr(su.psutil, "process_iter", lambda attrs=None: iter(procs))
# ----------------------------------------------------------------------
# _pid_on_port
# ----------------------------------------------------------------------
def test_pid_on_port_finds_own_listening_socket():
"""Real integration: a listening socket is attributed to this process's PID."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(1)
port = srv.getsockname()[1]
try:
assert su._pid_on_port(port) == os.getpid()
finally:
srv.close()
def test_pid_on_port_none_when_nobody_listening():
"""A port with no listener resolves to None."""
# Grab then immediately release an ephemeral port to get a number that is
# (almost certainly) unbound right now.
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
free_port = s.getsockname()[1]
s.close()
assert su._pid_on_port(free_port) is None
def test_pid_on_port_skips_inaccessible_processes(monkeypatch):
"""AccessDenied on one process must not abort the scan of the rest."""
procs = [
_FakeProc(111, conn_exc=psutil.AccessDenied(pid=111)),
_FakeProc(222, conn_exc=psutil.NoSuchProcess(pid=222)),
_FakeProc(333, conns=[_conn(9999)]),
]
_patch_iter(monkeypatch, procs)
assert su._pid_on_port(9999) == 333
def test_pid_on_port_ignores_non_listen_and_mismatched(monkeypatch):
"""Only LISTEN sockets on the exact port match."""
procs = [
_FakeProc(1, conns=[_conn(9999, status=psutil.CONN_ESTABLISHED)]), # right port, wrong state
_FakeProc(2, conns=[_conn(8888)]), # listening, wrong port
_FakeProc(3, conns=[_conn(9999, has_laddr=False)]), # no laddr
]
_patch_iter(monkeypatch, procs)
assert su._pid_on_port(9999) is None
# ----------------------------------------------------------------------
# _scan_reme_procs
# ----------------------------------------------------------------------
def test_scan_reme_procs_parses_host_and_port(monkeypatch):
procs = [
_FakeProc(
123,
cmdline=["python", "-m", "reme.reme", "start", "service.host=0.0.0.0", "service.port=8123"],
),
]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(123, "0.0.0.0", 8123)]
def test_scan_reme_procs_accepts_prefixed_cli_arguments(monkeypatch):
procs = [
_FakeProc(
123,
cmdline=["reme", "start", "--service.host=0.0.0.0", "-service.port=8123"],
),
]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(123, "0.0.0.0", 8123)]
def test_scan_reme_procs_defaults_when_args_absent(monkeypatch):
procs = [_FakeProc(7, cmdline=["reme", "start"])]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(7, su.REME_DEFAULT_HOST, su.REME_DEFAULT_PORT)]
def test_scan_reme_procs_filters_unrelated(monkeypatch):
procs = [
_FakeProc(1, cmdline=["reme", "find_reme"]), # no 'start' token
_FakeProc(2, cmdline=["python", "-m", "http.server", "start"]), # no reme token
_FakeProc(3, cmdline=[]), # empty cmdline
]
_patch_iter(monkeypatch, procs)
assert not su._scan_reme_procs()
def test_scan_reme_procs_ignores_non_digit_port(monkeypatch):
"""A malformed service.port= falls back to the default port, not a crash."""
procs = [_FakeProc(9, cmdline=["reme", "start", "service.port=notaport"])]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(9, su.REME_DEFAULT_HOST, su.REME_DEFAULT_PORT)]
def test_scan_reme_procs_skips_access_denied(monkeypatch):
"""A process that denies cmdline access is skipped, others still scanned."""
procs = [
_RaisingInfoProc(psutil.AccessDenied(pid=99)),
_FakeProc(5, cmdline=["reme", "start"]),
]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(5, su.REME_DEFAULT_HOST, su.REME_DEFAULT_PORT)]
def test_connect_host_converts_wildcard_bind_address():
assert normalize_connect_host("0.0.0.0") == "127.0.0.1"
assert normalize_connect_host("192.0.2.10") == "192.0.2.10"
def test_running_app_config_preserves_plugins(monkeypatch):
"""Process replay exposes the full config while the compatibility helper returns service only."""
monkeypatch.setattr(su, "_reme_start_argv", lambda: [["config=example"]])
monkeypatch.setattr("reme.config.parse_args", lambda *_args: ("start", {"config": "example"}))
monkeypatch.setattr(
"reme.config.resolve_app_config",
lambda **_kwargs: {
"plugins": ["example"],
"service": {"backend": "plugin-client", "port": 9911},
},
)
assert su.running_app_config() == {
"plugins": ["example"],
"service": {"backend": "plugin-client", "port": 9911},
}
assert su.running_service_config() == {"backend": "plugin-client", "port": 9911}
def test_discovery_ignores_container_init_and_replays_real_cli(monkeypatch):
"""Tini's original argv omits environment overrides applied by the entrypoint."""
procs = [
_FakeProc(1, cmdline=["/usr/bin/tini", "--", "python", "/usr/local/lib/reme_container.py", "start"]),
_FakeProc(2, cmdline=["/opt/venv/bin/python", "/opt/venv/bin/reme", "start", 'service={"port":8124}']),
]
_patch_iter(monkeypatch, procs)
assert su._reme_start_argv() == [['service={"port":8124}']]
assert su.running_service_config()["port"] == 8124
assert su._scan_reme_procs() == [(2, su.REME_DEFAULT_HOST, 8124)]
def test_discovery_resolves_mounted_config_port(tmp_path, monkeypatch):
"""find_reme follows ports from JSON configs as well as dot-notation overrides."""
config = tmp_path / "reme.json"
config.write_text(json.dumps({"service": {"backend": "http", "host": "0.0.0.0", "port": 8125}}))
_patch_iter(monkeypatch, [_FakeProc(2, cmdline=["reme", "start", f"config={config}"])])
assert su._scan_reme_procs() == [(2, "0.0.0.0", 8125)]
@pytest.mark.parametrize(
"command",
[
["reme", "--start"],
["python3.11", "-u", "-m", "reme.reme", "-start"],
["/opt/venv/bin/python", "/opt/venv/bin/reme", "start"],
["C:\\Python\\python.exe", "C:\\Python\\Scripts\\reme.exe", "start"],
],
)
def test_discovery_accepts_cli_launchers_and_action_prefixes(command, monkeypatch):
_patch_iter(monkeypatch, [_FakeProc(7, cmdline=command)])
assert su._reme_start_argv() == [[]]
assert su._scan_reme_procs() == [(7, su.REME_DEFAULT_HOST, su.REME_DEFAULT_PORT)]
@pytest.mark.parametrize(
"command",
[
["tini", "--", "reme", "start"],
["dumb-init", "reme", "start"],
["python", "/usr/local/lib/reme_container.py", "start"],
["python", "-c", "reme.py", "start"],
["other-reme-app", "start"],
],
)
def test_discovery_filters_supervisors_and_unrelated_commands(command, monkeypatch):
_patch_iter(monkeypatch, [_FakeProc(7, cmdline=command)])
assert not su._reme_start_argv()
assert not su._scan_reme_procs()