From 07298df80589129bc1af290cfd890872a72d0dbc Mon Sep 17 00:00:00 2001 From: RelaxJonh <92573950+RelaxJonh@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:14:55 +0700 Subject: [PATCH] fix(gateway): reap orphaned gateways before spawning restart (#77276) _spawn_gateway_restart() now calls _reap_unsupervised_gateway_orphans() before spawning a new `hermes gateway restart` child. On desktop-app restart the old serve exits but its gateway child gets reparented to launchd (PPID=1) and keeps its platform connection alive. The new serve then spawns a fresh gateway, resulting in two live gateways racing the same connection. The reap was already implemented for the CLI restart path (#75936) but the dashboard's _spawn_gateway_restart path was not covered. Fixes #77276 --- hermes_cli/web_server.py | 13 +++++ .../test_spawn_gateway_restart_reap.py | 52 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/hermes_cli/test_spawn_gateway_restart_reap.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 454e120ddf..62bef20e38 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4018,8 +4018,21 @@ def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Po concurrent ``hermes gateway restart`` children race each other on the manual kill-and-start path, so reuse the live one instead. + Before spawning, sweep for orphaned gateway processes whose parent has + exited (e.g. desktop-app restarts leaving a reparented gateway child + under launchd/PPID=1). Without this the orphan keeps its platform + connection alive and the fresh gateway stacks a duplicate (#77276). + Returns ``(proc, reused)``. """ + # Reap orphaned gateways before spawning a new one (#77276). + try: + from hermes_cli.gateway import _reap_unsupervised_gateway_orphans + + _reap_unsupervised_gateway_orphans() + except Exception: + pass # best-effort — don't block the restart on a reap failure + subcommand = _gateway_subcommand(profile, "restart") existing = _ACTION_PROCS.get("gateway-restart") if existing is not None and existing.poll() is None: diff --git a/tests/hermes_cli/test_spawn_gateway_restart_reap.py b/tests/hermes_cli/test_spawn_gateway_restart_reap.py new file mode 100644 index 0000000000..8b2832660f --- /dev/null +++ b/tests/hermes_cli/test_spawn_gateway_restart_reap.py @@ -0,0 +1,52 @@ +"""Tests for _spawn_gateway_restart orphan-reap guard (#77276).""" +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + + +class TestSpawnGatewayRestartReapsOrphans: + """_spawn_gateway_restart must reap orphaned gateways before spawning.""" + + @patch("hermes_cli.web_server._gateway_subcommand", return_value=["gateway", "restart"]) + @patch("hermes_cli.web_server._spawn_hermes_action") + @patch("hermes_cli.web_server._ACTION_PROCS", {}) + def test_reap_called_before_spawn(self, mock_spawn, mock_subcmd): + """Orphan reap runs before the new gateway process is spawned.""" + mock_proc = MagicMock(spec=subprocess.Popen) + mock_proc.poll.return_value = None + mock_spawn.return_value = mock_proc + + from hermes_cli.web_server import _spawn_gateway_restart + + with patch( + "hermes_cli.gateway._reap_unsupervised_gateway_orphans" + ) as mock_reap: + proc, reused = _spawn_gateway_restart() + + mock_reap.assert_called_once() + mock_spawn.assert_called_once() + assert proc is mock_proc + assert reused is False + + @patch("hermes_cli.web_server._gateway_subcommand", return_value=["gateway", "restart"]) + @patch("hermes_cli.web_server._spawn_hermes_action") + @patch("hermes_cli.web_server._ACTION_PROCS", {}) + def test_reap_failure_does_not_block_spawn(self, mock_spawn, mock_subcmd): + """If reap raises, the restart still proceeds.""" + mock_proc = MagicMock(spec=subprocess.Popen) + mock_proc.poll.return_value = None + mock_spawn.return_value = mock_proc + + from hermes_cli.web_server import _spawn_gateway_restart + + with patch( + "hermes_cli.gateway._reap_unsupervised_gateway_orphans", + side_effect=OSError("permission denied"), + ): + proc, reused = _spawn_gateway_restart() + + mock_spawn.assert_called_once() + assert proc is mock_proc