Compare commits

...

2 Commits

Author SHA1 Message Date
677604b538 fix(chat): 运行中逐帧贴底锁,修复块外框与内容移动不同步 2026-08-03 23:18:49 +08:00
a7a40a8a2f fix(utils): 原子写重试预算上调至8次/4.75s,索引保存失败留证与日志落盘
- replace_with_retry: attempts 6→8,新增 max_delay 参数(默认1.6s),覆盖 Defender/Search 索引器持续持锁超过原1.55s预算的场景
2026-08-03 22:18:41 +08:00
3 changed files with 101 additions and 26 deletions

View File

@ -1215,6 +1215,52 @@ const { anchorBlockElement, stopAll: stopBlockExpansionAnchors } = useBlockExpan
scrollRef,
{ stopScroll }
);
//
// stick resize ResizeObserver rAF scrollTop
// .messages-flow virtua
// 1~2 //
//
// rAF scrollHeight
// escapedFromLock/isAtBottom false
// useBlockExpansionAnchor
let liveStickLockRaf: number | null = null;
function liveStickLockTick() {
liveStickLockRaf = null;
if (!props.streamingMessage) return;
const el = scrollRef.value;
if (el && isAtBottom.value && !escapedFromLock.value) {
const maxTop = el.scrollHeight - el.clientHeight;
if (maxTop - el.scrollTop > 1) {
markProgrammaticHint('ChatArea.liveStickLock');
el.scrollTop = maxTop;
}
}
liveStickLockRaf = requestAnimationFrame(liveStickLockTick);
}
function stopLiveStickLock() {
if (liveStickLockRaf !== null) {
cancelAnimationFrame(liveStickLockRaf);
liveStickLockRaf = null;
}
}
watch(
() => props.streamingMessage,
(streaming) => {
if (streaming) {
if (liveStickLockRaf === null) {
liveStickLockRaf = requestAnimationFrame(liveStickLockTick);
}
} else {
stopLiveStickLock();
}
},
{ immediate: true }
);
const thinkingRefs = new Map<string, HTMLElement | null>();
let scrollbarResizeObserver: ResizeObserver | null = null;
let bounceTraceCount = 0;
@ -2082,6 +2128,7 @@ watch(scrollRef, () => {
onBeforeUnmount(() => {
detachBounceListener();
stopBlockExpansionAnchors();
stopLiveStickLock();
if (scrollbarResizeObserver) {
scrollbarResizeObserver.disconnect();
scrollbarResizeObserver = null;

View File

@ -7,6 +7,10 @@ Windows Search 索引、同进程并发的读取线程都会造成这类短暂
POSIX rename 不受打开句柄影响无此问题
对策仅对这两种 winerror 做短退避重试其他错误立即抛出
2026-08 调整默认预算由 6 / 1.55s 上调至 8 / 4.75s
实测 Defender 实时扫描 / Search 索引器等外部程序偶发持锁超过原预算
如对话索引 index.json 保存失败 WinError 5提高预算以覆盖此类场景
"""
from __future__ import annotations
@ -25,13 +29,14 @@ def replace_with_retry(
src: PathLike,
dst: PathLike,
*,
attempts: int = 6,
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.8s
最坏情况约 1.55s最后一次失败时原样抛出该 OSError
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))
@ -43,4 +48,4 @@ def replace_with_retry(
if getattr(exc, "winerror", None) not in _RETRY_WINERRORS or i == total - 1:
raise
time.sleep(delay)
delay = min(delay * 2, 0.8)
delay = min(delay * 2, max_delay)

View File

@ -11,15 +11,16 @@ from typing import Dict, List, Optional, Any
from dataclasses import dataclass
from utils.atomic_io import replace_with_retry
from utils.log_rotation import append_line
try:
from config import DATA_DIR, HOST_WORKSPACES_FILE
from config import DATA_DIR, HOST_WORKSPACES_FILE, LOGS_DIR
except ImportError:
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import DATA_DIR, HOST_WORKSPACES_FILE
from config import DATA_DIR, HOST_WORKSPACES_FILE, LOGS_DIR
try:
from utils.perf_log import perf_log
@ -117,7 +118,18 @@ class IndexMixin:
os.fsync(fh.fileno())
# Windows 下目标文件被并发读取/杀软扫描短暂持锁时 replace 会抛
# WinError 5/32replace_with_retry 做短退避重试POSIX 行为不变)
replace_with_retry(temp_path, target_file)
try:
replace_with_retry(temp_path, target_file)
except OSError:
# 重试预算耗尽仍失败:把已写好的新内容转移为 .last_failed.tmp 留证,
# 便于事后恢复与频率统计(固定文件名覆盖,不累积垃圾);原异常继续抛出
try:
orphan = target_file.with_name(f".{target_file.name}.last_failed.tmp")
replace_with_retry(temp_path, orphan)
temp_path = None # 已转移finally 不再删除
except OSError:
pass
raise
finally:
if temp_path and temp_path.exists():
try:
@ -198,27 +210,29 @@ class IndexMixin:
try:
index: Dict = {}
if self.index_file.exists():
# 只在 with 块内读字节json.loads 期间不持有文件句柄,避免
# Windows 下读取方持锁阻塞写方 os.replace无 FILE_SHARE_DELETE
with open(self.index_file, 'r', encoding='utf-8') as f:
content = f.read().strip()
if content:
index = json.loads(content)
if index:
if ensure_integrity and not self._index_verified:
if self._index_missing_conversations(index):
rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt:
self._save_index(rebuilt)
index = rebuilt
self._index_verified = True
return index
# 索引为空但对话文件仍然存在时尝试重建
rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt:
self._save_index(rebuilt)
if ensure_integrity:
self._index_verified = True
return rebuilt
return {}
if content:
index = json.loads(content)
if index:
if ensure_integrity and not self._index_verified:
if self._index_missing_conversations(index):
rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt:
self._save_index(rebuilt)
index = rebuilt
self._index_verified = True
return index
# 索引为空但对话文件仍然存在时尝试重建
rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt:
self._save_index(rebuilt)
if ensure_integrity:
self._index_verified = True
return rebuilt
return {}
# 索引缺失但存在对话文件时重建
rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt:
@ -254,6 +268,15 @@ class IndexMixin:
self._atomic_write_json(self.index_file, index)
except Exception as e:
print(f"⌘ 保存对话索引失败: {e}")
# 落盘留证(线程安全、自动轮转、异常自吞),便于统计失败频率与诊断
append_line(
Path(LOGS_DIR) / "conversation_index_failures.log",
json.dumps({
"ts": datetime.now().isoformat(timespec="milliseconds"),
"index_file": str(self.index_file),
"error": str(e), # str 含 [WinError N] 与源/目标路径repr 会丢失
}, ensure_ascii=False),
)
def _ensure_index_covering(self, limit: int, offset: int) -> Dict:
"""