Files
hermes-agent/tests/tools/test_mcp_discovery_cross_process.py
Teknium f6938b37f3 simplify(compat): terminal/file/environments — drop 42 re-exports/aliases, repoint 20 callers + 41 test files
tools/terminal_tool.py: drop 30 pure re-export names (lifecycle/config/backends/
sudo/guards/result/interrupt/utils/_DockerEnvironment/is_managed_tool_gateway_ready)
and the noqa-F401 comments on the 25 names the facade itself uses. Sibling modules
(terminal_tool_backends/_result/_sudo/_lifecycle, environments/base, process_registry)
that read removed names through the facade now import from the defining module.
tools/environments/base.py: drop 11 re-exports (base_output/base_session_env/
path_utils) and the BaseEnvironment.stop() compat alias (no in-tree caller; the
lifecycle hasattr(env, 'stop') fallback stays for third-party envs).
tools/environments/docker.py: drop 1 re-export + the re-export comment.
Callers/tests repointed to tools.terminal_tool_{lifecycle,backends,sudo,config,
guards,result}, tools.interrupt, tools.environments.{base_output,base_session_env,
path_utils}.
2026-09-03 13:29:55 -07:00

190 lines
6.1 KiB
Python

"""Cross-process regression coverage for MCP discovery serialization.
Two independent Hermes processes can start MCP discovery at the same time
(dashboard and gateway startup). The losing process must wait for the shared
lock and then perform its own local discovery; another process's registry is
not usable because ``_servers`` is process-local.
"""
import json
import os
from pathlib import Path
import subprocess
import sys
import textwrap
import time
_REPO_ROOT = Path(__file__).resolve().parents[2]
def _wait_for_file(path: Path, *, timeout: float = 10.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if path.exists():
return
time.sleep(0.01)
raise AssertionError(f"timed out waiting for {path}")
def test_two_processes_each_complete_local_mcp_discovery(tmp_path):
"""A lock loser waits, acquires the lock, and builds its own registry."""
hermes_home = tmp_path / "hermes-home"
hermes_home.mkdir()
holder_ready = tmp_path / "holder-ready"
release_holder = tmp_path / "release-holder"
loser_started = tmp_path / "loser-started"
holder_output = tmp_path / "holder.json"
loser_output = tmp_path / "loser.json"
child_script = tmp_path / "mcp-discovery-child.py"
child_script.write_text(
textwrap.dedent(
"""
import json
import os
from pathlib import Path
import sys
import time
from types import SimpleNamespace
repo_root, role, ready_arg, release_arg, started_arg, output_arg = sys.argv[1:]
sys.path.insert(0, repo_root)
import tools.mcp_tool as mcp_tool
from tools import mcp_tool_config as _mcp_config
from tools import mcp_tool_discovery as _mcp_discovery
ready = Path(ready_arg)
release = Path(release_arg)
started = Path(started_arg)
output = Path(output_arg)
mcp_tool._MCP_AVAILABLE = True
mcp_tool._MCP_DISCOVERY_LOCK_PATH = None
mcp_tool._MCP_DISCOVERY_LOCK_MAX_RETRIES = 200
mcp_tool._MCP_DISCOVERY_LOCK_RETRY_DELAY_S = 0.01
mcp_tool._servers.clear()
config = {
"test_srv": {
"command": "fake",
"enabled": True,
}
}
_mcp_config._load_mcp_config = lambda: config
def fake_register_mcp_servers(servers):
tool_name = "mcp__test_srv__ping"
mcp_tool._servers["test_srv"] = SimpleNamespace(
_registered_tool_names=[tool_name],
)
if role == "holder":
ready.write_text("1", encoding="utf-8")
deadline = time.monotonic() + 10.0
while not release.exists():
if time.monotonic() >= deadline:
raise RuntimeError("holder release signal timed out")
time.sleep(0.01)
return [tool_name]
_mcp_discovery.register_mcp_servers = fake_register_mcp_servers
started.write_text("1", encoding="utf-8")
result = _mcp_discovery.discover_mcp_tools()
server = mcp_tool._servers.get("test_srv")
output.write_text(
json.dumps(
{
"pid": os.getpid(),
"result": result,
"server_present": server is not None,
"registered_tools": (
list(server._registered_tool_names)
if server is not None
else []
),
}
),
encoding="utf-8",
)
"""
),
encoding="utf-8",
)
env = os.environ.copy()
env["HERMES_HOME"] = str(hermes_home)
holder_started = tmp_path / "holder-started"
holder = subprocess.Popen(
[
sys.executable,
str(child_script),
str(_REPO_ROOT),
"holder",
str(holder_ready),
str(release_holder),
str(holder_started),
str(holder_output),
],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
loser = None
try:
_wait_for_file(holder_ready)
loser = subprocess.Popen(
[
sys.executable,
str(child_script),
str(_REPO_ROOT),
"loser",
str(holder_ready),
str(release_holder),
str(loser_started),
str(loser_output),
],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
_wait_for_file(loser_started)
# The loser must not finish from an empty process-local registry while
# the holder owns the lock.
time.sleep(0.1)
assert loser.poll() is None
assert not loser_output.exists()
release_holder.write_text("1", encoding="utf-8")
holder_stdout, holder_stderr = holder.communicate(timeout=15)
loser_stdout, loser_stderr = loser.communicate(timeout=15)
assert holder.returncode == 0, holder_stdout + holder_stderr
assert loser.returncode == 0, loser_stdout + loser_stderr
finally:
release_holder.touch(exist_ok=True)
for process in (holder, loser):
if process is not None and process.poll() is None:
process.terminate()
process.wait(timeout=5)
expected_tools = ["mcp__test_srv__ping"]
holder_result = json.loads(holder_output.read_text(encoding="utf-8"))
loser_result = json.loads(loser_output.read_text(encoding="utf-8"))
assert holder_result["pid"] != loser_result["pid"]
for result in (holder_result, loser_result):
assert result["result"] == expected_tools
assert result["server_present"] is True
assert result["registered_tools"] == expected_tools