Skip to content

Commit 20ecc86

Browse files
author
Christopher Herasme
committed
test(gamemode): cover parallel registration races
1 parent 3284860 commit 20ecc86

1 file changed

Lines changed: 243 additions & 0 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
# pyright: reportPrivateUsage=false
2+
"""Parallel GameMode registration repro (no network, no real D-Bus)."""
3+
4+
from __future__ import annotations
5+
6+
import os
7+
import signal
8+
import subprocess
9+
import time
10+
from concurrent.futures import ThreadPoolExecutor
11+
from contextlib import suppress
12+
from pathlib import Path
13+
from typing import Any
14+
15+
import pytest
16+
17+
from box.errors import LaunchError
18+
from box.launch import gamemode as gamemode_module
19+
from box.launch import supervisor as supervisor_module
20+
from box.launch.gamemode import GAMEMODE_PROXY_SOCKET_NAME
21+
from box.paths import AppPaths
22+
23+
24+
class _ExitSupervisor(Exception):
25+
def __init__(self, code: int) -> None:
26+
super().__init__(code)
27+
self.code = code
28+
29+
30+
class _HangingProc:
31+
"""Popen double whose bounded wait always times out."""
32+
33+
_next_pid = 70000
34+
35+
def __init__(self) -> None:
36+
_HangingProc._next_pid += 1
37+
self.pid = _HangingProc._next_pid
38+
self.terminated = False
39+
self.wait_timeouts: list[float | None] = []
40+
41+
def poll(self) -> int | None:
42+
return None
43+
44+
def terminate(self) -> None:
45+
self.terminated = True
46+
47+
def wait(self, timeout: float | None = None) -> int:
48+
self.wait_timeouts.append(timeout)
49+
raise subprocess.TimeoutExpired(cmd=["bwrap"], timeout=timeout)
50+
51+
52+
class _QuickProc:
53+
"""Popen double that exits immediately."""
54+
55+
_next_pid = 71000
56+
57+
def __init__(self) -> None:
58+
_QuickProc._next_pid += 1
59+
self.pid = _QuickProc._next_pid
60+
self.terminated = False
61+
62+
def poll(self) -> int | None:
63+
return 0
64+
65+
def terminate(self) -> None:
66+
self.terminated = True
67+
68+
def wait(self, timeout: float | None = None) -> int:
69+
return 0
70+
71+
72+
def _supervisor_fixture(
73+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
74+
) -> tuple[AppPaths, str, str, Path, int, int, int, dict[str, Any]]:
75+
paths = AppPaths(config_root=tmp_path / "config", cache_root=tmp_path / "cache")
76+
identifier = "testgame"
77+
name = "testsession"
78+
root = tmp_path / "session"
79+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
80+
(root / supervisor_module.SESSION_LOCK_NAME).touch(exist_ok=True)
81+
read_fd, write_fd = os.pipe2(os.O_CLOEXEC)
82+
parent_fd = os.open(tmp_path, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
83+
session_fd = os.open(tmp_path, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
84+
state: dict[str, Any] = {"pipe_errors": [], "removed": [], "handlers": {}}
85+
86+
def _keep_none(keep: set[int]) -> None:
87+
return None
88+
89+
def _fake_dup2(old: int, new: int) -> None:
90+
return None
91+
92+
def _no_sessions(paths: AppPaths, identifier: str) -> list[str]:
93+
return []
94+
95+
def _session_idle(paths: AppPaths, identifier: str, name: str) -> bool:
96+
return False
97+
98+
def _no_launch_status(paths: AppPaths, identifier: str, name: str) -> int | None:
99+
return None
100+
101+
def _record_handler(signum: int, handler: Any) -> Any:
102+
return state["handlers"].setdefault(signum, handler)
103+
104+
monkeypatch.setattr(supervisor_module, "_close_extra_fds", _keep_none)
105+
monkeypatch.setattr(os, "dup2", _fake_dup2)
106+
monkeypatch.setattr(supervisor_module, "_list_session_names", _no_sessions)
107+
monkeypatch.setattr(supervisor_module, "is_session_running", _session_idle)
108+
monkeypatch.setattr(supervisor_module, "poll_launch_status", _no_launch_status)
109+
monkeypatch.setattr(signal, "signal", _record_handler)
110+
111+
def fake_pipe_error(pipe: int, message: str) -> None:
112+
state["pipe_errors"].append(message)
113+
114+
def fake_remove(p: Any, r: Path, parent_descriptor: int, session_descriptor: int) -> None:
115+
state["removed"].append(r)
116+
117+
def fake_exit(code: int) -> None:
118+
raise _ExitSupervisor(code)
119+
120+
monkeypatch.setattr(supervisor_module, "_pipe_error", fake_pipe_error)
121+
monkeypatch.setattr(supervisor_module, "_remove_supervised", fake_remove)
122+
monkeypatch.setattr(os, "_exit", fake_exit)
123+
state["read_fd"] = read_fd
124+
return paths, identifier, name, root, parent_fd, session_fd, write_fd, state
125+
126+
127+
def test_gamemode_teardown_wait_timeout_reaches_pipe_and_cleanup(
128+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
129+
) -> None:
130+
"""A hanging game during gamemode teardown still reports and cleans up."""
131+
paths, identifier, name, root, parent_fd, session_fd, pipe_write, state = _supervisor_fixture(
132+
tmp_path, monkeypatch
133+
)
134+
monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus")
135+
136+
def failing_register(pid: int, **kwargs: Any) -> None:
137+
raise LaunchError("cannot register game with GameMode; retry without --gamemode")
138+
139+
monkeypatch.setattr(supervisor_module._gamemode, "register_host_game", failing_register)
140+
proxy = _QuickProc()
141+
bwrap = _HangingProc()
142+
143+
def fake_popen(args: list[str], **kwargs: Any) -> Any:
144+
return proxy if str(args[0]).endswith("xdg-dbus-proxy") else bwrap
145+
146+
monkeypatch.setattr(supervisor_module.subprocess, "Popen", fake_popen)
147+
monkeypatch.setattr(supervisor_module, "_wait_for_proxy_socket", lambda path, proc: True)
148+
terminated_proxies: list[Any] = []
149+
150+
def _record_terminate_proxy(proc: Any) -> None:
151+
terminated_proxies.append(proc)
152+
153+
monkeypatch.setattr(supervisor_module, "_terminate_proxy", _record_terminate_proxy)
154+
try:
155+
with pytest.raises(_ExitSupervisor) as excinfo:
156+
supervisor_module._supervisor_main(
157+
paths,
158+
identifier,
159+
name,
160+
root,
161+
[str(supervisor_module.BWRAP)],
162+
(),
163+
parent_fd,
164+
session_fd,
165+
pipe_write,
166+
use_gamemode=True,
167+
gamemode_proxy=root / GAMEMODE_PROXY_SOCKET_NAME,
168+
)
169+
finally:
170+
os.close(state["read_fd"])
171+
with suppress(OSError):
172+
os.close(pipe_write)
173+
assert excinfo.value.code == 1
174+
assert bwrap.terminated is True
175+
assert bwrap.wait_timeouts == [5]
176+
assert terminated_proxies == [proxy]
177+
assert state["removed"]
178+
assert any("GameMode" in message for message in state["pipe_errors"])
179+
180+
181+
def test_parallel_gamemode_register_timeout_fails_closed(
182+
monkeypatch: pytest.MonkeyPatch,
183+
) -> None:
184+
"""Concurrent busctl timeouts keep the GameMode cause and stay bounded."""
185+
monkeypatch.setattr(gamemode_module, "require_bus_client", lambda: str(gamemode_module.BUSCTL))
186+
187+
def fake_run(args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]:
188+
assert "timeout" in kwargs
189+
assert kwargs.get("timeout") == gamemode_module.HOST_DBUS_TIMEOUT
190+
timeout = kwargs.get("timeout", 5.0)
191+
raise subprocess.TimeoutExpired(cmd=list(args), timeout=timeout)
192+
193+
monkeypatch.setattr(gamemode_module.subprocess, "run", fake_run)
194+
started = time.monotonic()
195+
with ThreadPoolExecutor(max_workers=2) as pool:
196+
futures = [pool.submit(gamemode_module.register_host_game, 3000 + i) for i in range(2)]
197+
causes: list[str] = []
198+
for future in futures:
199+
try:
200+
future.result(timeout=10.0)
201+
except LaunchError as exc:
202+
causes.append(str(exc))
203+
elapsed = time.monotonic() - started
204+
assert len(causes) == 2
205+
assert all("GameMode" in cause for cause in causes)
206+
assert elapsed < supervisor_module._SUPERVISOR_READY_TIMEOUT
207+
208+
209+
@pytest.mark.parametrize("payload", ["i 0\n", "i -1\n", "i -2\n"])
210+
def test_parallel_second_register_payload_status_fails_closed_unless_boosted(
211+
monkeypatch: pytest.MonkeyPatch, payload: str
212+
) -> None:
213+
"""A RegisterGame reply that leaves the game unboosted must fail closed.
214+
215+
Upstream gamemoded always answers RegisterGame with a D-Bus success
216+
carrying an int32 status (``i 0`` registered, ``i -1`` accepted but not
217+
registered, ``i -2`` rejected), so busctl exits 0 even when the second
218+
game was never boosted. The returncode-only check lets the supervisor
219+
send READY and start silently unboosted.
220+
221+
Manual repro (real games, never automated here): launch Grimm's Hollow
222+
with ``--gamemode``, then the Summer Memories DLC with ``--ci-mount
223+
--gamemode``; compare ``busctl --user call com.feralinteractive.GameMode
224+
/com/feralinteractive/GameMode com.feralinteractive.GameMode ListGames``
225+
against both bwrap PIDs (``status.json`` ``child_pid`` below
226+
``~/.cache/box-rpg/sessions/``). A missing second PID with no launcher
227+
error is this bug.
228+
"""
229+
monkeypatch.setattr(gamemode_module, "require_bus_client", lambda: str(gamemode_module.BUSCTL))
230+
seen_timeouts: list[float | None] = []
231+
232+
def fake_run(args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]:
233+
assert kwargs.get("timeout") == 0.5
234+
seen_timeouts.append(kwargs.get("timeout"))
235+
return subprocess.CompletedProcess(list(args), 0, stdout=payload, stderr="")
236+
237+
monkeypatch.setattr(gamemode_module.subprocess, "run", fake_run)
238+
if payload == "i 0\n":
239+
gamemode_module.register_host_game(4242, timeout=0.5)
240+
else:
241+
with pytest.raises(LaunchError, match="GameMode"):
242+
gamemode_module.register_host_game(4242, timeout=0.5)
243+
assert seen_timeouts == [0.5]

0 commit comments

Comments
 (0)