fix: block duplicate webui start when launchd owns 8787
This commit is contained in:
committed by
nesquena-hermes
parent
f24d633189
commit
91717b7e47
25
ctl.sh
25
ctl.sh
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user