mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
1a80466369
reconcile_sync no longer abandons a DB-driven removal that timed out: both the removal loop and the config-update loop keep the name in _db_managed (and skip the follow-on add) when remove_server_sync returns its mutated-nothing False, so the next pass retries instead of the deleted/reconfigured server serving stale tools until restart. remove_server_sync is now cancel-safe end to end: it FORCE-drops the session before queueing (parked push runners bail at their session gate instead of serializing ≤30s list calls ahead of the removal — the noisy #839 server was exactly the one whose runners could starve its own removal), and wraps the post-lock cleanup in try/finally so a caller-timeout cancel landing mid-teardown still completes the state pop, catalog rebuild, and lock retirement rather than stranding a config-gone ghost catalog. Config survives a park-cancel, so the health loop recovers it. _refresh_all reports None (not a fake ([], [])) for a busy-skip or supersede, stamps a 'skipped' status row, and /mcp refresh renders it distinctly — the operator is no longer told a never-refreshed server is current. A same-kind push lost to the debounce window (the prior runner already finished; the server won't re-announce) arms the health-tick retry, closing the one staleness hole the per-kind debounce still had; a push covered by a queued runner does not arm (no lost change). Static resource/prompt catalogs are capped at connect discovery and every refresh. _list_resource_pair's reap is bounded so a future SDK cancel-regression can't wedge the lock. Cleanups: _arm_refresh_retry (retry-arm gate, ×3), _spawn_full_refresh (discard+spawn, ×3), _popen_mcp_server (live-server spawn, ×2), the tautological stamp-arithmetic TestNotificationDebounce deleted. Suite 9395 green. Refs #839
162 lines
6.1 KiB
Python
162 lines
6.1 KiB
Python
"""Live push-refresh smoke test: a real ``tools/list_changed`` lands, no wedge.
|
|
|
|
End-to-end regression for #839: the static-path notification handler used to
|
|
await its catalog refresh inline in the SDK's receive loop, but the refresh
|
|
issues a request on the SAME session — a request whose response only that
|
|
(now parked) receive loop could route. The refresh never completed, the
|
|
receive loop wedged permanently, and every call on the shared per-node
|
|
session stalled behind it; the only "recovery" was the health loop's ping
|
|
timeout tearing the transport down.
|
|
|
|
A real streamable-http MCP server (FastMCP, subprocess) registers an extra
|
|
tool at runtime inside a tool call and pushes ``notifications/tools/
|
|
list_changed`` on the live session — so the notification and the call result
|
|
are multiplexed on the real SDK receive loop, exactly the production shape.
|
|
Pass criteria are discriminating on purpose:
|
|
|
|
* the TRIGGERING ``call_tool`` returns promptly — on the pre-fix code its
|
|
result could never route past the parked receive loop, so this call is
|
|
itself the deadlock repro;
|
|
* the pushed catalog change lands on the SAME session object (health loop
|
|
slowed to keep teardown/reconnect out of the picture) — the push did the
|
|
work, not a rebuild;
|
|
* the newly pushed tool DISPATCHES — the merged tool map was rebuilt, and
|
|
the receive loop is still routing responses afterwards.
|
|
|
|
Self-contained (spawns its own server; no LLM backend, no network beyond
|
|
127.0.0.1) — deliberately NOT marked ``live``, mirroring
|
|
``test_mcp_live_flaky_server.py``. Wall clock ~5s.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import textwrap
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import (
|
|
_free_port,
|
|
_poll_until,
|
|
_popen_mcp_server,
|
|
_wait_session_live,
|
|
_wait_tcp_ready,
|
|
)
|
|
from turnstone.core.mcp_client import MCPClientManager
|
|
|
|
if TYPE_CHECKING:
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
SERVER_SRC = textwrap.dedent(
|
|
'''
|
|
"""Streamable-http MCP server that grows a tool at runtime (#839 repro)."""
|
|
import sys
|
|
|
|
from mcp.server.fastmcp import Context, FastMCP
|
|
|
|
port = int(sys.argv[1])
|
|
mcp = FastMCP("push-victim", host="127.0.0.1", port=port)
|
|
|
|
|
|
@mcp.tool()
|
|
def ping_me(x: int) -> int:
|
|
"""Return x + 1."""
|
|
return x + 1
|
|
|
|
|
|
def extra_tool(y: int) -> int:
|
|
"""Return y * 2."""
|
|
return y * 2
|
|
|
|
|
|
@mcp.tool()
|
|
async def register_extra(ctx: Context) -> str:
|
|
"""Register extra_tool, then push tools/list_changed on this session."""
|
|
mcp.add_tool(extra_tool)
|
|
await ctx.session.send_tool_list_changed()
|
|
return "registered"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
mcp.run(transport="streamable-http")
|
|
'''
|
|
).lstrip()
|
|
|
|
|
|
def _wait_tool_visible(mgr: MCPClientManager, server: str, tool: str, timeout: float) -> bool:
|
|
"""Poll the per-server catalog for *tool* — the push refresh landing."""
|
|
|
|
def _visible() -> bool:
|
|
state = mgr._static_servers.get(server)
|
|
return state is not None and any(t["function"]["name"] == tool for t in state.tools)
|
|
|
|
return _poll_until(_visible, timeout)
|
|
|
|
|
|
class TestPushRefreshNoDeadlock:
|
|
def test_list_changed_push_refreshes_without_teardown(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# The subprocess runs sys.executable, so importability HERE is a
|
|
# faithful proxy for the server side. Environment gaps skip, not fail.
|
|
pytest.importorskip("mcp.server.fastmcp")
|
|
script = tmp_path / "push_srv.py"
|
|
script.write_text(SERVER_SRC)
|
|
port = _free_port()
|
|
|
|
# Bound connect/discovery/refresh phases for a unit-test budget, but
|
|
# SLOW the health loop right down: pre-fix, its ping-timeout teardown
|
|
# was the accidental recovery path, and this test must prove the push
|
|
# itself does the work on the ORIGINAL session.
|
|
monkeypatch.setattr(MCPClientManager, "_CONNECT_TIMEOUT", 5)
|
|
monkeypatch.setattr(MCPClientManager, "_TCP_PROBE_TIMEOUT", 1)
|
|
|
|
proc: subprocess.Popen[bytes] | None = None
|
|
mgr: MCPClientManager | None = None
|
|
try:
|
|
proc = _popen_mcp_server(script, port)
|
|
if not _wait_tcp_ready(port, 10.0):
|
|
pytest.skip("push-refresh server subprocess did not come up")
|
|
with patch(
|
|
"turnstone.core.mcp_client.load_config",
|
|
return_value={"static_health_check_seconds": 30},
|
|
):
|
|
mgr = MCPClientManager(
|
|
{"push": {"type": "http", "url": f"http://127.0.0.1:{port}/mcp"}}
|
|
)
|
|
mgr.start()
|
|
assert _wait_session_live(mgr, "push", 8.0), "initial connect failed"
|
|
state = mgr._static_servers["push"]
|
|
session_before = state.session
|
|
assert not any(t["function"]["name"] == "mcp__push__extra_tool" for t in state.tools), (
|
|
"extra_tool must not exist before the push"
|
|
)
|
|
|
|
# THE repro: the server pushes list_changed while this call is in
|
|
# flight, so its result and the notification share the receive
|
|
# loop. Pre-fix, the inline-await handler parked that loop and
|
|
# this call never returned.
|
|
out = mgr.call_tool_sync("mcp__push__register_extra", {}, timeout=10)
|
|
assert "registered" in out
|
|
|
|
# The push-driven refresh completes on its own — no teardown.
|
|
assert _wait_tool_visible(mgr, "push", "mcp__push__extra_tool", 8.0), (
|
|
"pushed tools/list_changed never refreshed the catalog"
|
|
)
|
|
assert mgr._static_servers["push"].session is session_before, (
|
|
"catalog arrived via teardown/reconnect, not via the push refresh"
|
|
)
|
|
|
|
# The merged map rebuilt AND the receive loop still routes:
|
|
# the brand-new tool dispatches end-to-end.
|
|
out = mgr.call_tool_sync("mcp__push__extra_tool", {"y": 21}, timeout=10)
|
|
assert "42" in out
|
|
finally:
|
|
if mgr is not None:
|
|
mgr.shutdown()
|
|
if proc is not None:
|
|
proc.kill()
|
|
proc.wait(timeout=5)
|