Ngày 21/08 của làn N1 (Nam): R02-T01 và R02-T04.
--- Lỗi phải sửa trước khi làm được gì ---
Kế hoạch đặt tên một tầng là platform/. Tôi dựng đúng theo đó sáng nay, có
kiểm "platform stdlib không bị che" và báo là an toàn. Kiểm đó SAI: tôi chỉ
thử từ thư mục cha. Chạy từ gốc repo — đúng cách 26 script trong tools/ và
scripts/ được gọi — thì platform/ che khuất platform của thư viện chuẩn, và
import keyring chết ngay:
AttributeError: module 'platform' has no attribute 'system'
Nghĩa là R02-T04 không thể làm được chừng nào thư mục đó còn tên cũ. Đổi
platform/ -> adapters/. Đây là lệch khỏi plan.md và ảnh hưởng Team Hoa (họ sở
hữu platform/qt/qt_scheduler_clock.py) — đã ghi vào GammaTeam_decisions.md.
tests/test_no_stdlib_shadow.py chặn lỗi tái diễn, hai lớp: một bài so tên thư
mục gốc repo với sys.stdlib_module_names, một bài chạy tiến trình con với cwd
là gốc repo rồi import keyring thật. Dựng lại platform/ là cả hai đỏ.
--- R02-T01: AtomicJsonFile ---
config.py::save() đang gọi path.write_text(), tức là cắt file về 0 byte rồi
mới ghi. Chết giữa chừng là mất sạch cấu hình. Thay bằng: ghi file tạm cùng
thư mục -> flush + fsync -> os.replace (nguyên tử trên cả Windows và POSIX).
Test tiêm lỗi đúng như cột nghiệm thu của plan.md: cho os.replace ném lỗi
ngay bước cuối rồi khẳng định file cũ còn nguyên. Chỉ test "ghi rồi đọc lại"
thì write_text() cũ cũng qua — mà đó chính là thứ đang thay.
Phần đọc: file hỏng được dời thành .bad-<thời điểm> rồi trả mặc định. Giữ
đúng hành vi "hỏng cấu hình không chặn khởi động" của config.py, thêm phần
cứu được bản hỏng.
--- R02-T04: KeyringAdapter ---
Windows Credential Manager / macOS Keychain / Linux Secret Service. Không bao
giờ ném lỗi: máy không có kho (Linux headless, CI) thì available=False và trả
None, để tầng UI nói "chưa lưu được khoá" thay vì sập app. Test tiêm backend
giả, không đụng keyring thật của máy chạy test.
119 test xanh (102 + 17 mới). CASAN Check 1 sạch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
103 lines
4.2 KiB
Python
103 lines
4.2 KiB
Python
"""Ghi JSON kiểu không-hỏng-file — R02-T01.
|
|
|
|
Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm
|
|
đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay
|
|
process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở**
|
|
— và người dùng mất toàn bộ cấu hình.
|
|
|
|
Cách làm ở đây theo đúng thứ tự bắt buộc:
|
|
|
|
1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử)
|
|
2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong
|
|
bộ đệm của hệ điều hành
|
|
3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX
|
|
|
|
Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không
|
|
bao giờ có trạng thái ghi dở.
|
|
|
|
Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn
|
|
cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động,
|
|
đúng như ``config.py`` hiện tại đang làm.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
class AtomicJsonFile:
|
|
"""Một file JSON, đọc ghi an toàn.
|
|
|
|
>>> f = AtomicJsonFile(Path("cau_hinh.json"))
|
|
>>> f.write({"theme": "dark"})
|
|
>>> f.read(default={})
|
|
{'theme': 'dark'}
|
|
"""
|
|
|
|
def __init__(self, path: Path, *, indent: int = 2):
|
|
self.path = Path(path)
|
|
self.indent = indent
|
|
|
|
# ---- đọc ------------------------------------------------------------
|
|
def read(self, default: Any = None) -> Any:
|
|
"""Nội dung file, hoặc ``default`` nếu chưa có / hỏng.
|
|
|
|
Không ném lỗi. File hỏng được đổi tên thành ``<tên>.bad-<thời điểm>``
|
|
rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng.
|
|
"""
|
|
if not self.path.exists():
|
|
return default
|
|
try:
|
|
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
self._quarantine()
|
|
return default
|
|
except OSError:
|
|
# Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file
|
|
# có thể vẫn tốt nguyên.
|
|
return default
|
|
|
|
def _quarantine(self) -> Path | None:
|
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}")
|
|
try:
|
|
os.replace(self.path, target)
|
|
return target
|
|
except OSError:
|
|
return None
|
|
|
|
# ---- ghi ------------------------------------------------------------
|
|
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)
|
|
text = json.dumps(data, indent=self.indent, ensure_ascii=False)
|
|
|
|
# File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng
|
|
# một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến
|
|
# thành copy + delete — mất luôn tính nguyên tử.
|
|
fd, tmp_name = tempfile.mkstemp(
|
|
dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp")
|
|
tmp = Path(tmp_name)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
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ử
|
|
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.
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
|
|
# ---- tiện ích -------------------------------------------------------
|
|
def exists(self) -> bool:
|
|
return self.path.exists()
|
|
|
|
def __repr__(self) -> str:
|
|
return f"AtomicJsonFile({self.path})"
|