359 lines
15 KiB
Python
359 lines
15 KiB
Python
|
|
"""
|
||
|
|
Tests for bootstrap.py --foreground / supervisor auto-detect (issue #1458, Bug #1).
|
||
|
|
|
||
|
|
Background
|
||
|
|
----------
|
||
|
|
Issue #1458 reports: under launchd / systemd / supervisord with KeepAlive=true
|
||
|
|
or Restart=always, bootstrap.py exits after spawning the server child via
|
||
|
|
``Popen + wait_for_health``. The supervisor sees the parent exit, marks the
|
||
|
|
program as "completed," and respawns it — but the original server child is
|
||
|
|
still holding port 8787 in a detached process group. The new bootstrap fails
|
||
|
|
to bind, exits non-zero, supervisor respawns again, loops until something
|
||
|
|
crashes the orphan and frees the port.
|
||
|
|
|
||
|
|
The fix
|
||
|
|
-------
|
||
|
|
Add ``--foreground`` flag and supervisor-environment auto-detection. In
|
||
|
|
foreground mode we replace the current process via ``os.execv`` with the
|
||
|
|
server, so the supervisor sees the long-lived server as the original child.
|
||
|
|
The legacy ``Popen + wait_for_health`` path is preserved for interactive
|
||
|
|
``bash start.sh`` runs.
|
||
|
|
|
||
|
|
Coverage
|
||
|
|
--------
|
||
|
|
1. ``--foreground`` is a recognized argparse flag
|
||
|
|
2. ``_detect_supervisor()`` returns None on a clean env
|
||
|
|
3. ``_detect_supervisor()`` returns the env-var name on each known supervisor
|
||
|
|
(``INVOCATION_ID`` / ``JOURNAL_STREAM`` / ``NOTIFY_SOCKET`` /
|
||
|
|
``XPC_SERVICE_NAME`` / ``SUPERVISOR_ENABLED``)
|
||
|
|
4. ``_detect_supervisor()`` returns ``HERMES_WEBUI_FOREGROUND`` for the
|
||
|
|
explicit opt-in, accepting ``1``/``true``/``yes``/``on`` (case-insensitive)
|
||
|
|
5. ``_detect_supervisor()`` ignores ``HERMES_WEBUI_FOREGROUND=0`` /
|
||
|
|
``=false`` / ``=`` and falls through to env-var probing
|
||
|
|
6. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when
|
||
|
|
``--foreground`` is passed
|
||
|
|
7. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when a supervisor
|
||
|
|
env var is set even without the explicit flag
|
||
|
|
8. Default ``main()`` path (no flag, clean env) still uses ``Popen``
|
||
|
|
9. Foreground path chdir's to ``agent_dir or REPO_ROOT`` before execv (matches
|
||
|
|
the cwd the legacy Popen uses)
|
||
|
|
10. Foreground path exports ``HERMES_WEBUI_HOST`` / ``HERMES_WEBUI_PORT`` /
|
||
|
|
``HERMES_WEBUI_AGENT_DIR`` / ``HERMES_WEBUI_STATE_DIR`` to ``os.environ``
|
||
|
|
so the post-exec server picks them up
|
||
|
|
11. Foreground path skips ``wait_for_health`` (no client to retry from)
|
||
|
|
12. ``--foreground`` help text mentions launchd / systemd / supervisord
|
||
|
|
|
||
|
|
These tests do NOT actually exec — ``os.execv`` is monkeypatched. We're
|
||
|
|
pinning the structural choice (which path runs, which cwd, which env) not the
|
||
|
|
post-exec behavior (which is the OS kernel's job).
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
REPO_ROOT = Path(__file__).parent.parent
|
||
|
|
sys.path.insert(0, str(REPO_ROOT))
|
||
|
|
|
||
|
|
|
||
|
|
# ---------- helpers --------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def clean_env(monkeypatch):
|
||
|
|
"""Strip all known supervisor env vars so detection starts from a clean state."""
|
||
|
|
for name in (
|
||
|
|
"INVOCATION_ID",
|
||
|
|
"JOURNAL_STREAM",
|
||
|
|
"NOTIFY_SOCKET",
|
||
|
|
"XPC_SERVICE_NAME",
|
||
|
|
"SUPERVISOR_ENABLED",
|
||
|
|
"HERMES_WEBUI_FOREGROUND",
|
||
|
|
):
|
||
|
|
monkeypatch.delenv(name, raising=False)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def import_bootstrap():
|
||
|
|
"""Import bootstrap freshly each test to avoid module-level state bleed."""
|
||
|
|
# bootstrap.py runs ``_load_repo_dotenv()`` at import time; that's idempotent.
|
||
|
|
if "bootstrap" in sys.modules:
|
||
|
|
del sys.modules["bootstrap"]
|
||
|
|
import bootstrap as bs
|
||
|
|
return bs
|
||
|
|
|
||
|
|
|
||
|
|
# ---------- argparse coverage ---------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestForegroundFlag:
|
||
|
|
|
||
|
|
def test_foreground_is_recognized_flag(self, import_bootstrap, monkeypatch):
|
||
|
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||
|
|
args = import_bootstrap.parse_args()
|
||
|
|
assert args.foreground is True
|
||
|
|
assert args.port == import_bootstrap.DEFAULT_PORT # default preserved
|
||
|
|
assert args.host == import_bootstrap.DEFAULT_HOST
|
||
|
|
|
||
|
|
def test_foreground_default_is_false(self, import_bootstrap, monkeypatch):
|
||
|
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py"])
|
||
|
|
args = import_bootstrap.parse_args()
|
||
|
|
assert args.foreground is False
|
||
|
|
|
||
|
|
def test_foreground_help_mentions_supervisors(self, import_bootstrap, monkeypatch, capsys):
|
||
|
|
# argparse prints help and exits — capture and verify content.
|
||
|
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--help"])
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
import_bootstrap.parse_args()
|
||
|
|
out = capsys.readouterr().out
|
||
|
|
assert "--foreground" in out
|
||
|
|
assert "launchd" in out
|
||
|
|
assert "systemd" in out
|
||
|
|
assert "supervisord" in out
|
||
|
|
|
||
|
|
|
||
|
|
# ---------- _detect_supervisor() ------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestDetectSupervisor:
|
||
|
|
|
||
|
|
def test_clean_env_returns_none(self, import_bootstrap, clean_env):
|
||
|
|
assert import_bootstrap._detect_supervisor() is None
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("var", [
|
||
|
|
"INVOCATION_ID",
|
||
|
|
"JOURNAL_STREAM",
|
||
|
|
"NOTIFY_SOCKET",
|
||
|
|
"XPC_SERVICE_NAME",
|
||
|
|
"SUPERVISOR_ENABLED",
|
||
|
|
])
|
||
|
|
def test_each_supervisor_var_triggers(self, import_bootstrap, clean_env, monkeypatch, var):
|
||
|
|
monkeypatch.setenv(var, "anything-truthy")
|
||
|
|
assert import_bootstrap._detect_supervisor() == var
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "Yes", "on", "ON"])
|
||
|
|
def test_explicit_opt_in_truthy_values(self, import_bootstrap, clean_env, monkeypatch, value):
|
||
|
|
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", value)
|
||
|
|
assert import_bootstrap._detect_supervisor() == "HERMES_WEBUI_FOREGROUND"
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("value", ["0", "false", "FALSE", "no", "off", "", " "])
|
||
|
|
def test_explicit_opt_in_falsy_values_fall_through(self, import_bootstrap, clean_env, monkeypatch, value):
|
||
|
|
# When HERMES_WEBUI_FOREGROUND is falsy, we should NOT short-circuit on it.
|
||
|
|
# If no other supervisor var is set, returns None.
|
||
|
|
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", value)
|
||
|
|
assert import_bootstrap._detect_supervisor() is None
|
||
|
|
|
||
|
|
def test_explicit_opt_in_takes_precedence_over_supervisor_var(self, import_bootstrap, clean_env, monkeypatch):
|
||
|
|
# Both set → explicit flag wins (returned name reflects user intent).
|
||
|
|
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", "1")
|
||
|
|
monkeypatch.setenv("INVOCATION_ID", "deadbeef")
|
||
|
|
assert import_bootstrap._detect_supervisor() == "HERMES_WEBUI_FOREGROUND"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------- main() routing ------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestMainForegroundRouting:
|
||
|
|
"""Verify which code path main() takes under each input combination.
|
||
|
|
|
||
|
|
These are STRUCTURAL tests — they pin which call (execv vs Popen) is made,
|
||
|
|
not the result. We monkeypatch every external side effect so main() runs
|
||
|
|
in a hermetic environment.
|
||
|
|
"""
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def stub_main_dependencies(self, monkeypatch, tmp_path):
|
||
|
|
"""Stub out everything main() calls except the routing decision."""
|
||
|
|
import bootstrap as bs
|
||
|
|
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
||
|
|
monkeypatch.setattr(bs, "discover_agent_dir", lambda: tmp_path / "agent")
|
||
|
|
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
||
|
|
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
|
||
|
|
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda p: p)
|
||
|
|
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
|
||
|
|
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
|
||
|
|
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path / "state"))
|
||
|
|
# Make agent_dir exist so chdir doesn't fail.
|
||
|
|
(tmp_path / "agent").mkdir(parents=True, exist_ok=True)
|
||
|
|
return bs
|
||
|
|
|
||
|
|
def test_default_path_uses_popen(self, stub_main_dependencies, clean_env, monkeypatch):
|
||
|
|
bs = stub_main_dependencies
|
||
|
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--no-browser"])
|
||
|
|
|
||
|
|
execv_calls = []
|
||
|
|
popen_calls = []
|
||
|
|
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
|
||
|
|
|
||
|
|
class FakePopen:
|
||
|
|
pid = 12345
|
||
|
|
def __init__(self, *args, **kwargs):
|
||
|
|
popen_calls.append((args, kwargs))
|
||
|
|
monkeypatch.setattr(subprocess, "Popen", FakePopen)
|
||
|
|
|
||
|
|
rc = bs.main()
|
||
|
|
assert rc == 0
|
||
|
|
assert len(popen_calls) == 1, "Default path should call subprocess.Popen exactly once"
|
||
|
|
assert len(execv_calls) == 0, "Default path must NOT call os.execv"
|
||
|
|
|
||
|
|
def test_foreground_flag_uses_execv(self, stub_main_dependencies, clean_env, monkeypatch):
|
||
|
|
bs = stub_main_dependencies
|
||
|
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||
|
|
|
||
|
|
execv_calls = []
|
||
|
|
popen_calls = []
|
||
|
|
# execv normally replaces the process; we capture+raise SystemExit so
|
||
|
|
# main() returns control to us instead of falling through to the
|
||
|
|
# legacy Popen branch.
|
||
|
|
def fake_execv(path, argv):
|
||
|
|
execv_calls.append((path, argv))
|
||
|
|
raise SystemExit(0)
|
||
|
|
monkeypatch.setattr(os, "execv", fake_execv)
|
||
|
|
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||
|
|
|
||
|
|
def fake_popen(*args, **kwargs):
|
||
|
|
popen_calls.append((args, kwargs))
|
||
|
|
return None
|
||
|
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||
|
|
|
||
|
|
with pytest.raises(SystemExit) as ei:
|
||
|
|
bs.main()
|
||
|
|
assert ei.value.code == 0
|
||
|
|
assert len(execv_calls) == 1, "--foreground must call os.execv exactly once"
|
||
|
|
assert len(popen_calls) == 0, "--foreground must NOT call subprocess.Popen"
|
||
|
|
|
||
|
|
path, argv = execv_calls[0]
|
||
|
|
assert path == "/usr/bin/python3"
|
||
|
|
# argv[0] is the program name (convention), argv[1] is the script
|
||
|
|
assert argv[0] == "/usr/bin/python3"
|
||
|
|
assert argv[1].endswith("server.py")
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("var", [
|
||
|
|
"INVOCATION_ID",
|
||
|
|
"JOURNAL_STREAM",
|
||
|
|
"NOTIFY_SOCKET",
|
||
|
|
"XPC_SERVICE_NAME",
|
||
|
|
"SUPERVISOR_ENABLED",
|
||
|
|
])
|
||
|
|
def test_supervisor_env_var_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch, var):
|
||
|
|
bs = stub_main_dependencies
|
||
|
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground
|
||
|
|
monkeypatch.setenv(var, "deadbeef")
|
||
|
|
|
||
|
|
execv_calls = []
|
||
|
|
popen_calls = []
|
||
|
|
def fake_execv(path, argv):
|
||
|
|
execv_calls.append((path, argv))
|
||
|
|
raise SystemExit(0)
|
||
|
|
monkeypatch.setattr(os, "execv", fake_execv)
|
||
|
|
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||
|
|
|
||
|
|
def fake_popen(*args, **kwargs):
|
||
|
|
popen_calls.append((args, kwargs))
|
||
|
|
return None
|
||
|
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||
|
|
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
bs.main()
|
||
|
|
assert len(execv_calls) == 1, f"{var} must auto-promote to execv"
|
||
|
|
assert len(popen_calls) == 0, f"{var} must NOT use Popen"
|
||
|
|
|
||
|
|
def test_explicit_opt_in_env_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch):
|
||
|
|
bs = stub_main_dependencies
|
||
|
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground flag
|
||
|
|
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", "1")
|
||
|
|
|
||
|
|
execv_calls = []
|
||
|
|
def fake_execv(path, argv):
|
||
|
|
execv_calls.append((path, argv))
|
||
|
|
raise SystemExit(0)
|
||
|
|
monkeypatch.setattr(os, "execv", fake_execv)
|
||
|
|
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||
|
|
monkeypatch.setattr(subprocess, "Popen", lambda *a, **kw: None)
|
||
|
|
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
bs.main()
|
||
|
|
assert len(execv_calls) == 1
|
||
|
|
|
||
|
|
|
||
|
|
class TestForegroundEnvAndCwd:
|
||
|
|
"""The post-execv server.py inherits os.environ and cwd from us."""
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def setup(self, monkeypatch, tmp_path):
|
||
|
|
import bootstrap as bs
|
||
|
|
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
||
|
|
agent_dir = tmp_path / "agent"
|
||
|
|
agent_dir.mkdir()
|
||
|
|
monkeypatch.setattr(bs, "discover_agent_dir", lambda: agent_dir)
|
||
|
|
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
||
|
|
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
|
||
|
|
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda p: p)
|
||
|
|
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
|
||
|
|
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
|
||
|
|
# State-dir + every var we care about is captured.
|
||
|
|
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path / "state"))
|
||
|
|
return bs, agent_dir
|
||
|
|
|
||
|
|
def test_foreground_chdirs_to_agent_dir_before_exec(self, setup, monkeypatch, clean_env):
|
||
|
|
bs, agent_dir = setup
|
||
|
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground", "--host", "127.0.0.1", "9999"])
|
||
|
|
|
||
|
|
chdir_calls = []
|
||
|
|
monkeypatch.setattr(os, "chdir", lambda p: chdir_calls.append(p))
|
||
|
|
|
||
|
|
def fake_execv(*a):
|
||
|
|
raise SystemExit(0)
|
||
|
|
monkeypatch.setattr(os, "execv", fake_execv)
|
||
|
|
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
bs.main()
|
||
|
|
assert len(chdir_calls) == 1
|
||
|
|
assert chdir_calls[0] == str(agent_dir)
|
||
|
|
|
||
|
|
def test_foreground_exports_resolved_env_vars(self, setup, monkeypatch, clean_env):
|
||
|
|
bs, agent_dir = setup
|
||
|
|
monkeypatch.setattr(sys, "argv", [
|
||
|
|
"bootstrap.py", "--foreground", "--host", "0.0.0.0", "9119"
|
||
|
|
])
|
||
|
|
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||
|
|
|
||
|
|
def fake_execv(*a):
|
||
|
|
raise SystemExit(0)
|
||
|
|
monkeypatch.setattr(os, "execv", fake_execv)
|
||
|
|
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
bs.main()
|
||
|
|
|
||
|
|
# Post-execv server.py inherits these — verify we set them on os.environ
|
||
|
|
# (not just a local copy).
|
||
|
|
assert os.environ["HERMES_WEBUI_HOST"] == "0.0.0.0"
|
||
|
|
assert os.environ["HERMES_WEBUI_PORT"] == "9119"
|
||
|
|
assert os.environ["HERMES_WEBUI_AGENT_DIR"] == str(agent_dir)
|
||
|
|
# state-dir was already set by the fixture; verify it survived.
|
||
|
|
assert "HERMES_WEBUI_STATE_DIR" in os.environ
|
||
|
|
|
||
|
|
def test_foreground_does_not_call_wait_for_health(self, setup, monkeypatch, clean_env):
|
||
|
|
bs, _ = setup
|
||
|
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||
|
|
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||
|
|
|
||
|
|
wait_calls = []
|
||
|
|
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: (wait_calls.append(a), True)[1])
|
||
|
|
|
||
|
|
def fake_execv(*a):
|
||
|
|
raise SystemExit(0)
|
||
|
|
monkeypatch.setattr(os, "execv", fake_execv)
|
||
|
|
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
bs.main()
|
||
|
|
|
||
|
|
# In foreground mode there's no parent left to retry from — the
|
||
|
|
# supervisor's KeepAlive handles it. wait_for_health must not run.
|
||
|
|
assert len(wait_calls) == 0
|