Преглед на файлове

fix: atomic_write_json 对 WinError 5 退避重试(#125)

Windows 上 os.replace 的目标文件被其他进程瞬时打开(VSCode file watcher/
杀软/同步盘/索引器,均未开 FILE_SHARE_DELETE)时报 WinError 5 拒绝访问;
占用通常毫秒级,一次性调用直接失败。

- _replace_with_retry:PermissionError 指数退避重试(20ms→500ms 共 10 次,
  约 2.6s 窗口),穷尽后如实抛错;其余异常不吞
- 全部 JSON 投影(state.json/memory_scratchpad.json/summaries 等)走同一
  写入函数,一并受益
- 测试 ×4:瞬时占用自愈+退避序列、持续占用穷尽抛 AtomicWriteError 且原文
  完好、测试沙箱降级分支行为保持、Windows 真实句柄占用复现(win32 only)
lingfengQAQ преди 2 месеца
родител
ревизия
f75e913d5e
променени са 2 файла, в които са добавени 129 реда и са изтрити 2 реда
  1. 31 2
      webnovel-writer/scripts/security_utils.py
  2. 98 0
      webnovel-writer/scripts/tests/test_security_utils_atomic.py

+ 31 - 2
webnovel-writer/scripts/security_utils.py

@@ -13,6 +13,7 @@ import os
 import re
 import sys
 import tempfile
+import time
 from pathlib import Path
 
 from runtime_compat import enable_windows_utf8_stdio
@@ -343,6 +344,34 @@ class AtomicWriteError(Exception):
     pass
 
 
+def _replace_with_retry(
+    temp_path: Union[str, Path],
+    file_path: Union[str, Path],
+    *,
+    attempts: int = 10,
+    first_delay: float = 0.02,
+    max_delay: float = 0.5,
+) -> None:
+    """
+    os.replace 带退避重试(仅针对 PermissionError)。
+
+    Windows 上目标文件被其他进程瞬时打开(编辑器 file watcher、杀毒软件、
+    同步盘、索引器——均未开 FILE_SHARE_DELETE 共享位)时,os.replace 报
+    WinError 5;占用通常是毫秒级,短退避重试即可穿过(issue #125)。
+    重试穷尽后抛出最后一次的 PermissionError。
+    """
+    delay = first_delay
+    for attempt in range(attempts):
+        try:
+            os.replace(temp_path, file_path)
+            return
+        except PermissionError:
+            if attempt == attempts - 1:
+                raise
+            time.sleep(delay)
+            delay = min(delay * 2, max_delay)
+
+
 def atomic_write_json(
     file_path: Union[str, Path],
     data: Dict[str, Any],
@@ -424,9 +453,9 @@ def atomic_write_json(
                 except OSError:
                     pass  # 备份失败不阻止写入
 
-            # Step 4: 原子重命名
+            # Step 4: 原子重命名(Windows 上目标被瞬时占用会 WinError 5,带退避重试)
             try:
-                os.replace(temp_path, file_path)
+                _replace_with_retry(temp_path, file_path)
                 temp_path = None  # 标记已成功,不需要清理
             except PermissionError:
                 if os.environ.get("WEBNOVEL_TEST_RELAX_ATOMIC_REPLACE") != "1":

+ 98 - 0
webnovel-writer/scripts/tests/test_security_utils_atomic.py

@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+import sys
+import threading
+import time
+from pathlib import Path
+
+import pytest
+
+import security_utils
+from security_utils import AtomicWriteError, atomic_write_json, read_json_safe
+
+
+def test_atomic_write_retries_transient_permission_error(tmp_path, monkeypatch):
+    """瞬时占用(WinError 5)在退避重试窗口内自愈——issue #125 主场景。"""
+    target = tmp_path / "memory_scratchpad.json"
+    target.write_text('{"old": true}', encoding="utf-8")
+
+    real_replace = security_utils.os.replace
+    calls = {"n": 0}
+
+    def flaky_replace(src, dst):
+        calls["n"] += 1
+        if calls["n"] <= 2:
+            raise PermissionError(5, "拒绝访问。")
+        return real_replace(src, dst)
+
+    sleeps: list[float] = []
+    monkeypatch.setattr(security_utils.os, "replace", flaky_replace)
+    monkeypatch.setattr(security_utils.time, "sleep", sleeps.append)
+
+    atomic_write_json(target, {"new": 1}, use_lock=False, backup=False)
+
+    assert read_json_safe(target) == {"new": 1}
+    assert calls["n"] == 3
+    assert sleeps == [0.02, 0.04]  # 指数退避
+    assert not list(tmp_path.glob("*.tmp"))  # 成功后无临时文件残留
+
+
+def test_atomic_write_raises_when_target_stays_locked(tmp_path, monkeypatch):
+    """持续占用:重试穷尽后如实抛 AtomicWriteError(生产模式),原文件不被破坏。"""
+    monkeypatch.delenv("WEBNOVEL_TEST_RELAX_ATOMIC_REPLACE", raising=False)
+    target = tmp_path / "state.json"
+    target.write_text("{}", encoding="utf-8")
+
+    def always_denied(src, dst):
+        raise PermissionError(5, "拒绝访问。")
+
+    monkeypatch.setattr(security_utils.os, "replace", always_denied)
+    monkeypatch.setattr(security_utils.time, "sleep", lambda _s: None)
+
+    with pytest.raises(AtomicWriteError):
+        atomic_write_json(target, {"x": 1}, use_lock=False, backup=False)
+
+    assert read_json_safe(target) == {}
+    assert not list(tmp_path.glob("*.tmp"))  # 失败路径清理了临时文件
+
+
+def test_atomic_write_relaxed_fallback_still_writes(tmp_path, monkeypatch):
+    """测试沙箱降级分支(WEBNOVEL_TEST_RELAX_ATOMIC_REPLACE=1)行为保持:穷尽后覆写成功。"""
+    monkeypatch.setenv("WEBNOVEL_TEST_RELAX_ATOMIC_REPLACE", "1")
+    target = tmp_path / "state.json"
+    target.write_text("{}", encoding="utf-8")
+
+    def always_denied(src, dst):
+        raise PermissionError(5, "拒绝访问。")
+
+    monkeypatch.setattr(security_utils.os, "replace", always_denied)
+    monkeypatch.setattr(security_utils.time, "sleep", lambda _s: None)
+
+    atomic_write_json(target, {"x": 1}, use_lock=False, backup=False)
+
+    assert read_json_safe(target) == {"x": 1}
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows 独有的 replace 共享冲突")
+def test_atomic_write_survives_real_windows_file_hold(tmp_path):
+    """真实复现 issue #125:另一线程 open 持有目标文件(无 FILE_SHARE_DELETE),
+    句柄释放前 os.replace 报 WinError 5,退避重试窗口内自愈。"""
+    target = tmp_path / "memory_scratchpad.json"
+    target.write_text('{"old": true}', encoding="utf-8")
+
+    opened = threading.Event()
+
+    def hold():
+        with open(target, "r", encoding="utf-8"):
+            opened.set()
+            time.sleep(0.15)
+
+    t = threading.Thread(target=hold)
+    t.start()
+    try:
+        assert opened.wait(timeout=2)
+        atomic_write_json(target, {"new": 1}, use_lock=False, backup=False)
+    finally:
+        t.join()
+
+    assert read_json_safe(target) == {"new": 1}