Fixed issues with gamescope not properly launching.

This commit is contained in:
2026-09-06 22:57:54 +02:00
parent f53f4d0b08
commit bea9d6fda7
9 changed files with 252 additions and 18 deletions

View File

@@ -7,7 +7,12 @@ from datetime import datetime
import fcntl
import os
from pathlib import Path
from typing import Iterator, TextIO
from typing import Callable, Iterator, Sequence, TextIO
ActiveGameProbe = Callable[[], bool]
MAX_PROCESS_ENVIRONMENT_BYTES = 1024 * 1024
MAX_PROCESS_COMMAND_BYTES = 128 * 1024
class OperationBusyError(RuntimeError):
@@ -51,9 +56,22 @@ class LauncherPlayLease:
def record_owned(self) -> bool:
return self._record_owned
@property
def inherited_fds(self) -> tuple[int, ...]:
"""Keep the shared lease alive in the launched game supervisor."""
if self._handle is None:
return ()
return (self._handle.fileno(),)
@contextmanager
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
def launcher_operation_lock(
lock_path: Path,
operation: str,
*,
active_game_probe: ActiveGameProbe | None = None,
) -> Iterator[None]:
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as handle:
try:
@@ -64,6 +82,11 @@ 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
if active_game_probe is not None and active_game_probe():
raise OperationBusyError(
"launcher files are busy (active managed game client detected); "
"close every game client and try again"
)
_write_owner(handle, operation)
try:
yield
@@ -78,6 +101,8 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
def launcher_play_lock(
lock_path: Path,
operation: str = "play",
*,
active_game_probe: ActiveGameProbe | None = None,
) -> Iterator[LauncherPlayLease]:
"""Serialize preparation, then allow multiple active game clients.
@@ -105,12 +130,24 @@ def launcher_play_lock(
record_owned=False,
)
else:
_write_owner(handle, f"{operation} preparation")
lease = LauncherPlayLease(
handle,
preparation_allowed=True,
record_owned=True,
)
if active_game_probe is not None and active_game_probe():
# A game from an older launcher may have outlived its Python
# supervisor and therefore its inherited lock. Rejoin that live
# prefix without mutating its runtime view or waiting for it to exit.
_write_owner(handle, operation)
fcntl.flock(handle.fileno(), fcntl.LOCK_SH)
lease = LauncherPlayLease(
handle,
preparation_allowed=False,
record_owned=True,
)
else:
_write_owner(handle, f"{operation} preparation")
lease = LauncherPlayLease(
handle,
preparation_allowed=True,
record_owned=True,
)
try:
yield lease
finally:
@@ -123,6 +160,79 @@ def launcher_play_lock(
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def managed_game_process_is_active(
runtime_view: Path,
wine_prefixes: Sequence[Path],
*,
process_root: Path = Path("/proc"),
) -> bool:
"""Detect a managed Wine game that outlived its launcher supervisor."""
prefixes = {_normalized_path(prefix) for prefix in wine_prefixes}
prefixes.discard("")
if not prefixes:
return False
runtime_marker = _normalized_path(runtime_view).casefold().replace("\\", "/")
if not runtime_marker:
return False
runtime_marker = runtime_marker.rstrip("/") + "/"
try:
processes = tuple(process_root.iterdir())
except OSError:
return False
for process in processes:
if not process.name.isdecimal():
continue
try:
name = (process / "comm").read_text(
encoding="utf-8",
errors="replace",
).strip().casefold()
except OSError:
continue
if not name.endswith(".exe"):
continue
environment = _read_bounded(process / "environ", MAX_PROCESS_ENVIRONMENT_BYTES)
if environment is None:
continue
wine_prefix = next(
(
entry.removeprefix(b"WINEPREFIX=")
for entry in environment.split(b"\0")
if entry.startswith(b"WINEPREFIX=")
),
None,
)
if (
wine_prefix is None
or _normalized_path(os.fsdecode(wine_prefix)) not in prefixes
):
continue
command = _read_bounded(process / "cmdline", MAX_PROCESS_COMMAND_BYTES)
if command is None:
continue
normalized_command = os.fsdecode(command).casefold().replace("\\", "/")
if runtime_marker in normalized_command:
return True
return False
def _read_bounded(path: Path, maximum_bytes: int) -> bytes | None:
try:
with path.open("rb") as handle:
value = handle.read(maximum_bytes + 1)
except OSError:
return None
return value if len(value) <= maximum_bytes else None
def _normalized_path(path: Path | str) -> str:
value = str(path)
if not value:
return ""
return str(Path(value).expanduser().resolve(strict=False))
def _read_owner(handle: TextIO) -> str:
handle.seek(0)
return handle.read().strip() or "unknown operation"