196 lines
5.9 KiB
Python
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)
|