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
This commit is contained in:
liuhao1024 2026-06-03 16:33:06 +08:00 committed by teknium1
parent e3b8b6d32c
commit 866d54a41c
No known key found for this signature in database
2 changed files with 41 additions and 0 deletions

View File

@ -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,

View File

@ -823,6 +823,40 @@ class TestWebServerEndpoints:
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