From 8d5cce4d9450a30071f85262cfc0f2f80a863ac6 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Tue, 15 Sep 2026 05:46:19 +0000 Subject: [PATCH] fix(cli): warn when hermes runs unsupervised as PID 1 A deployment that overrides the image's `entrypoint:` to invoke hermes directly skips docker/entrypoint-dispatch.sh entirely, so hermes itself becomes PID 1 with no s6-overlay /init (or any other init) above it. Nothing then reaps orphaned grandchildren (browser tooling, MCP subprocesses, shell-tool children) reparented to PID 1, and they accumulate as zombies without bound. entrypoint-dispatch.sh already warns on its own non-PID-1 fallback path, but that script never runs in the entrypoint-override case, so there was no signal at all. Add the same style of warning inside hermes_cli.main, gated on being PID 1 on Linux, pointing users at the image's default ENTRYPOINT or `docker run --init` / `init: true`. Fixes #111577 --- hermes_cli/main.py | 33 +++++++++++++++++++ .../test_unsupervised_pid1_warning.py | 33 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/hermes_cli/test_unsupervised_pid1_warning.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ac4e0a4fb7..1fb2be5108 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -183,6 +183,38 @@ def _run_and_exit_oneshot( _exit_after_oneshot(rc) +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. + """ + 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 + + def _set_process_title() -> None: """Cosmetic: show 'hermes' instead of 'python3.xx' in ps/top/htop. @@ -3396,6 +3428,7 @@ def _default_to_chat(args) -> None: def main(): """Main entry point for hermes CLI.""" _set_process_title() + _warn_if_unsupervised_pid1() _advertise_agent_env() # Force UTF-8 stdio on Windows before anything prints. No-op elsewhere. diff --git a/tests/hermes_cli/test_unsupervised_pid1_warning.py b/tests/hermes_cli/test_unsupervised_pid1_warning.py new file mode 100644 index 0000000000..33a81cccad --- /dev/null +++ b/tests/hermes_cli/test_unsupervised_pid1_warning.py @@ -0,0 +1,33 @@ +"""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. +""" + +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_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 == ""