Files
hermes-webui/tests/test_bootstrap_foreground.py
Hermes Bot 0076f3d9ab test(bootstrap): widen ensure_python_has_webui_deps stub for rebase onto v0.50.269
The PR added an `agent_dir` parameter to ensure_python_has_webui_deps. The
test_bootstrap_foreground.py tests (added in #1478) had `lambda p: p` stubs
that were 1-arg only. Widened to `lambda *a, **kw: a[0]` so the stubs
accept the new signature on the rebased base.

Co-authored-by: ccqqlo <ccqqlo@users.noreply.github.com>
2026-05-02 19:35:42 +00:00

456 lines
19 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. ``XPC_SERVICE_NAME`` noise filter: bare ``"0"`` and ``application.<id>``
values do NOT trigger foreground (the macOS Terminal default state),
while real launchd Labels (``com.<rdns>.<svc>``) do
7. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when
``--foreground`` is passed
8. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when a supervisor
env var is set even without the explicit flag
9. Default ``main()`` path (no flag, clean env) still uses ``Popen``
10. Foreground path chdir's to ``agent_dir or REPO_ROOT`` before execv (matches
the cwd the legacy Popen uses)
11. 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
12. Foreground path skips ``wait_for_health`` (no client to retry from)
13. ``--foreground`` help text mentions launchd / systemd / supervisord
14. Non-executable ``python_exe`` raises ``RuntimeError`` instead of
looping the supervisor on ``execv`` failure
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 + resolved bootstrap vars so each
test starts from a known-clean state.
The resolved-vars stripping (HERMES_WEBUI_HOST etc.) prevents leakage
where a previous test's ``main()`` mutated ``os.environ`` and a later
test re-imports ``bootstrap``, picking up the polluted defaults. With
these stripped, ``DEFAULT_HOST`` / ``DEFAULT_PORT`` fall back to their
hardcoded defaults at module load time.
"""
for name in (
# Supervisor-detection env vars
"INVOCATION_ID",
"JOURNAL_STREAM",
"NOTIFY_SOCKET",
"XPC_SERVICE_NAME",
"SUPERVISOR_ENABLED",
"HERMES_WEBUI_FOREGROUND",
# Bootstrap-resolved env vars (mutated by main(), can leak across tests)
"HERMES_WEBUI_HOST",
"HERMES_WEBUI_PORT",
"HERMES_WEBUI_AGENT_DIR",
):
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"
class TestXPCServiceNameNoiseFilter:
"""macOS launchd sets XPC_SERVICE_NAME in EVERY Terminal-spawned shell.
Without filtering, every Mac dev running ``./start.sh`` would silently
auto-promote to foreground mode and lose the /health probe + browser open
+ bootstrap log. We narrow to launchd Label-style names (com.<rdns>.<svc>)
while rejecting the well-known noise values.
"""
@pytest.mark.parametrize("noise_value", [
"0", # launchd descendants
"application.com.apple.Terminal.0BCDDEAD-1234-5678", # Terminal.app shells
"application.com.googlecode.iterm2", # iTerm2
"application.com.microsoft.VSCode", # VSCode terminal
])
def test_xpc_noise_values_do_not_trigger(self, import_bootstrap, clean_env, monkeypatch, noise_value):
monkeypatch.setenv("XPC_SERVICE_NAME", noise_value)
assert import_bootstrap._detect_supervisor() is None, (
f"XPC_SERVICE_NAME={noise_value!r} should not trigger foreground "
f"mode — that would break interactive ./start.sh on every Mac."
)
@pytest.mark.parametrize("real_value", [
"com.example.hermes-webui",
"com.acme.production-server",
"io.github.user.my-service",
])
def test_xpc_real_label_triggers(self, import_bootstrap, clean_env, monkeypatch, real_value):
monkeypatch.setenv("XPC_SERVICE_NAME", real_value)
assert import_bootstrap._detect_supervisor() == "XPC_SERVICE_NAME", (
f"XPC_SERVICE_NAME={real_value!r} is a launchd Label and should "
f"trigger foreground mode."
)
def test_xpc_noise_does_not_block_other_supervisor_var(self, import_bootstrap, clean_env, monkeypatch):
# If XPC has a noise value but INVOCATION_ID is set (mixed env, e.g.
# systemd unit run on a Mac CI runner), we should still detect via
# INVOCATION_ID rather than swallow it.
monkeypatch.setenv("XPC_SERVICE_NAME", "0")
monkeypatch.setenv("INVOCATION_ID", "deadbeef")
assert import_bootstrap._detect_supervisor() == "INVOCATION_ID"
# ---------- 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 *a, **kw: a[0])
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 *a, **kw: a[0])
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
class TestForegroundExecutabilityGuard:
"""If python_exe is missing or non-executable, raise a clear error
instead of letting os.execv raise OSError → SystemExit(1) → supervisor
restart loop. This guard prevents the exact failure mode #1458 reports."""
@pytest.fixture
def setup_with_bad_python(self, monkeypatch, tmp_path):
import bootstrap as bs
agent_dir = tmp_path / "agent"
agent_dir.mkdir()
# Create a non-executable file at the python path
bad_python = tmp_path / "bad-python"
bad_python.write_text("#!/bin/bash\necho hi", encoding="utf-8")
bad_python.chmod(0o644) # NOT executable
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
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: str(bad_python))
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path / "state"))
return bs
def test_non_executable_python_raises_runtime_error(self, setup_with_bad_python, monkeypatch, clean_env):
bs = setup_with_bad_python
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
execv_calls = []
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
monkeypatch.setattr(os, "chdir", lambda p: None)
with pytest.raises(RuntimeError, match="not executable"):
bs.main()
# execv must NOT have been called when the guard fires
assert len(execv_calls) == 0