{"repo":"NousResearch/hermes-agent","public_id":"1617","display_label":"Gateway shutdown, orphan cleanup, and systemd restart issues","summary":"These items address problems with gateway process shutdown: orphaned child processes blocking systemd restarts, hung or slow graceful shutdown on SIGTERM, and fixes for subprocess cleanup across PTY, TUI, and bridge contexts.","subsystem":"apps/desktop","counts":{"members":16,"open_issues":1,"open_issues_distinct":1,"open_prs":2,"closed_issues":5,"merged_prs":4,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":14728},"resolution":{"state":"partially_resolved","by":"pr-14728,pr-54090"},"freshness":{"state":"verified","last_verified_at":"2026-08-31T19:04:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09833333333333334},"fix_edges":[{"issue_number":8202,"pr_number":14728,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":37454,"pr_number":54090,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":64155,"pr_number":99663,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":64155,"pr_number":99675,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":82874,"pr_number":99675,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":1617,"title":"Gateway systemd service fails to auto-restart when browser processes orphaned","url":null,"state":"closed"},{"kind":"issue","number":8202,"title":"Gateway process not cleaned up on unclean shutdown after provider timeout — bash/sleep children SIGKILL'd by systemd","url":null,"state":"closed"},{"kind":"issue","number":19153,"title":"[Feature]: Gateway shutdown can interrupt active session and force restart after SIGTERM/SIGINT`","url":null,"state":"closed"},{"kind":"issue","number":37454,"title":"gateway: orphan processes in cgroup block systemd restart for 6+ minutes","url":null,"state":"closed"},{"kind":"issue","number":59057,"title":"Implement Robust Signal Propagation and Graces for PTY Subprocesses in TUI Gateway","url":null,"state":"open"},{"kind":"issue","number":64155,"title":"hermes-gateway: slow/hanging graceful shutdown on SIGTERM causes systemd timeout","url":null,"state":"closed"},{"kind":"pr","number":1618,"title":"fix: improve systemd gateway service resilience","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":14728,"title":"fix(gateway): kill tool subprocesses before adapter disconnect on drain timeout","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/gateway.py","tests/gateway/test_gateway_shutdown.py","tests/hermes_cli/test_gateway_service.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -2560,6 +2560,40 @@ async def stop(\n             return\n \n         async def _stop_impl() -> None:\n+            def _kill_tool_subprocesses(phase: str) -> None:\n+                \"\"\"Kill tool subprocesses + tear down terminal envs + browsers.\n+\n+                Called twice in the shutdown path: once eagerly after a\n+                drain timeout forces agent interrupt (so we reclaim bash/\n+                sleep children before systemd TimeoutStopSec escalates to\n+                SIGKILL on the cgroup — #8202), and once as a final\n+                catch-all at the end of _stop_impl() for the graceful\n+                path or anything respawned mid-teardown.\n+\n+                All steps are best-effort; exceptions are swallowed so\n+                one subsystem's failure doesn't block the rest.\n+                \"\"\"\n+                try:\n+                    from tools.process_registry import process_registry\n+                    _killed = process_registry.kill_all()\n+                    if _killed:\n+                        logger.info(\n+                            \"Shutdown (%s): killed %d tool subprocess(es)\",\n+                            phase, _killed,\n+                        )\n+                except Exception as _e:\n+                    logger.debug(\"process_registry.kill_all (%s) error: %s\", phase, _e)\n+                try:\n+                    from tools.terminal_tool import cleanup_all_environments\n+                    cleanup_all_environments()\n+                except Exception as _e:\n+                    logger.debug(\"cleanup_all_environments (%s) error: %s\", phase, _e)\n+                try:\n+                    from tools.browser_tool import cleanup_all_browsers\n+                    cleanup_all_browsers()\n+                except Exception as _e:\n+                    logger.debug(\"cleanup_all_browsers (%s) error: %s\", phase, _e)\n+\n             logger.info(\n                 \"Stopping gateway%s...\",\n                 \" for restart\" if self._restart_requested else \"\",","@@ -2621,6 +2655,16 @@ async def _stop_impl() -> None:\n                     self._update_runtime_status(\"draining\")\n                     await asyncio.sleep(0.1)\n \n+                # Kill lingering tool subprocesses NOW, before we spend more\n+                # budget on adapter disconnect / session DB close.  Under\n+                # systemd (TimeoutStopSec bounded by drain_timeout+headroom),\n+                # deferring this to the end of stop() risks systemd escalating\n+                # to SIGKILL on the cgroup first — at which point bash/sleep\n+                # children left behind by an interrupted terminal tool get\n+                # killed by systemd instead of us (issue #8202).  The final\n+                # catch-all cleanup below still runs for the graceful path.\n+                _kill_tool_subprocesses(\"post-interrupt\")\n+\n             if self._restart_requested and self._restart_detached:\n                 try:\n                     await self._launch_detached_restart_command()","@@ -2656,22 +2700,13 @@ async def _stop_impl() -> None:\n             self._shutdown_event.set()\n \n             # Global cleanup: kill any remaining tool subprocesses not tied\n-            # to a specific agent (catch-all for zombie prevention).\n-            try:\n-                from tools.process_registry import process_registry\n-                process_registry.kill_all()\n-            except Exception:\n-                pass\n-            try:\n-                from tools.terminal_tool import cleanup_all_environments\n-                cleanup_all_environments()\n-            except Exception:\n-                pass\n-            try:\n-                from tools.browser_tool import cleanup_all_browsers\n-                cleanup_all_browsers()\n-            except Exception:\n-                pass\n+            # to a specific agent (catch-all for zombie prevention). On the\n+            # drain-timeout path we already did this earlier after agent\n+            # interrupt — this second call catches (a) the graceful path\n+            # where drain succeeded without interrupt, and (b) anything\n+            # that got respawned between the earlier call and adapter\n+            # disconnect (defense in depth; safe to call repeatedly).\n+            _kill_tool_subprocesses(\"final-cleanup\")\n \n             # Close SQLite session DBs so the WAL write lock is released.\n             # Without this, --replace and similar restart flows leave the"]},{"file":"hermes_cli/gateway.py","hunks":["@@ -1469,7 +1469,14 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)\n             path_entries.append(resolved_node_dir)\n \n     common_bin_paths = [\"/usr/local/sbin\", \"/usr/local/bin\", \"/usr/sbin\", \"/usr/bin\", \"/sbin\", \"/bin\"]\n-    restart_timeout = max(60, int(_get_restart_drain_timeout() or 0))\n+    # systemd's TimeoutStopSec must exceed the gateway's drain_timeout so\n+    # there's budget left for post-interrupt cleanup (tool subprocess kill,\n+    # adapter disconnect, session DB close) before systemd escalates to\n+    # SIGKILL on the cgroup — otherwise bash/sleep tool-call children left\n+    # by a force-interrupted agent get reaped by systemd instead of us\n+    # (#8202). 30s of headroom covers the worst case we've observed.\n+    _drain_timeout = int(_get_restart_drain_timeout() or 0)\n+    restart_timeout = max(60, _drain_timeout) + 30\n \n     if system:\n         username, group_name, home_dir = _system_service_identity(run_as_user)"]},{"file":"tests/gateway/test_gateway_shutdown.py","hunks":["@@ -145,3 +145,86 @@ async def finish_agents():\n     # Start, one count-change update, and final update. Allow one extra update\n     # if the loop observes the zero-agent state before exiting.\n     assert 3 <= runner._update_runtime_status.call_count <= 4\n+\n+\n[redacted:email]\n+async def test_gateway_stop_kills_tool_subprocesses_before_adapter_disconnect_on_timeout(monkeypatch):\n+    \"\"\"On drain timeout, tool subprocesses must be killed BEFORE adapter\n+    disconnect so systemd's TimeoutStopSec doesn't SIGKILL the cgroup with\n+    bash/sleep children still attached (#8202).\"\"\"\n+    runner, adapter = make_restart_runner()\n+    runner._restart_drain_timeout = 0.01  # force timeout path\n+\n+    call_order: list[str] = []\n+\n+    def _fake_kill_all(task_id=None):\n+        call_order.append(\"kill_all\")\n+        return 2\n+\n+    def _fake_cleanup_envs():\n+        call_order.append(\"cleanup_environments\")\n+\n+    def _fake_cleanup_browsers():\n+        call_order.append(\"cleanup_browsers\")\n+\n+    async def _disconnect():\n+        call_order.append(\"disconnect\")\n+\n+    # Patch the module-level names the stop() helper imports lazily.\n+    import tools.process_registry as _pr\n+    import tools.terminal_tool as _tt\n+    import tools.browser_tool as _bt\n+    monkeypatch.setattr(_pr.process_registry, \"kill_all\", _fake_kill_all)\n+    monkeypatch.setattr(_tt, \"cleanup_all_environments\", _fake_cleanup_envs)\n+    monkeypatch.setattr(_bt, \"cleanup_all_browsers\", _fake_cleanup_browsers)\n+\n+    adapter.disconnect = _disconnect\n+\n+    runner._running_agents = {\"session\": MagicMock()}\n+\n+    with patch(\"gateway.status.remove_pid_file\"), patch(\"gateway.status.write_runtime_status\"):\n+        await runner.stop()\n+\n+    # First kill_all must precede the first disconnect.  (Both the eager\n+    # post-interrupt cleanup and the final catch-all call _kill_tool_\n+    # subprocesses, so we expect kill_all to appear twice total.)\n+    assert \"kill_all\" in call_order\n+    assert \"disconnect\" in call_order\n+    first_kill = call_order.index(\"kill_all\")\n+    first_disconnect = call_order.index(\"disconnect\")\n+    assert first_kill < first_disconnect, (\n+        f\"Tool subprocesses must be killed before adapter disconnect on \"\n+        f\"drain timeout, got order: {call_order}\"\n+    )\n+    # Defense-in-depth final cleanup still runs.\n+    assert call_order.count(\"kill_all\") >= 2\n+\n+\n[redacted:email]\n+async def test_gateway_stop_kills_tool_subprocesses_on_graceful_path(monkeypatch):\n+    \"\"\"Graceful shutdown (no drain timeout) must still kill tool subprocesses\n+    exactly once via the final catch-all — regression guard against\n+    accidentally removing that call when refactoring.\"\"\"\n+    runner, adapter = make_restart_runner()\n+    adapter.disconnect = AsyncMock()\n+\n+    kill_count = 0\n+\n+    def _fake_kill_all(task_id=None):\n+        nonlocal kill_count\n+        kill_count += 1\n+        return 0\n+\n+    import tools.process_registry as _pr\n+    import tools.terminal_tool as _tt\n+    import tools.browser_tool as _bt\n+    monkeypatch.setattr(_pr.process_registry, \"kill_all\", _fake_kill_all)\n+    monkeypatch.setattr(_tt, \"cleanup_all_environments\", lambda: None)\n+    monkeypatch.setattr(_bt, \"cleanup_all_browsers\", lambda: None)\n+\n+    # No running agents → drain returns immediately, no timeout, no eager cleanup.\n+    with patch(\"gateway.status.remove_pid_file\"), patch(\"gateway.status.write_runtime_status\"):\n+        await runner.stop()\n+\n+    # Only the final catch-all fires on the graceful path.\n+    assert kill_count == 1"]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -95,7 +95,10 @@ def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self\n         assert \"ExecStop=\" not in unit\n         assert \"ExecReload=/bin/kill -USR1 $MAINPID\" in unit\n         assert f\"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}\" in unit\n-        assert \"TimeoutStopSec=60\" in unit\n+        # TimeoutStopSec must exceed the default drain_timeout (60s) so\n+        # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup\n+        # (tool subprocess kill, adapter disconnect) runs — issue #8202.\n+        assert \"TimeoutStopSec=90\" in unit\n \n     def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch):\n         monkeypatch.setattr(gateway_cli.shutil, \"which\", lambda cmd: \"/home/test/.nvm/versions/node/v24.14.0/bin/node\" if cmd == \"node\" else None)","@@ -111,7 +114,10 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se\n         assert \"ExecStop=\" not in unit\n         assert \"ExecReload=/bin/kill -USR1 $MAINPID\" in unit\n         assert f\"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}\" in unit\n-        assert \"TimeoutStopSec=60\" in unit\n+        # TimeoutStopSec must exceed the default drain_timeout (60s) so\n+        # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup\n+        # (tool subprocess kill, adapter disconnect) runs — issue #8202.\n+        assert \"TimeoutStopSec=90\" in unit\n         assert \"WantedBy=multi-user.target\" in unit\n \n \n"]}]},{"kind":"pr","number":37550,"title":"fix(gateway): reap cgroup orphans via ExecStopPost to unblock restart","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54090","number":54090}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":37603,"title":"Fix desktop IME enter handling and gateway restart guards","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"e551da6ddb39a712c93e6c48a16aa748e2c7bc95","number":0},{"kind":"commit","value":"245b95b09470bb3887943122a7d0de5bf20da055","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":39577,"title":"fix(gateway): systemd KillMode=mixed + restart drain timeout + cron stop guard (#37454 #37453 #37858)","url":null,"state":"open","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":54090,"title":"fix(gateway): reap cgroup orphans via ExecStopPost to unblock systemd restart","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/cgroup_cleanup.py","hermes_cli/gateway.py","scripts/release.py","tests/gateway/test_cgroup_cleanup.py","tests/hermes_cli/test_gateway_service.py"],"fix_hunks":[{"file":"gateway/cgroup_cleanup.py","hunks":["@@ -0,0 +1,81 @@\n+\"\"\"SIGKILL any process left in this systemd unit's cgroup.\n+\n+Runs as ``ExecStopPost=`` so it only fires after the gateway's main process\n+has exited. The gateway already reaps its own tool subprocesses on a clean\n+shutdown; this is the safety net for long-lived helpers it doesn't track\n+(``adb``, platform bridges, etc.) that would otherwise be orphaned in the\n+cgroup and block ``Restart=always`` — issue #37454.\n+\n+We deliberately iterate ``cgroup.procs`` and send per-PID SIGKILLs instead\n+of writing ``1`` to ``cgroup.kill``: the original failure mode in #37454\n+was the kernel returning ``EINVAL`` on the cgroup-wide kill, while per-PID\n+signal delivery uses a separate code path that still works.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+import re\n+import signal\n+import sys\n+from pathlib import Path\n+\n+\n+def _own_cgroup_path() -> str | None:\n+    \"\"\"Return the cgroup v2 path for the calling process, or None.\"\"\"\n+    try:\n+        text = Path(\"/proc/self/cgroup\").read_text(encoding=\"utf-8\")\n+    except OSError:\n+        return None\n+    match = re.search(r\"^0::(.+)$\", text, re.MULTILINE)\n+    if not match:\n+        return None\n+    return match.group(1).strip()\n+\n+\n+def _read_cgroup_pids(cgroup_path: str) -> list[int]:\n+    procs_file = Path(f\"/sys/fs/cgroup{cgroup_path}/cgroup.procs\")\n+    try:\n+        raw = procs_file.read_text(encoding=\"utf-8\")\n+    except OSError:\n+        return []\n+    pids: list[int] = []\n+    for line in raw.splitlines():\n+        line = line.strip()\n+        if not line:\n+            continue\n+        try:\n+            pids.append(int(line))\n+        except ValueError:\n+            continue\n+    return pids\n+\n+\n+def reap_cgroup(cgroup_path: str | None = None) -> int:\n+    \"\"\"SIGKILL every PID in the cgroup other than the caller. Returns the count killed.\"\"\"\n+    if cgroup_path is None:\n+        cgroup_path = _own_cgroup_path()\n+    if not cgroup_path:\n+        return 0\n+    own = os.getpid()\n+    killed = 0\n+    for pid in _read_cgroup_pids(cgroup_path):\n+        if pid == own:\n+            continue\n+        try:\n+            os.kill(pid, signal.SIGKILL)  # windows-footgun: ok — Linux-only (reads /proc, /sys/fs/cgroup; runs from a systemd unit)\n+            killed += 1\n+        except ProcessLookupError:\n+            continue\n+        except PermissionError:\n+            continue\n+    return killed\n+\n+\n+def main() -> int:\n+    reap_cgroup()\n+    return 0\n+\n+\n+if __name__ == \"__main__\":\n+    sys.exit(main())"]},{"file":"hermes_cli/gateway.py","hunks":["@@ -2706,6 +2706,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)\n KillMode=mixed\n KillSignal=SIGTERM\n ExecReload=/bin/kill -USR1 $MAINPID\n+ExecStopPost=-{python_path} -m gateway.cgroup_cleanup\n TimeoutStopSec={restart_timeout}\n StandardOutput=journal\n StandardError=journal","@@ -2739,6 +2740,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)\n KillMode=mixed\n KillSignal=SIGTERM\n ExecReload=/bin/kill -USR1 $MAINPID\n+ExecStopPost=-{python_path} -m gateway.cgroup_cleanup\n TimeoutStopSec={restart_timeout}\n StandardOutput=journal\n StandardError=journal"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"PRATHAMESH75\",  # PR #37550 salvage (ExecStopPost cgroup-orphan reaper to unblock systemd restart; #37454)\n     \"[redacted:email]\": \"konsisumer\",  # PR #19608 salvage (read-modify-write merge in write_credential_pool to preserve concurrently-added credentials; #19566)\n     \"[redacted:email]\": \"linyubin\",  # PR #50228 salvage (eager fallback on persistent transport timeout/overloaded; #22277)\n     \"[redacted:email]\": \"djstunami\",  # PR #5316 salvage / co-author (suppress transient check_fn flakes so subagents keep file/terminal tools; #21658 / #5304)"]},{"file":"tests/gateway/test_cgroup_cleanup.py","hunks":["@@ -0,0 +1,100 @@\n+\"\"\"Tests for the systemd ExecStopPost cgroup reaper (issue #37454).\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+import signal\n+from pathlib import Path\n+\n+import pytest\n+\n+from gateway import cgroup_cleanup\n+\n+\n+class TestOwnCgroupPath:\n+    def test_parses_v2_cgroup_path(self, tmp_path, monkeypatch):\n+        proc_self = tmp_path / \"cgroup\"\n+        proc_self.write_text(\"0::/user.slice/user-1000.slice/hermes-gateway.service\\n\")\n+        monkeypatch.setattr(\n+            cgroup_cleanup,\n+            \"Path\",\n+            lambda p: proc_self if p == \"/proc/self/cgroup\" else Path(p),\n+        )\n+\n+        assert cgroup_cleanup._own_cgroup_path() == \"/user.slice/user-1000.slice/hermes-gateway.service\"\n+\n+    def test_returns_none_when_proc_missing(self, monkeypatch):\n+        def _raise(_path):\n+            raise FileNotFoundError\n+\n+        monkeypatch.setattr(cgroup_cleanup.Path, \"read_text\", lambda self, *a, **k: _raise(self))\n+        assert cgroup_cleanup._own_cgroup_path() is None\n+\n+\n+class TestReapCgroup:\n+    def test_skips_own_pid_and_kills_the_rest(self, tmp_path, monkeypatch):\n+        own = os.getpid()\n+        cgroup_path = \"/test.slice/hermes-gateway.service\"\n+        procs_file = tmp_path / \"cgroup.procs\"\n+        procs_file.write_text(f\"{own}\\n1001\\n1002\\n\\n\")\n+\n+        def _fake_path(p):\n+            if p == f\"/sys/fs/cgroup{cgroup_path}/cgroup.procs\":\n+                return procs_file\n+            return Path(p)\n+\n+        monkeypatch.setattr(cgroup_cleanup, \"Path\", _fake_path)\n+\n+        killed_pids: list[tuple[int, int]] = []\n+        monkeypatch.setattr(cgroup_cleanup.os, \"kill\", lambda pid, sig: killed_pids.append((pid, sig)))\n+\n+        count = cgroup_cleanup.reap_cgroup(cgroup_path)\n+\n+        assert count == 2\n+        assert (own, signal.SIGKILL) not in killed_pids\n+        assert (1001, signal.SIGKILL) in killed_pids\n+        assert (1002, signal.SIGKILL) in killed_pids\n+\n+    def test_tolerates_already_exited_pids(self, tmp_path, monkeypatch):\n+        cgroup_path = \"/test.slice/hermes-gateway.service\"\n+        procs_file = tmp_path / \"cgroup.procs\"\n+        procs_file.write_text(\"1001\\n1002\\n\")\n+\n+        monkeypatch.setattr(\n+            cgroup_cleanup,\n+            \"Path\",\n+            lambda p: procs_file if p.endswith(\"cgroup.procs\") else Path(p),\n+        )\n+\n+        def _kill(pid, _sig):\n+            if pid == 1001:\n+                raise ProcessLookupError\n+            if pid == 1002:\n+                raise PermissionError\n+\n+        monkeypatch.setattr(cgroup_cleanup.os, \"kill\", _kill)\n+\n+        assert cgroup_cleanup.reap_cgroup(cgroup_path) == 0\n+\n+    def test_noop_when_cgroup_path_unknown(self, monkeypatch):\n+        monkeypatch.setattr(cgroup_cleanup, \"_own_cgroup_path\", lambda: None)\n+\n+        def _explode(*_a, **_kw):\n+            pytest.fail(\"os.kill must not be called when cgroup path is unknown\")\n+\n+        monkeypatch.setattr(cgroup_cleanup.os, \"kill\", _explode)\n+        assert cgroup_cleanup.reap_cgroup() == 0\n+\n+    def test_noop_when_procs_file_missing(self, tmp_path, monkeypatch):\n+        cgroup_path = \"/missing.slice/hermes-gateway.service\"\n+        monkeypatch.setattr(\n+            cgroup_cleanup,\n+            \"Path\",\n+            lambda p: tmp_path / \"does-not-exist\" if \"cgroup.procs\" in p else Path(p),\n+        )\n+\n+        def _explode(*_a, **_kw):\n+            pytest.fail(\"os.kill must not be called when cgroup.procs is unreadable\")\n+\n+        monkeypatch.setattr(cgroup_cleanup.os, \"kill\", _explode)\n+        assert cgroup_cleanup.reap_cgroup(cgroup_path) == 0"]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -431,6 +431,14 @@ def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self\n         # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup\n         # (tool subprocess kill, adapter disconnect) runs — issue #8202.\n         assert self._expected_timeout_stop_sec() in unit\n+        # ExecStopPost reaps any process the gateway didn't clean up itself,\n+        # so long-lived helpers (e.g. adb) can't be left orphaned in the\n+        # cgroup and block Restart=always — issue #37454.\n+        assert \"ExecStopPost=\" in unit\n+        assert \"-m gateway.cgroup_cleanup\" in unit\n+        # KillMode=mixed is preserved so the gateway still reaps its own\n+        # tool-call children before systemd SIGKILLs the cgroup — #8202.\n+        assert \"KillMode=mixed\" in unit\n \n     def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch):\n         monkeypatch.setattr(gateway_cli.shutil, \"which\", lambda cmd: \"/home/test/.nvm/versions/node/v24.14.0/bin/node\" if cmd == \"node\" else None)","@@ -493,6 +501,14 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se\n         # (tool subprocess kill, adapter disconnect) runs — issue #8202.\n         assert self._expected_timeout_stop_sec() in unit\n         assert \"WantedBy=multi-user.target\" in unit\n+        # ExecStopPost reaps any process the gateway didn't clean up itself,\n+        # so long-lived helpers (e.g. adb) can't be left orphaned in the\n+        # cgroup and block Restart=always — issue #37454.\n+        assert \"ExecStopPost=\" in unit\n+        assert \"-m gateway.cgroup_cleanup\" in unit\n+        # KillMode=mixed is preserved so the gateway still reaps its own\n+        # tool-call children before systemd SIGKILLs the cgroup — #8202.\n+        assert \"KillMode=mixed\" in unit\n \n \n class TestGatewayStopCleanup:\n"]}]},{"kind":"pr","number":64352,"title":"fix(whatsapp): reap bridge process on disconnect","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":65084,"title":"fix(gateway): bound SIGTERM post-interrupt grace","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"221312834e","number":0},{"kind":"pr","value":"#99663","number":99663},{"kind":"pr","value":"#64155","number":64155},{"kind":"pr","value":"#82874","number":82874},{"kind":"pr","value":"#99675","number":99675}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":99663,"title":"fix(gateway): bound SIGTERM post-interrupt grace (salvage of #65084)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli-config.yaml.example","gateway/restart.py","gateway/run.py","hermes_cli/config_defaults.py","tests/gateway/restart_test_helpers.py","tests/gateway/test_gateway_shutdown.py","tests/gateway/test_restart_drain.py"]},{"kind":"pr","number":99675,"title":"fix(gateway): run MCP shutdown off-loop with a bounded wait on the SIGTERM path","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_gateway_shutdown.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}