"""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()