TUI slash_worker subprocess leak and zombie cleanup
NousResearch/hermes-agent · cluster 21370 · gateway
Data as of
Nothing open on GitHub. Fix merged in #42132. Check it covers your case.
The short answer
- Known?
- Yes. 12 related items grouped here, 6 issues already closed.
- Fix event
- A fix was merged to main in PR #42132. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #42132, #42149. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes all concern the same problem: tui_gateway slash_worker subprocesses are not reliably terminated on session disconnect, PTY EOF, or WebSocket close, causing zombie processes, file descriptor exhaustion, and memory pressure. The fixes address cleanup in various code paths including finalize_session, transport disconnect, and orphan reaping.
Open issues0
Open PRs0
Fixes on record
-
#42132 fix(tui/pty): reap leaked slash_worker subprocesses on PTY chat disconnect
files:
hermes_cli/pty_bridge.py,scripts/release.py,tests/hermes_cli/test_pty_bridge.py,tests/test_slash_worker_watchdog.py,tui_gateway/slash_worker.pyShow the diff
-
hermes_cli/pty_bridge.py@@ -250,13 +250,23 @@ def close(self) -> None: return self._closed = True + try: + pgid = os.getpgid(self._proc.pid) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) + except Exception: + pgid = None + # SIGHUP is the conventional "your terminal went away" signal. - # We escalate if the child ignores it. + # Send it to the whole foreground process group, not just the PTY + # leader: the dashboard TUI starts helper children such as the Python + # slash worker, and killing only the leader can strand those helpers. for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) if not self._proc.isalive(): break try: - self._proc.kill(sig) + if pgid is not None: + os.killpg(pgid, sig) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) + else: + self._proc.kill(sig) except Exception: pass deadline = time.monotonic() + 0.5 -
scripts/release.py@@ -1260,6 +1260,7 @@ "[redacted:email]": "LeonSGP43", # PR #18739 salvage of #14570 "[redacted:email]": "Foolafroos", # PR #20329 French locale "[redacted:email]": "Montbra", # PR #20897 salvage of #16189 (TUI voice PTT) + "[redacted:email]": "paulb26", # PR #24135 salvage (pty-bridge killpg) "[redacted:email]": "firefly", # PR #18123 salvage of #16660 (ContextVars) "[redacted:email]": "WuTianyi123", # PR #20275 salvage of #13723 (feishu markdown) "[redacted:email]": "hanzckernel", # PR #20311 (api-server approval events) -
tests/hermes_cli/test_pty_bridge.py@@ -8,6 +8,7 @@ import os import shutil +import signal import sys import time @@ -211,6 +212,75 @@ def test_close_terminates_long_running_child(self): break assert reaped, f"pid {pid} still running after close()" + def test_close_signals_child_process_group(self, monkeypatch): + sent: list[tuple[int, signal.Signals]] = [] + + class _FakeProc: + pid = 12345 + fd = -1 + + def __init__(self): + self.alive = True + + def isalive(self): + return self.alive + + def kill(self, sig): + raise AssertionError(f"single-process kill used: {sig}") + + def close(self, force=False): + self.closed = force + + fake = _FakeProc() + + def fake_killpg(pgid, sig): + sent.append((pgid, sig)) + fake.alive = False + + monkeypatch.setattr(os, "getpgid", lambda pid: 67890) + monkeypatch.setattr(os, "killpg", fake_killpg) + + bridge = PtyBridge.__new__(PtyBridge) + bridge._proc = fake + bridge._fd = -1 + bridge._closed = False + + bridge.close() + + assert sent == [(67890, signal.SIGHUP)] + assert bridge._closed is True + + def test_close_falls_back_to_single_process_signal_when_group_unknown(self, monkeypatch): + sent: list[signal.Signals] = [] + + class _FakeProc: + pid = 12345 + fd = -1 + + def __init__(self): + self.alive = True + + def isalive(self): + return self.alive + + def kill(self, sig): + sent.append(sig) + self.alive = False + + def close(self, force=False): + self.closed = force + + monkeypatch.setattr(os, "getpgid", lambda pid: (_ for _ in ()).throw(OSError())) + + bridge = PtyBridge.__new__(PtyBridge) + bridge._proc = _FakeProc() + bridge._fd = -1 + bridge._closed = False + + bridge.close() + + assert sent == [signal.SIGHUP] + @skip_on_windows class TestPtyBridgeEnv: -
tests/test_slash_worker_watchdog.py@@ -0,0 +1,21 @@ +import psutil + +from tui_gateway import slash_worker + + +def test_is_orphaned_true_when_ppid_changes(): + # Our parent went away and we were reparented to a subreaper/init. + assert slash_worker._is_orphaned(1234, 1.0, getppid=lambda: 999999) is True + + +def test_is_orphaned_true_when_parent_create_time_mismatch(): + # Same ppid but a different create_time means the PID was reused. + me = psutil.Process() + assert slash_worker._is_orphaned(me.pid, 0.0, getppid=lambda: me.pid) is True + + +def test_is_orphaned_false_when_parent_alive_and_matches(): + me = psutil.Process() + assert ( + slash_worker._is_orphaned(me.pid, me.create_time(), getppid=lambda: me.pid) is False + ) -
tui_gateway/slash_worker.py@@ -9,11 +9,46 @@ import json import os import sys +import threading +import time + +import psutil import cli as cli_mod from cli import HermesCLI from rich.console import Console +# Env-overridable so the integration test can drive sub-second timing. +_WATCHDOG_POLL_S = float(os.environ.get("HERMES_SLASH_WATCHDOG_POLL_S", 2.0)) +_ORPHAN_GRACE_S = float(os.environ.get("HERMES_SLASH_WATCHDOG_GRACE_S", 5.0)) +_in_flight = threading.Event() # set while a command is executing + + +def _is_orphaned(original_ppid, parent_create_time, getppid=os.getppid) -> bool: + """True once our spawning gateway is gone. Compare to the ORIGINAL ppid + (never ==1: Linux reparents to a subreaper) and guard PID reuse via + create_time.""" + if getppid() != original_ppid: + return True + try: + if not psutil.pid_exists(original_ppid): + return True + return psutil.Process(original_ppid).create_time() != parent_create_time + except psutil.Error: + return True + + +def _start_parent_death_watchdog(original_ppid, parent_create_time) -> None: + def _loop(): + while not _is_orphaned(original_ppid, parent_create_time): + time.sleep(_WATCHDOG_POLL_S) + deadline = time.monotonic() + _ORPHAN_GRACE_S + while _in_flight.is_set() and time.monotonic() < deadline: + time.sleep(0.05) # let an in-flight command finish/flush + os._exit(0) + + threading.Thread(target=_loop, daemon=True).start() + def _run(cli: HermesCLI, command: str) -> str: cmd = (command or "").strip() @@ -52,6 +87,15 @@ def main(): os.environ["HERMES_SESSION_KEY"] = args.session_key os.environ["HERMES_INTERACTIVE"] = "1" + # Start before the (hundreds-of-ms) HermesCLI build — that window is itself + # an orphan risk if the gateway dies mid-spawn. + orig_ppid = os.getppid() + try: + parent_create_time = psutil.Process(orig_ppid).create_time() + except psutil.Error: + parent_create_time = 0.0 + _start_parent_death_watchdog(orig_ppid, parent_create_time) + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False) @@ -60,6 +104,7 @@ def main(): if not line: continue + _in_flight.set() rid = None try: req = json.loads(line) @@ -70,6 +115,8 @@ def main(): except Exception as e: sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n") sys.stdout.flush() + finally: + _in_flight.clear() if __name__ == "__main__":
-
-
#42149 fix(tui): close slash_worker inside _finalize_session (defense-in-depth, #38095)
files:
tests/test_tui_gateway_server.py,tui_gateway/server.py
4 PRs closed without merging
From the close comments: superseded by another PR 3 · done elsewhere 1
- #21401 fix(tui-gateway): close sidecar sessions on disconnect · done elsewhere
- #32483 [codex] fix(tui): reap dashboard slash workers · superseded by another PR
- #38114 Fix TUI slash worker zombie subprocess leak (#38095) · superseded by another PR
- #38218 fix(tui): close slash_worker in _finalize_session to prevent zombie processes #38095 · superseded by another PR
Closed issues · 6
- Closed#21370Bug: dashboard chat handler leaks tui_gateway.slash_worker processes after each gateway turn
- Closed#21467tui_gateway.slash_worker subprocesses leak under dashboard usage (swap-pinning a 7.8GB box at 128 workers)
- Closed#22855Bug: slash_worker lifecycle gaps and system fragility observed during intensive Dashboard usage (#21370)
- Closed#24775Dashboard: leaked tui_gateway.slash_worker processes accumulate, exhaust file descriptors on macOS launchd
- Closed#32377Dashboard hangs: tui_gateway.slash_worker subprocesses leak on PTY chat disconnect (524 via reverse proxy)
- Closed#38095[Bug]: TUI slash worker subprocesses leak after session close, accumulating zombie processes
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.14: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab