From c44784aabdaffa74e4ea428056b921a45c1c8b47 Mon Sep 17 00:00:00 2001 From: Andy Jost Date: Mon, 5 Oct 2026 16:06:46 -0700 Subject: [PATCH 1/2] test(helpers): keep the EGL device probe from importing pyglet.gl select_headless_egl_device_for_cuda runs before the graphics tests set pyglet.options["headless_device"]. Importing pyglet.gl there creates pyglet's shadow window, which in headless mode opens the EGL display on device 0 and caches it, so the later headless_device assignment has no effect and the GL context lands on EGL device 0 again (#2864). Match MissingFunctionException by module and name instead, as is_gl_context_unavailable already does, and add a subprocess test that asserts the probe leaves pyglet.gl unimported. Follow-up to #3020. Co-Authored-By: Claude Fable 5.1 --- cuda_core/tests/test_graphics.py | 27 +++++++++++++++++ .../cuda_python_test_helpers/graphics.py | 30 +++++++++++++++---- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index 0f1bd621f7a..f9579290305 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -7,7 +7,9 @@ import ctypes.util import gc import os +import subprocess import sys +import textwrap import numpy as np import pyglet @@ -71,6 +73,31 @@ 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" + """ + ) + subprocess.run([sys.executable, "-c", code], check=True) # noqa: S603 - trusted argv: this interpreter + + 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 From 6dd84a10ad98fd8806687d89b9eae91e90318524 Mon Sep 17 00:00:00 2001 From: Andy Jost Date: Tue, 6 Oct 2026 08:17:14 -0700 Subject: [PATCH 2/2] test(graphics): hand the probe child the test-helpers package path CI does not install cuda_python_test_helpers; the parent imports it only through the conftest sys.path fallback, which the -c child does not inherit, so test_egl_device_probe_does_not_import_pyglet_gl failed in every Linux row with ModuleNotFoundError. The child now gets a PYTHONPATH that names the parent directory of the package the parent imported, and a failure reports the child's stderr. Co-Authored-By: Claude Fable 5.1 --- cuda_core/tests/test_graphics.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index f9579290305..d3e508ba342 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -7,6 +7,7 @@ import ctypes.util import gc import os +import pathlib import subprocess import sys import textwrap @@ -20,6 +21,7 @@ select_headless_egl_device_for_cuda, ) +import cuda_python_test_helpers from cuda.core import ( Buffer, Device, @@ -95,7 +97,15 @@ def test_egl_device_probe_does_not_import_pyglet_gl(): assert "pyglet.gl" not in sys.modules, "the EGL device probe imported pyglet.gl" """ ) - subprocess.run([sys.executable, "-c", code], check=True) # noqa: S603 - trusted argv: this interpreter + # 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):