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:
@@ -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
|
||||
Reference in New Issue
Block a user