111 lines
4.5 KiB
Python
111 lines
4.5 KiB
Python
"""Exercise supervisor failure paths without launching a game or display server."""
|
|
|
|
from contextlib import redirect_stderr
|
|
import io
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from ptlinux.presentation import gamescope
|
|
from scripts.repository_paths import SOURCE_ROOT
|
|
|
|
|
|
class GamescopeShutdownRegressions(unittest.TestCase):
|
|
def test_missing_status_storage_cannot_be_reported_as_a_clean_game_exit(self) -> None:
|
|
process = MagicMock()
|
|
process.poll.return_value = 0
|
|
output = io.StringIO()
|
|
with (
|
|
patch.object(gamescope, "_select_host_backend", return_value="sdl"),
|
|
patch.object(gamescope, "_open_host_x11", return_value=None),
|
|
patch.object(gamescope, "_create_child_status_file", side_effect=OSError("no space left")),
|
|
patch.object(gamescope.subprocess, "Popen", return_value=process) as popen,
|
|
redirect_stderr(output),
|
|
):
|
|
self.assertEqual(gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960), 1)
|
|
self.assertNotIn("--keep-alive", popen.call_args.args[0])
|
|
self.assertIn("supervision is unavailable", output.getvalue())
|
|
self.assertIn("without a recorded game status (compositor status 0)", output.getvalue())
|
|
|
|
def test_interrupt_during_startup_tuning_closes_the_compositor_and_state_files(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
mapper = MagicMock()
|
|
process = MagicMock()
|
|
process.poll.return_value = None
|
|
process.wait.return_value = -15
|
|
with (
|
|
patch.dict(os.environ, {"DISPLAY": ":0", "XDG_RUNTIME_DIR": temporary}, clear=True),
|
|
patch.object(gamescope, "_select_host_backend", return_value="sdl"),
|
|
patch.object(gamescope, "_open_host_x11", return_value=mapper),
|
|
patch.object(gamescope, "_tune_process_window", side_effect=KeyboardInterrupt),
|
|
patch.object(gamescope.subprocess, "Popen", return_value=process),
|
|
self.assertRaises(KeyboardInterrupt),
|
|
):
|
|
gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960)
|
|
process.terminate.assert_called_once_with()
|
|
process.wait.assert_called_once_with(timeout=gamescope.GAMESCOPE_SHUTDOWN_TIMEOUT_SECONDS)
|
|
mapper.close.assert_called_once_with()
|
|
self.assertEqual(list(Path(temporary).iterdir()), [])
|
|
|
|
def test_child_report_failure_does_not_leave_an_empty_compositor_alive(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
fake_gamescope = root / "gamescope"
|
|
fake_gamescope.write_text(
|
|
f"#!{sys.executable}\n" + textwrap.dedent("""\
|
|
import subprocess, sys, time
|
|
separator = sys.argv.index('--')
|
|
subprocess.run(sys.argv[separator + 1:], check=False)
|
|
# Gamescope's default exits when the primary child finishes;
|
|
# --keep-alive disables that behavior, even if the child failed.
|
|
if '--keep-alive' in sys.argv[1:separator]:
|
|
time.sleep(15)
|
|
"""), encoding="utf-8",
|
|
)
|
|
fake_gamescope.chmod(0o700)
|
|
runner = root / "supervisor.py"
|
|
runner.write_text(textwrap.dedent("""\
|
|
from pathlib import Path
|
|
import sys
|
|
from unittest.mock import patch
|
|
from ptlinux.presentation import gamescope
|
|
status = Path(sys.argv[2])
|
|
with (
|
|
patch.object(gamescope, '_select_host_backend', return_value='wayland'),
|
|
patch.object(gamescope, '_select_wayland_gamescope', return_value=(sys.argv[1], True, False)),
|
|
patch.object(gamescope, '_create_child_status_file', return_value=status),
|
|
):
|
|
raise SystemExit(gamescope.run_gamescope(
|
|
[sys.argv[1], '--', sys.executable, '-c', 'raise SystemExit(7)'], 1280, 960,
|
|
))
|
|
"""), encoding="utf-8")
|
|
for status, expected in ((root / "status", 7), (root / "removed-directory" / "status", 1)):
|
|
with self.subTest(status=status):
|
|
process = subprocess.Popen(
|
|
[sys.executable, str(runner), str(fake_gamescope), str(status)],
|
|
env={**os.environ, "PYTHONPATH": str(SOURCE_ROOT)},
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True,
|
|
)
|
|
try:
|
|
_stdout, stderr = process.communicate(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
process.communicate(timeout=5)
|
|
raise
|
|
self.assertEqual(process.returncode, expected, stderr)
|
|
if expected == 1:
|
|
self.assertIn("Could not report the nested game status", stderr)
|
|
self.assertIn("without a recorded game status", stderr)
|
|
else:
|
|
self.assertNotIn("without a recorded game status", stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|