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:
@@ -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
@@ -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.")
|
||||||
|
|||||||
Reference in New Issue
Block a user