diff --git a/ctl.sh b/ctl.sh index bba8c58f..127fdf93 100755 --- a/ctl.sh +++ b/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 diff --git a/docs/supervisor.md b/docs/supervisor.md index 85821a69..b3f2ad5f 100644 --- a/docs/supervisor.md +++ b/docs/supervisor.md @@ -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: diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index 526d5ded..cd878e00 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -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()