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