fix: block duplicate webui start when launchd owns 8787
This commit is contained in:
1 parent
f24d633189
commit
91717b7e47
3 files changed
+72
No files matched your search
@@ -7,6 +7,7 @@ PID_FILE="${HERMES_WEBUI_PID_FILE:-${HERMES_HOME}/webui.pid}"
|
|||||||
LOG_FILE="${HERMES_WEBUI_LOG_FILE:-${HERMES_HOME}/webui.log}"
|
LOG_FILE="${HERMES_WEBUI_LOG_FILE:-${HERMES_HOME}/webui.log}"
|
||||||
STATE_FILE="${HERMES_WEBUI_CTL_STATE_FILE:-${HERMES_HOME}/webui.ctl.env}"
|
STATE_FILE="${HERMES_WEBUI_CTL_STATE_FILE:-${HERMES_HOME}/webui.ctl.env}"
|
||||||
DEFAULT_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${HERMES_HOME}/webui}"
|
DEFAULT_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${HERMES_HOME}/webui}"
|
||||||
|
DEFAULT_LAUNCHD_LABEL="${HERMES_WEBUI_LAUNCHD_LABEL:-com.parantoux.hermes-webui}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -197,6 +198,24 @@ _clear_stale_pid() {
|
|||||||
fi
|
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() {
|
start_cmd() {
|
||||||
ensure_home
|
ensure_home
|
||||||
_load_repo_dotenv_preserving_env
|
_load_repo_dotenv_preserving_env
|
||||||
@@ -212,6 +231,12 @@ start_cmd() {
|
|||||||
echo "[ctl] Hermes WebUI is already running (PID ${existing_pid})"
|
echo "[ctl] Hermes WebUI is already running (PID ${existing_pid})"
|
||||||
return 0
|
return 0
|
||||||
fi
|
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
|
_clear_stale_pid >/dev/null 2>&1 || true
|
||||||
|
|
||||||
local python_exe pid
|
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
|
auto-detect launchd / systemd / supervisord even without the flag, but being
|
||||||
explicit is safer.
|
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
|
## Why ``--foreground`` matters
|
||||||
|
|
||||||
Without it, ``bootstrap.py`` does this:
|
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),
|
"FAKE_PYTHON_LOG": str(fake_log),
|
||||||
"HERMES_WEBUI_HOST": "0.0.0.0",
|
"HERMES_WEBUI_HOST": "0.0.0.0",
|
||||||
"HERMES_WEBUI_PORT": "18991",
|
"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),
|
"HERMES_WEBUI_PYTHON": str(fake_python),
|
||||||
"FAKE_PYTHON_LOG": str(fake_log),
|
"FAKE_PYTHON_LOG": str(fake_log),
|
||||||
"HERMES_WEBUI_HOST": "0.0.0.0",
|
"HERMES_WEBUI_HOST": "0.0.0.0",
|
||||||
|
"HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1",
|
||||||
},
|
},
|
||||||
repo_root=repo_root,
|
repo_root=repo_root,
|
||||||
)
|
)
|
||||||
@@ -201,6 +203,49 @@ def test_stale_pid_file_is_removed_without_killing_unrelated_process(tmp_path):
|
|||||||
sleeper.kill()
|
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):
|
def test_logs_supports_non_following_line_count(tmp_path):
|
||||||
hermes_home = tmp_path / ".hermes"
|
hermes_home = tmp_path / ".hermes"
|
||||||
hermes_home.mkdir()
|
hermes_home.mkdir()
|
||||||
|
|||||||
Reference in new issue
Block a user