Skip to content

Commit 996b0a0

Browse files
authored
cuda.core: fix a few test issues (#2714)
Address PR #2714 review findings 1-8 and sharpen the test guidance: * Drop GLException from the GL-unavailable set; restrict the skip catch to context creation so GL allocation errors propagate. * Recognize Linux GL/EGL ImportError and Windows opengl32 FileNotFoundError/AttributeError as genuine GL-unavailable cases. * Consolidate the GL availability predicate into cuda_python_test_helpers/graphics.py with focused tests. * Fix supports_ipc_mempool to inspect the raw CUresult; only CUDA_ERROR_NOT_SUPPORTED means unsupported. * Fix nvfatbin and NVRTC probes to catch loader/symbol failures (DynamicLibNotFoundError/FunctionNotFoundError), let genuine API errors propagate (2, 3). * Clean up partial GL resources on setup failure. * Share the conditional-handle skip helper between helpers/graph_kernels.py and the integration tests. * Move is_gl_context_unavailable tests to cuda_core/tests/test_helpers.py with provenance markers so CI runs them. * Update AGENTS.md with the corrected examples and new guidance.
1 parent 5c53b84 commit 996b0a0

12 files changed

Lines changed: 484 additions & 142 deletions

File tree

cuda_bindings/tests/test_graphics_apis.py

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,73 +9,95 @@
99

1010
import pyglet
1111
import pytest
12+
from cuda_python_test_helpers.graphics import is_gl_context_unavailable
1213

1314
from cuda.bindings import runtime as cudart
1415

1516

16-
def _configure_pyglet_headless(pyglet):
17+
def _configure_pyglet_headless():
1718
"""On headless Linux: enable EGL mode or skip if EGL is absent."""
1819
if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
1920
if ctypes.util.find_library("EGL") is None:
2021
pytest.skip("No DISPLAY and no EGL runtime available for headless context.")
2122
pyglet.options["headless"] = True
2223

2324

24-
def _setup_gl_texture(pyglet):
25-
"""Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target)."""
25+
def _open_gl_window():
26+
"""Open a hidden window (or configure EGL headless). Returns the window or None.
27+
28+
Closes the window if switch_to() fails so a partially-constructed window does not leak.
29+
"""
2630
if not pyglet.options.get("headless"):
2731
# Hidden window path (WGL on Windows, GLX/WLS on Linux)
2832
from pyglet import gl
2933

3034
config = gl.Config(double_buffer=False)
3135
win = pyglet.window.Window(visible=False, config=config)
32-
win.switch_to()
36+
try:
37+
win.switch_to()
38+
except Exception:
39+
with contextlib.suppress(Exception):
40+
win.close()
41+
raise
42+
return win
3343
else:
3444
# Headless EGL path; pyglet will arrange a pbuffer-like headless context
3545
from pyglet.gl import headless # noqa: F401
3646

37-
win = None
47+
return None
48+
49+
50+
def _allocate_gl_texture(win):
51+
"""Allocate a 2-D RGBA8 texture. Caller must have a current GL context.
3852
39-
# Make a tiny texture so we have a real GL object to register
53+
Deletes the generated texture if a later GL call fails, so a partial
54+
resource does not leak.
55+
"""
4056
from pyglet.gl import gl as _gl
4157

4258
tex_id = _gl.GLuint(0)
43-
_gl.glGenTextures(1, ctypes.byref(tex_id))
44-
target = _gl.GL_TEXTURE_2D
45-
_gl.glBindTexture(target, tex_id.value)
46-
_gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST)
47-
_gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST)
48-
width, height = 16, 16
49-
_gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None)
50-
return win, tex_id, target
59+
try:
60+
_gl.glGenTextures(1, ctypes.byref(tex_id))
61+
target = _gl.GL_TEXTURE_2D
62+
_gl.glBindTexture(target, tex_id.value)
63+
_gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST)
64+
_gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST)
65+
width, height = 16, 16
66+
_gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None)
67+
return tex_id, target
68+
except Exception:
69+
if tex_id.value:
70+
with contextlib.suppress(Exception):
71+
_gl.glDeleteTextures(1, ctypes.byref(tex_id))
72+
raise
5173

5274

5375
@contextlib.contextmanager
5476
def _gl_context():
5577
"""Yield ``(tex_id, tex_target)`` with a current GL context, or skip if GL is unavailable."""
56-
_configure_pyglet_headless(pyglet)
78+
_configure_pyglet_headless()
5779

5880
try:
59-
win, tex_id, target = _setup_gl_texture(pyglet)
81+
win = _open_gl_window()
6082
except Exception as e:
61-
pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}")
83+
if is_gl_context_unavailable(e):
84+
pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}")
85+
raise
6286

87+
tex_id = None
6388
try:
89+
tex_id, target = _allocate_gl_texture(win)
6490
yield int(tex_id.value), int(target)
6591
finally:
66-
# Best-effort cleanup
67-
try:
68-
from pyglet.gl import gl as _gl
92+
if tex_id is not None:
93+
with contextlib.suppress(Exception):
94+
from pyglet.gl import gl as _gl
6995

70-
if tex_id.value:
71-
_gl.glDeleteTextures(1, ctypes.byref(tex_id))
72-
except Exception: # noqa: S110
73-
pass
74-
try:
96+
if tex_id.value:
97+
_gl.glDeleteTextures(1, ctypes.byref(tex_id))
98+
with contextlib.suppress(Exception):
7599
if win is not None:
76100
win.close()
77-
except Exception: # noqa: S110
78-
pass
79101

80102

81103
@pytest.mark.parametrize(

cuda_core/tests/AGENTS.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,166 @@ Follow these rules when adding or moving shared test code:
8383
`tests/helpers/` instead.
8484
- Import helpers explicitly from the test root, for example:
8585
`from helpers.memory import create_managed_memory_resource_or_skip`.
86+
- Search `tests/helpers/` and `cuda_python_test_helpers/` for prior art
87+
before adding a new helper; consolidate duplicates across
88+
89+
packages into `cuda_python_test_helpers` (both `cuda_core` and
90+
`cuda_bindings` test environments already depend on it).
8691
- Fixtures in a nested `conftest.py` are available to tests in its directory
8792
and descendants; fixtures from applicable parent `conftest.py` files remain
8893
available.
8994
- Do not add `__init__.py` solely because a test directory contains a
9095
`conftest.py`.
9196
- In directories without `__init__.py`, keep test-module basenames unique
9297
within this test suite.
98+
99+
## Skip only real setup failures
100+
101+
`pytest.skip(reason)` records the test as SKIPPED with `reason` in the
102+
report. A helper that wraps `yield` in `except Exception: pytest.skip(...)`
103+
therefore records every test-body failure as a skip — a real regression, a
104+
`TypeError`, an `AttributeError` all become "SKIPPED: <reason>" instead of
105+
"FAILED", and the suite goes green regardless of whether the code under
106+
test works.
107+
108+
Catch only the specific exception that legitimately means "not available",
109+
and only around the setup call — never around `yield`:
110+
111+
```python
112+
@contextlib.contextmanager
113+
def _gl_context():
114+
try:
115+
win, tex_id = _setup_gl_texture() # setup only
116+
except (pyglet.NoSuchConfigException, GLContextError) as e:
117+
pytest.skip(f"GL unavailable: {e}")
118+
try:
119+
yield tex_id # body exceptions propagate
120+
finally:
121+
_cleanup(win, tex_id)
122+
```
123+
124+
The exception names in the example are illustrative — `GLContextError`
125+
is not a real pyglet class. Match real pyglet exception names by type, or
126+
use the shared `is_gl_context_unavailable` helper in
127+
`cuda_python_test_helpers.graphics`.
128+
129+
`GLException` is pyglet's generic GL-error class, raised after any GL call that reports an error
130+
(`GL_INVALID_ENUM`, etc.). Do **not** include it in the "GL unavailable" set — it hides real bugs in GL allocation code as skips.
131+
132+
Platform-specific "library not loadable" manifestations (genuine "GL unavailable"):
133+
134+
- Linux without libGL/libEGL: `ImportError('Library "GL" not found.')` / `ImportError('Library "EGL" not found.')` from `pyglet/lib.py`.
135+
- Windows without opengl32.dll: `FileNotFoundError` from `ctypes.windll.opengl32`; on Python 3.12+ `ctypes.LibraryLoader` re-raises `AttributeError("opengl32")`.
136+
137+
When a CUDA call's error means "feature refused by this driver" (e.g.
138+
`CUDA_ERROR_OPERATING_SYSTEM` for CUDA-GL interop on WSL), skip at the call
139+
site with a narrow catch on the specific error, not inside the GL helper —
140+
see `_register_gl_buffer` / `_register_gl_image` in `tests/test_graphics.py`.
141+
142+
## `importorskip` is for optional dependencies only
143+
144+
`pytest.importorskip("X")` is correct when `X` is genuinely optional
145+
(platform-gated binding, parametrized "test each available module"). It is
146+
dead code when `X` is a declared test or runtime dependency: the skip then
147+
fires only when the environment is broken, which is the case you want to fail
148+
loudly, not hide. Use a bare top-level `import` for declared deps.
149+
150+
Before adding `importorskip`, check `cuda_core/pyproject.toml`'s `test`
151+
and `test-cu*` groups and `cuda_core`'s `dependencies`. If the target is
152+
listed, import it directly.
153+
154+
## Capability probes must not swallow real bugs
155+
156+
A probe function that answers "is feature X available?" by catching
157+
`Exception` and returning `False` will report "not available" even when the
158+
probed API failed for a real, unexpected reason — silently enabling a skip
159+
that hides the bug. Catch only the exception that genuinely means "not
160+
available", and split the checks so each catch is narrow:
161+
162+
```python
163+
def _is_nvfatbin_available():
164+
from cuda.bindings._internal.utils import FunctionNotFoundError
165+
from cuda.pathfinder import DynamicLibNotFoundError
166+
167+
try:
168+
from cuda.bindings import nvfatbin
169+
except ImportError:
170+
return False
171+
try:
172+
nvfatbin.version()
173+
except (DynamicLibNotFoundError, FunctionNotFoundError):
174+
# libnvfatbin not loadable, or nvFatbinVersion symbol missing.
175+
return False
176+
return True
177+
```
178+
179+
Catch only the exceptions that mean "not installed / not loadable".
180+
A genuine API-status failure (e.g. `nvfatbin.nvFatbinError` from a
181+
successfully loaded library) must propagate so a real bug is not hidden
182+
as "unavailable".
183+
184+
For a probe that calls a CUDA API returning a `CUresult`, let `handle_return`
185+
raise on any non-success result and classify only a successful bitmask
186+
lacking the documented bit as `False`; other failures propagate so a real driver bug is not hidden as "unsupported":
187+
188+
```python
189+
@functools.cache
190+
def supports_ipc_mempool(device_id):
191+
from cuda.bindings import driver
192+
193+
handle_return(driver.cuInit(0))
194+
dev_id = int(getattr(device_id, "device_id", device_id))
195+
attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES
196+
mask = handle_return(driver.cuDeviceGetAttribute(attr, dev_id))
197+
posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR
198+
return (int(mask) & int(posix_fd)) != 0
199+
```
200+
201+
Do not catch `ImportError` for a hard runtime dependency (e.g.
202+
`cuda.bindings` for `cuda.core`) — that is a broken environment and should
203+
surface at collection time.
204+
205+
## Clean up partial setup on failure
206+
207+
If a setup step allocates a resource (GL object, window, file handle) and a later
208+
step fails, clean up the partial resource before re-raising so it does not
209+
leak. Wrap the allocation in `try/except` and delete the generated object in
210+
the `except` before re-raising:
211+
212+
```python
213+
def _allocate_gl_buffer(win, nbytes):
214+
from pyglet.gl import gl as _gl
215+
216+
buf_id = _gl.GLuint(0)
217+
try:
218+
_gl.glGenBuffers(1, ctypes.byref(buf_id))
219+
_gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value)
220+
_gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW)
221+
return buf_id
222+
except Exception:
223+
if buf_id.value:
224+
with contextlib.suppress(Exception):
225+
_gl.glDeleteBuffers(1, ctypes.byref(buf_id))
226+
raise
227+
```
228+
229+
Initialize handles to `None` before the protected region so the `finally`
230+
cleanup does not `NameError` when allocation raises before returning a handle.
231+
232+
## Tests that touch CUDA must establish their own context
233+
234+
The `init_cuda` fixture pops the CUDA context on teardown, so a test
235+
that calls a CUDA API without `init_cuda` (or an explicit
236+
`Device.set_current()`) inherits whatever context the previous test happened
237+
to leave current on the thread — possibly none. With `pytest-randomly` that
238+
makes the pass/fail outcome depend on test order, so it moves seed to seed and
239+
looks like flakiness. Request `init_cuda` for any test that calls into the
240+
driver, or set up and tear down a context yourself.
241+
242+
## Assert on behavior, not implementation
243+
244+
Pin on observable behavior the contract guarantees — return values, raised
245+
exception types, public state transitions. Avoid asserting on internal
246+
call counts, private helper invocation order, or error message substrings
247+
that are not part of the contract. A refactor that preserves behavior but
248+
changes internals should not break the test.

cuda_core/tests/graph/test_device_launch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88
from cuda_python_test_helpers.marks import requires_module
99

10+
import cuda.pathfinder as pathfinder
1011
from cuda.core import (
1112
Device,
1213
LaunchConfig,
@@ -48,7 +49,6 @@ def _compile_device_launcher_kernel():
4849
4950
Raises pytest.skip if libcudadevrt.a cannot be found.
5051
"""
51-
pathfinder = pytest.importorskip("cuda.pathfinder")
5252
try:
5353
cudadevrt_path = pathfinder.find_static_lib("cudadevrt")
5454
except pathfinder.StaticLibNotFoundError as e:

cuda_core/tests/graph/test_graph_definition_integration.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77

88
import numpy as np
99
import pytest
10+
from helpers.graph_kernels import skip_if_nvrtc_lacks_conditional_handle
1011
from helpers.memory import xfail_on_graph_mempool_oom
1112

1213
from cuda.core import Device, EventOptions, LaunchConfig, Program, ProgramOptions
13-
from cuda.core._utils.cuda_utils import driver, handle_return
14+
from cuda.core._utils.cuda_utils import CUDAError, driver, handle_return
1415
from cuda.core.graph import GraphDefinition
1516

1617
SIZEOF_FLOAT = 4
@@ -128,8 +129,9 @@ def _compile_heat_kernels():
128129
"cubin",
129130
name_expressions=("heat_step", "countdown"),
130131
)
131-
except Exception:
132-
pytest.skip("NVRTC does not support cudaGraphConditionalHandle")
132+
except CUDAError as exc:
133+
skip_if_nvrtc_lacks_conditional_handle(exc)
134+
raise
133135
return mod.get_kernel("heat_step"), mod.get_kernel("countdown")
134136

135137

@@ -145,8 +147,9 @@ def _compile_bisect_kernels():
145147
prog = Program(_BISECT_KERNEL_SOURCE, code_type="c++", options=_nvrtc_opts())
146148
try:
147149
mod = prog.compile("cubin", name_expressions=names)
148-
except Exception:
149-
pytest.skip("NVRTC does not support cudaGraphConditionalHandle")
150+
except CUDAError as exc:
151+
skip_if_nvrtc_lacks_conditional_handle(exc)
152+
raise
150153
return tuple(mod.get_kernel(n) for n in names)
151154

152155

cuda_core/tests/helpers/__init__.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,26 +28,29 @@ def supports_ipc_mempool(device_id: int | object) -> bool:
2828
Uses cuDeviceGetAttribute(CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES)
2929
to check for CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR support. Does not
3030
require an active CUDA context.
31+
32+
Unsupported handle types are represented by a successful query whose bitmask
33+
lacks the POSIX-FD bit, so the check below naturally returns False.
34+
Other driver errors (invalid device, deinitialized driver) propagate via
35+
handle_return so a real bug is not hidden as "unsupported".
3136
"""
3237
if IS_WSL:
3338
return False
3439

35-
try:
36-
# Lazy import to avoid hard dependency when not running GPU tests
37-
from cuda.bindings import driver # type: ignore
40+
# Lazy import to avoid hard dependency when not running GPU tests
41+
from cuda.bindings import driver # type: ignore
3842

39-
# Initialize CUDA
40-
handle_return(driver.cuInit(0))
43+
# Initialize CUDA
44+
handle_return(driver.cuInit(0))
4145

42-
# Resolve device id from int or Device-like object
43-
dev_id = int(getattr(device_id, "device_id", device_id))
46+
# Resolve device id from int or Device-like object
47+
dev_id = int(getattr(device_id, "device_id", device_id))
4448

45-
# Query supported mempool handle types bitmask
46-
attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES
47-
mask = handle_return(driver.cuDeviceGetAttribute(attr, dev_id))
49+
# Query supported mempool handle types bitmask. Unsupported handle types are
50+
# represented by a successful query whose bitmask lacks the POSIX-FD bit.
51+
attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES
52+
mask = handle_return(driver.cuDeviceGetAttribute(attr, dev_id))
4853

49-
# Check POSIX FD handle type support via bitmask
50-
posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR
51-
return (int(mask) & int(posix_fd)) != 0
52-
except Exception:
53-
return False
54+
# Check POSIX FD handle type support via bitmask
55+
posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR
56+
return (int(mask) & int(posix_fd)) != 0

0 commit comments

Comments
 (0)