agent-Specialization/utils/atomic_io.py
JOJO d9bd599c1c feat(runtime): Gateway 独立启动与公共协议落地(改造第 0-3 步)
- 闭环修复:审批 task_id 显式化(N2)、通知回退路径门闸 finally 释放(N1)、
  personalization 原子写(S10,公共 atomic_write_json)
- server.tasks 拆解:__init__ 轻量化、Blueprint/路由移至 blueprint.py/web.py,
  app_legacy 装配点惰性挂载;models.py 循环依赖死导入移除
- 执行链 Web 解耦:emit_event/run_background 安全包装(socketio 未绑定静默),
  清除 4 处裸 socketio.emit(含 task_stopped 先写事件流再推送的顺序修复)
- RuntimeService 补建对话下沉:chat 任务无 conversation_id 时服务层装配,
  客户端无需复制 Web 两步流程
- 事件协议:get_task_events 返回 meta.window_start 缺口检测水位
- 验收:test_runtime_standalone_lifecycle 子进程隔离(config import 固化问题),
  独立启动 + 协议全链路 + 审批语义全绿;全量 74 测试失败恰为 4 项存量
2026-09-07 23:00:15 +08:00

82 lines
2.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""原子写文件工具temp file + os.replace 的 Windows 加固版。
背景Windows 上目标文件被任何句柄以默认共享模式打开时msvcrt 默认共享读/写,
不含 FILE_SHARE_DELETEos.replace 会抛 WinError 5拒绝访问
并发写入者之间则可能抛 WinError 32共享冲突。杀毒软件实时扫描、
Windows Search 索引、同进程并发的读取线程都会造成这类短暂持锁。
POSIX 下 rename 不受打开句柄影响,无此问题。
对策:仅对这两种 winerror 做短退避重试;其他错误立即抛出。
2026-08 调整:默认预算由 6 次/约 1.55s 上调至 8 次/约 4.75s——
实测 Defender 实时扫描 / Search 索引器等外部程序偶发持锁超过原预算
(如对话索引 index.json 保存失败 WinError 5提高预算以覆盖此类场景。
"""
from __future__ import annotations
import json
import os
import tempfile
import time
from pathlib import Path
from typing import Any, Dict, Union
# ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION
_RETRY_WINERRORS = {5, 32}
PathLike = Union[str, Path]
def replace_with_retry(
src: PathLike,
dst: PathLike,
*,
attempts: int = 8,
initial_delay: float = 0.05,
max_delay: float = 1.6,
) -> None:
"""os.replace 的 Windows 加固版:对瞬时持锁做指数退避重试。
attempts 为总尝试次数(含首次),退避序列默认 0.05/0.1/0.2/0.4/0.8/1.6/1.6s
最坏情况约 4.75s。最后一次失败时原样抛出该 OSError。
"""
delay = initial_delay
total = max(1, int(attempts))
for i in range(total):
try:
os.replace(str(src), str(dst))
return
except OSError as exc:
if getattr(exc, "winerror", None) not in _RETRY_WINERRORS or i == total - 1:
raise
time.sleep(delay)
delay = min(delay * 2, max_delay)
def atomic_write_json(path: PathLike, data: Dict[str, Any]) -> None:
"""原子写入 JSON 文件:唯一临时文件 + fsync + replace_with_retry。
防止写中断留下半截文件(直接 open(path,"w") 覆写的风险)。
临时文件与目标同目录,保证 os.replace 同卷原子性;异常时清理临时文件。
"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.flush()
os.fsync(fp.fileno())
# Windows 瞬时持锁(并发读取/杀软扫描重试POSIX 行为不变
replace_with_retry(tmp_path, target)
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except Exception:
pass