fix: block duplicate webui start when launchd owns 8787

This commit is contained in:
Andy Kang
2026-06-01 07:51:22 +09:00
committed by nesquena-hermes
parent f24d633189
commit 91717b7e47
3 changed files with 72 additions and 0 deletions

25
ctl.sh
View File

@@ -7,6 +7,7 @@ PID_FILE="${HERMES_WEBUI_PID_FILE:-${HERMES_HOME}/webui.pid}"
LOG_FILE="${HERMES_WEBUI_LOG_FILE:-${HERMES_HOME}/webui.log}"
STATE_FILE="${HERMES_WEBUI_CTL_STATE_FILE:-${HERMES_HOME}/webui.ctl.env}"
DEFAULT_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${HERMES_HOME}/webui}"
DEFAULT_LAUNCHD_LABEL="${HERMES_WEBUI_LAUNCHD_LABEL:-com.parantoux.hermes-webui}"
usage() {
cat <<'EOF'
@@ -197,6 +198,24 @@ _clear_stale_pid() {
fi
}
_launchd_webui_pid() {
[[ "${HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT:-0}" == "1" ]] && return 1
command -v launchctl >/dev/null 2>&1 || return 1
local label="${HERMES_WEBUI_LAUNCHD_LABEL:-${DEFAULT_LAUNCHD_LABEL}}"
[[ -n "${label}" ]] || return 1
local uid launchd_out pid
uid="$(id -u)"
launchd_out="$(launchctl print "gui/${uid}/${label}" 2>/dev/null)" || return 1
pid="$(printf '%s\n' "${launchd_out}" | awk '/^[[:space:]]*pid = / {print $3; exit}')"
[[ "${pid}" =~ ^[0-9]+$ ]] || return 1
(( pid > 0 )) || return 1
if _is_alive "${pid}"; then
printf '%s\n' "${pid}"
return 0
fi
return 1
}
start_cmd() {
ensure_home
_load_repo_dotenv_preserving_env
@@ -212,6 +231,12 @@ start_cmd() {
echo "[ctl] Hermes WebUI is already running (PID ${existing_pid})"
return 0
fi
local launchd_pid
if launchd_pid="$(_launchd_webui_pid 2>/dev/null)"; then
echo "[ctl] Refusing to start a second Hermes WebUI while launchd job ${HERMES_WEBUI_LAUNCHD_LABEL:-${DEFAULT_LAUNCHD_LABEL}} is running (PID ${launchd_pid})." >&2
echo "[ctl] Use launchctl kickstart -k gui/$(id -u)/${HERMES_WEBUI_LAUNCHD_LABEL:-${DEFAULT_LAUNCHD_LABEL}} or disable the launchd job before using ctl.sh start." >&2
return 2
fi
_clear_stale_pid >/dev/null 2>&1 || true
local python_exe pid

View File

@@ -16,6 +16,8 @@ Or set ``HERMES_WEBUI_FOREGROUND=1`` in the environment. The Web UI will
auto-detect launchd / systemd / supervisord even without the flag, but being
explicit is safer.
**Important (launchd on macOS):** if the ``com.parantoux.hermes-webui`` LaunchAgent is enabled, treat launchd as the single source of truth for WebUI lifecycle. Do **not** also run ``./ctl.sh start``, ``bash start.sh``, ``python bootstrap.py``, or ``python server.py`` against the same state dir/port, or you can create a second WebUI instance and trigger port-8787 restart churn.
## Why ``--foreground`` matters
Without it, ``bootstrap.py`` does this:

View File

@@ -110,6 +110,7 @@ def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs(
"FAKE_PYTHON_LOG": str(fake_log),
"HERMES_WEBUI_HOST": "0.0.0.0",
"HERMES_WEBUI_PORT": "18991",
"HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1",
},
)
@@ -166,6 +167,7 @@ def test_start_loads_dotenv_but_inline_overrides_win(tmp_path):
"HERMES_WEBUI_PYTHON": str(fake_python),
"FAKE_PYTHON_LOG": str(fake_log),
"HERMES_WEBUI_HOST": "0.0.0.0",
"HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1",
},
repo_root=repo_root,
)
@@ -201,6 +203,49 @@ def test_stale_pid_file_is_removed_without_killing_unrelated_process(tmp_path):
sleeper.kill()
def test_start_refuses_second_instance_when_launchd_job_is_running(tmp_path):
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
launchctl = fake_bin / "launchctl"
launchctl.write_text(
textwrap.dedent(
f"""
#!/usr/bin/env bash
if [[ "$1" == "print" ]]; then
printf '\tpid = {sleeper.pid}\\n'
exit 0
fi
exit 1
"""
).lstrip(),
encoding="utf-8",
)
launchctl.chmod(0o755)
try:
result = run_ctl(
tmp_path,
"start",
env={
"PATH": f"{fake_bin}:{os.environ.get('PATH', '')}",
"HERMES_WEBUI_LAUNCHD_LABEL": "com.parantoux.hermes-webui",
},
)
assert result.returncode == 2
combined = result.stdout + result.stderr
assert "Refusing to start a second Hermes WebUI" in combined
assert "launchctl kickstart -k" in combined
assert not (tmp_path / ".hermes" / "webui.pid").exists()
finally:
sleeper.terminate()
try:
sleeper.wait(timeout=3)
except subprocess.TimeoutExpired:
sleeper.kill()
def test_logs_supports_non_following_line_count(tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()