Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3916,8 +3916,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:
Expand Down
52 changes: 52 additions & 0 deletions tests/hermes_cli/test_spawn_gateway_restart_reap.py
Original file line number Diff line number Diff line change
@@ -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
Loading