diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1fb2be5108..8a81c187ac 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -186,33 +186,23 @@ def _run_and_exit_oneshot( def _warn_if_unsupervised_pid1(pid: "int | None" = None) -> None: """Warn when this process is PID 1 with nothing above it to reap orphans. - Docker/Podman normally run the image's own supervisor - (``docker/entrypoint-dispatch.sh`` -> s6-overlay's ``/init``) as PID 1, - which reaps orphaned grandchildren reparented to it. A deployment that - overrides ``entrypoint:`` to invoke hermes directly skips that dispatcher - entirely, so hermes itself becomes PID 1: nothing calls ``wait()`` on - orphaned children (browser tooling, MCP subprocesses, shell-tool - children), and they accumulate as zombies without bound. See - NousResearch/hermes-agent#111577. This mirrors the warning - entrypoint-dispatch.sh already prints on its own non-PID-1 fallback path. + The official image's ENTRYPOINT (``docker/entrypoint-dispatch.sh`` -> s6-overlay's + ``/init``) is the reaper for orphaned grandchildren (browser tooling, MCP servers, shell + children). A Compose service that overrides ``entrypoint:`` to invoke hermes directly makes + hermes itself PID 1 — nothing then ``wait()``s on those orphans and they accumulate as + zombies without bound (#111577). Outside a container a user process is never PID 1, so + this is quiet everywhere else; it mirrors the dispatcher's own non-PID-1 warning. """ - try: - import platform - - if platform.system() != "Linux": - return - if (pid if pid is not None else os.getpid()) != 1: - return - print( - "[hermes] WARNING: this process is PID 1 with no init above it " - "(entrypoint override?). Orphaned child processes will not be " - "reaped and will accumulate as zombies. Use the image's default " - "ENTRYPOINT (docker/entrypoint-dispatch.sh) instead of overriding " - "it, or run with `docker run --init` / `init: true` in Compose.", - file=sys.stderr, - ) - except Exception: - pass + if (pid if pid is not None else os.getpid()) != 1: + return + print( + "[hermes] WARNING: this process is PID 1 with no init above it " + "(entrypoint override?). Orphaned child processes will not be " + "reaped and will accumulate as zombies. Use the image's default " + "ENTRYPOINT (docker/entrypoint-dispatch.sh) instead of overriding " + "it, or run with `docker run --init` / `init: true` in Compose.", + file=sys.stderr, + ) def _set_process_title() -> None: diff --git a/tests/hermes_cli/test_unsupervised_pid1_warning.py b/tests/hermes_cli/test_unsupervised_pid1_warning.py index 33a81cccad..37859a799a 100644 --- a/tests/hermes_cli/test_unsupervised_pid1_warning.py +++ b/tests/hermes_cli/test_unsupervised_pid1_warning.py @@ -1,33 +1,16 @@ -"""Tests for the PID-1-with-no-init warning (NousResearch/hermes-agent#111577). - -When a deployment overrides the image's ``entrypoint:`` to invoke hermes -directly, ``docker/entrypoint-dispatch.sh`` never runs and hermes itself -becomes PID 1 with no supervisor above it to reap orphaned children — -they accumulate as zombies without bound. ``_warn_if_unsupervised_pid1`` -surfaces that trap at startup, mirroring the warning -``entrypoint-dispatch.sh`` already prints on its own non-PID-1 fallback path. -""" +"""The PID-1-with-no-init startup warning (#111577): a Compose ``entrypoint:`` override +makes hermes PID 1 with no reaper above it, so orphaned children pile up as zombies.""" from hermes_cli.main import _warn_if_unsupervised_pid1 -class TestWarnIfUnsupervisedPid1: - def test_warns_when_pid_is_1_on_linux(self, monkeypatch, capsys): - monkeypatch.setattr("platform.system", lambda: "Linux") - _warn_if_unsupervised_pid1(pid=1) - captured = capsys.readouterr() - assert "PID 1" in captured.err - assert "zombies" in captured.err +def test_warns_when_process_is_pid_1(capsys): + _warn_if_unsupervised_pid1(pid=1) + err = capsys.readouterr().err + assert "PID 1" in err and "init: true" in err - def test_silent_when_not_pid_1(self, monkeypatch, capsys): - monkeypatch.setattr("platform.system", lambda: "Linux") - _warn_if_unsupervised_pid1(pid=4242) - captured = capsys.readouterr() - assert captured.err == "" - assert captured.out == "" - def test_silent_on_non_linux_even_as_pid_1(self, monkeypatch, capsys): - monkeypatch.setattr("platform.system", lambda: "Darwin") - _warn_if_unsupervised_pid1(pid=1) - captured = capsys.readouterr() - assert captured.err == "" +def test_silent_when_not_pid_1(capsys): + _warn_if_unsupervised_pid1(pid=4242) + captured = capsys.readouterr() + assert captured.err == "" and captured.out == "" diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index 5d073664ce..773e985c01 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -532,6 +532,23 @@ The container ENTRYPOINT is now the `entrypoint-dispatch.sh` dispatcher (which d Do not override the image entrypoint unless you keep `/init` (or, equivalently, the legacy `docker/entrypoint.sh` shim that forwards to the stage2 hook) in the command chain. s6-overlay's `/init` runs as root so it can chown the volume on first boot, then drops to the `hermes` user via `s6-setuidgid` for every supervised service AND for the main program. Starting `hermes gateway run` as root inside the official image is refused by default because it can leave root-owned files in `/opt/data` and break later dashboard or gateway starts. Set `HERMES_ALLOW_ROOT_GATEWAY=1` only when you intentionally accept that risk. ::: +:::warning Overriding `entrypoint:` also removes the zombie reaper +`/init` is what reaps orphaned grandchildren (headless browsers, MCP servers, `git`/`npm` helpers spawned by tools). A Compose service that overrides `entrypoint:` to call `hermes` directly — for example to run the dashboard as a non-root user — makes the hermes process itself PID 1, and nothing above it ever calls `wait()`: every orphan stays a `` entry forever (one deployment reached 284 zombies in under three hours). Hermes prints `[hermes] WARNING: this process is PID 1 with no init above it` at startup in that configuration. + +If you must override the entrypoint, add Docker's init as PID 1 so orphans are reaped: + +```yaml +services: + hermes-dashboard: + image: nousresearch/hermes-agent:latest + init: true # docker-init becomes PID 1 and reaps orphans + entrypoint: ["/opt/hermes/.venv/bin/hermes"] + command: ["dashboard", "--host", "0.0.0.0", "--port", "9119", "--no-open", "--skip-build"] +``` + +(`docker run --init …` is the equivalent flag.) This fixes the zombie accumulation only — with `/init` out of the chain, the s6 supervision tree is still gone: the dashboard, `hermes gateway run` and per-profile gateways are unsupervised, exactly as the dispatcher's own non-PID-1 warning says. Keep the default `ENTRYPOINT` whenever you can. +::: + ### `docker exec` automatically drops to the `hermes` user `docker exec hermes ` defaults to running as root inside the container, but the image ships a thin shim at `/opt/hermes/bin/hermes` (earliest on PATH) that detects root callers and transparently re-execs through `s6-setuidgid hermes`. So `docker exec hermes login`, `docker exec hermes profile create …`, `docker exec hermes setup`, etc. all write files owned by UID 10000 — i.e. readable by the supervised gateway — with no extra `--user` flag needed. Non-root callers (the supervised processes themselves, `docker exec --user hermes`, kanban subagents inside the container) hit a short-circuit that exec's the venv binary directly, so there's no overhead on the hot paths. @@ -846,6 +863,10 @@ Pulling a newer image and recreating the container fixes it permanently (the ins docker exec -u root hermes chmod 0755 /opt/hermes ``` +### Zombie (``) processes piling up under PID 1 + +`ps -eo stat,ppid,comm | awk '$1 ~ /^Z/'` inside the container lists dead children that were never reaped. This happens when hermes itself is PID 1 — almost always because a Compose service overrides `entrypoint:` and so skips `docker/entrypoint-dispatch.sh` → `/init`. Hermes also warns about it at startup (`this process is PID 1 with no init above it`). Restore the default entrypoint, or add `init: true` (Compose) / `docker run --init` so `docker-init` reaps orphans; see [What the Dockerfile does](#what-the-dockerfile-does). Recreating the container clears the existing zombies. + ### Browser tools not working Playwright needs shared memory. Add `--shm-size=1g` to your Docker run command: