fix: window_timeout for one-shot tasks, drop debug stderr logging
_poll_task now accepts window_timeout. For DirSize and MD5 (one-shot result windows), if only 599 errors arrive for window_timeout seconds without ever seeing the task alive (finished=False), return a fast "result window missed — please retry" error instead of waiting the full 60 s. Tasks that return finished=False at least once (large dirs, large files) are unaffected. Also removes the stale [dsm] debug stderr.write left in client.request(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-filestation"
|
name = "mcp-synology-filestation"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
description = "MCP server for Synology FileStation"
|
description = "MCP server for Synology FileStation"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""MCP server for Synology FileStation."""
|
"""MCP server for Synology FileStation."""
|
||||||
|
|
||||||
__version__ = "0.2.4"
|
__version__ = "0.2.5"
|
||||||
|
|||||||
@@ -247,8 +247,6 @@ class FileStationClient:
|
|||||||
Raises:
|
Raises:
|
||||||
SynologyError: On API errors.
|
SynologyError: On API errors.
|
||||||
"""
|
"""
|
||||||
sys.stderr.write(f"[dsm] request: {api}/{method}\n")
|
|
||||||
sys.stderr.flush()
|
|
||||||
if not self._initializing:
|
if not self._initializing:
|
||||||
await self._ensure_initialized()
|
await self._ensure_initialized()
|
||||||
http = self._get_http()
|
http = self._get_http()
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ def register_filestation(
|
|||||||
version: int,
|
version: int,
|
||||||
taskid: str,
|
taskid: str,
|
||||||
initial_delay: float = 0.2,
|
initial_delay: float = 0.2,
|
||||||
|
window_timeout: float | None = None,
|
||||||
) -> tuple[bool, dict[str, Any] | str]:
|
) -> tuple[bool, dict[str, Any] | str]:
|
||||||
"""Poll a DSM async task until finished or timeout.
|
"""Poll a DSM async task until finished or timeout.
|
||||||
|
|
||||||
@@ -76,6 +77,11 @@ def register_filestation(
|
|||||||
initial_delay: Seconds to wait before the first status poll.
|
initial_delay: Seconds to wait before the first status poll.
|
||||||
Set to 0.0 for tasks that may finish before the first poll
|
Set to 0.0 for tasks that may finish before the first poll
|
||||||
interval (e.g. DirSize on small directories, MD5 on small files).
|
interval (e.g. DirSize on small directories, MD5 on small files).
|
||||||
|
window_timeout: For one-shot tasks (DirSize, MD5) whose result is
|
||||||
|
available exactly once: if we receive nothing but 599 errors for
|
||||||
|
this many seconds without ever seeing the task running
|
||||||
|
(``finished=False``), the result window was missed — return an
|
||||||
|
error immediately instead of waiting for the full 60 s timeout.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
|
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
|
||||||
@@ -86,6 +92,7 @@ def register_filestation(
|
|||||||
delay = 0.2
|
delay = 0.2
|
||||||
elapsed = initial_delay
|
elapsed = initial_delay
|
||||||
timeout = 60.0
|
timeout = 60.0
|
||||||
|
seen_task_alive = False # True once we receive any non-599 status response
|
||||||
|
|
||||||
if initial_delay > 0:
|
if initial_delay > 0:
|
||||||
await asyncio.sleep(initial_delay)
|
await asyncio.sleep(initial_delay)
|
||||||
@@ -100,13 +107,25 @@ def register_filestation(
|
|||||||
)
|
)
|
||||||
except _SynologyError as e:
|
except _SynologyError as e:
|
||||||
if e.code == 599:
|
if e.code == 599:
|
||||||
# DSM returns 599 while the async task is still initialising
|
# DSM 599 = task not found. For one-shot tasks (DirSize, MD5)
|
||||||
# or running (task-not-yet-available). Treat it the same as
|
# this means either the task hasn't started yet or the result
|
||||||
# finished=False and keep polling until the 60 s timeout.
|
# window has already closed. If we've never seen the task
|
||||||
pass
|
# running and window_timeout has elapsed, the window is gone —
|
||||||
|
# fail fast so the caller can retry rather than wait 60 s.
|
||||||
|
if (
|
||||||
|
window_timeout is not None
|
||||||
|
and not seen_task_alive
|
||||||
|
and elapsed >= window_timeout
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"Error: Could not read task result — the operation finished"
|
||||||
|
" before the first successful poll. Please retry.",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return False, f"Error: {e}"
|
return False, f"Error: {e}"
|
||||||
else:
|
else:
|
||||||
|
seen_task_alive = True
|
||||||
if status_data.get("finished"):
|
if status_data.get("finished"):
|
||||||
return True, status_data
|
return True, status_data
|
||||||
|
|
||||||
@@ -821,7 +840,9 @@ def register_filestation(
|
|||||||
if not taskid:
|
if not taskid:
|
||||||
return "Error: DSM did not return a task ID."
|
return "Error: DSM did not return a task ID."
|
||||||
|
|
||||||
ok, result = await _poll_task("SYNO.FileStation.DirSize", 1, taskid, initial_delay=0.0)
|
ok, result = await _poll_task(
|
||||||
|
"SYNO.FileStation.DirSize", 1, taskid, initial_delay=0.0, window_timeout=3.0
|
||||||
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
return result # type: ignore[return-value]
|
return result # type: ignore[return-value]
|
||||||
|
|
||||||
@@ -882,7 +903,9 @@ def register_filestation(
|
|||||||
if not taskid:
|
if not taskid:
|
||||||
return "Error: DSM did not return a task ID."
|
return "Error: DSM did not return a task ID."
|
||||||
|
|
||||||
ok, result = await _poll_task("SYNO.FileStation.MD5", 1, taskid, initial_delay=0.0)
|
ok, result = await _poll_task(
|
||||||
|
"SYNO.FileStation.MD5", 1, taskid, initial_delay=0.0, window_timeout=3.0
|
||||||
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
return result # type: ignore[return-value]
|
return result # type: ignore[return-value]
|
||||||
|
|
||||||
|
|||||||
@@ -1635,8 +1635,13 @@ async def test_dir_size_retries_on_transient_599(config: AppConfig) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dir_size_times_out_on_persistent_599(config: AppConfig) -> None:
|
async def test_dir_size_window_timeout_on_persistent_599(config: AppConfig) -> None:
|
||||||
"""dir_size returns Error: after 60 s timeout when DSM keeps returning 599."""
|
"""dir_size fails fast (window_timeout=3 s) when DSM returns only 599s.
|
||||||
|
|
||||||
|
Once the window_timeout elapses without ever seeing the task running
|
||||||
|
(finished=False), _poll_task returns the "result window missed" error
|
||||||
|
rather than waiting the full 60 s.
|
||||||
|
"""
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
|
|
||||||
async def _request(api, method, version=None, params=None, **kwargs):
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
@@ -1651,7 +1656,7 @@ async def test_dir_size_times_out_on_persistent_599(config: AppConfig) -> None:
|
|||||||
result = await tools["dir_size"](path="/dead")
|
result = await tools["dir_size"](path="/dead")
|
||||||
|
|
||||||
assert result.startswith("Error:")
|
assert result.startswith("Error:")
|
||||||
assert "timed out" in result.lower() or "60 seconds" in result
|
assert "retry" in result.lower() or "window" in result.lower() or "poll" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user