Browse Source

Merge pull request #126 from lingfengQAQ/fix/issue-125-winerror5-retry

fix: Windows 原子替换 WinError 5 退避重试 + v6.2.1(#125)
聆风 3 months ago
parent
commit
59654ccaa1

+ 1 - 1
.claude-plugin/marketplace.json

@@ -11,7 +11,7 @@
     {
       "name": "webnovel-writer",
       "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
-      "version": "6.2.0",
+      "version": "6.2.1",
       "author": {
         "name": "lingfengQAQ"
       },

+ 23 - 0
CHANGELOG.md

@@ -2,6 +2,29 @@
 
 这里记录每个正式版本对作者和维护者的影响。发布说明优先面向中文网文作者:先说写作体验有什么变化,再补维护者关心的技术细节。
 
+## v6.2.1 - 修复 Windows 下写章提交偶发的「拒绝访问」
+
+发版范围:`v6.2.0..v6.2.1`。
+
+### 给作者看的变化
+
+- 修复 Windows 上写章提交时偶发的 `WinError 5(拒绝访问)`:`.webnovel/` 下的故事资料文件被 VSCode、杀毒软件或同步盘短暂占用时,系统会自动等待并重试,不再直接失败(#125)。
+- 建议 VSCode 用户把 `**/.webnovel/**` 加入 `files.watcherExclude`,项目尽量不放同步盘目录,可进一步降低占用冲突。
+
+### 是否需要改旧项目
+
+不需要。已有书项目继续使用,无需任何迁移。
+
+### 给维护者
+
+- `atomic_write_json` 的 `os.replace` 遇 `PermissionError` 改为指数退避重试(约 2.6 秒窗口),穷尽后如实抛错;全部 JSON 投影共用该写入函数,一并受益。
+- 新增 4 个针对性测试,含 Windows 真实句柄占用复现。
+
+### 验证
+
+- 全量 pytest 通过(774 passed)。
+- 版本同步、发布说明与插件包校验通过。
+
 ## v6.2.0 - 写章结果更清楚,失败后更好恢复
 
 发版范围:`v6.1.0..v6.2.0`。

+ 3 - 2
README.md

@@ -1,7 +1,7 @@
 # Webnovel Writer
 
 [![License](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](LICENSE)
-[![Version](https://img.shields.io/badge/version-6.2.0-brightgreen.svg)](.claude-plugin/marketplace.json)
+[![Version](https://img.shields.io/badge/version-6.2.1-brightgreen.svg)](.claude-plugin/marketplace.json)
 [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
 [![Claude Code](https://img.shields.io/badge/Claude%20Code-Compatible-purple.svg)](https://claude.ai/claude-code)
 [![Marketplace](https://img.shields.io/badge/Claude%20Code-Marketplace-black.svg)](.claude-plugin/marketplace.json)
@@ -315,7 +315,8 @@ Webnovel Writer 用业余时间维护。如果它帮你省下了梳理设定、
 
 | 版本 | 主要变化 |
 |------|----------|
-| **v6.2.0 (当前)** | 写章结果更清楚,失败后更好恢复 |
+| **v6.2.1 (当前)** | 修复 Windows 写章提交偶发的拒绝访问(WinError 5):资料文件被短暂占用时自动重试 |
+| **v6.2.0** | 写章结果更清楚,失败后更好恢复 |
 | **v6.1.0** | 插件运行时加固:新增 doctor/project-status/write-gate/projection 重放、hooks、行为 eval 与发布校验 |
 | **v6.0.0** | Story System 全链路上线(合同种子 + 运行时合同 + 章节提交 + 事件审计),补齐集成测试 |
 | **v5.5.5** | 长期记忆闭环:写前注入 + 写后沉淀,新增 `memory` 运维命令 |

+ 28 - 0
releases/v6.2.1.md

@@ -0,0 +1,28 @@
+# v6.2.1 - 修复 Windows 下写章提交偶发的「拒绝访问」
+
+## 发版范围
+
+本次发布覆盖 `v6.2.0..v6.2.1`,核心是修复 issue #125。
+
+## 给作者看的变化
+
+- 修复 Windows 上写章提交时偶发的 `WinError 5(拒绝访问)`:`.webnovel/memory_scratchpad.json`、`state.json` 等故事资料文件在被 VSCode、杀毒软件或同步盘短暂占用时,系统现在会自动等待并重试,不再直接失败(#125)。
+- 若项目放在 VSCode 工作区内,建议顺手把 `**/.webnovel/**` 加入 `files.watcherExclude`,可进一步降低占用冲突;项目尽量不要放在 OneDrive 等同步目录里。
+
+## 是否需要改旧项目
+
+不需要。已有书项目继续使用,无需任何迁移。
+
+## 给维护者
+
+- `security_utils.atomic_write_json`:`os.replace` 遇 `PermissionError` 改为指数退避重试(20ms→500ms 共 10 次,约 2.6 秒窗口),穷尽后如实抛错,其余异常不吞;全部 JSON 投影(state.json / memory_scratchpad.json / summaries 等)共用该写入函数,一并受益。
+- 新增 `scripts/tests/test_security_utils_atomic.py` 四个用例:瞬时占用自愈与退避序列、持续占用穷尽抛 `AtomicWriteError` 且原文完好、测试沙箱降级分支行为保持、Windows 真实句柄占用复现(win32 only)。
+- 版本元数据同步到 `6.2.1`。
+
+## 验证
+
+- `python -m pytest`(774 passed)
+- `python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --check --expected-version 6.2.1`
+- `python -X utf8 webnovel-writer/scripts/validate_release_notes.py --version 6.2.1`
+- `python -X utf8 webnovel-writer/scripts/validate_plugin_package.py`
+- `git diff --check`

+ 1 - 1
webnovel-writer/.claude-plugin/plugin.json

@@ -1,6 +1,6 @@
 {
   "name": "webnovel-writer",
-  "version": "6.2.0",
+  "version": "6.2.1",
   "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
   "author": {
     "name": "lingfengQAQ"

+ 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}