fix(infra): AtomicJsonFile — os.replace trên Windows thỉnh thoảng bị từ chối
Bắt được nhờ merge Delta: bộ test của họ chạy lâu hơn nên lộ ra một bài
của tôi chập chờn. Truy ra không phải lỗi test mà là lỗi thật trong code
chạy máy người dùng:
PermissionError: [WinError 5] Access is denied
.dem.json.l7x2a8pd.tmp -> dem.json
MoveFileEx trả ERROR_ACCESS_DENIED khi tiến trình khác đang giữ handle
lên nguồn hoặc đích — trên Windows gần như luôn là Defender hoặc Search
Indexer quét file vừa tạo, giữ vài chục mili-giây rồi nhả.
Đo được: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức khoảng 1 trên 140 lần
lưu. Nghĩa là người dùng thỉnh thoảng bấm Lưu là văng lỗi, và không tài
nào tái hiện được để báo.
Thêm vòng thử lại 6 lượt, nghỉ tăng dần 20ms → 640ms. Hết lượt vẫn ném
lỗi, không nuốt lỗi quyền thật, và luôn dọn file tạm.
Hai bài test mới, đã kiểm ngược: bỏ vòng thử lại thì bài thứ nhất đỏ.
Chạy lại 30 lượt sau khi vá: 0 hỏng (trước khi vá: 4).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dcf2e8f995
commit
9d6a7be31b
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -71,6 +72,34 @@ class AtomicJsonFile:
|
||||
return None
|
||||
|
||||
# ---- ghi ------------------------------------------------------------
|
||||
#: Số lần thử lại ``os.replace`` và khoảng nghỉ giữa các lần (giây).
|
||||
_REPLACE_TRIES = 6
|
||||
_REPLACE_BACKOFF = 0.02
|
||||
|
||||
@classmethod
|
||||
def _replace_ben_bi(cls, src: Path, dst: Path) -> None:
|
||||
"""``os.replace`` có thử lại — bắt buộc trên Windows.
|
||||
|
||||
MoveFileEx trả ERROR_ACCESS_DENIED khi có tiến trình khác đang giữ
|
||||
handle lên nguồn hoặc đích. Trên Windows thật thì gần như luôn là
|
||||
Defender hoặc Search Indexer quét file vừa tạo, giữ handle vài chục
|
||||
mili-giây rồi nhả. Không phải lỗi quyền thật, thử lại là hết.
|
||||
|
||||
Đo trên máy dev 25/08: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức
|
||||
khoảng 1 trên 140 lần lưu. Không có vòng này thì người dùng thỉnh
|
||||
thoảng bấm Lưu là văng lỗi mà không tài nào tái hiện.
|
||||
|
||||
POSIX không có kiểu hỏng này nên vòng lặp chạy đúng một lượt.
|
||||
"""
|
||||
for lan in range(cls._REPLACE_TRIES):
|
||||
try:
|
||||
os.replace(src, dst)
|
||||
return
|
||||
except PermissionError:
|
||||
if lan == cls._REPLACE_TRIES - 1:
|
||||
raise
|
||||
time.sleep(cls._REPLACE_BACKOFF * (2 ** lan))
|
||||
|
||||
def write(self, data: Any) -> None:
|
||||
"""Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -87,7 +116,7 @@ class AtomicJsonFile:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm
|
||||
os.replace(tmp, self.path) # nguyên tử
|
||||
self._replace_ben_bi(tmp, self.path) # nguyên tử, có thử lại
|
||||
except BaseException:
|
||||
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng
|
||||
# để rác .tmp nằm lại cạnh file cấu hình.
|
||||
|
||||
@@ -103,3 +103,40 @@ def test_json_ghi_ra_doc_duoc_bang_thu_vien_chuan(tmp_path):
|
||||
path = tmp_path / "c.json"
|
||||
AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]})
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"n": [1, 2, {"m": None}]}
|
||||
|
||||
|
||||
# ---- Windows: os.replace bị Defender/Indexer chặn tạm thời -----------------
|
||||
|
||||
def test_thu_lai_khi_windows_chan_tam_thoi(tmp_path, monkeypatch):
|
||||
"""Hỏng 2 lần đầu rồi thành công — phải ghi được, không ném lỗi.
|
||||
|
||||
Đây là lỗi thật bắt được ngày 25/08: chạy vòng 20 lần ghi thì cứ 7 lượt
|
||||
lại có 1 lượt văng ``PermissionError: [WinError 5]`` ở ``os.replace``.
|
||||
"""
|
||||
that = os.replace
|
||||
con_hong = [2]
|
||||
|
||||
def replace_do_dong(src, dst):
|
||||
if con_hong[0]:
|
||||
con_hong[0] -= 1
|
||||
raise PermissionError(5, "Access is denied")
|
||||
return that(src, dst)
|
||||
|
||||
monkeypatch.setattr(os, "replace", replace_do_dong)
|
||||
AtomicJsonFile(tmp_path / "a.json").write({"x": 1})
|
||||
|
||||
assert con_hong[0] == 0, "phải thật sự có thử lại, không phải may mà qua"
|
||||
assert json.loads((tmp_path / "a.json").read_text(encoding="utf-8")) == {"x": 1}
|
||||
assert list(tmp_path.glob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_hong_that_thi_van_nem_loi_va_khong_de_lai_rac(tmp_path, monkeypatch):
|
||||
"""Thử lại không được phép nuốt lỗi quyền thật — hết lượt là ném."""
|
||||
def luon_hong(src, dst):
|
||||
raise PermissionError(5, "Access is denied")
|
||||
|
||||
monkeypatch.setattr(os, "replace", luon_hong)
|
||||
with pytest.raises(PermissionError):
|
||||
AtomicJsonFile(tmp_path / "b.json").write({"x": 1})
|
||||
|
||||
assert list(tmp_path.glob("*.tmp")) == [], "phải dọn file tạm"
|
||||
|
||||
Reference in New Issue
Block a user