revert: restore _poll_task and dir_size/get_md5 to 0.2.2 state

All changes since 0.2.2 to _poll_task, dir_size, and get_md5 (window_timeout,
_poll_oneshot, start_and_poll_immediately) are reverted. The 0.2.2 behaviour
worked reliably for small directories and is the last known-good baseline.

The remaining known limitation (occasional 599 on large directories) is
documented in SPEC.md. Retry the operation as a workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 14:18:43 +02:00
parent 62f8e41931
commit 8b2f07d9c3
6 changed files with 55 additions and 169 deletions
@@ -69,15 +69,13 @@ def register_filestation(
) -> tuple[bool, dict[str, Any] | str]:
"""Poll a DSM async task until finished or timeout.
For tasks that return intermediate ``finished=False`` status while
running (CopyMove, Delete, Compress, Extract, Search). Use
``_poll_oneshot`` for DirSize and MD5.
Args:
api: DSM API name (e.g. "SYNO.FileStation.CopyMove").
version: API version to use for the status call.
taskid: Task ID returned by the corresponding start method.
initial_delay: Seconds to wait before the first status 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).
Returns:
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
@@ -88,6 +86,7 @@ def register_filestation(
delay = 0.2
elapsed = initial_delay
timeout = 60.0
consecutive_599 = 0
if initial_delay > 0:
await asyncio.sleep(initial_delay)
@@ -100,9 +99,14 @@ def register_filestation(
version=version,
params={"taskid": taskid},
)
consecutive_599 = 0
except _SynologyError as e:
if e.code == 599:
pass # task not yet visible — keep polling
# 599 can be transient (task just started, not yet available).
# Retry up to 5 times before giving up.
consecutive_599 += 1
if consecutive_599 >= 5:
return False, f"Error: {e}"
else:
return False, f"Error: {e}"
else:
@@ -119,89 +123,6 @@ def register_filestation(
elapsed += delay
delay = min(delay * 2, 2.0)
async def _poll_oneshot(
api: str,
version: int,
taskid: str,
first_status: dict[str, Any] | None,
) -> tuple[bool, dict[str, Any] | str]:
"""Continue polling a one-shot DSM task after the first status poll.
Called after ``client.start_and_poll_immediately`` has already made
the first status request. Handles three outcomes for ``first_status``:
* ``finished=True`` — return immediately (task done on first poll).
* ``finished=False`` — task confirmed running; enter Phase 2
(exponential backoff until ``finished=True`` or 60 s timeout).
* ``None`` (first poll returned 599) — burst-retry 10× at 10 ms,
then enter Phase 2 regardless (large directories will eventually
return ``finished=False``; a 599 after the task was seen alive
means the window closed — fail fast with a retry message).
Returns:
``(True, status_dict)`` on success, or ``(False, "Error: …")``
on DSM error or timeout.
"""
from mcp_synology_filestation.client import SynologyError as _SynologyError
seen_alive = False
if first_status is not None:
if first_status.get("finished"):
return True, first_status
seen_alive = True # finished=False: task is running
else:
# 599 on the immediate poll: burst-retry (10×, 10 ms apart)
for _ in range(10):
await asyncio.sleep(0.01)
try:
s = await client.request(
api, "status", version=version, params={"taskid": taskid}
)
except _SynologyError as e:
if e.code == 599:
continue
return False, f"Error: {e}"
if s.get("finished"):
return True, s
seen_alive = True
break # finished=False: enter Phase 2
# ── Phase 2: exponential backoff until finished or 60 s timeout ──
delay = 0.2
elapsed = 0.0
timeout = 60.0
while True:
await asyncio.sleep(delay)
elapsed += delay
delay = min(delay * 2, 2.0)
try:
s = await client.request(api, "status", version=version, params={"taskid": taskid})
except _SynologyError as e:
if e.code == 599:
if seen_alive:
# Task was running but the one-shot window closed before we read it
return (
False,
"Error: Could not read task result — the operation finished"
" before the result was polled. Please retry.",
)
# Not yet seen alive: large dir still initialising, keep polling
else:
return False, f"Error: {e}"
else:
seen_alive = True
if s.get("finished"):
return True, s
if elapsed >= timeout:
return (
False,
"Error: Operation timed out after 60 seconds — check NAS manually.",
)
@mcp.tool()
async def list_shares():
"""List all shared folders. Returns name/path/volume-usage table."""
@@ -890,16 +811,20 @@ def register_filestation(
return "Error: no path provided."
try:
taskid, first_status = await client.start_and_poll_immediately(
start_data = await client.request(
"SYNO.FileStation.DirSize",
start_params={"path": json.dumps(paths)},
poll_version=1,
start_version=2,
"start",
version=2,
params={"path": json.dumps(paths)},
)
except SynologyError as e:
return f"Error: {e}"
ok, result = await _poll_oneshot("SYNO.FileStation.DirSize", 1, taskid, first_status)
taskid: str = start_data.get("taskid", "")
if not taskid:
return "Error: DSM did not return a task ID."
ok, result = await _poll_task("SYNO.FileStation.DirSize", 1, taskid, initial_delay=0.0)
if not ok:
return result # type: ignore[return-value]
@@ -947,16 +872,20 @@ def register_filestation(
from mcp_synology_filestation.client import SynologyError
try:
taskid, first_status = await client.start_and_poll_immediately(
start_data = await client.request(
"SYNO.FileStation.MD5",
start_params={"file_path": json.dumps(path)},
poll_version=1,
start_version=2,
"start",
version=2,
params={"file_path": json.dumps(path)},
)
except SynologyError as e:
return f"Error: {e}"
ok, result = await _poll_oneshot("SYNO.FileStation.MD5", 1, taskid, first_status)
taskid: str = start_data.get("taskid", "")
if not taskid:
return "Error: DSM did not return a task ID."
ok, result = await _poll_task("SYNO.FileStation.MD5", 1, taskid, initial_delay=0.0)
if not ok:
return result # type: ignore[return-value]