Files
2026-07-13 13:08:41 +08:00

196 lines
5.9 KiB
Python

import errno
import json
import os
import shutil
import time
import uuid
from contextlib import contextmanager
from typing import Callable, Iterator
LOCK_TIMEOUT_SECONDS = 5.0
LOCK_STALE_SECONDS = 30.0
RETRY_DELAY_SECONDS = 0.05
MAX_IO_ATTEMPTS = 6
def _backup_path(config_path: str) -> str:
return f"{config_path}.bak"
def _lock_path(config_path: str) -> str:
return f"{config_path}.lock"
def _is_retryable_os_error(error: BaseException) -> bool:
return isinstance(error, OSError) and error.errno in {
errno.EACCES,
errno.EBUSY,
errno.EPERM,
}
def _retry(label: str, operation: Callable[[], object]) -> object:
last_error: Exception | None = None
for attempt in range(MAX_IO_ATTEMPTS):
try:
return operation()
except Exception as error:
last_error = error
if not _is_retryable_os_error(error):
raise
if attempt == MAX_IO_ATTEMPTS - 1:
break
time.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
raise RuntimeError(f"Failed to {label}: {last_error}") from last_error
def _ensure_parent_directory(config_path: str) -> None:
directory = os.path.dirname(config_path)
if directory:
_retry(
"create user config directory",
lambda: os.makedirs(directory, exist_ok=True),
)
def _read_json_if_valid(file_path: str) -> dict | None:
def read_text() -> str:
with open(file_path, "r", encoding="utf-8") as file:
return file.read()
try:
content = str(_retry(f"read {os.path.basename(file_path)}", read_text)).strip()
if not content:
return {}
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else None
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
def _read_snapshot(config_path: str) -> tuple[dict, bool]:
primary = _read_json_if_valid(config_path)
if primary is not None:
return primary, True
backup = _read_json_if_valid(_backup_path(config_path))
return backup or {}, False
def _remove_stale_lock(lock_file_path: str) -> None:
try:
stat = os.stat(lock_file_path)
except FileNotFoundError:
return
if time.time() - stat.st_mtime >= LOCK_STALE_SECONDS:
_retry("remove stale user config lock", lambda: os.unlink(lock_file_path))
@contextmanager
def _user_config_lock(config_path: str) -> Iterator[None]:
_ensure_parent_directory(config_path)
lock_file_path = _lock_path(config_path)
started_at = time.monotonic()
while True:
fd: int | None = None
try:
fd = os.open(lock_file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(
fd,
json.dumps(
{
"pid": os.getpid(),
"createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
).encode("utf-8"),
)
os.close(fd)
fd = None
break
except FileExistsError:
_remove_stale_lock(lock_file_path)
except OSError as error:
if not _is_retryable_os_error(error):
raise
finally:
if fd is not None:
os.close(fd)
if time.monotonic() - started_at >= LOCK_TIMEOUT_SECONDS:
raise TimeoutError(f"Timed out waiting for user config lock: {lock_file_path}")
time.sleep(RETRY_DELAY_SECONDS)
try:
yield
finally:
try:
_retry("release user config lock", lambda: os.unlink(lock_file_path))
except FileNotFoundError:
pass
except Exception as error:
print(f"[Presenton] Failed to release user config lock: {error}")
def _copy_backup_if_possible(config_path: str, primary_valid: bool) -> None:
config_backup_path = _backup_path(config_path)
try:
if primary_valid and os.path.exists(config_path):
_retry(
"write user config backup",
lambda: shutil.copy2(config_path, config_backup_path),
)
elif not os.path.exists(config_backup_path) and os.path.exists(config_path):
_retry(
"initialize user config backup",
lambda: shutil.copy2(config_path, config_backup_path),
)
except Exception as error:
print(f"[Presenton] Failed to update user config backup: {error}")
def _write_atomic_json(config_path: str, config: dict, primary_valid: bool) -> None:
_ensure_parent_directory(config_path)
_copy_backup_if_possible(config_path, primary_valid)
temp_path = (
f"{config_path}.{os.getpid()}.{int(time.time() * 1000)}."
f"{uuid.uuid4().hex}.tmp"
)
try:
with open(temp_path, "w", encoding="utf-8") as file:
json.dump(config, file, separators=(",", ":"))
file.flush()
os.fsync(file.fileno())
_retry("replace user config", lambda: os.replace(temp_path, config_path))
_copy_backup_if_possible(config_path, False)
except Exception:
try:
os.unlink(temp_path)
except FileNotFoundError:
pass
raise
def read_user_config_file(config_path: str) -> dict:
try:
_ensure_parent_directory(config_path)
config, _ = _read_snapshot(config_path)
return dict(config)
except Exception:
return {}
def update_user_config_file(config_path: str, update: Callable[[dict], dict]) -> dict:
with _user_config_lock(config_path):
existing_config, primary_valid = _read_snapshot(config_path)
next_config = update(dict(existing_config))
if not isinstance(next_config, dict):
raise TypeError("User config updater must return a dict")
_write_atomic_json(config_path, next_config, primary_valid)
return dict(next_config)