Files
pristontale-eu-linux/ptlinux/locking.py

139 lines
3.7 KiB
Python

"""Cross-process locks for launcher-prefix mutations."""
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime
import fcntl
import os
from pathlib import Path
from typing import Iterator, TextIO
class OperationBusyError(RuntimeError):
"""Raised when another launcher-prefix operation is still active."""
class LauncherPlayLease:
"""Exclusive preparation lease that can become a shared game lease."""
def __init__(
self,
handle: TextIO | None,
*,
preparation_allowed: bool,
record_owned: bool,
) -> None:
self._handle = handle
self.preparation_allowed = preparation_allowed
self._record_owned = record_owned
self._shared = not preparation_allowed
@classmethod
def dry_run(cls) -> LauncherPlayLease:
return cls(None, preparation_allowed=True, record_owned=False)
@property
def shared(self) -> bool:
return self._shared
def allow_shared_clients(self) -> None:
"""Downgrade after all runtime mutations have completed."""
if self._shared:
return
if self._handle is not None:
_write_owner(self._handle, "play")
fcntl.flock(self._handle.fileno(), fcntl.LOCK_SH)
self._shared = True
@property
def record_owned(self) -> bool:
return self._record_owned
@contextmanager
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as handle:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
handle.seek(0)
owner = handle.read().strip() or "unknown operation"
raise OperationBusyError(
f"launcher files are busy ({owner}); wait for it to finish and try again"
) from exc
_write_owner(handle, operation)
try:
yield
finally:
handle.seek(0)
handle.truncate()
handle.flush()
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
@contextmanager
def launcher_play_lock(
lock_path: Path,
operation: str = "play",
) -> Iterator[LauncherPlayLease]:
"""Serialize preparation, then allow multiple active game clients.
The first play process acquires an exclusive lease so it may safely rebuild
the runtime view or prefix. It downgrades that lease immediately before
starting Game.exe. Later play processes then receive shared, read-only leases,
while updates and repairs remain excluded until every client exits.
"""
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as handle:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
except BlockingIOError as exc:
owner = _read_owner(handle)
raise OperationBusyError(
f"launcher files are busy ({owner}); wait for it to finish and try again"
) from exc
lease = LauncherPlayLease(
handle,
preparation_allowed=False,
record_owned=False,
)
else:
_write_owner(handle, f"{operation} preparation")
lease = LauncherPlayLease(
handle,
preparation_allowed=True,
record_owned=True,
)
try:
yield lease
finally:
# A downgraded owner record remains useful while any secondary shared
# lease is active. The next exclusive operation atomically replaces it.
if lease.record_owned and not lease.shared:
handle.seek(0)
handle.truncate()
handle.flush()
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def _read_owner(handle: TextIO) -> str:
handle.seek(0)
return handle.read().strip() or "unknown operation"
def _write_owner(handle: TextIO, operation: str) -> None:
handle.seek(0)
handle.truncate()
handle.write(
f"{operation}, pid={os.getpid()}, "
f"started={datetime.now().astimezone().isoformat()}\n"
)
handle.flush()