"""Local configuration snapshots and bounded, process-safe updates.""" import copy import errno import json import os import tempfile import time from contextlib import contextmanager class ConfigSnapshot(dict): def __init__(self, values): super().__init__(values) self.original = copy.deepcopy(values) @contextmanager def file_lock(path, timeout=45): # An OS lock is released even if the owning process crashes. Do not delete # the lock file: replacing its inode would break coordination on POSIX. with open(path, "a+b") as handle: handle.seek(0, os.SEEK_END) if handle.tell() == 0: handle.write(b"\0") handle.flush() deadline = time.monotonic() + timeout while True: try: if os.name == "nt": import msvcrt handle.seek(0) msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) else: import fcntl fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) break except OSError as exc: if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK): raise if time.monotonic() >= deadline: raise TimeoutError("等待本机登录/配置锁超过 %s 秒" % timeout) time.sleep(0.05) try: yield finally: if os.name == "nt": handle.seek(0) msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) else: fcntl.flock(handle.fileno(), fcntl.LOCK_UN) def read_config(path): with open(path, "r", encoding="utf-8-sig") as handle: return ConfigSnapshot(json.load(handle)) def update_config(path, values, ignored=(), initialize=False): """Merge only changed fields into the latest file, then atomically replace it.""" original = getattr(values, "original", {}) changes = {key: value for key, value in values.items() if key not in ignored and (key not in original or value != original[key])} removed = {key for key in original if key not in values and key not in ignored} with file_lock(path + ".lock"): try: latest = read_config(path) if initialize: return latest except FileNotFoundError: latest = {} if not changes and not removed and os.path.exists(path): return ConfigSnapshot(latest) latest.update(changes) for key in removed: latest.pop(key, None) fd, temporary = tempfile.mkstemp(prefix="config.json.", suffix=".tmp", dir=os.path.dirname(path)) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(latest, handle, ensure_ascii=False, indent=2) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) finally: if os.path.exists(temporary): os.unlink(temporary) if isinstance(values, ConfigSnapshot): values.original = copy.deepcopy(dict(values)) return ConfigSnapshot(latest)