diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index 0f1bd621f7a..d3e508ba342 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -7,7 +7,10 @@ import ctypes.util import gc import os +import pathlib +import subprocess import sys +import textwrap import numpy as np import pyglet @@ -18,6 +21,7 @@ select_headless_egl_device_for_cuda, ) +import cuda_python_test_helpers from cuda.core import ( Buffer, Device, @@ -71,6 +75,39 @@ def _configure_pyglet_headless(): pyglet.options["headless_device"] = egl_device +@pytest.mark.agent_authored(model="claude-fable-5-1") +def test_egl_device_probe_does_not_import_pyglet_gl(): + """The probe runs before ``headless_device`` is set. + + Importing ``pyglet.gl`` there creates pyglet's shadow window, which in + headless mode opens EGL device 0 and keeps it for the whole session: the + failure that #2865 fixed. A subprocess gives a clean ``sys.modules``. + """ + if not sys.platform.startswith("linux") or ctypes.util.find_library("EGL") is None: + pytest.skip("needs a Linux EGL runtime") + code = textwrap.dedent( + """ + import sys + + import pyglet + from cuda_python_test_helpers.graphics import select_headless_egl_device_for_cuda + + pyglet.options["headless"] = True + select_headless_egl_device_for_cuda(0) + assert "pyglet.gl" not in sys.modules, "the EGL device probe imported pyglet.gl" + """ + ) + # The child inherits neither this process's sys.path nor conftest's + # source-checkout fallback, so point it at the package the parent imported. + helpers_root = pathlib.Path(cuda_python_test_helpers.__file__).parents[1] + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join(p for p in (str(helpers_root), env.get("PYTHONPATH")) if p) + proc = subprocess.run( # noqa: S603 - trusted argv: this interpreter + [sys.executable, "-c", code], capture_output=True, text=True, env=env + ) + assert proc.returncode == 0, proc.stderr + + def _allocate_gl_buffer(win, nbytes): """Allocate a GL buffer. Caller must have a current GL context. diff --git a/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py b/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py index 8f1d824393e..69dfc44e1d1 100644 --- a/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py +++ b/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py @@ -12,13 +12,27 @@ This module intentionally does **not** import ``pyglet`` at module load time: importing ``pyglet.gl`` / ``pyglet.window`` triggers pyglet's shadow-window creation, which fails on headless machines before the test has had a chance to -set ``pyglet.options["headless"]``. +set ``pyglet.options["headless"]``. For the same reason, +``select_headless_egl_device_for_cuda`` must not import ``pyglet.gl``: that +import creates the shadow window, and in headless mode the shadow window opens +the EGL display on ``pyglet.options["headless_device"]`` before the caller has +had a chance to set it. """ import contextlib import ctypes +def _is_pyglet_missing_function_exception(exc: BaseException) -> bool: + """True for ``pyglet.gl.lib.MissingFunctionException``. + + Matched by name, like ``is_gl_context_unavailable``, so that the check does + not import ``pyglet.gl``. + """ + exc_type = type(exc) + return exc_type.__module__.startswith("pyglet") and exc_type.__name__ == "MissingFunctionException" + + def select_headless_egl_device_for_cuda(cuda_device_ordinal: int) -> int | None: """Return the pyglet ``headless_device`` index matching *cuda_device_ordinal*. @@ -39,7 +53,6 @@ def select_headless_egl_device_for_cuda(cuda_device_ordinal: int) -> int | None: """ egl_cuda_device_nv = 0x323A - from pyglet.gl.lib import MissingFunctionException from pyglet.libs.egl import egl, eglext from pyglet.libs.egl.lib import link_EGL @@ -50,8 +63,11 @@ def select_headless_egl_device_for_cuda(cuda_device_ordinal: int) -> int | None: ) # link_EGL returns a stub for an unresolvable entry point that raises - # MissingFunctionException when called, so that is the only "extension not - # available" signal. Anything else is a real bug and must propagate. + # pyglet.gl.lib.MissingFunctionException when called, so that is the only + # "extension not available" signal. Anything else is a real bug and must + # propagate. The exception is matched by name: importing pyglet.gl here + # would create the shadow window on EGL device 0 before the caller has set + # pyglet.options["headless_device"] (see the module docstring). try: num_devices = egl.EGLint() if not eglext.eglQueryDevicesEXT(0, None, ctypes.byref(num_devices)) or num_devices.value <= 0: @@ -66,8 +82,10 @@ def select_headless_egl_device_for_cuda(cuda_device_ordinal: int) -> int | None: found = egl_query_device_attrib_ext(devices[index], egl_cuda_device_nv, ctypes.byref(queried_ordinal)) if found and queried_ordinal.value == cuda_device_ordinal: return index - except MissingFunctionException: - return None + except Exception as exc: + if _is_pyglet_missing_function_exception(exc): + return None + raise return None