fix: correct DirSize/MD5 polling — initial_delay=0 and MD5 status v1

Live NAS investigation (test_dirsize_md5.py) revealed two bugs:

1. _poll_task() always slept 200ms before the first status call.
   DirSize and MD5 complete near-instantly on small data, so the result
   window closes before the first poll. Fix: add initial_delay parameter
   (default 0.2s for CopyMove/Delete/Compress/Extract); DirSize and MD5
   pass initial_delay=0.0 to poll immediately after start.

2. get_md5 used status version=2, but the NAS only serves the result on
   status v1 (v2 always returns 599 regardless of timing). Fix: change
   _poll_task version to 1 for SYNO.FileStation.MD5.

MD5 is one-shot: the result is consumed on the first successful status
read. Polling at 0ms ensures we catch it before it expires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 12:35:43 +02:00
parent 04caaef003
commit 4145d929a6
2 changed files with 194 additions and 81 deletions
@@ -61,13 +61,21 @@ def register_filestation(
# ── internal polling helper ─────────────────────────────────────────── # ── internal polling helper ───────────────────────────────────────────
async def _poll_task(api: str, version: int, taskid: str) -> tuple[bool, dict[str, Any] | str]: async def _poll_task(
"""Poll a DSM async task (CopyMove / Delete) until finished or timeout. api: str,
version: int,
taskid: str,
initial_delay: float = 0.2,
) -> tuple[bool, dict[str, Any] | str]:
"""Poll a DSM async task until finished or timeout.
Args: Args:
api: DSM API name (e.g. "SYNO.FileStation.CopyMove"). api: DSM API name (e.g. "SYNO.FileStation.CopyMove").
version: API version to use for the status call. version: API version to use for the status call.
taskid: Task ID returned by the corresponding start method. 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: Returns:
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on ``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
@@ -76,13 +84,13 @@ def register_filestation(
from mcp_synology_filestation.client import SynologyError as _SynologyError from mcp_synology_filestation.client import SynologyError as _SynologyError
delay = 0.2 delay = 0.2
elapsed = 0.0 elapsed = initial_delay
timeout = 60.0 timeout = 60.0
while True: if initial_delay > 0:
await asyncio.sleep(delay) await asyncio.sleep(initial_delay)
elapsed += delay
while True:
try: try:
status_data = await client.request( status_data = await client.request(
api, api,
@@ -102,6 +110,8 @@ def register_filestation(
"Error: Operation timed out after 60 seconds — check NAS manually.", "Error: Operation timed out after 60 seconds — check NAS manually.",
) )
await asyncio.sleep(delay)
elapsed += delay
delay = min(delay * 2, 2.0) delay = min(delay * 2, 2.0)
@mcp.tool() @mcp.tool()
@@ -805,7 +815,7 @@ 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) ok, result = await _poll_task("SYNO.FileStation.DirSize", 1, taskid, initial_delay=0.0)
if not ok: if not ok:
return result # type: ignore[return-value] return result # type: ignore[return-value]
@@ -866,7 +876,7 @@ 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", 2, taskid) ok, result = await _poll_task("SYNO.FileStation.MD5", 1, taskid, initial_delay=0.0)
if not ok: if not ok:
return result # type: ignore[return-value] return result # type: ignore[return-value]
+176 -73
View File
@@ -2,26 +2,35 @@
Ergebnisse der Versionsanalyse (vollständig): Ergebnisse der Versionsanalyse (vollständig):
DirSize: start v2, status v1 DirSize: start v2, status v1
MD5: start v2, status v1 MD5: start v2, status v2
- Wichtig: MD5-Status ist ONE-SHOT nach dem ersten erfolgreichen - Wichtig: MD5-Status ist ONE-SHOT -- nach dem ersten erfolgreichen
Abruf verfällt die Task-ID sofort (code 599 bei allen Folgeabfragen). Abruf verfaellt die Task-ID sofort (code 599 bei allen Folgeabfragen).
- Erster status-Aufruf muss direkt nach start kommen (delay~0). - Erster status-Aufruf muss direkt nach start kommen (delay~0).
DirSize status-Response Felder: DirSize status-Response Felder:
finished: bool finished: bool
num_dir: int (Anzahl Unterordner) num_dir: int (Anzahl Unterordner)
num_file: int (Anzahl Dateien) num_file: int (Anzahl Dateien)
total_size: int (Gesamtgrösse in Bytes) total_size: int (Gesamtgroesse in Bytes)
MD5 status-Response Felder: MD5 status-Response Felder:
finished: bool finished: bool
md5: str (Hex-String, 32 Zeichen) md5: str (Hex-String, 32 Zeichen)
Ausführen: uv run python test_dirsize_md5.py Hypothese: _poll_task() schlaeft 200ms vor dem ersten status-Call.
Winzige Daten -> Task abgeschlossen bevor erster Poll -> 599.
Fix: initial_delay=0 fuer DirSize und MD5.
WICHTIG: Rohe HTTP-Tests treffen evt. falschen API-Pfad (entry.cgi),
waehrend die API wirklich auf einem anderen Pfad (z.B. FileStation.cgi)
laeuft. Test unten zeigt API-Pfad aus dem Cache.
Ausfuehren: uv run python test_dirsize_md5.py
""" """
import asyncio import asyncio
import json import json
import time
import httpx import httpx
@@ -33,15 +42,17 @@ DIRSIZE_PATH = "/test-mcp"
MD5_PATH = "/test-mcp/test.zip" MD5_PATH = "/test-mcp/test.zip"
def pp(label: str, data: object) -> None: def pp(label: str, data: object, elapsed_ms: float | None = None) -> None:
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(f" {label}") suffix = f" [{elapsed_ms:.1f} ms nach start]" if elapsed_ms is not None else ""
print(f" {label}{suffix}")
print("=" * 60) print("=" * 60)
print(json.dumps(data, indent=2, ensure_ascii=False)) print(json.dumps(data, indent=2, ensure_ascii=False))
async def raw(http: httpx.AsyncClient, base_url: str, sid: str, **params) -> dict: async def raw(http: httpx.AsyncClient, url: str, sid: str, **params) -> dict:
r = await http.get(f"{base_url}/webapi/entry.cgi", params={"_sid": sid, **params}) """Hit a specific URL (not hardcoded to entry.cgi)."""
r = await http.get(url, params={"_sid": sid, **params})
r.raise_for_status() r.raise_for_status()
try: try:
return r.json() return r.json()
@@ -49,6 +60,145 @@ async def raw(http: httpx.AsyncClient, base_url: str, sid: str, **params) -> dic
return {"_raw": r.text[:300], "_http_status": r.status_code} return {"_raw": r.text[:300], "_http_status": r.status_code}
async def probe_dirsize(
http: httpx.AsyncClient, base: str, sid: str, api_url: str
) -> None:
print(f"\n{'#'*60}")
print(f" DIRSIZE — Timing-Probe (URL: {api_url})")
print(" Delays: 0ms, 50ms, 200ms, 500ms, stale, fake")
print(f"{'#'*60}")
t0 = time.perf_counter()
start_body = await raw(
http, api_url, sid,
api="SYNO.FileStation.DirSize", version="2", method="start",
path=json.dumps([DIRSIZE_PATH]),
)
pp("DirSize::start", start_body, (time.perf_counter() - t0) * 1000)
taskid = (start_body.get("data") or {}).get("taskid")
if not taskid:
print("[!] No taskid -- aborting probe.")
return
for label, pre_sleep in [
("0ms delay", 0),
("~50ms delay", 0.05),
("~200ms delay", 0.15),
("~500ms delay", 0.3),
("stale ~2.5s", 2.0),
]:
if pre_sleep:
await asyncio.sleep(pre_sleep)
t1 = time.perf_counter()
r = await raw(
http, api_url, sid,
api="SYNO.FileStation.DirSize", version="1", method="status",
taskid=taskid,
)
pp(f"DirSize::status [{label}]", r, (t1 - t0) * 1000)
data = (r.get("data") or {})
if data.get("finished"):
print("[*] finished=True. Stopping here.")
break
if not r.get("success"):
error_code = (r.get("error") or {}).get("code")
print(f"[!] Error {error_code}")
# Fake taskid
rf = await raw(
http, api_url, sid,
api="SYNO.FileStation.DirSize", version="1", method="status",
taskid="fake-task-id-does-not-exist",
)
pp("DirSize::status [fake taskid]", rf)
async def probe_md5(
http: httpx.AsyncClient, base: str, sid: str, api_url: str
) -> None:
print(f"\n{'#'*60}")
print(f" MD5 -- Timing-Probe (URL: {api_url})")
print(" Delays: 0ms, 50ms, 200ms, stale, fake")
print(f"{'#'*60}")
t0 = time.perf_counter()
start_body = await raw(
http, api_url, sid,
api="SYNO.FileStation.MD5", version="2", method="start",
file_path=json.dumps(MD5_PATH),
)
pp("MD5::start", start_body, (time.perf_counter() - t0) * 1000)
taskid = (start_body.get("data") or {}).get("taskid")
if not taskid:
print("[!] No taskid -- aborting probe.")
return
# Try both versions at 0ms for the same taskid
for ver in [1, 2]:
t1 = time.perf_counter()
r = await raw(
http, api_url, sid,
api="SYNO.FileStation.MD5", version=str(ver), method="status",
taskid=taskid,
)
pp(f"MD5::status v{ver} [0ms delay]", r, (t1 - t0) * 1000)
data = (r.get("data") or {})
if data.get("finished"):
print(f"[*] v{ver} finished=True!")
for label, pre_sleep in [
("~50ms delay", 0.05),
("~200ms delay", 0.15),
("stale ~2.5s", 2.0),
]:
await asyncio.sleep(pre_sleep)
for ver in [1, 2]:
t1 = time.perf_counter()
r = await raw(
http, api_url, sid,
api="SYNO.FileStation.MD5", version=str(ver), method="status",
taskid=taskid,
)
pp(f"MD5::status v{ver} [{label}]", r, (t1 - t0) * 1000)
data = (r.get("data") or {})
if data.get("finished"):
print(f"[*] v{ver} finished=True! md5={data.get('md5')}")
# What does start response look like for a new task?
print("\n[*] Starting a second MD5 task to compare...")
t2 = time.perf_counter()
start2 = await raw(
http, api_url, sid,
api="SYNO.FileStation.MD5", version="2", method="start",
file_path=json.dumps(MD5_PATH),
)
pp("MD5::start (2nd)", start2, (time.perf_counter() - t2) * 1000)
taskid2 = (start2.get("data") or {}).get("taskid")
if taskid2:
# Poll immediately with both versions
for ver in [1, 2]:
t3 = time.perf_counter()
r = await raw(
http, api_url, sid,
api="SYNO.FileStation.MD5", version=str(ver), method="status",
taskid=taskid2,
)
pp(f"MD5::status v{ver} [0ms, task2]", r, (t3 - t2) * 1000)
data = (r.get("data") or {})
if data.get("finished"):
print(f"[*] v{ver} task2 finished=True! md5={data.get('md5')}")
# Fake taskid
rf = await raw(
http, api_url, sid,
api="SYNO.FileStation.MD5", version="2", method="status",
taskid="fake-task-id-does-not-exist",
)
pp("MD5::status [fake taskid]", rf)
async def main() -> None: async def main() -> None:
config = load_config() config = load_config()
auth = AuthManager(config) auth = AuthManager(config)
@@ -58,72 +208,25 @@ async def main() -> None:
await client._ensure_initialized() # noqa: SLF001 await client._ensure_initialized() # noqa: SLF001
sid = client.sid sid = client.sid
base = config.base_url base = config.base_url
print(f"[*] SID: {'OK' if sid else 'MISSING'}")
# --- Show API paths from cache ---
print(f"\n[*] SID: {'OK' if sid else 'MISSING'}")
for api_name in ["SYNO.FileStation.DirSize", "SYNO.FileStation.MD5"]:
info = client._api_cache.get(api_name) # noqa: SLF001
if info:
print(f"[*] {api_name}: path={info['path']} v{info['minVersion']}-v{info['maxVersion']}")
else:
print(f"[!] {api_name}: NOT in API cache!")
dirsize_info = client._api_cache.get("SYNO.FileStation.DirSize", {}) # noqa: SLF001
md5_info = client._api_cache.get("SYNO.FileStation.MD5", {}) # noqa: SLF001
dirsize_url = f"{base}/webapi/{dirsize_info.get('path', 'entry.cgi')}"
md5_url = f"{base}/webapi/{md5_info.get('path', 'entry.cgi')}"
async with httpx.AsyncClient(verify=config.connection.verify_ssl, timeout=30.0) as http: async with httpx.AsyncClient(verify=config.connection.verify_ssl, timeout=30.0) as http:
await probe_dirsize(http, base, sid, dirsize_url)
# ── DirSize: start v2, status v1 ───────────────────────────── await probe_md5(http, base, sid, md5_url)
print(f"\n{'#'*60}")
print(" SYNO.FileStation.DirSize (start v2, status v1)")
print(f"{'#'*60}")
start_body = await raw(
http, base, sid,
api="SYNO.FileStation.DirSize", version="2", method="start",
path=json.dumps([DIRSIZE_PATH]),
)
pp("DirSize::start", start_body)
taskid = (start_body.get("data") or {}).get("taskid")
if taskid:
for attempt in range(1, 11):
await asyncio.sleep(0.2)
status_body = await raw(
http, base, sid,
api="SYNO.FileStation.DirSize", version="1", method="status",
taskid=taskid,
)
pp(f"DirSize::status (attempt {attempt})", status_body)
data = (status_body.get("data") or {})
if data.get("finished"):
print("\n[*] FINISHED. Fields:")
for k, v in data.items():
print(f" {k!r}: {type(v).__name__} = {v!r}")
break
# ── MD5: start v2, status v1 (one-shot!) ─────────────────────
print(f"\n{'#'*60}")
print(" SYNO.FileStation.MD5 (start v2, status v1, ONE-SHOT)")
print(f"{'#'*60}")
start_body = await raw(
http, base, sid,
api="SYNO.FileStation.MD5", version="2", method="start",
file_path=json.dumps(MD5_PATH),
)
pp("MD5::start", start_body)
taskid = (start_body.get("data") or {}).get("taskid")
if taskid:
# Immediately poll status v1 — one-shot window, don't delay
for attempt in range(1, 6):
if attempt > 1:
await asyncio.sleep(0.05)
status_body = await raw(
http, base, sid,
api="SYNO.FileStation.MD5", version="1", method="status",
taskid=taskid,
)
pp(f"MD5::status (attempt {attempt})", status_body)
data = (status_body.get("data") or {})
if data.get("finished"):
print("\n[*] FINISHED. Fields:")
for k, v in data.items():
print(f" {k!r}: {type(v).__name__} = {v!r}")
break
if not status_body.get("success"):
print("[!] 599 — task window closed, done probing.")
break
await auth.logout(client) await auth.logout(client)
print("\n[*] Logout OK.") print("\n[*] Logout OK.")