|
18 | 18 | import time |
19 | 19 | from pathlib import Path |
20 | 20 |
|
| 21 | +try: # Windows 无 fcntl——锁降级为可选,原子替换仍生效 |
| 22 | + import fcntl |
| 23 | +except ImportError: # pragma: no cover |
| 24 | + fcntl = None |
| 25 | + |
21 | 26 | PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hooks/ 的上级 = 插件根 |
22 | 27 | sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) |
23 | 28 |
|
@@ -155,18 +160,23 @@ def _worktree_fingerprint(project_root: Path) -> str: |
155 | 160 | h.update(("\n".join(names)).encode("utf-8", "replace")) |
156 | 161 | if args[:2] == ("diff", "--name-only"): |
157 | 162 | # 未暂存已跟踪文件:内容改动名字不变,必须叠加 stat 指纹 |
158 | | - # (staged 路不需要——暂存内容变化必动 index,由键内 idx_sig 兜底) |
159 | | - for name in names[:500]: |
| 163 | + # (staged 路不需要——暂存内容变化必动 index,由键内 idx_sig 兜底)。 |
| 164 | + # 名字集合不截断:截断会让第 N+1 个文件的修复在缓存窗口内被旧 |
| 165 | + # 指纹掩盖(retry-timing 陷阱对大改动面复活)。stat 本身足够便宜。 |
| 166 | + for name in names: |
160 | 167 | try: |
161 | 168 | st = (project_root / name).stat() |
162 | 169 | h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) |
163 | 170 | except OSError: |
164 | 171 | h.update(name.encode("utf-8", "replace")) |
165 | 172 | # 未跟踪文件:ls-files 只给文件名——内容改动名字不变,必须叠加 stat 指纹 |
166 | 173 | others = (_git(project_root, "ls-files", "--others") or "").splitlines() |
167 | | - for name in sorted(others)[:500]: |
168 | | - if not name.strip(): |
169 | | - continue |
| 174 | + for name in sorted(n for n in others if n.strip()): |
| 175 | + try: |
| 176 | + st = (project_root / name).stat() |
| 177 | + h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) |
| 178 | + except OSError: |
| 179 | + h.update(name.encode("utf-8", "replace")) |
170 | 180 | try: |
171 | 181 | st = (project_root / name).stat() |
172 | 182 | h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) |
@@ -288,9 +298,13 @@ def run_gate( |
288 | 298 |
|
289 | 299 | if ck: |
290 | 300 | with contextlib.suppress(OSError): |
291 | | - cache_file.write_text(json.dumps( |
| 301 | + # 原子替换:UPS 与 PreToolUse 并发写同一 cache 文件时, |
| 302 | + # 裸 write_text 可能让对方读到半截 JSON(ValueError → 缓存失效重扫)。 |
| 303 | + tmp = cache_file.with_name(f"{cache_file.name}.{os.getpid()}.tmp") |
| 304 | + tmp.write_text(json.dumps( |
292 | 305 | {"key": ck, "ts": _time.time(), "failures": failures, "skipped": skipped}, |
293 | 306 | ensure_ascii=False)) |
| 307 | + os.replace(tmp, cache_file) |
294 | 308 | return failures, skipped |
295 | 309 |
|
296 | 310 |
|
@@ -769,26 +783,33 @@ def should_suppress_event(key: str, window: float = 2.0) -> bool: |
769 | 783 | import hashlib |
770 | 784 | import time as _time |
771 | 785 | path = codeguard_home() / "hook_dedup.json" |
772 | | - now = _time.monotonic() |
| 786 | + now = _time.time() |
773 | 787 | try: |
774 | 788 | path.parent.mkdir(parents=True, exist_ok=True) |
775 | | - dedup = {} |
776 | | - if path.exists(): |
777 | | - dedup = json.loads(path.read_text(encoding="utf-8")) |
778 | | - h = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:24] |
779 | | - last = dedup.get(h) or {} |
780 | | - stale = [k for k, v in dedup.items() if now - v.get("ts", 0) > 60] |
781 | | - for k in stale: |
782 | | - dedup.pop(k, None) |
783 | | - if now - last.get("ts", 0) < window: |
784 | | - dedup[h] = last |
785 | | - path.write_text(json.dumps(dedup), encoding="utf-8") |
786 | | - return True |
787 | | - dedup[h] = {"ts": now} |
788 | | - path.write_text(json.dumps(dedup), encoding="utf-8") |
| 789 | + # 并发安全:UPS 与 PreToolUse 两条钩子在数秒窗口内先后读写本文件, |
| 790 | + # 裸 read-modify-write 会互相覆盖(丢抑制事件 → 双份报告)或读到半截 |
| 791 | + # JSON(ValueError → 去重静默失效)。进程锁 + tmp+replace 原子替换。 |
| 792 | + # 时间戳用墙钟:monotonic 跨重启回绕,持久化文件不能存。 |
| 793 | + lock_path = path.with_name(path.name + ".lock") |
| 794 | + with open(lock_path, "w") as lock: |
| 795 | + if fcntl: |
| 796 | + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) |
| 797 | + dedup = {} |
| 798 | + if path.exists(): |
| 799 | + dedup = json.loads(path.read_text(encoding="utf-8")) |
| 800 | + h = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:24] |
| 801 | + last = dedup.get(h) or {} |
| 802 | + stale = [k for k, v in dedup.items() if now - v.get("ts", 0) > 60] |
| 803 | + for k in stale: |
| 804 | + dedup.pop(k, None) |
| 805 | + suppress = now - last.get("ts", 0) < window |
| 806 | + dedup[h] = {"ts": last.get("ts", now)} if suppress else {"ts": now} |
| 807 | + tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") |
| 808 | + tmp.write_text(json.dumps(dedup), encoding="utf-8") |
| 809 | + os.replace(tmp, path) |
| 810 | + return suppress |
789 | 811 | except (OSError, ValueError): |
790 | 812 | return False |
791 | | - return False |
792 | 813 |
|
793 | 814 |
|
794 | 815 | def summarize_failures(failures: list) -> str: |
|
0 commit comments