From 866d54a41ca6af17f96ba8821d203b48a12e6ace Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Wed, 3 Jun 2026 16:33:06 +0800 Subject: [PATCH 1/2] fix(web_server): reap finished action subprocesses to prevent zombie accumulation get_action_status() calls proc.poll() to check if a dashboard action has finished, but never calls proc.wait() afterward. On POSIX systems the kernel retains the process table entry until a blocking waitpid() is issued, so every completed action remains as a zombie for the lifetime of the web server. After poll() returns a non-None exit code, call proc.wait(timeout=1) to reap the child and remove the handle from _ACTION_PROCS. Fixes #38032 --- hermes_cli/web_server.py | 7 ++++++ tests/hermes_cli/test_web_server.py | 34 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 7a4703f2dbc77..9a81e84fd0ed9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1691,6 +1691,13 @@ async def get_action_status(name: str, lines: int = 200): exit_code = proc.poll() running = exit_code is None pid = proc.pid + if not running: + # Reap the finished child to prevent zombie accumulation. + try: + proc.wait(timeout=1) + except Exception: + pass + _ACTION_PROCS.pop(name, None) return { "name": name, diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 11e6eb4dea0d6..8b5a98d85b5b6 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -823,6 +823,40 @@ def fake_spawn(subcommand, name): assert resp.json() == {"ok": True, "pid": 12345, "name": "hermes-update"} assert calls == [(["update"], "hermes-update")] + def test_finished_action_proc_is_reaped_and_removed(self): + """Processes that have exited are reaped via .wait() and removed + from _ACTION_PROCS so they do not accumulate as zombies.""" + import hermes_cli.web_server as web_server + + waited = [] + + class FinishedProc: + pid = 99999 + + def poll(self): + return 0 + + def wait(self, timeout=None): + waited.append(timeout) + return 0 + + name = "gateway-restart" + proc = FinishedProc() + web_server._ACTION_PROCS[name] = proc + web_server._ACTION_RESULTS.pop(name, None) + try: + status = self.client.get(f"/api/actions/{name}/status") + assert status.status_code == 200 + data = status.json() + assert data["running"] is False + assert data["exit_code"] == 0 + assert data["pid"] == 99999 + # The proc should have been reaped and removed. + assert waited, "proc.wait() was not called" + assert name not in web_server._ACTION_PROCS + finally: + web_server._ACTION_PROCS.pop(name, None) + def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch): import gateway.config as gateway_config import hermes_cli.web_server as web_server From f1e27d8138d573a8ce30c6358616147471764d43 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:46:05 -0700 Subject: [PATCH 2/2] fix(web_server): preserve action exit code after reaping zombie proc Follow-up on the zombie-reap fix: once the Popen handle is reaped and dropped from _ACTION_PROCS, migrate the exit code/pid into _ACTION_RESULTS so subsequent /api/actions/{name}/status polls keep reporting the real result instead of falling back to None. The dashboard polls repeatedly, so without this the status flips from 'exited N' to 'unknown' on the next poll. --- hermes_cli/web_server.py | 4 ++++ tests/hermes_cli/test_web_server.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9a81e84fd0ed9..4bc4ce8eacbad 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1698,6 +1698,10 @@ async def get_action_status(name: str, lines: int = 200): except Exception: pass _ACTION_PROCS.pop(name, None) + # Preserve the result so subsequent polls keep reporting the real + # exit code/pid instead of falling back to None once the handle + # is gone. + _ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid} return { "name": name, diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 8b5a98d85b5b6..c1f94a4340535 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -854,8 +854,18 @@ def wait(self, timeout=None): # The proc should have been reaped and removed. assert waited, "proc.wait() was not called" assert name not in web_server._ACTION_PROCS + + # A second poll, after the handle is gone, must still report the + # real exit code/pid from _ACTION_RESULTS rather than None. + status2 = self.client.get(f"/api/actions/{name}/status") + assert status2.status_code == 200 + data2 = status2.json() + assert data2["running"] is False + assert data2["exit_code"] == 0 + assert data2["pid"] == 99999 finally: web_server._ACTION_PROCS.pop(name, None) + web_server._ACTION_RESULTS.pop(name, None) def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch): import gateway.config as gateway_config