Make long-running tools fire-and-return to fix intermittent timeouts
redeploy_project, create_project and pull_image used to block the tool call while polling DSM until the project reached RUNNING / the image appeared - up to 300s and 240s respectively. On large images this regularly ran past Claude Desktop's ~4 min tool-call ceiling (the live MCP log shows redeploy_project calls up to 260s), which the client reported as a timeout even though the server kept working. Whether a call crossed the ceiling depended on image size and NAS load, which is why the failure was intermittent. These tools now fire-and-return: they trigger the operation, consume build_stream only for a short early-error window (20s, to catch fast daemon errors like "manifest unknown"), then return a "running in the background - check get_project_status / check_image_updates" hint. Completion is observed via the existing fast status tools. - DsmClient.trigger_build_stream gains a `budget` parameter - remove _wait_for_project_running and the _POLL_*/_BUILD_POLL_TIMEOUT constants (projects.py) and _PULL_POLL_* constants (registry.py) - update tests, CLAUDE.md DSM quirks and CHANGELOG; bump 0.7.0 -> 0.8.0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -285,30 +285,15 @@ async def test_pull_image_already_present():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_confirmed_success(monkeypatch):
|
||||
"""pull_start succeeds, then Image/list shows the new tag on first poll."""
|
||||
import mcp_synology_container.modules.registry as registry_mod
|
||||
async def test_pull_image_confirmed_starts_background():
|
||||
"""pull_start is invoked, then the tool returns immediately with a background hint —
|
||||
it does NOT poll Image/list until the tag appears (fire-and-return)."""
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
# Make polling instant
|
||||
async def fake_sleep(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||
|
||||
state = {"pulled": False}
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
if state["pulled"]:
|
||||
return {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
|
||||
]
|
||||
}
|
||||
return {"images": []}
|
||||
return {"images": []} # not present locally → proceed to pull
|
||||
if api == "SYNO.Docker.Image" and method == "pull_start":
|
||||
state["pulled"] = True
|
||||
return {}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
@@ -319,11 +304,12 @@ async def test_pull_image_confirmed_success(monkeypatch):
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "Pulled" in result
|
||||
assert "started in the background" in result
|
||||
assert "nginx:1.24" in result
|
||||
assert "check_image_updates" in result
|
||||
|
||||
# Verify pull_start was invoked on SYNO.Docker.Image (not Registry)
|
||||
# with JSON-encoded params.
|
||||
# with JSON-encoded params, exactly once.
|
||||
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
|
||||
assert len(pull_calls) == 1
|
||||
assert pull_calls[0].args[0] == "SYNO.Docker.Image"
|
||||
@@ -332,37 +318,9 @@ async def test_pull_image_confirmed_success(monkeypatch):
|
||||
assert json.loads(params["tag"]) == "1.24"
|
||||
assert pull_calls[0].kwargs.get("version") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_timeout(monkeypatch):
|
||||
"""If the image never appears, the tool returns a still-running hint."""
|
||||
import mcp_synology_container.modules.registry as registry_mod
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
# Make polling instant
|
||||
async def fake_sleep(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||
# Shrink the budget so the loop exits quickly
|
||||
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Image" and method == "pull_start":
|
||||
return {}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "still running" in result
|
||||
assert "nginx:1.24" in result
|
||||
# Exactly one Image/list pre-check — no post-pull polling loop.
|
||||
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
|
||||
assert len(list_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user