From 35dbfa523c9f73f670204425dc0e5c0a34d0702a Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 9 Sep 2026 08:43:28 -0600 Subject: [PATCH] fix(api): serialize the job progress replace to stop Windows errno 13 Concurrent threads replacing the same progress file exhausted the retry budget on Windows. A module lock removes the in-process contention and the retry loop now raises on its last attempt. --- unshackle/core/api/download_worker.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/unshackle/core/api/download_worker.py b/unshackle/core/api/download_worker.py index f57042ac..c5a87e8f 100644 --- a/unshackle/core/api/download_worker.py +++ b/unshackle/core/api/download_worker.py @@ -23,25 +23,31 @@ def read_payload(path: Path) -> Dict[str, Any]: return json.load(handle) +_replace_lock = threading.Lock() + + def write_result(path: Path, payload: Dict[str, Any]) -> None: """Write the payload with an atomic replace, because the parent polls this file during the write. Each call gets its own temp name, so two threads writing this destination cannot replace away the temp file the other is about to move. - Windows denies the replace while the parent holds the destination open, so retry with a backoff. + Windows denies the replace while another thread or the parent holds the destination open. The + lock removes the in-process contention; the retry covers the parent process. """ path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(f"{path.name}.{uuid4().hex}.tmp") try: tmp.write_text(json.dumps(payload), encoding="utf-8") - for attempt in range(5): - try: - os.replace(tmp, path) - return - except PermissionError: - time.sleep(0.02 * (attempt + 1)) - os.replace(tmp, path) + with _replace_lock: + for attempt in range(10): + try: + os.replace(tmp, path) + return + except PermissionError: + if attempt == 9: + raise + time.sleep(0.02 * (attempt + 1)) except OSError: tmp.unlink(missing_ok=True) raise