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:
2026-04-14 13:30:20 +02:00
parent 4bf655236d
commit 6510493930
5 changed files with 39 additions and 13 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""MCP server for Synology FileStation.""" """MCP server for Synology FileStation."""
__version__ = "0.2.4" __version__ = "0.2.5"
-2
View File
@@ -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]
+8 -3
View File
@@ -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()
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────