mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-22 18:30:28 +00:00
0076f3d9ab
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>
456 lines
19 KiB
Python
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
|