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
This commit is contained in:
RelaxJonh
2026-08-04 09:14:55 +07:00
committed by Teknium
parent a1bfbccc02
commit 07298df805
2 changed files with 65 additions and 0 deletions
+13
View File
@@ -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:
@@ -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