From ca51e5a84231ec3c74061167fadd15376a626fcb Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:15:30 -0700 Subject: [PATCH 01/18] cuda.core: fix a few test issues From 2ad24f647457e0ae49cc8ffefdb9aa41ff3b96d3 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:16:15 -0700 Subject: [PATCH 02/18] address PR #2701 feedback --- cuda_bindings/tests/test_graphics_apis.py | 45 ++++++---- cuda_core/tests/test_graphics.py | 104 ++++++++++------------ 2 files changed, 77 insertions(+), 72 deletions(-) diff --git a/cuda_bindings/tests/test_graphics_apis.py b/cuda_bindings/tests/test_graphics_apis.py index 5e4ae636d69..f3a87e686cd 100644 --- a/cuda_bindings/tests/test_graphics_apis.py +++ b/cuda_bindings/tests/test_graphics_apis.py @@ -13,7 +13,7 @@ from cuda.bindings import runtime as cudart -def _configure_pyglet_headless(pyglet): +def _configure_pyglet_headless(): """On headless Linux: enable EGL mode or skip if EGL is absent.""" if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): if ctypes.util.find_library("EGL") is None: @@ -21,8 +21,11 @@ def _configure_pyglet_headless(pyglet): pyglet.options["headless"] = True -def _setup_gl_texture(pyglet): - """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target).""" +def _setup_gl_texture(): + """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target). + + Cleans up the window if texture allocation raises, so partial resources do not leak. + """ if not pyglet.options.get("headless"): # Hidden window path (WGL on Windows, GLX/WLS on Linux) from pyglet import gl @@ -36,27 +39,35 @@ def _setup_gl_texture(pyglet): win = None - # Make a tiny texture so we have a real GL object to register - from pyglet.gl import gl as _gl - - tex_id = _gl.GLuint(0) - _gl.glGenTextures(1, ctypes.byref(tex_id)) - target = _gl.GL_TEXTURE_2D - _gl.glBindTexture(target, tex_id.value) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) - width, height = 16, 16 - _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) - return win, tex_id, target + try: + # Make a tiny texture so we have a real GL object to register + from pyglet.gl import gl as _gl + + tex_id = _gl.GLuint(0) + _gl.glGenTextures(1, ctypes.byref(tex_id)) + target = _gl.GL_TEXTURE_2D + _gl.glBindTexture(target, tex_id.value) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) + width, height = 16, 16 + _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) + return win, tex_id, target + except Exception: + try: + if win is not None: + win.close() + except Exception: # noqa: S110 + pass + raise @contextlib.contextmanager def _gl_context(): """Yield ``(tex_id, tex_target)`` with a current GL context, or skip if GL is unavailable.""" - _configure_pyglet_headless(pyglet) + _configure_pyglet_headless() try: - win, tex_id, target = _setup_gl_texture(pyglet) + win, tex_id, target = _setup_gl_texture() except Exception as e: pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index f31f2d14a8b..eb96ae056af 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -8,7 +8,6 @@ import gc import os import sys -from unittest.mock import patch import numpy as np import pyglet @@ -50,7 +49,7 @@ def _register_gl_image(tex_id, target): raise -def _configure_pyglet_headless(pyglet): +def _configure_pyglet_headless(): """On headless Linux: enable EGL mode or skip if EGL is absent.""" if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): if ctypes.util.find_library("EGL") is None: @@ -58,7 +57,7 @@ def _configure_pyglet_headless(pyglet): pyglet.options["headless"] = True -def _open_gl_window(pyglet): +def _open_gl_window(): """Open a hidden window (or configure EGL headless). Returns the window or None.""" if not pyglet.options.get("headless"): from pyglet import gl @@ -73,40 +72,62 @@ def _open_gl_window(pyglet): return None -def _setup_gl_buffer(pyglet, nbytes): - """Open a GL context and allocate a buffer. Returns (win, buf_id).""" - win = _open_gl_window(pyglet) - from pyglet.gl import gl as _gl +def _setup_gl_buffer(nbytes): + """Open a GL context and allocate a buffer. Returns (win, buf_id). - buf_id = _gl.GLuint(0) - _gl.glGenBuffers(1, ctypes.byref(buf_id)) - _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) - _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) - return win, buf_id + Cleans up the window if buffer allocation raises, so partial resources do not leak. + """ + win = _open_gl_window() + try: + from pyglet.gl import gl as _gl + + buf_id = _gl.GLuint(0) + _gl.glGenBuffers(1, ctypes.byref(buf_id)) + _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) + _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) + return win, buf_id + except Exception: + try: + if win is not None: + win.close() + except Exception: # noqa: S110 + pass + raise -def _setup_gl_texture(pyglet, width, height): - """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target).""" - win = _open_gl_window(pyglet) - from pyglet.gl import gl as _gl +def _setup_gl_texture(width, height): + """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target). - tex_id = _gl.GLuint(0) - _gl.glGenTextures(1, ctypes.byref(tex_id)) - target = _gl.GL_TEXTURE_2D - _gl.glBindTexture(target, tex_id.value) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) - _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) - return win, tex_id, target + Cleans up the window if texture allocation raises, so partial resources do not leak. + """ + win = _open_gl_window() + try: + from pyglet.gl import gl as _gl + + tex_id = _gl.GLuint(0) + _gl.glGenTextures(1, ctypes.byref(tex_id)) + target = _gl.GL_TEXTURE_2D + _gl.glBindTexture(target, tex_id.value) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) + _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) + return win, tex_id, target + except Exception: + try: + if win is not None: + win.close() + except Exception: # noqa: S110 + pass + raise @contextlib.contextmanager def _gl_context_and_buffer(nbytes=1024): """Yield ``(gl_buffer_name, nbytes)`` with a current GL context, or skip if GL is unavailable.""" - _configure_pyglet_headless(pyglet) + _configure_pyglet_headless() try: - win, buf_id = _setup_gl_buffer(pyglet, nbytes) + win, buf_id = _setup_gl_buffer(nbytes) except Exception as e: pytest.skip(f"Could not create GL context/buffer: {type(e).__name__}: {e}") @@ -130,10 +151,10 @@ def _gl_context_and_buffer(nbytes=1024): @contextlib.contextmanager def _gl_context_and_texture(width=16, height=16): """Yield ``(tex_id, tex_target)`` with a current GL context, or skip if GL is unavailable.""" - _configure_pyglet_headless(pyglet) + _configure_pyglet_headless() try: - win, tex_id, target = _setup_gl_texture(pyglet, width, height) + win, tex_id, target = _setup_gl_texture(width, height) except Exception as e: pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") @@ -419,33 +440,6 @@ def test_close_while_mapped(init_cuda): assert buf.handle == 0 -@pytest.mark.xfail( - reason="Buffer is an immutable Cython type; patch.object and __class__ assignment both fail", - raises=TypeError, - strict=True, -) -def test_close_while_mapped_passes_stream_override(init_cuda): - with _gl_context_and_buffer() as (gl_buf, _): - map_stream = init_cuda.create_stream() - close_stream = init_cuda.create_stream() - resource = _register_gl_buffer(gl_buf, flags="write_discard") - resource.map(stream=map_stream) - - original_close = Buffer.close - - def tracking_close(self, stream=None): - tracking_close.calls.append(stream) - return original_close(self, stream=stream) - - tracking_close.calls = [] - - with patch.object(Buffer, "close", new=tracking_close): - resource.close(stream=close_stream) - - assert tracking_close.calls == [close_stream] - assert not resource.is_mapped - - def test_buffer_close_updates_resource_state(init_cuda): with _gl_context_and_buffer() as (gl_buf, _): stream = init_cuda.create_stream() From 3fa837bb7337deaf099cd828931b91a4300b2eb9 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:25:31 -0700 Subject: [PATCH 03/18] drop problematic importorskip --- cuda_core/tests/graph/test_device_launch.py | 2 +- cuda_core/tests/test_build_hooks.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cuda_core/tests/graph/test_device_launch.py b/cuda_core/tests/graph/test_device_launch.py index d77ceeec37f..5056b01fd43 100644 --- a/cuda_core/tests/graph/test_device_launch.py +++ b/cuda_core/tests/graph/test_device_launch.py @@ -7,6 +7,7 @@ import pytest from cuda_python_test_helpers.marks import requires_module +import cuda.pathfinder as pathfinder from cuda.core import ( Device, LaunchConfig, @@ -48,7 +49,6 @@ def _compile_device_launcher_kernel(): Raises pytest.skip if libcudadevrt.a cannot be found. """ - pathfinder = pytest.importorskip("cuda.pathfinder") try: cudadevrt_path = pathfinder.find_static_lib("cudadevrt") except pathfinder.StaticLibNotFoundError as e: diff --git a/cuda_core/tests/test_build_hooks.py b/cuda_core/tests/test_build_hooks.py index da410257e24..b9359164faf 100644 --- a/cuda_core/tests/test_build_hooks.py +++ b/cuda_core/tests/test_build_hooks.py @@ -24,14 +24,15 @@ from pathlib import Path from unittest import mock +# build_hooks.py imports Cython and setuptools at the top level; both are +# declared test dependencies, so a missing install must surface as an +# ImportError at collection time rather than being hidden by importorskip. +import Cython # noqa: F401 import pytest +import setuptools # noqa: F401 from cuda.pathfinder import get_cuda_path_or_home -# build_hooks.py imports Cython and setuptools at the top level, so skip if not available -pytest.importorskip("Cython") -pytest.importorskip("setuptools") - def _load_build_hooks(): """Load build_hooks module from source without permanently modifying sys.path. From f7440ea1ff1618cdca071d21751d5e11c538ea54 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:35:39 -0700 Subject: [PATCH 04/18] sharpen skip condition in graph def tests --- .../test_graph_definition_integration.py | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/cuda_core/tests/graph/test_graph_definition_integration.py b/cuda_core/tests/graph/test_graph_definition_integration.py index 629f302bd75..df6eee57f4b 100644 --- a/cuda_core/tests/graph/test_graph_definition_integration.py +++ b/cuda_core/tests/graph/test_graph_definition_integration.py @@ -10,7 +10,7 @@ from helpers.memory import xfail_on_graph_mempool_oom from cuda.core import Device, EventOptions, LaunchConfig, Program, ProgramOptions -from cuda.core._utils.cuda_utils import driver, handle_return +from cuda.core._utils.cuda_utils import CUDAError, driver, handle_return from cuda.core.graph import GraphDefinition SIZEOF_FLOAT = 4 @@ -121,6 +121,25 @@ def _nvrtc_opts(): return ProgramOptions(std="c++17", arch=f"sm_{arch}") +# NVRTC diagnostic phrases that indicate cudaGraphConditionalHandle itself is unknown +# to the compiler (older NVRTC builds predate the type). Matched narrowly so a +# genuine compile error (syntax error, etc.) is not hidden as a skip. +# Phrase #1 is the exact diagnostic observed on this machine's NVRTC; phrase #2 +# is a common clang/NVRTC wording for an unknown type, not verified against the +# cudaGraphConditionalHandle case on an old NVRTC build. +_COND_HANDLE_UNKNOWN = ( + 'identifier "cudaGraphConditionalHandle" is undefined', + 'unknown type name "cudaGraphConditionalHandle"', +) + + +def _skip_if_nvrtc_lacks_conditional_handle(exc): + msg = str(exc) + if any(phrase in msg for phrase in _COND_HANDLE_UNKNOWN): + pytest.skip("NVRTC does not support cudaGraphConditionalHandle") + raise + + def _compile_heat_kernels(): prog = Program(_HEAT_KERNEL_SOURCE, code_type="c++", options=_nvrtc_opts()) try: @@ -128,8 +147,9 @@ def _compile_heat_kernels(): "cubin", name_expressions=("heat_step", "countdown"), ) - except Exception: - pytest.skip("NVRTC does not support cudaGraphConditionalHandle") + except CUDAError as exc: + _skip_if_nvrtc_lacks_conditional_handle(exc) + raise return mod.get_kernel("heat_step"), mod.get_kernel("countdown") @@ -145,8 +165,9 @@ def _compile_bisect_kernels(): prog = Program(_BISECT_KERNEL_SOURCE, code_type="c++", options=_nvrtc_opts()) try: mod = prog.compile("cubin", name_expressions=names) - except Exception: - pytest.skip("NVRTC does not support cudaGraphConditionalHandle") + except CUDAError as exc: + _skip_if_nvrtc_lacks_conditional_handle(exc) + raise return tuple(mod.get_kernel(n) for n in names) From d1f8e3aacf32c50a79106bce4f64cf7cfc318a9d Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:51:32 -0700 Subject: [PATCH 05/18] sharpen a few more catches --- cuda_core/tests/helpers/__init__.py | 5 +++-- cuda_core/tests/test_module.py | 8 +++++--- cuda_core/tests/test_program.py | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/cuda_core/tests/helpers/__init__.py b/cuda_core/tests/helpers/__init__.py index 2305cfaa1e5..ef6c06654ac 100644 --- a/cuda_core/tests/helpers/__init__.py +++ b/cuda_core/tests/helpers/__init__.py @@ -4,7 +4,7 @@ import functools import os -from cuda.core._utils.cuda_utils import handle_return +from cuda.core._utils.cuda_utils import CUDAError, handle_return from cuda.pathfinder import get_cuda_path_or_home from cuda_python_test_helpers import * @@ -49,5 +49,6 @@ def supports_ipc_mempool(device_id: int | object) -> bool: # Check POSIX FD handle type support via bitmask posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR return (int(mask) & int(posix_fd)) != 0 - except Exception: + except CUDAError: + # cuInit or cuDeviceGetAttribute failed: IPC mempool not usable here. return False diff --git a/cuda_core/tests/test_module.py b/cuda_core/tests/test_module.py index 7a0ba965b5d..7da7905c063 100644 --- a/cuda_core/tests/test_module.py +++ b/cuda_core/tests/test_module.py @@ -42,11 +42,13 @@ def _is_nvfatbin_available(): """Check if nvfatbin bindings are available.""" try: from cuda.bindings import nvfatbin - + except ImportError: + return False + try: nvfatbin.version() - return True - except Exception: + except nvfatbin.nvFatbinError: return False + return True nvfatbin_available = pytest.mark.skipif(not _is_nvfatbin_available(), reason="nvfatbin bindings not available") diff --git a/cuda_core/tests/test_program.py b/cuda_core/tests/test_program.py index 3b280cc48cf..040eb7e8e96 100644 --- a/cuda_core/tests/test_program.py +++ b/cuda_core/tests/test_program.py @@ -54,7 +54,9 @@ def _get_nvrtc_version_for_tests(): nvrtc_major, nvrtc_minor = handle_return(nvrtc.nvrtcVersion()) version = nvrtc_major * 1000 + nvrtc_minor * 100 return version - except Exception: + except (AttributeError, CUDAError): + # AttributeError: nvrtc not imported (suppressed above) or missing nvrtcVersion. + # CUDAError: driver not loaded or nvrtc call failed. return None From 8e838f566fd9583f16dd0b41ffc398f1d3f20288 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:52:13 -0700 Subject: [PATCH 06/18] add guidance to AGENTS.md --- cuda_core/tests/AGENTS.md | 85 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/cuda_core/tests/AGENTS.md b/cuda_core/tests/AGENTS.md index fe6f100b923..ee5b3438099 100644 --- a/cuda_core/tests/AGENTS.md +++ b/cuda_core/tests/AGENTS.md @@ -90,3 +90,88 @@ Follow these rules when adding or moving shared test code: `conftest.py`. - In directories without `__init__.py`, keep test-module basenames unique within this test suite. + +## Skip only real setup failures + +`pytest.skip(reason)` records the test as SKIPPED with `reason` in the +report. A helper that wraps `yield` in `except Exception: pytest.skip(...)` +therefore records every test-body failure as a skip — a real regression, a +`TypeError`, an `AttributeError` all become "SKIPPED: " instead of +"FAILED", and the suite goes green regardless of whether the code under +test works. + +Catch only the specific exception that legitimately means "not available", +and only around the setup call — never around `yield`: + +```python +@contextlib.contextmanager +def _gl_context(): + try: + win, tex_id = _setup_gl_texture() # setup only + except (pyglet.NoSuchConfigException, GLContextError) as e: + pytest.skip(f"GL unavailable: {e}") + try: + yield tex_id # body exceptions propagate + finally: + _cleanup(win, tex_id) +``` + +When a CUDA call's error means "feature refused by this driver" (e.g. +`CUDA_ERROR_OPERATING_SYSTEM` for CUDA-GL interop on WSL), skip at the call +site with a narrow catch on the specific error, not inside the GL helper — +see `_register_gl_buffer` / `_register_gl_image` in `tests/test_graphics.py`. + +## `importorskip` is for optional dependencies only + +`pytest.importorskip("X")` is correct when `X` is genuinely optional +(platform-gated binding, parametrized "test each available module"). It is +dead code when `X` is a declared test or runtime dependency: the skip then +fires only when the environment is broken, which is the case you want to fail +loudly, not hide. Use a bare top-level `import` for declared deps. + +Before adding `importorskip`, check `cuda_core/pyproject.toml`'s `test` +and `test-cu*` groups and `cuda_core`'s `dependencies`. If the target is +listed, import it directly. + +## Capability probes must not swallow real bugs + +A probe function that answers "is feature X available?" by catching +`Exception` and returning `False` will report "not available" even when the +probed API failed for a real, unexpected reason — silently enabling a skip +that hides the bug. Catch only the exception that genuinely means "not +available", and split the checks so each catch is narrow: + +```python +def _is_nvfatbin_available(): + try: + from cuda.bindings import nvfatbin + except ImportError: + return False + try: + nvfatbin.version() + except nvfatbin.nvFatbinError: + return False + return True +``` + +Do not catch `ImportError` for a hard runtime dependency (e.g. +`cuda.bindings` for `cuda.core`) — that is a broken environment and should +surface at collection time. + +## Tests that touch CUDA must establish their own context + +The `init_cuda` fixture pops the CUDA context on teardown, so a test +that calls a CUDA API without `init_cuda` (or an explicit +`Device.set_current()`) inherits whatever context the previous test happened +to leave current on the thread — possibly none. With `pytest-randomly` that +makes the pass/fail outcome depend on test order, so it moves seed to seed and +looks like flakiness. Request `init_cuda` for any test that calls into the +driver, or set up and tear down a context yourself. + +## Assert on behavior, not implementation + +Pin on observable behavior the contract guarantees — return values, raised +exception types, public state transitions. Avoid asserting on internal +call counts, private helper invocation order, or error message substrings +that are not part of the contract. A refactor that preserves behavior but +changes internals should not break the test. From e56897c407205fbaed7aef40d566629e11322dc0 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 15:07:47 -0700 Subject: [PATCH 07/18] sharpen gl setup to align with guidance --- cuda_bindings/tests/test_graphics_apis.py | 34 +++++++++++++++++++- cuda_core/tests/test_graphics.py | 38 +++++++++++++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/cuda_bindings/tests/test_graphics_apis.py b/cuda_bindings/tests/test_graphics_apis.py index f3a87e686cd..53a8741f6f5 100644 --- a/cuda_bindings/tests/test_graphics_apis.py +++ b/cuda_bindings/tests/test_graphics_apis.py @@ -12,6 +12,36 @@ from cuda.bindings import runtime as cudart +# pyglet raises these when GL context/window creation fails. Matched by type +# name (not by class) because importing pyglet.gl / pyglet.window at +# module top triggers pyglet's shadow-window creation, which fails on +# headless machines before _configure_pyglet_headless() has set the +# headless option. A bug in our own setup code (e.g. a TypeError) comes +# from builtins, not pyglet, so it re-raises and fails the test rather than +# being hidden as a skip. +_GL_UNAVAILABLE_EXC_NAMES = frozenset( + { + "NoSuchDisplayException", + "NoSuchConfigException", + "NoSuchScreenModeException", + "WindowException", + "ContextException", + "GLException", + } +) + + +def _is_gl_unavailable(exc): + if type(exc).__module__.startswith("pyglet") and type(exc).__name__ in _GL_UNAVAILABLE_EXC_NAMES: + return True + # Windows CI runners may lack opengl32.dll; pyglet's WGL backend raises + # FileNotFoundError from ctypes.windll.opengl32. On newer Python + # (3.12+) ctypes.LibraryLoader catches that and re-raises + # AttributeError(dll_name). Match narrowly on the dll name so a + # different FileNotFoundError or AttributeError from our own code + # does not match. + return isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc) + def _configure_pyglet_headless(): """On headless Linux: enable EGL mode or skip if EGL is absent.""" @@ -69,7 +99,9 @@ def _gl_context(): try: win, tex_id, target = _setup_gl_texture() except Exception as e: - pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + if _is_gl_unavailable(e): + pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + raise try: yield int(tex_id.value), int(target) diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index eb96ae056af..91cf5e51f70 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -49,6 +49,36 @@ def _register_gl_image(tex_id, target): raise +# pyglet raises these when GL context/window creation fails. Matched by type +# name (not by class) because importing pyglet.gl / pyglet.window at module +# top triggers pyglet's shadow-window creation, which fails on headless +# machines before _configure_pyglet_headless() has set the headless option. +# A bug in our own setup code (e.g. a TypeError) comes from builtins, not +# pyglet, so it re-raises and fails the test rather than being hidden as a skip. +_GL_UNAVAILABLE_EXC_NAMES = frozenset( + { + "NoSuchDisplayException", + "NoSuchConfigException", + "NoSuchScreenModeException", + "WindowException", + "ContextException", + "GLException", + } +) + + +def _is_gl_unavailable(exc): + if type(exc).__module__.startswith("pyglet") and type(exc).__name__ in _GL_UNAVAILABLE_EXC_NAMES: + return True + # Windows CI runners may lack opengl32.dll; pyglet's WGL backend raises + # FileNotFoundError from ctypes.windll.opengl32. On newer Python + # (3.12+) ctypes.LibraryLoader catches that and re-raises + # AttributeError(dll_name). Match narrowly on the dll name so a + # different FileNotFoundError or AttributeError from our own code + # does not match. + return isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc) + + def _configure_pyglet_headless(): """On headless Linux: enable EGL mode or skip if EGL is absent.""" if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): @@ -129,7 +159,9 @@ def _gl_context_and_buffer(nbytes=1024): try: win, buf_id = _setup_gl_buffer(nbytes) except Exception as e: - pytest.skip(f"Could not create GL context/buffer: {type(e).__name__}: {e}") + if _is_gl_unavailable(e): + pytest.skip(f"Could not create GL context/buffer: {type(e).__name__}: {e}") + raise try: yield int(buf_id.value), nbytes @@ -156,7 +188,9 @@ def _gl_context_and_texture(width=16, height=16): try: win, tex_id, target = _setup_gl_texture(width, height) except Exception as e: - pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + if _is_gl_unavailable(e): + pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + raise try: yield int(tex_id.value), int(target) From 5a55a48228c8438f9d9409d1b9eac485c9672469 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 09:48:39 -0700 Subject: [PATCH 08/18] fix(tests): restrict GL skip to context creation, drop GLException Per PR #2714 review (Ralf K): GLException is pyglet's generic GL-error class, raised after any GL call that reports an error (GL_INVALID_ENUM, etc.). Including it in the "GL unavailable" set hid real bugs in our GL allocation code as skips. Drop GLException from _GL_UNAVAILABLE_EXC_NAMES and restructure the helpers so the skip catch wraps only context/window creation (_open_gl_window). GL object allocation (_allocate_gl_buffer / _allocate_gl_texture) runs outside the catch, so a GLException from allocation propagates and fails the test. --- cuda_bindings/tests/test_graphics_apis.py | 57 ++++++++--------- cuda_core/tests/test_graphics.py | 78 ++++++++--------------- 2 files changed, 53 insertions(+), 82 deletions(-) diff --git a/cuda_bindings/tests/test_graphics_apis.py b/cuda_bindings/tests/test_graphics_apis.py index 53a8741f6f5..b06474d412b 100644 --- a/cuda_bindings/tests/test_graphics_apis.py +++ b/cuda_bindings/tests/test_graphics_apis.py @@ -16,9 +16,10 @@ # name (not by class) because importing pyglet.gl / pyglet.window at # module top triggers pyglet's shadow-window creation, which fails on # headless machines before _configure_pyglet_headless() has set the -# headless option. A bug in our own setup code (e.g. a TypeError) comes -# from builtins, not pyglet, so it re-raises and fails the test rather than -# being hidden as a skip. +# headless option. GLException is intentionally excluded: pyglet +# raises it after any GL call that reports an error (GL_INVALID_ENUM, +# etc.), so catching it would hide real bugs in our own GL +# allocation code as "GL unavailable" skips. _GL_UNAVAILABLE_EXC_NAMES = frozenset( { "NoSuchDisplayException", @@ -26,7 +27,6 @@ "NoSuchScreenModeException", "WindowException", "ContextException", - "GLException", } ) @@ -51,11 +51,8 @@ def _configure_pyglet_headless(): pyglet.options["headless"] = True -def _setup_gl_texture(): - """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target). - - Cleans up the window if texture allocation raises, so partial resources do not leak. - """ +def _open_gl_window(): + """Open a hidden window (or configure EGL headless). Returns the window or None.""" if not pyglet.options.get("headless"): # Hidden window path (WGL on Windows, GLX/WLS on Linux) from pyglet import gl @@ -63,32 +60,27 @@ def _setup_gl_texture(): config = gl.Config(double_buffer=False) win = pyglet.window.Window(visible=False, config=config) win.switch_to() + return win else: # Headless EGL path; pyglet will arrange a pbuffer-like headless context from pyglet.gl import headless # noqa: F401 - win = None + return None - try: - # Make a tiny texture so we have a real GL object to register - from pyglet.gl import gl as _gl - - tex_id = _gl.GLuint(0) - _gl.glGenTextures(1, ctypes.byref(tex_id)) - target = _gl.GL_TEXTURE_2D - _gl.glBindTexture(target, tex_id.value) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) - width, height = 16, 16 - _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) - return win, tex_id, target - except Exception: - try: - if win is not None: - win.close() - except Exception: # noqa: S110 - pass - raise + +def _allocate_gl_texture(win): + """Allocate a 2-D RGBA8 texture. Caller must have a current GL context.""" + from pyglet.gl import gl as _gl + + tex_id = _gl.GLuint(0) + _gl.glGenTextures(1, ctypes.byref(tex_id)) + target = _gl.GL_TEXTURE_2D + _gl.glBindTexture(target, tex_id.value) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) + width, height = 16, 16 + _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) + return tex_id, target @contextlib.contextmanager @@ -97,13 +89,14 @@ def _gl_context(): _configure_pyglet_headless() try: - win, tex_id, target = _setup_gl_texture() + win = _open_gl_window() except Exception as e: if _is_gl_unavailable(e): - pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") raise try: + tex_id, target = _allocate_gl_texture(win) yield int(tex_id.value), int(target) finally: # Best-effort cleanup diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index 91cf5e51f70..06460b149bc 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -53,8 +53,9 @@ def _register_gl_image(tex_id, target): # name (not by class) because importing pyglet.gl / pyglet.window at module # top triggers pyglet's shadow-window creation, which fails on headless # machines before _configure_pyglet_headless() has set the headless option. -# A bug in our own setup code (e.g. a TypeError) comes from builtins, not -# pyglet, so it re-raises and fails the test rather than being hidden as a skip. +# GLException is intentionally excluded: pyglet raises it after any GL +# call that reports an error (GL_INVALID_ENUM, etc.), so catching it would +# hide real bugs in our own GL allocation code as "GL unavailable" skips. _GL_UNAVAILABLE_EXC_NAMES = frozenset( { "NoSuchDisplayException", @@ -62,7 +63,6 @@ def _register_gl_image(tex_id, target): "NoSuchScreenModeException", "WindowException", "ContextException", - "GLException", } ) @@ -102,53 +102,29 @@ def _open_gl_window(): return None -def _setup_gl_buffer(nbytes): - """Open a GL context and allocate a buffer. Returns (win, buf_id). +def _allocate_gl_buffer(win, nbytes): + """Allocate a GL buffer. Caller must have a current GL context.""" + from pyglet.gl import gl as _gl - Cleans up the window if buffer allocation raises, so partial resources do not leak. - """ - win = _open_gl_window() - try: - from pyglet.gl import gl as _gl - - buf_id = _gl.GLuint(0) - _gl.glGenBuffers(1, ctypes.byref(buf_id)) - _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) - _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) - return win, buf_id - except Exception: - try: - if win is not None: - win.close() - except Exception: # noqa: S110 - pass - raise + buf_id = _gl.GLuint(0) + _gl.glGenBuffers(1, ctypes.byref(buf_id)) + _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) + _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) + return buf_id -def _setup_gl_texture(width, height): - """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target). +def _allocate_gl_texture(win, width, height): + """Allocate a 2-D RGBA8 texture. Caller must have a current GL context.""" + from pyglet.gl import gl as _gl - Cleans up the window if texture allocation raises, so partial resources do not leak. - """ - win = _open_gl_window() - try: - from pyglet.gl import gl as _gl - - tex_id = _gl.GLuint(0) - _gl.glGenTextures(1, ctypes.byref(tex_id)) - target = _gl.GL_TEXTURE_2D - _gl.glBindTexture(target, tex_id.value) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) - _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) - return win, tex_id, target - except Exception: - try: - if win is not None: - win.close() - except Exception: # noqa: S110 - pass - raise + tex_id = _gl.GLuint(0) + _gl.glGenTextures(1, ctypes.byref(tex_id)) + target = _gl.GL_TEXTURE_2D + _gl.glBindTexture(target, tex_id.value) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) + _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) + return tex_id, target @contextlib.contextmanager @@ -157,13 +133,14 @@ def _gl_context_and_buffer(nbytes=1024): _configure_pyglet_headless() try: - win, buf_id = _setup_gl_buffer(nbytes) + win = _open_gl_window() except Exception as e: if _is_gl_unavailable(e): - pytest.skip(f"Could not create GL context/buffer: {type(e).__name__}: {e}") + pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") raise try: + buf_id = _allocate_gl_buffer(win, nbytes) yield int(buf_id.value), nbytes finally: try: @@ -186,13 +163,14 @@ def _gl_context_and_texture(width=16, height=16): _configure_pyglet_headless() try: - win, tex_id, target = _setup_gl_texture(width, height) + win = _open_gl_window() except Exception as e: if _is_gl_unavailable(e): - pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") raise try: + tex_id, target = _allocate_gl_texture(win, width, height) yield int(tex_id.value), int(target) finally: try: From ed07c1e276d81661fd0deb94f0e1d76c1032e67b Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 09:49:05 -0700 Subject: [PATCH 09/18] fix(tests): skip on Linux GL/EGL library import errors Per PR #2714 review (Ralf K): on Linux without libGL or libEGL, pyglet raises ImportError('Library "GL" not found.') / ImportError('Library "EGL" not found.') from pyglet/lib.py. These are genuine "GL unavailable" setup outcomes but the predicate only recognized pyglet exceptions and the Windows opengl32 built-in exceptions, so the tests failed on such Linux runners. Recognize the exact pyglet GL/EGL loader messages so a genuine GL setup is skipped. A different ImportError from our own code does not match. --- cuda_bindings/tests/test_graphics_apis.py | 11 ++++++++++- cuda_core/tests/test_graphics.py | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/cuda_bindings/tests/test_graphics_apis.py b/cuda_bindings/tests/test_graphics_apis.py index b06474d412b..3c5b0290c5b 100644 --- a/cuda_bindings/tests/test_graphics_apis.py +++ b/cuda_bindings/tests/test_graphics_apis.py @@ -40,7 +40,16 @@ def _is_gl_unavailable(exc): # AttributeError(dll_name). Match narrowly on the dll name so a # different FileNotFoundError or AttributeError from our own code # does not match. - return isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc) + if isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc): + return True + # Linux without libGL/libEGL: pyglet raises ImportError with these + # exact messages from pyglet/lib.py. Match them so a genuine GL + # setup is skipped, not failed; a different ImportError from our + # own code does not match. + return isinstance(exc, ImportError) and str(exc) in ( + 'Library "GL" not found.', + 'Library "EGL" not found.', + ) def _configure_pyglet_headless(): diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index 06460b149bc..bc66551d6a6 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -76,7 +76,16 @@ def _is_gl_unavailable(exc): # AttributeError(dll_name). Match narrowly on the dll name so a # different FileNotFoundError or AttributeError from our own code # does not match. - return isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc) + if isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc): + return True + # Linux without libGL/libEGL: pyglet raises ImportError with these + # exact messages from pyglet/lib.py. Match them so a genuine GL + # setup is skipped, not failed; a different ImportError from our + # own code does not match. + return isinstance(exc, ImportError) and str(exc) in ( + 'Library "GL" not found.', + 'Library "EGL" not found.', + ) def _configure_pyglet_headless(): From 2a8c55b9b27aaf1dda4f85969cd1cb25221b62bc Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 09:51:08 -0700 Subject: [PATCH 10/18] refactor(tests): consolidate GL availability predicate in test-helpers Per PR #2714 review (Ralf K): the GL availability predicate was duplicated in cuda_core/tests/test_graphics.py and cuda_bindings/tests/test_graphics_apis.py. Both test environments already depend on cuda-python-test-helpers. Move the predicate to cuda_python_test_helpers/graphics.py as is_gl_context_unavailable and import it from both test files. The shared helper does not import pyglet (importing pyglet.gl / pyglet.window triggers the shadow-window side effect) and classifies by exception module/name and tightly matched built-in loader errors. Add focused tests for the predicate: accepts the pyglet context/window exceptions, the Windows opengl32 failures, and the Linux GL/EGL loader ImportErrors; rejects unrelated TypeError/AttributeError/ ImportError/FileNotFoundError and an allocation-time GLException. --- cuda_bindings/tests/test_graphics_apis.py | 42 +------------ cuda_core/tests/test_graphics.py | 44 +------------ .../cuda_python_test_helpers/graphics.py | 62 +++++++++++++++++++ .../tests/test_graphics.py | 54 ++++++++++++++++ 4 files changed, 121 insertions(+), 81 deletions(-) create mode 100644 cuda_python_test_helpers/cuda_python_test_helpers/graphics.py create mode 100644 cuda_python_test_helpers/tests/test_graphics.py diff --git a/cuda_bindings/tests/test_graphics_apis.py b/cuda_bindings/tests/test_graphics_apis.py index 3c5b0290c5b..a547cf3c966 100644 --- a/cuda_bindings/tests/test_graphics_apis.py +++ b/cuda_bindings/tests/test_graphics_apis.py @@ -9,48 +9,10 @@ import pyglet import pytest +from cuda_python_test_helpers.graphics import is_gl_context_unavailable from cuda.bindings import runtime as cudart -# pyglet raises these when GL context/window creation fails. Matched by type -# name (not by class) because importing pyglet.gl / pyglet.window at -# module top triggers pyglet's shadow-window creation, which fails on -# headless machines before _configure_pyglet_headless() has set the -# headless option. GLException is intentionally excluded: pyglet -# raises it after any GL call that reports an error (GL_INVALID_ENUM, -# etc.), so catching it would hide real bugs in our own GL -# allocation code as "GL unavailable" skips. -_GL_UNAVAILABLE_EXC_NAMES = frozenset( - { - "NoSuchDisplayException", - "NoSuchConfigException", - "NoSuchScreenModeException", - "WindowException", - "ContextException", - } -) - - -def _is_gl_unavailable(exc): - if type(exc).__module__.startswith("pyglet") and type(exc).__name__ in _GL_UNAVAILABLE_EXC_NAMES: - return True - # Windows CI runners may lack opengl32.dll; pyglet's WGL backend raises - # FileNotFoundError from ctypes.windll.opengl32. On newer Python - # (3.12+) ctypes.LibraryLoader catches that and re-raises - # AttributeError(dll_name). Match narrowly on the dll name so a - # different FileNotFoundError or AttributeError from our own code - # does not match. - if isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc): - return True - # Linux without libGL/libEGL: pyglet raises ImportError with these - # exact messages from pyglet/lib.py. Match them so a genuine GL - # setup is skipped, not failed; a different ImportError from our - # own code does not match. - return isinstance(exc, ImportError) and str(exc) in ( - 'Library "GL" not found.', - 'Library "EGL" not found.', - ) - def _configure_pyglet_headless(): """On headless Linux: enable EGL mode or skip if EGL is absent.""" @@ -100,7 +62,7 @@ def _gl_context(): try: win = _open_gl_window() except Exception as e: - if _is_gl_unavailable(e): + if is_gl_context_unavailable(e): pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") raise diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index bc66551d6a6..e1f9615ea80 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -12,6 +12,7 @@ import numpy as np import pyglet import pytest +from cuda_python_test_helpers.graphics import is_gl_context_unavailable from cuda.core import ( Buffer, @@ -49,45 +50,6 @@ def _register_gl_image(tex_id, target): raise -# pyglet raises these when GL context/window creation fails. Matched by type -# name (not by class) because importing pyglet.gl / pyglet.window at module -# top triggers pyglet's shadow-window creation, which fails on headless -# machines before _configure_pyglet_headless() has set the headless option. -# GLException is intentionally excluded: pyglet raises it after any GL -# call that reports an error (GL_INVALID_ENUM, etc.), so catching it would -# hide real bugs in our own GL allocation code as "GL unavailable" skips. -_GL_UNAVAILABLE_EXC_NAMES = frozenset( - { - "NoSuchDisplayException", - "NoSuchConfigException", - "NoSuchScreenModeException", - "WindowException", - "ContextException", - } -) - - -def _is_gl_unavailable(exc): - if type(exc).__module__.startswith("pyglet") and type(exc).__name__ in _GL_UNAVAILABLE_EXC_NAMES: - return True - # Windows CI runners may lack opengl32.dll; pyglet's WGL backend raises - # FileNotFoundError from ctypes.windll.opengl32. On newer Python - # (3.12+) ctypes.LibraryLoader catches that and re-raises - # AttributeError(dll_name). Match narrowly on the dll name so a - # different FileNotFoundError or AttributeError from our own code - # does not match. - if isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc): - return True - # Linux without libGL/libEGL: pyglet raises ImportError with these - # exact messages from pyglet/lib.py. Match them so a genuine GL - # setup is skipped, not failed; a different ImportError from our - # own code does not match. - return isinstance(exc, ImportError) and str(exc) in ( - 'Library "GL" not found.', - 'Library "EGL" not found.', - ) - - def _configure_pyglet_headless(): """On headless Linux: enable EGL mode or skip if EGL is absent.""" if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): @@ -144,7 +106,7 @@ def _gl_context_and_buffer(nbytes=1024): try: win = _open_gl_window() except Exception as e: - if _is_gl_unavailable(e): + if is_gl_context_unavailable(e): pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") raise @@ -174,7 +136,7 @@ def _gl_context_and_texture(width=16, height=16): try: win = _open_gl_window() except Exception as e: - if _is_gl_unavailable(e): + if is_gl_context_unavailable(e): pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") raise diff --git a/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py b/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py new file mode 100644 index 00000000000..917537b62bb --- /dev/null +++ b/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""GL availability classification for graphics interop tests. + +Both ``cuda_core`` and ``cuda_bindings`` graphics tests need to skip +when the GL backend cannot be made current, and that decision must not +hide real bugs in the tests' own GL allocation code. This module owns the +shared predicate so the two test suites stay in sync. + +The helper intentionally does **not** import ``pyglet``: 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"]``. Classification is by +exception module/name and tightly matched built-in loader errors instead. +""" + +_GL_CONTEXT_UNAVAILABLE_EXC_NAMES = frozenset( + { + "NoSuchDisplayException", + "NoSuchConfigException", + "NoSuchScreenModeException", + "WindowException", + "ContextException", + } +) + +# pyglet raises these from pyglet/lib.py when libGL/libEGL cannot be loaded. +_PYGLET_GL_LIBRARY_IMPORT_ERRORS = frozenset( + { + 'Library "GL" not found.', + 'Library "EGL" not found.', + } +) + + +def is_gl_context_unavailable(exc: BaseException) -> bool: + """Return True if *exc* means "no GL context could be created". + + Returns False for any other exception, so a real bug in the caller's + own GL allocation code (e.g. a ``GLException`` from an invalid-enum GL + call, a ``TypeError`` from wrong argument types) propagates and + fails the test rather than being hidden as a skip. + """ + exc_type = type(exc) + if exc_type.__module__.startswith("pyglet") and exc_type.__name__ in _GL_CONTEXT_UNAVAILABLE_EXC_NAMES: + return True + + # Windows CI runners may lack opengl32.dll; pyglet's WGL backend raises + # FileNotFoundError from ctypes.windll.opengl32. On newer Python + # (3.12+) ctypes.LibraryLoader catches that and re-raises + # AttributeError(dll_name). Match narrowly on the dll name so a + # different FileNotFoundError or AttributeError from our own code + # does not match. + if isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc): + return True + + # Linux without libGL/libEGL: pyglet raises ImportError with the + # exact messages above from pyglet/lib.py. A different ImportError + # from our own code does not match. + return isinstance(exc, ImportError) and str(exc) in _PYGLET_GL_LIBRARY_IMPORT_ERRORS diff --git a/cuda_python_test_helpers/tests/test_graphics.py b/cuda_python_test_helpers/tests/test_graphics.py new file mode 100644 index 00000000000..fc481a859c7 --- /dev/null +++ b/cuda_python_test_helpers/tests/test_graphics.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from cuda_python_test_helpers.graphics import is_gl_context_unavailable + + +class _PygletError(Exception): + pass + + +# Simulate a pyglet-namespaced exception by name. +def _make_pyglet_exc(name): + cls = type(name, (_PygletError,), {}) + cls.__module__ = "pyglet.window" + return cls + + +@pytest.mark.parametrize( + "exc", + [ + _make_pyglet_exc("NoSuchDisplayException")("x"), + _make_pyglet_exc("NoSuchConfigException")("x"), + _make_pyglet_exc("NoSuchScreenModeException")("x"), + _make_pyglet_exc("WindowException")("x"), + _make_pyglet_exc("ContextException")("x"), + FileNotFoundError("Could not find module 'opengl32' (or one of its dependencies)."), + AttributeError("opengl32"), + ImportError('Library "GL" not found.'), + ImportError('Library "EGL" not found.'), + ], +) +def test_is_gl_context_unavailable_accepts_genuine(exc): + assert is_gl_context_unavailable(exc) is True + + +@pytest.mark.parametrize( + "exc", + [ + # pyglet exception names that are not context-creation failures + _make_pyglet_exc("GLException")("GL_INVALID_ENUM"), + _make_pyglet_exc("ImageException")("x"), + # Built-in exceptions that do not mention opengl32 / GL library + TypeError("bug"), + AttributeError("'NoneType' object has no attribute 'Config'"), + FileNotFoundError("No such file: /tmp/missing"), + ImportError("No module named 'foo'"), + OSError("disk full"), + RuntimeError("bug"), + ], +) +def test_is_gl_context_unavailable_rejects_unrelated(exc): + assert is_gl_context_unavailable(exc) is False From 52e4bf66a65a6c12eb7e40952a0b0467b57ded8c Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 09:51:48 -0700 Subject: [PATCH 11/18] fix(tests): nvfatbin probe must catch loader failures, not nvFatbinError Per PR #2714 review (Ralf K): _is_nvfatbin_available caught only nvfatbin.nvFatbinError around nvfatbin.version(), but loading is lazy. An absent libnvfatbin raises cuda.pathfinder.DynamicLibNotFoundError; an absent nvFatbinVersion symbol raises cuda.bindings._internal.utils.FunctionNotFoundError. Neither is an nvFatbinError, so normal unavailability aborted collection of the whole module instead of skipping the two nvfatbin-dependent tests. Catch ImportError, DynamicLibNotFoundError, FunctionNotFoundError as "not available" and let nvFatbinError (a genuine API-status failure from a successfully loaded library) propagate. Update the AGENTS.md capability-probe example to match. --- cuda_core/tests/AGENTS.md | 11 ++++++++++- cuda_core/tests/test_module.py | 13 +++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/cuda_core/tests/AGENTS.md b/cuda_core/tests/AGENTS.md index ee5b3438099..4c538185172 100644 --- a/cuda_core/tests/AGENTS.md +++ b/cuda_core/tests/AGENTS.md @@ -143,17 +143,26 @@ available", and split the checks so each catch is narrow: ```python def _is_nvfatbin_available(): + from cuda.bindings._internal.utils import FunctionNotFoundError + from cuda.pathfinder import DynamicLibNotFoundError + try: from cuda.bindings import nvfatbin except ImportError: return False try: nvfatbin.version() - except nvfatbin.nvFatbinError: + except (DynamicLibNotFoundError, FunctionNotFoundError): + # libnvfatbin not loadable, or nvFatbinVersion symbol missing. return False return True ``` +Catch only the exceptions that mean "not installed / not loadable". +A genuine API-status failure (e.g. `nvfatbin.nvFatbinError` from a +successfully loaded library) must propagate so a real bug is not hidden +as "unavailable". + Do not catch `ImportError` for a hard runtime dependency (e.g. `cuda.bindings` for `cuda.core`) — that is a broken environment and should surface at collection time. diff --git a/cuda_core/tests/test_module.py b/cuda_core/tests/test_module.py index 7da7905c063..3e85101bd85 100644 --- a/cuda_core/tests/test_module.py +++ b/cuda_core/tests/test_module.py @@ -39,14 +39,23 @@ def _is_nvfatbin_available(): - """Check if nvfatbin bindings are available.""" + """Check if nvfatbin bindings are available. + + Catches only the exceptions that mean "not installed / not loadable" + (ImportError, DynamicLibNotFoundError, FunctionNotFoundError). A + genuine nvfatbin API-status failure (nvFatbinError) propagates so a + real bug is not hidden as "unavailable". + """ + from cuda.bindings._internal.utils import FunctionNotFoundError + from cuda.pathfinder import DynamicLibNotFoundError + try: from cuda.bindings import nvfatbin except ImportError: return False try: nvfatbin.version() - except nvfatbin.nvFatbinError: + except (DynamicLibNotFoundError, FunctionNotFoundError): return False return True From fa160c40eee402c4bf1ac4c9ff26e2f11dc7f28d Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 09:53:30 -0700 Subject: [PATCH 12/18] fix(tests): NVRTC version probe must catch loader failures Per PR #2714 review (Ralf K): the suppressed import of nvrtc plus the (AttributeError, CUDAError) catch around nvrtcVersion() had two bugs. - Missing libnvrtc raises cuda.pathfinder.DynamicLibNotFoundError; missing nvrtcVersion raises cuda.bindings._internal.utils.FunctionNotFoundError. Neither is a CUDAError. - If the suppressed import fails, nvrtc stays unbound and nvrtc.nvrtcVersion() raises NameError, not AttributeError. The PCH marker is evaluated at module import time, so these aborted collection instead of reporting PCH/NVRTC as unavailable. Replace the suppressed import with an explicit sentinel under a narrow ImportError catch, then catch the exact dynamic-library and missing-symbol exceptions around nvrtcVersion(). A CUDAError from a successfully loaded version API propagates as a real bug. --- cuda_core/tests/test_program.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/cuda_core/tests/test_program.py b/cuda_core/tests/test_program.py index 040eb7e8e96..ac6610d3d6f 100644 --- a/cuda_core/tests/test_program.py +++ b/cuda_core/tests/test_program.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: Apache-2.0 -import contextlib import re import shutil import subprocess @@ -11,12 +10,14 @@ import pytest +from cuda.bindings._internal.utils import FunctionNotFoundError from cuda.core import _linker from cuda.core._device import Device from cuda.core._module import Kernel, ObjectCode from cuda.core._program import Program, ProgramOptions from cuda.core._utils.cuda_utils import CUDAError, handle_return from cuda.core.typing import CompilerBackendType, PCHStatusType +from cuda.pathfinder import DynamicLibNotFoundError pytest_plugins = ("cuda_python_test_helpers.nvvm_bitcode",) @@ -38,8 +39,14 @@ def _is_nvvm_available(): not _is_nvvm_available(), reason="NVVM not available (libNVVM not found or cuda-bindings < 12.9.0)" ) -with contextlib.suppress(Exception): +# nvrtc is imported lazily (libnvrtc loads on first API call), so an import +# failure means cuda.bindings is missing; a library/symbol failure surfaces on +# nvrtcVersion() below. Establish a sentinel so a missing library does not +# turn into a NameError on the nvrtcVersion() call. +try: from cuda.core._utils.cuda_utils import nvrtc +except ImportError: + nvrtc = None def _get_nvrtc_version_for_tests(): @@ -50,14 +57,15 @@ def _get_nvrtc_version_for_tests(): int: Version in format major * 1000 + minor * 100 (e.g., 13200 for CUDA 13.2) None: If NVRTC is not available """ + if nvrtc is None: + return None try: nvrtc_major, nvrtc_minor = handle_return(nvrtc.nvrtcVersion()) - version = nvrtc_major * 1000 + nvrtc_minor * 100 - return version - except (AttributeError, CUDAError): - # AttributeError: nvrtc not imported (suppressed above) or missing nvrtcVersion. - # CUDAError: driver not loaded or nvrtc call failed. + return nvrtc_major * 1000 + nvrtc_minor * 100 + except (DynamicLibNotFoundError, FunctionNotFoundError): + # libnvrtc not loadable, or nvrtcVersion symbol missing. return None + # CUDAError from a successfully loaded library propagates (real bug). def _has_nvrtc_pch_apis_for_tests(): From ed8b0eaabf6b64fe23a11a305395f494a6dd2227 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 09:54:50 -0700 Subject: [PATCH 13/18] fix(tests): supports_ipc_mempool must not hide driver errors Per PR #2714 review (Ralf K): supports_ipc_mempool caught CUDAError broadly and returned False, so any cuInit or cuDeviceGetAttribute failure (invalid device, deinitialized driver) was silently treated as "IPC unsupported" and callers skipped. Unsupported handle types are normally represented by a successful query whose bitmask lacks the POSIX-FD bit. Inspect the raw CUresult from cuDeviceGetAttribute and treat only CUDA_ERROR_NOT_SUPPORTED as "unsupported"; let other driver errors propagate via handle_return so a real bug is not hidden as a skip. --- cuda_core/tests/helpers/__init__.py | 39 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/cuda_core/tests/helpers/__init__.py b/cuda_core/tests/helpers/__init__.py index ef6c06654ac..bce234881bb 100644 --- a/cuda_core/tests/helpers/__init__.py +++ b/cuda_core/tests/helpers/__init__.py @@ -4,7 +4,7 @@ import functools import os -from cuda.core._utils.cuda_utils import CUDAError, handle_return +from cuda.core._utils.cuda_utils import handle_return from cuda.pathfinder import get_cuda_path_or_home from cuda_python_test_helpers import * @@ -28,27 +28,32 @@ def supports_ipc_mempool(device_id: int | object) -> bool: Uses cuDeviceGetAttribute(CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES) to check for CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR support. Does not require an active CUDA context. + + Only CUDA_ERROR_NOT_SUPPORTED (the documented "attribute not + available" result) is treated as "unsupported"; other driver errors + (invalid device, deinitialized driver) propagate so a real + bug is not hidden as "unsupported". """ if IS_WSL: return False - try: - # Lazy import to avoid hard dependency when not running GPU tests - from cuda.bindings import driver # type: ignore - - # Initialize CUDA - handle_return(driver.cuInit(0)) + # Lazy import to avoid hard dependency when not running GPU tests + from cuda.bindings import driver # type: ignore - # Resolve device id from int or Device-like object - dev_id = int(getattr(device_id, "device_id", device_id)) + # Initialize CUDA + handle_return(driver.cuInit(0)) - # Query supported mempool handle types bitmask - attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES - mask = handle_return(driver.cuDeviceGetAttribute(attr, dev_id)) + # Resolve device id from int or Device-like object + dev_id = int(getattr(device_id, "device_id", device_id)) - # Check POSIX FD handle type support via bitmask - posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR - return (int(mask) & int(posix_fd)) != 0 - except CUDAError: - # cuInit or cuDeviceGetAttribute failed: IPC mempool not usable here. + # Query supported mempool handle types bitmask. Inspect the raw CUresult + # so only the documented "not available" case is treated as unsupported. + attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES + result, mask = driver.cuDeviceGetAttribute(attr, dev_id) + if result == driver.CUresult.CUDA_ERROR_NOT_SUPPORTED: return False + handle_return((result, mask)) # raise CUDAError for other driver errors + + # Check POSIX FD handle type support via bitmask + posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR + return (int(mask) & int(posix_fd)) != 0 From 8463cc500fb0dd1dea8302c085610a1f1773384c Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 09:58:33 -0700 Subject: [PATCH 14/18] fix(tests): clean up partial GL resources on setup failure Per PR #2714 review (Ralf K): the GL setup cleanup began too late. - _open_gl_window: win.switch_to() could fail after the window was constructed but before the protected cleanup region could access it. - _allocate_gl_buffer / _allocate_gl_texture: if glGen* succeeded and a later setup call failed, the generated GL object was not deleted. - In headless mode there is no window close to release the object. Wrap switch_to() in _open_gl_window so it closes a constructed window on failure. Wrap the GL allocation calls after glGen* in _allocate_gl_buffer / _allocate_gl_texture so they delete the generated object on failure. Initialize buf_id / tex_id to None in the context managers so the finally cleanup does not NameError when allocation raises before returning a handle. Use contextlib.suppress for the best-effort cleanup blocks. --- cuda_bindings/tests/test_graphics_apis.py | 57 ++++++++------ cuda_core/tests/test_graphics.py | 90 ++++++++++++++--------- 2 files changed, 93 insertions(+), 54 deletions(-) diff --git a/cuda_bindings/tests/test_graphics_apis.py b/cuda_bindings/tests/test_graphics_apis.py index a547cf3c966..ddf35109f19 100644 --- a/cuda_bindings/tests/test_graphics_apis.py +++ b/cuda_bindings/tests/test_graphics_apis.py @@ -23,14 +23,22 @@ def _configure_pyglet_headless(): def _open_gl_window(): - """Open a hidden window (or configure EGL headless). Returns the window or None.""" + """Open a hidden window (or configure EGL headless). Returns the window or None. + + Closes the window if switch_to() fails so a partially-constructed window does not leak. + """ if not pyglet.options.get("headless"): # Hidden window path (WGL on Windows, GLX/WLS on Linux) from pyglet import gl config = gl.Config(double_buffer=False) win = pyglet.window.Window(visible=False, config=config) - win.switch_to() + try: + win.switch_to() + except Exception: + with contextlib.suppress(Exception): + win.close() + raise return win else: # Headless EGL path; pyglet will arrange a pbuffer-like headless context @@ -40,18 +48,28 @@ def _open_gl_window(): def _allocate_gl_texture(win): - """Allocate a 2-D RGBA8 texture. Caller must have a current GL context.""" + """Allocate a 2-D RGBA8 texture. Caller must have a current GL context. + + Deletes the generated texture if a later GL call fails, so a partial + resource does not leak. + """ from pyglet.gl import gl as _gl tex_id = _gl.GLuint(0) - _gl.glGenTextures(1, ctypes.byref(tex_id)) - target = _gl.GL_TEXTURE_2D - _gl.glBindTexture(target, tex_id.value) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) - width, height = 16, 16 - _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) - return tex_id, target + try: + _gl.glGenTextures(1, ctypes.byref(tex_id)) + target = _gl.GL_TEXTURE_2D + _gl.glBindTexture(target, tex_id.value) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) + width, height = 16, 16 + _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) + return tex_id, target + except Exception: + if tex_id.value: + with contextlib.suppress(Exception): + _gl.glDeleteTextures(1, ctypes.byref(tex_id)) + raise @contextlib.contextmanager @@ -66,23 +84,20 @@ def _gl_context(): pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") raise + tex_id = None try: tex_id, target = _allocate_gl_texture(win) yield int(tex_id.value), int(target) finally: - # Best-effort cleanup - try: - from pyglet.gl import gl as _gl + if tex_id is not None: + with contextlib.suppress(Exception): + from pyglet.gl import gl as _gl - if tex_id.value: - _gl.glDeleteTextures(1, ctypes.byref(tex_id)) - except Exception: # noqa: S110 - pass - try: + if tex_id.value: + _gl.glDeleteTextures(1, ctypes.byref(tex_id)) + with contextlib.suppress(Exception): if win is not None: win.close() - except Exception: # noqa: S110 - pass @pytest.mark.parametrize( diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index e1f9615ea80..8a63aa4254d 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -59,13 +59,21 @@ def _configure_pyglet_headless(): def _open_gl_window(): - """Open a hidden window (or configure EGL headless). Returns the window or None.""" + """Open a hidden window (or configure EGL headless). Returns the window or None. + + Closes the window if switch_to() fails so a partially-constructed window does not leak. + """ if not pyglet.options.get("headless"): from pyglet import gl config = gl.Config(double_buffer=False) win = pyglet.window.Window(visible=False, config=config) - win.switch_to() + try: + win.switch_to() + except Exception: + with contextlib.suppress(Exception): + win.close() + raise return win else: from pyglet.gl import headless # noqa: F401 @@ -74,28 +82,48 @@ def _open_gl_window(): def _allocate_gl_buffer(win, nbytes): - """Allocate a GL buffer. Caller must have a current GL context.""" + """Allocate a GL buffer. Caller must have a current GL context. + + Deletes the generated buffer if a later GL call fails, so a partial + resource does not leak. + """ from pyglet.gl import gl as _gl buf_id = _gl.GLuint(0) - _gl.glGenBuffers(1, ctypes.byref(buf_id)) - _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) - _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) - return buf_id + try: + _gl.glGenBuffers(1, ctypes.byref(buf_id)) + _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) + _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) + return buf_id + except Exception: + if buf_id.value: + with contextlib.suppress(Exception): + _gl.glDeleteBuffers(1, ctypes.byref(buf_id)) + raise def _allocate_gl_texture(win, width, height): - """Allocate a 2-D RGBA8 texture. Caller must have a current GL context.""" + """Allocate a 2-D RGBA8 texture. Caller must have a current GL context. + + Deletes the generated texture if a later GL call fails, so a partial + resource does not leak. + """ from pyglet.gl import gl as _gl tex_id = _gl.GLuint(0) - _gl.glGenTextures(1, ctypes.byref(tex_id)) - target = _gl.GL_TEXTURE_2D - _gl.glBindTexture(target, tex_id.value) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) - _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) - return tex_id, target + try: + _gl.glGenTextures(1, ctypes.byref(tex_id)) + target = _gl.GL_TEXTURE_2D + _gl.glBindTexture(target, tex_id.value) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) + _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) + return tex_id, target + except Exception: + if tex_id.value: + with contextlib.suppress(Exception): + _gl.glDeleteTextures(1, ctypes.byref(tex_id)) + raise @contextlib.contextmanager @@ -110,22 +138,20 @@ def _gl_context_and_buffer(nbytes=1024): pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") raise + buf_id = None try: buf_id = _allocate_gl_buffer(win, nbytes) yield int(buf_id.value), nbytes finally: - try: - from pyglet.gl import gl as _gl + if buf_id is not None: + with contextlib.suppress(Exception): + from pyglet.gl import gl as _gl - if buf_id.value: - _gl.glDeleteBuffers(1, ctypes.byref(buf_id)) - except Exception: # noqa: S110 - pass - try: + if buf_id.value: + _gl.glDeleteBuffers(1, ctypes.byref(buf_id)) + with contextlib.suppress(Exception): if win is not None: win.close() - except Exception: # noqa: S110 - pass @contextlib.contextmanager @@ -140,22 +166,20 @@ def _gl_context_and_texture(width=16, height=16): pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") raise + tex_id = None try: tex_id, target = _allocate_gl_texture(win, width, height) yield int(tex_id.value), int(target) finally: - try: - from pyglet.gl import gl as _gl + if tex_id is not None: + with contextlib.suppress(Exception): + from pyglet.gl import gl as _gl - if tex_id.value: - _gl.glDeleteTextures(1, ctypes.byref(tex_id)) - except Exception: # noqa: S110 - pass - try: + if tex_id.value: + _gl.glDeleteTextures(1, ctypes.byref(tex_id)) + with contextlib.suppress(Exception): if win is not None: win.close() - except Exception: # noqa: S110 - pass # --------------------------------------------------------------------------- From 83a9f5ebbbf6d3fcfe1a870871ed6ee9ab9f41cc Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 09:59:37 -0700 Subject: [PATCH 15/18] refactor(tests): share conditional-handle skip helper Per PR #2714 review (Ralf K): the conditional-handle diagnostic skip was duplicated between test_graph_definition_integration.py and helpers/graph_kernels.py, and the two copies already differed (one caught CUDAError and accepted two spellings; the other caught NVRTCError and accepted one). Extract skip_if_nvrtc_lacks_conditional_handle into helpers/graph_kernels.py (with the two narrow diagnostic phrases) and reuse it from both compile_conditional_kernels and _compile_heat_kernels / _compile_bisect_kernels. A genuine compile error re-raises so a real bug is not hidden as a skip. --- .../test_graph_definition_integration.py | 24 ++------------ cuda_core/tests/helpers/graph_kernels.py | 32 ++++++++++++++++--- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/cuda_core/tests/graph/test_graph_definition_integration.py b/cuda_core/tests/graph/test_graph_definition_integration.py index df6eee57f4b..1adce901d16 100644 --- a/cuda_core/tests/graph/test_graph_definition_integration.py +++ b/cuda_core/tests/graph/test_graph_definition_integration.py @@ -7,6 +7,7 @@ import numpy as np import pytest +from helpers.graph_kernels import skip_if_nvrtc_lacks_conditional_handle from helpers.memory import xfail_on_graph_mempool_oom from cuda.core import Device, EventOptions, LaunchConfig, Program, ProgramOptions @@ -121,25 +122,6 @@ def _nvrtc_opts(): return ProgramOptions(std="c++17", arch=f"sm_{arch}") -# NVRTC diagnostic phrases that indicate cudaGraphConditionalHandle itself is unknown -# to the compiler (older NVRTC builds predate the type). Matched narrowly so a -# genuine compile error (syntax error, etc.) is not hidden as a skip. -# Phrase #1 is the exact diagnostic observed on this machine's NVRTC; phrase #2 -# is a common clang/NVRTC wording for an unknown type, not verified against the -# cudaGraphConditionalHandle case on an old NVRTC build. -_COND_HANDLE_UNKNOWN = ( - 'identifier "cudaGraphConditionalHandle" is undefined', - 'unknown type name "cudaGraphConditionalHandle"', -) - - -def _skip_if_nvrtc_lacks_conditional_handle(exc): - msg = str(exc) - if any(phrase in msg for phrase in _COND_HANDLE_UNKNOWN): - pytest.skip("NVRTC does not support cudaGraphConditionalHandle") - raise - - def _compile_heat_kernels(): prog = Program(_HEAT_KERNEL_SOURCE, code_type="c++", options=_nvrtc_opts()) try: @@ -148,7 +130,7 @@ def _compile_heat_kernels(): name_expressions=("heat_step", "countdown"), ) except CUDAError as exc: - _skip_if_nvrtc_lacks_conditional_handle(exc) + skip_if_nvrtc_lacks_conditional_handle(exc) raise return mod.get_kernel("heat_step"), mod.get_kernel("countdown") @@ -166,7 +148,7 @@ def _compile_bisect_kernels(): try: mod = prog.compile("cubin", name_expressions=names) except CUDAError as exc: - _skip_if_nvrtc_lacks_conditional_handle(exc) + skip_if_nvrtc_lacks_conditional_handle(exc) raise return tuple(mod.get_kernel(n) for n in names) diff --git a/cuda_core/tests/helpers/graph_kernels.py b/cuda_core/tests/helpers/graph_kernels.py index d08837585fe..fec6c7b2d82 100644 --- a/cuda_core/tests/helpers/graph_kernels.py +++ b/cuda_core/tests/helpers/graph_kernels.py @@ -12,6 +12,30 @@ from cuda.core import Device, Program, ProgramOptions from cuda.core._utils.cuda_utils import NVRTCError, handle_return +# NVRTC diagnostic phrases that indicate cudaGraphConditionalHandle itself is unknown +# to the compiler (older NVRTC builds predate the type). Matched narrowly so a +# genuine compile error (syntax error, etc.) is not hidden as a skip. +# Phrase #1 is the exact diagnostic observed on this machine's NVRTC; phrase #2 +# is a common clang/NVRTC wording for an unknown type, not verified against the +# cudaGraphConditionalHandle case on an old NVRTC build. +_COND_HANDLE_UNKNOWN = ( + 'identifier "cudaGraphConditionalHandle" is undefined', + 'unknown type name "cudaGraphConditionalHandle"', +) + + +def skip_if_nvrtc_lacks_conditional_handle(exc): + """Skip when *exc* means NVRTC predates cudaGraphConditionalHandle. + + Catches only the documented "type unknown" cases; a genuine compile + error (syntax error, etc.) re-raises so a real bug is not hidden as a skip. + """ + msg = str(exc) + if any(phrase in msg for phrase in _COND_HANDLE_UNKNOWN): + nvrtc_version = handle_return(nvrtc.nvrtcVersion()) + pytest.skip(f"NVRTC version {nvrtc_version} does not support conditionals") + raise + def compile_common_kernels(): """Compile basic kernels for graph tests. @@ -78,11 +102,9 @@ def compile_conditional_kernels(cond_type): prog = Program(code, code_type="c++", options=program_options) try: mod = prog.compile("cubin", name_expressions=("empty_kernel", "add_one", "set_handle", "loop_kernel")) - except NVRTCError as e: - with pytest.raises(NVRTCError, match='error: identifier "cudaGraphConditionalHandle" is undefined'): - raise e - nvrtcVersion = handle_return(nvrtc.nvrtcVersion()) - pytest.skip(f"NVRTC version {nvrtcVersion} does not support conditionals") + except NVRTCError as exc: + skip_if_nvrtc_lacks_conditional_handle(exc) + raise return mod From b608b6470afd0b2495c6d23fc1a41d5e63636f65 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 11:20:24 -0700 Subject: [PATCH 16/18] docs(tests): sharpen AGENTS.md test guidance Apply the guidance updates suggested during PR #2714 review: - "Skip only real setup failures": note the example exception names are illustrative, add GLException to an explicit "do not include" list, and list the platform-specific "library not loadable" manifestations (Linux ImportError for GL/EGL, Windows FileNotFoundError/AttributeError for opengl32). - "Capability probes": add an example of a probe that inspects a raw CUresult for a documented availability code (the supports_ipc_mempool fix). - New section "Clean up partial setup on failure": if setup allocates a resource and a later step fails, clean up the partial resource before re-raising. - "Shared test support": search tests/helpers and cuda_python_test_helpers for prior art before adding a new helper; consolidate duplicates across packages into cuda_python_test_helpers. --- cuda_core/tests/AGENTS.md | 65 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/cuda_core/tests/AGENTS.md b/cuda_core/tests/AGENTS.md index 4c538185172..f4e0f240740 100644 --- a/cuda_core/tests/AGENTS.md +++ b/cuda_core/tests/AGENTS.md @@ -83,6 +83,11 @@ Follow these rules when adding or moving shared test code: `tests/helpers/` instead. - Import helpers explicitly from the test root, for example: `from helpers.memory import create_managed_memory_resource_or_skip`. +- Search `tests/helpers/` and `cuda_python_test_helpers/` for prior art + before adding a new helper; consolidate duplicates across + + packages into `cuda_python_test_helpers` (both `cuda_core` and + `cuda_bindings` test environments already depend on it). - Fixtures in a nested `conftest.py` are available to tests in its directory and descendants; fixtures from applicable parent `conftest.py` files remain available. @@ -116,6 +121,19 @@ def _gl_context(): _cleanup(win, tex_id) ``` +The exception names in the example are illustrative — `GLContextError` +is not a real pyglet class. Match real pyglet exception names by type, or +use the shared `is_gl_context_unavailable` helper in +`cuda_python_test_helpers.graphics`. + +`GLException` is pyglet's generic GL-error class, raised after any GL call that reports an error +(`GL_INVALID_ENUM`, etc.). Do **not** include it in the "GL unavailable" set — it hides real bugs in GL allocation code as skips. + +Platform-specific "library not loadable" manifestations (genuine "GL unavailable"): + +- Linux without libGL/libEGL: `ImportError('Library "GL" not found.')` / `ImportError('Library "EGL" not found.')` from `pyglet/lib.py`. +- Windows without opengl32.dll: `FileNotFoundError` from `ctypes.windll.opengl32`; on Python 3.12+ `ctypes.LibraryLoader` re-raises `AttributeError("opengl32")`. + When a CUDA call's error means "feature refused by this driver" (e.g. `CUDA_ERROR_OPERATING_SYSTEM` for CUDA-GL interop on WSL), skip at the call site with a narrow catch on the specific error, not inside the GL helper — @@ -163,10 +181,57 @@ A genuine API-status failure (e.g. `nvfatbin.nvFatbinError` from a successfully loaded library) must propagate so a real bug is not hidden as "unavailable". +For a probe that calls a CUDA API returning a `CUresult`, inspect the raw result and +classify only the documented "not available" code as `False`; let +other failures propagate so a real driver bug is not hidden as "unsupported": + +```python +@functools.cache +def supports_ipc_mempool(device_id): + from cuda.bindings import driver + + handle_return(driver.cuInit(0)) + dev_id = int(getattr(device_id, "device_id", device_id)) + attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES + result, mask = driver.cuDeviceGetAttribute(attr, dev_id) + if result == driver.CUresult.CUDA_ERROR_NOT_SUPPORTED: + return False + handle_return((result, mask)) # raise CUDAError for other driver errors + posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR + return (int(mask) & int(posix_fd)) != 0 +``` + Do not catch `ImportError` for a hard runtime dependency (e.g. `cuda.bindings` for `cuda.core`) — that is a broken environment and should surface at collection time. +## Clean up partial setup on failure + +If a setup step allocates a resource (GL object, window, file handle) and a later +step fails, clean up the partial resource before re-raising so it does not +leak. Wrap the allocation in `try/except` and delete the generated object in +the `except` before re-raising: + +```python +def _allocate_gl_buffer(win, nbytes): + from pyglet.gl import gl as _gl + + buf_id = _gl.GLuint(0) + try: + _gl.glGenBuffers(1, ctypes.byref(buf_id)) + _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) + _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) + return buf_id + except Exception: + if buf_id.value: + with contextlib.suppress(Exception): + _gl.glDeleteBuffers(1, ctypes.byref(buf_id)) + raise +``` + +Initialize handles to `None` before the protected region so the `finally` +cleanup does not `NameError` when allocation raises before returning a handle. + ## Tests that touch CUDA must establish their own context The `init_cuda` fixture pops the CUDA context on teardown, so a test From 7028efd78486af9d549705efb970f97b1692568b Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 12:01:41 -0700 Subject: [PATCH 17/18] Re-trigger pre-commit on Windows (trufflehog flaky) From 3d36ecb3f9f7841cd148dd2da3ea46a4718275be Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Fri, 28 Aug 2026 14:13:57 -0700 Subject: [PATCH 18/18] fix(tests): address PR #2714 review findings 1, 3, 4, 5 - Finding 1 (IPC probe): supports_ipc_mempool inspected the raw CUresult and special-cased CUDA_ERROR_NOT_SUPPORTED. Ralf says unsupported handle types are represented by a successful bitmask without the POSIX-FD bit, so the special case is unnecessary. Remove it; let handle_return raise on any non-success and rely on the bitmask check. Update AGENTS.md example to match. - Finding 3 (headless EGL): is_gl_context_unavailable rejected MissingFunctionException (pyglet raises it when libEGL exists but eglQueryDevicesEXT / eglGetPlatformDisplayEXT entry points do not). Add it to the recognized set. - Finding 4 (NVRTC sentinel): test_program caught ImportError for nvrtc but cuda_utils already imports nvrtc at module level, so the ImportError catch was dead code. Import nvrtc directly alongside CUDAError/handle_return and keep only the loader/symbol catches around nvrtcVersion(). - Finding 5 (provenance): move the is_gl_context_unavailable tests from cuda_python_test_helpers/tests/test_graphics.py to cuda_core/tests/test_helpers.py with human_reviewed markers, so CI actually runs them. Drop the source file. --- cuda_core/tests/AGENTS.md | 11 ++-- cuda_core/tests/helpers/__init__.py | 17 +++--- cuda_core/tests/test_helpers.py | 59 +++++++++++++++++++ cuda_core/tests/test_program.py | 13 +--- .../cuda_python_test_helpers/graphics.py | 3 + .../tests/test_graphics.py | 54 ----------------- 6 files changed, 74 insertions(+), 83 deletions(-) delete mode 100644 cuda_python_test_helpers/tests/test_graphics.py diff --git a/cuda_core/tests/AGENTS.md b/cuda_core/tests/AGENTS.md index f4e0f240740..a52ddb4032d 100644 --- a/cuda_core/tests/AGENTS.md +++ b/cuda_core/tests/AGENTS.md @@ -181,9 +181,9 @@ A genuine API-status failure (e.g. `nvfatbin.nvFatbinError` from a successfully loaded library) must propagate so a real bug is not hidden as "unavailable". -For a probe that calls a CUDA API returning a `CUresult`, inspect the raw result and -classify only the documented "not available" code as `False`; let -other failures propagate so a real driver bug is not hidden as "unsupported": +For a probe that calls a CUDA API returning a `CUresult`, let `handle_return` +raise on any non-success result and classify only a successful bitmask +lacking the documented bit as `False`; other failures propagate so a real driver bug is not hidden as "unsupported": ```python @functools.cache @@ -193,10 +193,7 @@ def supports_ipc_mempool(device_id): handle_return(driver.cuInit(0)) dev_id = int(getattr(device_id, "device_id", device_id)) attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES - result, mask = driver.cuDeviceGetAttribute(attr, dev_id) - if result == driver.CUresult.CUDA_ERROR_NOT_SUPPORTED: - return False - handle_return((result, mask)) # raise CUDAError for other driver errors + mask = handle_return(driver.cuDeviceGetAttribute(attr, dev_id)) posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR return (int(mask) & int(posix_fd)) != 0 ``` diff --git a/cuda_core/tests/helpers/__init__.py b/cuda_core/tests/helpers/__init__.py index bce234881bb..95b6bce9341 100644 --- a/cuda_core/tests/helpers/__init__.py +++ b/cuda_core/tests/helpers/__init__.py @@ -29,10 +29,10 @@ def supports_ipc_mempool(device_id: int | object) -> bool: to check for CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR support. Does not require an active CUDA context. - Only CUDA_ERROR_NOT_SUPPORTED (the documented "attribute not - available" result) is treated as "unsupported"; other driver errors - (invalid device, deinitialized driver) propagate so a real - bug is not hidden as "unsupported". + Unsupported handle types are represented by a successful query whose bitmask + lacks the POSIX-FD bit, so the check below naturally returns False. + Other driver errors (invalid device, deinitialized driver) propagate via + handle_return so a real bug is not hidden as "unsupported". """ if IS_WSL: return False @@ -46,13 +46,10 @@ def supports_ipc_mempool(device_id: int | object) -> bool: # Resolve device id from int or Device-like object dev_id = int(getattr(device_id, "device_id", device_id)) - # Query supported mempool handle types bitmask. Inspect the raw CUresult - # so only the documented "not available" case is treated as unsupported. + # Query supported mempool handle types bitmask. Unsupported handle types are + # represented by a successful query whose bitmask lacks the POSIX-FD bit. attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES - result, mask = driver.cuDeviceGetAttribute(attr, dev_id) - if result == driver.CUresult.CUDA_ERROR_NOT_SUPPORTED: - return False - handle_return((result, mask)) # raise CUDAError for other driver errors + mask = handle_return(driver.cuDeviceGetAttribute(attr, dev_id)) # Check POSIX FD handle type support via bitmask posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR diff --git a/cuda_core/tests/test_helpers.py b/cuda_core/tests/test_helpers.py index 77f8f416c47..8fc7b8bb75a 100644 --- a/cuda_core/tests/test_helpers.py +++ b/cuda_core/tests/test_helpers.py @@ -351,3 +351,62 @@ def test_oom_diagnostics_probe_basics_is_live_and_cheap(init_cuda): assert snapshot.pool_va_ok is None assert snapshot.get_mem_pool_ok is None assert snapshot.capped_pool_create_ok is None + + +# --------------------------------------------------------------------------- +# GL context availability predicate tests +# --------------------------------------------------------------------------- + +import pytest +from cuda_python_test_helpers.graphics import is_gl_context_unavailable + + +class _PygletError(Exception): + pass + + +# Simulate a pyglet-namespaced exception by name. +def _make_pyglet_exc(name, module="pyglet.window"): + cls = type(name, (_PygletError,), {}) + cls.__module__ = module + return cls + + +@pytest.mark.human_reviewed +@pytest.mark.parametrize( + "exc", + [ + _make_pyglet_exc("NoSuchDisplayException")("x"), + _make_pyglet_exc("NoSuchConfigException")("x"), + _make_pyglet_exc("NoSuchScreenModeException")("x"), + _make_pyglet_exc("WindowException")("x"), + _make_pyglet_exc("ContextException")("x"), + _make_pyglet_exc("MissingFunctionException", module="pyglet.gl.lib")("x"), + FileNotFoundError("Could not find module 'opengl32' (or one of its dependencies)."), + AttributeError("opengl32"), + ImportError('Library "GL" not found.'), + ImportError('Library "EGL" not found.'), + ], +) +def test_is_gl_context_unavailable_accepts_genuine(exc): + assert is_gl_context_unavailable(exc) is True + + +@pytest.mark.human_reviewed +@pytest.mark.parametrize( + "exc", + [ + # pyglet exception names that are not context-creation failures + _make_pyglet_exc("GLException")("GL_INVALID_ENUM"), + _make_pyglet_exc("ImageException")("x"), + # Built-in exceptions that do not mention opengl32 / GL library + TypeError("bug"), + AttributeError("'NoneType' object has no attribute 'Config'"), + FileNotFoundError("No such file: /tmp/missing"), + ImportError("No module named 'foo'"), + OSError("disk full"), + RuntimeError("bug"), + ], +) +def test_is_gl_context_unavailable_rejects_unrelated(exc): + assert is_gl_context_unavailable(exc) is False diff --git a/cuda_core/tests/test_program.py b/cuda_core/tests/test_program.py index ac6610d3d6f..8c6dfae8467 100644 --- a/cuda_core/tests/test_program.py +++ b/cuda_core/tests/test_program.py @@ -15,7 +15,7 @@ from cuda.core._device import Device from cuda.core._module import Kernel, ObjectCode from cuda.core._program import Program, ProgramOptions -from cuda.core._utils.cuda_utils import CUDAError, handle_return +from cuda.core._utils.cuda_utils import CUDAError, handle_return, nvrtc from cuda.core.typing import CompilerBackendType, PCHStatusType from cuda.pathfinder import DynamicLibNotFoundError @@ -39,15 +39,6 @@ def _is_nvvm_available(): not _is_nvvm_available(), reason="NVVM not available (libNVVM not found or cuda-bindings < 12.9.0)" ) -# nvrtc is imported lazily (libnvrtc loads on first API call), so an import -# failure means cuda.bindings is missing; a library/symbol failure surfaces on -# nvrtcVersion() below. Establish a sentinel so a missing library does not -# turn into a NameError on the nvrtcVersion() call. -try: - from cuda.core._utils.cuda_utils import nvrtc -except ImportError: - nvrtc = None - def _get_nvrtc_version_for_tests(): """ @@ -57,8 +48,6 @@ def _get_nvrtc_version_for_tests(): int: Version in format major * 1000 + minor * 100 (e.g., 13200 for CUDA 13.2) None: If NVRTC is not available """ - if nvrtc is None: - return None try: nvrtc_major, nvrtc_minor = handle_return(nvrtc.nvrtcVersion()) return nvrtc_major * 1000 + nvrtc_minor * 100 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 917537b62bb..0f25554a111 100644 --- a/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py +++ b/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py @@ -23,6 +23,9 @@ "NoSuchScreenModeException", "WindowException", "ContextException", + # Pyglet's headless display raises MissingFunctionException when libEGL + # exists but eglQueryDevicesEXT / eglGetPlatformDisplayEXT entry points do not. + "MissingFunctionException", } ) diff --git a/cuda_python_test_helpers/tests/test_graphics.py b/cuda_python_test_helpers/tests/test_graphics.py deleted file mode 100644 index fc481a859c7..00000000000 --- a/cuda_python_test_helpers/tests/test_graphics.py +++ /dev/null @@ -1,54 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -import pytest -from cuda_python_test_helpers.graphics import is_gl_context_unavailable - - -class _PygletError(Exception): - pass - - -# Simulate a pyglet-namespaced exception by name. -def _make_pyglet_exc(name): - cls = type(name, (_PygletError,), {}) - cls.__module__ = "pyglet.window" - return cls - - -@pytest.mark.parametrize( - "exc", - [ - _make_pyglet_exc("NoSuchDisplayException")("x"), - _make_pyglet_exc("NoSuchConfigException")("x"), - _make_pyglet_exc("NoSuchScreenModeException")("x"), - _make_pyglet_exc("WindowException")("x"), - _make_pyglet_exc("ContextException")("x"), - FileNotFoundError("Could not find module 'opengl32' (or one of its dependencies)."), - AttributeError("opengl32"), - ImportError('Library "GL" not found.'), - ImportError('Library "EGL" not found.'), - ], -) -def test_is_gl_context_unavailable_accepts_genuine(exc): - assert is_gl_context_unavailable(exc) is True - - -@pytest.mark.parametrize( - "exc", - [ - # pyglet exception names that are not context-creation failures - _make_pyglet_exc("GLException")("GL_INVALID_ENUM"), - _make_pyglet_exc("ImageException")("x"), - # Built-in exceptions that do not mention opengl32 / GL library - TypeError("bug"), - AttributeError("'NoneType' object has no attribute 'Config'"), - FileNotFoundError("No such file: /tmp/missing"), - ImportError("No module named 'foo'"), - OSError("disk full"), - RuntimeError("bug"), - ], -) -def test_is_gl_context_unavailable_rejects_unrelated(exc): - assert is_gl_context_unavailable(exc) is False