Added support for multiple clients to be ran at the same time.

This commit is contained in:
2026-08-30 12:32:39 +02:00
parent 994fda5cc7
commit 94f2260f43
4 changed files with 453 additions and 64 deletions

View File

@@ -7,13 +7,51 @@ from datetime import datetime
import fcntl
import os
from pathlib import Path
from typing import Iterator
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)
@@ -26,13 +64,7 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
raise OperationBusyError(
f"launcher files are busy ({owner}); wait for it to finish and try again"
) from exc
handle.seek(0)
handle.truncate()
handle.write(
f"{operation}, pid={os.getpid()}, "
f"started={datetime.now().astimezone().isoformat()}\n"
)
handle.flush()
_write_owner(handle, operation)
try:
yield
finally:
@@ -40,3 +72,67 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
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()