hermes-agent/tests/tools/test_mcp_stability.py
Teknium 597615ade4
fix(ci): make tests, workflows, and attribution reliable under load (#66373)
* feat(attribution): conflict-free contributor mappings via contributors/emails/ directory

The AUTHOR_MAP dict in scripts/release.py was a merge-conflict magnet:
every concurrent salvage PR appended entries to the same lines of the
same file, so parallel PRs re-conflicted on every merge to main.

New system: one file per email under contributors/emails/ — filename is
the commit-author email, first non-comment line is the GitHub login.
File additions never conflict, so any number of PRs can add mappings
concurrently.

- scripts/release.py: AUTHOR_MAP is now LEGACY_AUTHOR_MAP (frozen)
  merged with the directory at import time (directory wins). All
  existing consumers (resolve_author, contributor_audit.py) unchanged.
- scripts/add_contributor.py: idempotent CLI to add a mapping; refuses
  conflicting reassignments (incl. against the legacy map), validates
  email/login shapes.
- contributor-check.yml: attribution gate now accepts a mapping file OR
  a legacy entry; failure message prints the exact add_contributor
  command. Also auto-resolves bare <login>@users.noreply.github.com
  emails is intentionally NOT added (kept id+login form only, matching
  previous behavior).
- contributor_audit.py: guidance now points at add_contributor.py.
- tests/scripts/test_contributor_map.py: 12 tests covering loader,
  merge precedence, CLI idempotency/conflict/validation, subprocess E2E.

* feat(ci): one-shot per-file flake retry in the parallel test runner

A failing test FILE is re-run once in a fresh subprocess. Pass-on-retry
counts as green but is loudly reported in a '⚠ FLAKY' summary section
(with both attempts' output preserved) so the flake gets fixed instead
of eating a full-run rerun. Deterministic failures fail both attempts —
regressions cannot be laundered green.

- --file-retries N / HERMES_TEST_FILE_RETRIES (default 1, 0 disables)
- E2E verified: simulated first-run-fail flake goes green with banner;
  deterministic failure still exits 1; retries=0 restores old behavior.

This converts the dominant CI failure mode (one timing-sensitive test
flaking a 4600-test shard, requiring a manual 10-minute rerun and an
agent triage loop) into a self-healing retry that costs one file's
runtime.

* test(approval): loosen wall-clock perf bounds 0.15s -> 2.0s

These guard against catastrophic regex backtracking (seconds-to-minutes
class), but 0.15s is within scheduler-stall noise on loaded shared CI
runners — test_max_accepted_separator_free_input_is_fast failed a CI
shard this week on runner load alone. 2.0s still catches the regression
class with zero flake surface.

* fix(ci): job timeouts everywhere + retries on all network installs

Reliability pass over every workflow:
- timeout-minutes on all 21 jobs that lacked one (a hung job previously
  burned the 6-hour default runner budget)
- ./.github/actions/retry wrapped around every network-fetching install
  that lacked it: pip installs (deploy-site, skills-index), npm ci
  (deploy-site website, upload_to_pypi web + ui-tui), uv sync (docker
  test deps). Deterministic build steps (npm run build) deliberately
  NOT retried — split into separate steps so a real build failure fails
  fast instead of retrying 3x.

* docs(agents): document the file-retry flake policy

* fix(ci): curl retries on deploy hook + skills-index probe

* fix(ci): kill the remaining transient-failure classes in workflows + Dockerfile

From the workflow reliability audit:
- tests.yml: duration-cache restore had NO restore-keys while saves use
  run_id-suffixed keys — the cache never matched once, so LPT slicing
  always ran blind and unbalanced slices pushed heavy files toward the
  per-file timeout. One-line restore-keys fixes slice balancing.
- Label gates (lint ci-reviewed, supply-chain mcp-catalog-reviewed):
  'gh pr view || true' turned an API blip into 'label absent' → false
  BLOCKING failure. Now 3x retry, and API failure is reported as an API
  failure instead of a missing label.
- detect-changes action: compare API retried before failing open (was
  silently running all lanes on any blip).
- uv-lockfile-check: 'uv lock --check' resolves against PyPI — retried
  so registry blips don't read as 'lockfile stale'.
- docker.yml merge job: imagetools create retried (Docker Hub eventual
  consistency on just-pushed digests).
- Dockerfile: apt-get Acquire::Retries=3; s6-overlay ADDs converted to
  curl --retry 3 (ADD cannot retry; checksums still enforced); npm
  --fetch-retries=5; playwright chromium fetch retried 3x.
- Advisory artifact uploads (per-slice durations, ci-timings report)
  get continue-on-error so an artifact-service blip can't fail a green
  test slice.

* fix(tests): kill the two root-cause flakes — leaking pre-warm timer + env-dependent provider list

- test_tui_gateway_server.py: session.create / non-eager session.resume
  arm a 50ms threading.Timer (_schedule_agent_build) that outlives its
  test and fires into the NEXT test's _make_agent mock, racily
  corrupting captured state (the recurring session_resume shard
  failures). Replaced the per-test whack-a-mole stub with a module-wide
  autouse fixture; the 3 worker-lifecycle tests that genuinely need the
  deferred build opt back in via @pytest.mark.real_agent_prewarm (new
  marker in pyproject).
- test_api_key_providers.py: PROVIDER_ENV_VARS is now derived from the
  live PROVIDER_REGISTRY instead of a hand-list that had drifted
  (missing HF_TOKEN / DEEPINFRA_API_KEY) — resolve_provider('auto')
  tests failed on any machine with HF_TOKEN exported. E2E-verified with
  HF_TOKEN/DEEPINFRA_API_KEY set: 42/42 pass.

* test: de-flake 30 timing-sensitive test files for loaded CI runners

Root-cause fixes from the flake audit (session-DB mining + repo sweep):

Event-based sync instead of sleep-sync:
- title_generator: mock sets threading.Event, wait(10) replaces
  sleep(0.3) hoping the daemon thread got scheduled
- docker zombie_reaping / profile_gateway: poll-for-state helpers
  replace fixed 1-3s sleeps (s6 transitions + SIGCHLD reaping are async)
- process_registry tree test: select()-bounded readline replaces an
  unbounded blocking read (parent wedge now fails THIS test with a clear
  message instead of an opaque rc=124 file kill); SIGTERM grace 1s->2s
  (the 1s partition window mid-interpreter-startup is how a child PID
  escaped the live-system guard in CI)

Timeout raises (loaded 8-way-sliced runners see ~5s scheduling floors;
all of these complete in ms-to-1s when healthy so the raises cost
nothing on green runs):
- subprocess/thread waits <= 2s raised to 10-15s across mcp_tool,
  mcp_circuit_breaker, mcp_reconnect_retry_reset, mcp_parked_self_probe,
  mcp_cancelled_error_propagation, registry, clarify_gateway, interrupt,
  voice_cli_integration, docker_environment, session_store_lock_io,
  planned_stop_watcher, cli_interrupt_subagent, thread_scoped_output
  (joins now also assert not is_alive() so stragglers fail loudly)
- wall-clock discrimination ceilings loosened where the guarded hang is
  10x larger: local_background_child_hang 4s->10s, interrupt_cleanup
  setup 5s->20s + pgid-exit 30s->60s, mcp_stability grandchild spinup
  5s->15s, protocol/gil-starvation fast-handler 0.5s->2s,
  iso_certify_seam 1.5s->5s, wait_for_mcp_discovery 0.1s->1s
- narrow assertion windows widened: honcho first-turn wait 0.4..0.65 ->
  0.25..2.0 (property is bounded-not-hung, not an exact wall-clock);
  compression fork-lock TTL 1s->3s (12 refresh chances per lease);
  compression-lock expiry margins symmetric (ttl 0.05->0.5, sleep 1.0)
- telegram hung-DNS bound 1.0->1.4 (fake hang is 1.5s — must stay under)

* fix(tests): repair indentation from de-flake batch edit

* fix(tests): harden env isolation and replace remaining sleep-sync races

The full 42k-test run and complete npm check surfaced three more classes:

- Environment isolation: local ~/.honcho defaultHost and SSH_* variables
  leaked into Python/TUI tests. Pin the default Honcho host in the
  hermetic fixture, isolate the one fallback test from ~/.honcho, and
  blank SSH_* around terminalSetup tests. This flipped 20 false failures
  back to deterministic behavior on developer machines.
- Background-thread sleep-sync: Honcho async writer tests patched
  time.sleep globally, then busy-polled with that same mocked sleep. Under
  full-suite load the poller could starve the writer. Each test now waits
  on an Event emitted by the exact flush/retry transition; 30/30 passed
  under 15-way contention.
- Desktop streaming: the test slept 80ms and assumed a 500ms timer could
  not fire before its assertion. A loaded runner descheduled the test for
  >500ms and both chunks arrived. Producer controls now gate second-chunk
  and completion transitions explicitly.

Also make file-retry observability complete: a self-healed flaky file now
prints BOTH attempts' full output in the FLAKY summary. Two behavioral
runner tests prove pass-on-retry is green+loud+traceback-preserving, while
a deterministic failure remains red.

* refactor(ci): use gh bot pat, better retries

refactor(ci): use retry action for PR label fetch
the retry action now captures stdout as a step output, so it can serve
double duty: retry + output capture for commands like 'gh pr view' whose
result must be consumed by later steps.

Retry action gains:
- 'stdout' output (heredoc-delimited to preserve newlines)
- tee to temp file so stdout still streams to the job log
- step id 'retry' for output reference

Both lint.yml and supply-chain-audit.yml now use the retry action
directly with 'command: gh pr view ...' and read
steps.<id>.outputs.stdout.

ci: use AUTOFIX_BOT_PAT for all gh CLI / GitHub API auth

Replace secrets.GITHUB_TOKEN and github.token with
secrets.AUTOFIX_BOT_PAT across all workflows and composite actions
that use the gh CLI or GitHub API. The PAT has consistent permissions
across fork PRs (where GITHUB_TOKEN is read-only), avoids API rate
limit sharing with the default token, and is already used by
js-autofix.yml for the same reasons.

19 sites swapped across 9 files:
- lint.yml (3): label fetch, comment post/edit, comment update
- supply-chain-audit.yml (5): scan, critical comment, unbounded dep
  comment, label fetch, mcp-catalog comment
- lockfile-diff.yml (1): PR comment post/update
- skills-index-freshness.yml (1): issue creation on degraded probe
- skills-index.yml (2): index build, trigger deploy workflow
- upload_to_pypi.yml (2): release view poll, release upload
- ci.yml (1): timings report
- deploy-site.yml (2): skills index crawl
- detect-changes/action.yml (1): compare API call

---------

Co-authored-by: ethernet <arilotter@gmail.com>
2026-07-17 20:55:24 +00:00

738 lines
29 KiB
Python

"""Tests for MCP stability fixes — event loop handler, PID tracking, shutdown robustness."""
import asyncio
import os
import signal
from unittest.mock import patch, MagicMock
import pytest
# ---------------------------------------------------------------------------
# Fix 1: MCP event loop exception handler
# ---------------------------------------------------------------------------
class TestMCPLoopExceptionHandler:
"""_mcp_loop_exception_handler suppresses benign 'Event loop is closed'."""
def test_suppresses_event_loop_closed(self):
from tools.mcp_tool import _mcp_loop_exception_handler
loop = MagicMock()
context = {"exception": RuntimeError("Event loop is closed")}
# Should NOT call default handler
_mcp_loop_exception_handler(loop, context)
loop.default_exception_handler.assert_not_called()
def test_forwards_other_runtime_errors(self):
from tools.mcp_tool import _mcp_loop_exception_handler
loop = MagicMock()
context = {"exception": RuntimeError("some other error")}
_mcp_loop_exception_handler(loop, context)
loop.default_exception_handler.assert_called_once_with(context)
def test_forwards_non_runtime_errors(self):
from tools.mcp_tool import _mcp_loop_exception_handler
loop = MagicMock()
context = {"exception": ValueError("bad value")}
_mcp_loop_exception_handler(loop, context)
loop.default_exception_handler.assert_called_once_with(context)
def test_forwards_contexts_without_exception(self):
from tools.mcp_tool import _mcp_loop_exception_handler
loop = MagicMock()
context = {"message": "just a message"}
_mcp_loop_exception_handler(loop, context)
loop.default_exception_handler.assert_called_once_with(context)
def test_handler_installed_on_mcp_loop(self):
"""_ensure_mcp_loop installs the exception handler on the new loop."""
import tools.mcp_tool as mcp_mod
try:
mcp_mod._ensure_mcp_loop()
with mcp_mod._lock:
loop = mcp_mod._mcp_loop
assert loop is not None
assert loop.get_exception_handler() is mcp_mod._mcp_loop_exception_handler
finally:
mcp_mod._stop_mcp_loop()
def test_probe_cleanup_does_not_stop_loop_with_registered_servers(self):
"""Probe cleanup must not kill the shared loop used by live MCP tools."""
import tools.mcp_tool as mcp_mod
with mcp_mod._lock:
mcp_mod._servers.clear()
mcp_mod._server_connecting.clear()
try:
mcp_mod._ensure_mcp_loop()
with mcp_mod._lock:
loop = mcp_mod._mcp_loop
mcp_mod._servers["live"] = MagicMock(session=object())
assert mcp_mod._stop_mcp_loop_if_idle() is False
with mcp_mod._lock:
assert mcp_mod._mcp_loop is loop
assert mcp_mod._mcp_thread is not None
assert loop is not None
assert loop.is_running()
finally:
with mcp_mod._lock:
mcp_mod._servers.clear()
mcp_mod._server_connecting.clear()
mcp_mod._stop_mcp_loop()
# ---------------------------------------------------------------------------
# Fix 2: stdio PID tracking
# ---------------------------------------------------------------------------
class TestStdioPidTracking:
"""_snapshot_child_pids and _stdio_pids track subprocess PIDs."""
def test_snapshot_returns_set(self):
from tools.mcp_tool import _snapshot_child_pids
result = _snapshot_child_pids()
assert isinstance(result, set)
# All elements should be ints
for pid in result:
assert isinstance(pid, int)
def test_stdio_pids_starts_empty(self):
from tools.mcp_tool import _stdio_pids, _lock
with _lock:
# Might have residual state from other tests, just check type
assert isinstance(_stdio_pids, dict)
def test_kill_orphaned_noop_when_empty(self):
"""_kill_orphaned_mcp_children does nothing when no PIDs tracked."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pid_servers,
_orphan_stdio_pids,
_stdio_pids,
_lock,
)
with _lock:
_stdio_pids.clear()
_orphan_stdio_pids.clear()
_orphan_stdio_pid_servers.clear()
# Should not raise
_kill_orphaned_mcp_children()
def test_kill_orphaned_handles_dead_pids(self):
"""_kill_orphaned_mcp_children gracefully handles already-dead PIDs."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pid_servers,
_orphan_stdio_pids,
_lock,
)
# Use a PID that definitely doesn't exist
fake_pid = 999999999
with _lock:
_orphan_stdio_pids.add(fake_pid)
_orphan_stdio_pid_servers[fake_pid] = "orphan"
# Should not raise (ProcessLookupError is caught)
_kill_orphaned_mcp_children()
with _lock:
assert fake_pid not in _orphan_stdio_pids
def test_kill_orphaned_uses_sigkill_when_available(self, monkeypatch):
"""SIGTERM-first then SIGKILL after 2s for orphan cleanup."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pid_servers,
_orphan_stdio_pids,
_lock,
)
fake_pid = 424242
with _lock:
_orphan_stdio_pids.clear()
_orphan_stdio_pid_servers.clear()
_orphan_stdio_pids.add(fake_pid)
_orphan_stdio_pid_servers[fake_pid] = "orphan"
fake_sigkill = 9
monkeypatch.setattr(signal, "SIGKILL", fake_sigkill, raising=False)
# Post-#21561 the alive check routes through
# ``gateway.status._pid_exists`` (so it's safe on Windows — see
# bpo-14484). Return True so the SIGKILL escalation fires.
with patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=True), \
patch("tools.mcp_tool.time.sleep") as mock_sleep:
_kill_orphaned_mcp_children()
# SIGTERM then SIGKILL; the alive check no longer touches os.kill.
mock_kill.assert_any_call(fake_pid, signal.SIGTERM)
mock_kill.assert_any_call(fake_pid, fake_sigkill)
assert mock_kill.call_count == 2
mock_sleep.assert_called_once_with(2)
with _lock:
assert fake_pid not in _orphan_stdio_pids
def test_kill_orphaned_falls_back_without_sigkill(self, monkeypatch):
"""Without SIGKILL, SIGTERM is used for both phases."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pid_servers,
_orphan_stdio_pids,
_lock,
)
fake_pid = 434343
with _lock:
_orphan_stdio_pids.clear()
_orphan_stdio_pid_servers.clear()
_orphan_stdio_pids.add(fake_pid)
_orphan_stdio_pid_servers[fake_pid] = "orphan"
monkeypatch.delattr(signal, "SIGKILL", raising=False)
with patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("tools.mcp_tool.time.sleep") as mock_sleep:
_kill_orphaned_mcp_children()
# SIGTERM phase, alive check raises (process gone), no escalation
mock_kill.assert_any_call(fake_pid, signal.SIGTERM)
assert mock_sleep.called
with _lock:
assert fake_pid not in _orphan_stdio_pids
def test_run_stdio_reaps_orphans_before_spawn(self):
"""_run_stdio kills orphaned PIDs from prior failed attempts (#57355)."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pids,
_stdio_pgids,
_lock,
MCPServerTask,
)
from unittest.mock import patch, MagicMock, AsyncMock
# Seed an orphan PID that belongs to a prior failed connection.
fake_pid = 999999997
with _lock:
_orphan_stdio_pids.add(fake_pid)
server = MCPServerTask.__new__(MCPServerTask)
server.name = "test-zombie-reap"
server._ready = MagicMock()
server._shutdown_event = MagicMock()
server._shutdown_event.is_set.return_value = True
server._reconnect_event = MagicMock()
server._sampling = None
server._elicitation = None
server._registered_tool_names = []
config = {"command": "echo", "args": ["hello"]}
import asyncio
async def _run():
# _run_stdio should reap orphans before it gets to the
# stdio_client spawn. Patch the OSV check (local import)
# and stdio_client so no real subprocess is spawned.
with patch("tools.mcp_tool._MCP_AVAILABLE", True), \
patch("tools.mcp_tool._build_safe_env", return_value={}), \
patch("tools.mcp_tool._resolve_stdio_command",
return_value=("echo", {})), \
patch("tools.mcp_tool._write_stderr_log_header"), \
patch("tools.mcp_tool._get_mcp_stderr_log",
return_value=None), \
patch("tools.mcp_tool.check_package_for_malware",
return_value=None, create=True), \
patch("tools.osv_check.check_package_for_malware",
return_value=None):
# Patch stdio_client to raise so the test exits quickly
cm = MagicMock()
cm.__aenter__ = AsyncMock(side_effect=RuntimeError("test"))
cm.__aexit__ = AsyncMock(return_value=False)
with patch("tools.mcp_tool.stdio_client", return_value=cm):
try:
await server._run_stdio(config)
except Exception:
pass
asyncio.run(_run())
# The orphan must have been reaped before the spawn attempt.
with _lock:
assert fake_pid not in _orphan_stdio_pids
def test_kill_orphaned_can_filter_by_server_name(self):
"""Reconnect cleanup reaps only the orphan owned by that MCP server."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pid_servers,
_orphan_stdio_pids,
_lock,
)
target_pid = 454545
other_pid = 464646
with _lock:
_orphan_stdio_pids.clear()
_orphan_stdio_pid_servers.clear()
_orphan_stdio_pids.update({target_pid, other_pid})
_orphan_stdio_pid_servers[target_pid] = "feishu"
_orphan_stdio_pid_servers[other_pid] = "mimir"
with patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=False), \
patch("tools.mcp_tool.time.sleep") as mock_sleep:
_kill_orphaned_mcp_children(server_name="feishu")
mock_kill.assert_called_once_with(target_pid, signal.SIGTERM)
mock_sleep.assert_called_once_with(2)
with _lock:
assert target_pid not in _orphan_stdio_pids
assert target_pid not in _orphan_stdio_pid_servers
assert other_pid in _orphan_stdio_pids
assert _orphan_stdio_pid_servers[other_pid] == "mimir"
# ---------------------------------------------------------------------------
# Fix 2b: stdio descendant reaping via process group (issue #23799)
# ---------------------------------------------------------------------------
#
# When a stdio MCP wrapper (e.g. ``openclaw mcp serve``) itself spawns a
# helper subprocess (``claude mcp serve``) and then exits, the helper
# reparents to systemd-user and is invisible to the per-pid orphan reaper.
# The fix captures the wrapper's pgid at spawn time and reaps via killpg,
# which reaches same-group descendants whether or not the direct pid is alive.
class TestStdioPgroupReaping:
"""_kill_orphaned_mcp_children reaps via killpg when a pgid is tracked."""
def _reset_state(self):
from tools.mcp_tool import (
_orphan_stdio_pid_servers,
_orphan_stdio_pids,
_stdio_pgids,
_stdio_pids,
_lock,
)
with _lock:
_stdio_pids.clear()
_orphan_stdio_pids.clear()
_orphan_stdio_pid_servers.clear()
_stdio_pgids.clear()
def test_killpg_used_when_pgid_tracked(self, monkeypatch):
"""SIGTERM and SIGKILL route through killpg when pgid is known."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
self._reset_state()
fake_pid = 525252
fake_pgid = 525252 # session leader: pgid == pid
with _lock:
_orphan_stdio_pids.add(fake_pid)
_stdio_pgids[fake_pid] = fake_pgid
fake_sigkill = 9
monkeypatch.setattr(signal, "SIGKILL", fake_sigkill, raising=False)
# Ensure os.killpg exists on this platform for the test to make sense;
# the production fallback path is covered by the per-pid tests above.
if not hasattr(os, "killpg"):
pytest.skip("os.killpg not available on this platform")
with patch("tools.mcp_tool.os.killpg") as mock_killpg, \
patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=True), \
patch("time.sleep"):
_kill_orphaned_mcp_children()
# Both phases should have used killpg (pgroup reach), not per-pid kill.
mock_killpg.assert_any_call(fake_pgid, signal.SIGTERM)
mock_killpg.assert_any_call(fake_pgid, fake_sigkill)
assert mock_killpg.call_count == 2
mock_kill.assert_not_called()
with _lock:
assert fake_pid not in _orphan_stdio_pids
assert fake_pid not in _stdio_pgids
def test_killpg_skipped_when_pgid_matches_gateway_own_pgroup(self, monkeypatch):
"""#47134: when a tracked MCP child shares the gateway's OWN process
group, killpg(pgid) would signal the gateway itself and crash it.
The guard must skip killpg for that pgid and fall through to per-pid
os.kill instead."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
if not hasattr(os, "killpg") or not hasattr(os, "getpgrp"):
pytest.skip("os.killpg/os.getpgrp not available on this platform")
self._reset_state()
gateway_pgid = 424242
fake_pid = 717171 # a child pid that resolves to the gateway's pgid
other_pid = 818181 # a normal child in its OWN (non-gateway) group
other_pgid = 818181
with _lock:
_orphan_stdio_pids.add(fake_pid)
_stdio_pgids[fake_pid] = gateway_pgid # == gateway's own pgid
_orphan_stdio_pids.add(other_pid)
_stdio_pgids[other_pid] = other_pgid # distinct group → killpg OK
fake_sigkill = 9
monkeypatch.setattr(signal, "SIGKILL", fake_sigkill, raising=False)
with patch("tools.mcp_tool.os.getpgrp", return_value=gateway_pgid), \
patch("tools.mcp_tool.os.killpg") as mock_killpg, \
patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=True), \
patch("time.sleep"):
_kill_orphaned_mcp_children()
# killpg must NEVER be called for the gateway's own pgid (would self-kill).
killpg_pgids = [call.args[0] for call in mock_killpg.call_args_list]
assert gateway_pgid not in killpg_pgids, (
"killpg was called with the gateway's own pgid — self-kill (#47134)"
)
# The shared-pgid child must be reaped via per-pid kill instead.
mock_kill.assert_any_call(fake_pid, signal.SIGTERM)
mock_kill.assert_any_call(fake_pid, fake_sigkill)
# NEGATIVE CONTROL: a child in a DISTINCT group must STILL use killpg —
# the guard must skip only the gateway's own group, not all pgids.
assert other_pgid in killpg_pgids, (
"killpg must still be used for a non-gateway pgid (guard too broad)"
)
def test_killpg_failure_falls_back_to_kill(self, monkeypatch):
"""If killpg raises ProcessLookupError (pgroup gone), try os.kill."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
self._reset_state()
fake_pid = 636363
fake_pgid = 636363
with _lock:
_orphan_stdio_pids.add(fake_pid)
_stdio_pgids[fake_pid] = fake_pgid
if not hasattr(os, "killpg"):
pytest.skip("os.killpg not available on this platform")
with patch(
"tools.mcp_tool.os.killpg",
side_effect=ProcessLookupError("no such process group"),
) as mock_killpg, \
patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=False), \
patch("time.sleep"):
_kill_orphaned_mcp_children()
# killpg was attempted (phase 1 SIGTERM) and fell back to os.kill.
# Phase 3 skips because _pid_exists returns False (direct pid gone).
mock_killpg.assert_called()
mock_kill.assert_any_call(fake_pid, signal.SIGTERM)
with _lock:
assert fake_pid not in _orphan_stdio_pids
assert fake_pid not in _stdio_pgids
def test_no_pgid_uses_per_pid_kill(self, monkeypatch):
"""When no pgid is recorded (e.g. Windows), fall back to os.kill."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
self._reset_state()
fake_pid = 747474
with _lock:
_orphan_stdio_pids.add(fake_pid)
# No entry in _stdio_pgids.
with patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=False), \
patch("time.sleep"):
# killpg may or may not exist; either way the no-pgid path skips it.
_kill_orphaned_mcp_children()
mock_kill.assert_any_call(fake_pid, signal.SIGTERM)
with _lock:
assert fake_pid not in _orphan_stdio_pids
@pytest.mark.live_system_guard_bypass
@pytest.mark.skipif(
not hasattr(os, "killpg") or not hasattr(os, "setsid"),
reason="POSIX-only: requires os.killpg and os.setsid",
)
def test_grandchild_reaped_via_pgroup(self, tmp_path):
"""End-to-end: parent spawns grandchild, parent exits, killpg reaps grandchild.
Mirrors issue #23799: a stdio MCP wrapper (parent) launches a long-lived
helper subprocess (grandchild) in the same process group, then the
wrapper exits while the grandchild keeps running. killpg on the pgid
captured at spawn time must still deliver the signal to the grandchild.
Marked ``live_system_guard_bypass`` because this test genuinely needs
real signal delivery to its own subprocess tree (the conftest guard
only knows the test's *initial* children; the spawned tree here is
outside that allowlist).
"""
import subprocess
import sys
import time as _time
psutil = pytest.importorskip("psutil")
# Grandchild: sleep forever, write its pid then wait. The pid file
# is written to a temp path and os.replace()d into place so the
# polling reader below can never observe a created-but-empty file
# (CI flake: int('') ValueError when the reader won the race between
# open('w') creating the file and write() filling it).
grandchild_pid_file = tmp_path / "grandchild.pid"
grandchild_script = tmp_path / "grandchild.py"
grandchild_script.write_text(
"import os, sys, time\n"
f"tmp = {str(grandchild_pid_file)!r} + '.tmp'\n"
"with open(tmp, 'w') as f:\n"
" f.write(str(os.getpid()))\n"
f"os.replace(tmp, {str(grandchild_pid_file)!r})\n"
"while True:\n"
" time.sleep(0.5)\n"
)
# Parent: spawn grandchild, exit immediately (without killing it).
parent_script = tmp_path / "parent.py"
parent_script.write_text(
"import subprocess, sys\n"
f"subprocess.Popen([sys.executable, {str(grandchild_script)!r}])\n"
# Parent exits — grandchild reparents to init.
)
# Spawn parent in its own session (mirrors stdio_client behaviour).
parent = subprocess.Popen(
[sys.executable, str(parent_script)],
start_new_session=True,
)
parent_pgid = os.getpgid(parent.pid)
# Wait for parent to exit and grandchild to spin up.
parent.wait(timeout=15)
deadline = _time.time() + 15 # fresh CPython spinup dilates under CI load
while _time.time() < deadline and not grandchild_pid_file.exists():
_time.sleep(0.05)
assert grandchild_pid_file.exists(), "grandchild did not start"
grandchild_pid = int(grandchild_pid_file.read_text().strip())
# Sanity: grandchild is alive and shares the parent's pgid.
assert psutil.pid_exists(grandchild_pid)
assert os.getpgid(grandchild_pid) == parent_pgid
# Drive the reaper: register the parent pid + pgid as an orphan.
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pid_servers,
_orphan_stdio_pids,
_stdio_pgids,
_stdio_pids,
_lock,
)
with _lock:
_stdio_pids.clear()
_orphan_stdio_pids.clear()
_orphan_stdio_pid_servers.clear()
_stdio_pgids.clear()
_orphan_stdio_pids.add(parent.pid)
_orphan_stdio_pid_servers[parent.pid] = "orphan"
_stdio_pgids[parent.pid] = parent_pgid
try:
_kill_orphaned_mcp_children()
finally:
# Belt-and-suspenders: ensure grandchild is dead even if test fails.
try:
os.kill(grandchild_pid, signal.SIGKILL)
except ProcessLookupError:
pass
# Grandchild should be gone — SIGTERM via killpg in phase 1 reached it.
deadline = _time.time() + 10
while _time.time() < deadline and psutil.pid_exists(grandchild_pid):
_time.sleep(0.05)
assert not psutil.pid_exists(grandchild_pid), (
"grandchild survived killpg-based reaping (issue #23799 regression)"
)
# ---------------------------------------------------------------------------
# Fix 3: MCP reload timeout (cli.py)
# ---------------------------------------------------------------------------
class TestMCPReloadTimeout:
"""_check_config_mcp_changes uses a timeout on _reload_mcp."""
def test_reload_timeout_does_not_block_forever(self, tmp_path, monkeypatch):
"""If _reload_mcp hangs, the config watcher times out and returns."""
import time
# Create a mock HermesCLI-like object with the needed attributes
class FakeCLI:
_config_mtime = 0.0
_config_mcp_servers = {}
_last_config_check = 0.0
_command_running = False
config = {}
agent = None
def _reload_mcp(self):
# Simulate a hang — sleep longer than the timeout
time.sleep(60)
def _slow_command_status(self, cmd):
return cmd
# This test verifies the timeout mechanism exists in the code
# by checking that _check_config_mcp_changes doesn't call
# _reload_mcp directly (it uses a thread now)
import inspect
from cli import HermesCLI
source = inspect.getsource(HermesCLI._check_config_mcp_changes)
# The fix adds threading.Thread for _reload_mcp
assert "Thread" in source or "thread" in source.lower(), \
"_check_config_mcp_changes should use a thread for _reload_mcp"
# ---------------------------------------------------------------------------
# Fix 4: MCP initial connection retry with backoff
# (Ported from Kilo Code's MCP resilience fix)
# ---------------------------------------------------------------------------
class TestMCPInitialConnectionRetry:
"""MCPServerTask.run() retries initial connection failures instead of giving up."""
def test_initial_connect_retries_constant_exists(self):
"""_MAX_INITIAL_CONNECT_RETRIES should be defined."""
from tools.mcp_tool import _MAX_INITIAL_CONNECT_RETRIES
assert _MAX_INITIAL_CONNECT_RETRIES >= 1
def test_initial_connect_retry_succeeds_on_second_attempt(self):
"""Server succeeds after one transient initial failure."""
from tools.mcp_tool import MCPServerTask
call_count = 0
async def _run():
nonlocal call_count
server = MCPServerTask("test-retry")
# Track calls via patching the method on the class
original_run_stdio = MCPServerTask._run_stdio
async def fake_run_stdio(self_inner, config):
nonlocal call_count
call_count += 1
if call_count == 1:
raise ConnectionError("DNS resolution failed")
# Second attempt: success — set ready and "run" until shutdown
self_inner._ready.set()
await self_inner._shutdown_event.wait()
with patch.object(MCPServerTask, '_run_stdio', fake_run_stdio):
task = asyncio.ensure_future(server.run({"command": "fake"}))
await server._ready.wait()
# It should have succeeded (no error) after retrying
assert server._error is None, f"Expected no error, got: {server._error}"
assert call_count == 2, f"Expected 2 attempts, got {call_count}"
# Clean shutdown
server._shutdown_event.set()
await task
asyncio.get_event_loop().run_until_complete(_run())
def test_initial_connect_gives_up_after_max_retries(self):
"""Server parks (does not exit) after _MAX_INITIAL_CONNECT_RETRIES failures."""
from tools.mcp_tool import MCPServerTask, _MAX_INITIAL_CONNECT_RETRIES
call_count = 0
async def _run():
nonlocal call_count
server = MCPServerTask("test-exhaust")
async def fake_run_stdio(self_inner, config):
nonlocal call_count
call_count += 1
raise ConnectionError("DNS resolution failed")
with patch.object(MCPServerTask, '_run_stdio', fake_run_stdio):
task = asyncio.ensure_future(server.run({"command": "fake"}))
await server._ready.wait()
# Should have an error after exhausting retries
assert server._error is not None
assert "DNS resolution failed" in str(server._error)
# 1 initial + N retries = _MAX_INITIAL_CONNECT_RETRIES + 1 total attempts
assert call_count == _MAX_INITIAL_CONNECT_RETRIES + 1
# The task parks for later revival instead of exiting.
await asyncio.sleep(0)
assert not task.done(), "run task should park, not exit"
server._shutdown_event.set()
server._reconnect_event.set()
await asyncio.wait_for(task, timeout=5)
asyncio.get_event_loop().run_until_complete(_run())
def test_initial_connect_retry_respects_shutdown(self):
"""Shutdown during initial retry backoff aborts cleanly."""
from tools.mcp_tool import MCPServerTask
async def _run():
server = MCPServerTask("test-shutdown")
attempt = 0
async def fake_run_stdio(self_inner, config):
nonlocal attempt
attempt += 1
if attempt == 1:
raise ConnectionError("transient failure")
# Should not reach here because shutdown fires during sleep
raise AssertionError("Should not attempt after shutdown")
with patch.object(MCPServerTask, '_run_stdio', fake_run_stdio):
task = asyncio.ensure_future(server.run({"command": "fake"}))
# Give the first attempt time to fail, then set shutdown
# during the backoff sleep
await asyncio.sleep(0.1)
server._shutdown_event.set()
await server._ready.wait()
# Should have the error set and be done
assert server._error is not None
await task
asyncio.get_event_loop().run_until_complete(_run())