Skip to content

Commit 0e6d282

Browse files
authored
cuda.core: validate ctypes host callback signatures against CUhostFn (#2525)
* cuda.core: validate ctypes host callback signatures against CUhostFn Reject incompatible ctypes prototypes before CUDA sees them, document the required ABI, and note the stronger checking in the 1.2.0 release notes. * cuda.core: make ctypes flag lookups stubgen/mypy-friendly Use getattr for private ctypes calling-convention constants so the regenerated _host_callback.pyi type-checks cleanly. * cuda.core: check host callback prototypes via public ctypes attributes The previous check inspected ctypes' private _flags_ bits to identify the calling convention. That is wrong on Windows: CPython defines FUNCFLAG_STDCALL as 0, so a bitwise test can never match WINFUNCTYPE, and every win-64 test job rejected a valid callback. The 0x2 fallback used when _ctypes.FUNCFLAG_STDCALL is absent is FUNCFLAG_HRESULT, not stdcall. Drop the calling-convention check rather than repair the bit arithmetic. ctypes only honors stdcall when building a callback on 32-bit x86 Windows, which cuda.core does not support, and FUNCFLAG_PYTHONAPI is never consulted on the callback path, so CFUNCTYPE, WINFUNCTYPE, and PYFUNCTYPE all yield the same FFI_DEFAULT_ABI thunk. That leaves the declared result and argument types, which are reachable through the public restype/argtypes attributes. Reading those public attributes also lets a function pointer taken from a shared library be accepted once its restype and argtypes are declared, which the class-level lookup could never see.
1 parent 83b2de7 commit 0e6d282

11 files changed

Lines changed: 246 additions & 17 deletions

File tree

cuda_core/cuda/core/graph/_graph_builder.pyi

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,10 +409,12 @@ class GraphBuilder:
409409
- **Python callable**: Pass any callable. The GIL is acquired
410410
automatically. The callable must take no arguments; use closures
411411
or ``functools.partial`` to bind state.
412-
- **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance.
413-
The function receives a single ``void*`` argument (the
414-
``user_data``). The caller must keep the ctypes wrapper alive
415-
for the lifetime of the graph.
412+
- **ctypes function pointer**: The function receives a single
413+
``void*`` argument (the ``user_data``), and the caller must keep
414+
the ctypes wrapper alive for the lifetime of the graph. Its
415+
declared prototype must match the driver's ``CUhostFn``
416+
(``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``,
417+
or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows.
416418
417419
.. warning::
418420
@@ -432,6 +434,14 @@ class GraphBuilder:
432434
Only for ctypes function pointers. If ``int``, passed as a raw
433435
pointer (caller manages lifetime). If bytes-like, the data is
434436
copied and its lifetime is tied to the graph.
437+
438+
Raises
439+
------
440+
TypeError
441+
If ``fn`` is a ctypes function pointer whose declared prototype
442+
does not match ``CUhostFn``.
443+
ValueError
444+
If ``user_data`` is given for a Python callable.
435445
"""
436446

437447
class Graph:

cuda_core/cuda/core/graph/_graph_builder.pyx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -860,10 +860,12 @@ cdef class GraphBuilder:
860860
- **Python callable**: Pass any callable. The GIL is acquired
861861
automatically. The callable must take no arguments; use closures
862862
or ``functools.partial`` to bind state.
863-
- **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance.
864-
The function receives a single ``void*`` argument (the
865-
``user_data``). The caller must keep the ctypes wrapper alive
866-
for the lifetime of the graph.
863+
- **ctypes function pointer**: The function receives a single
864+
``void*`` argument (the ``user_data``), and the caller must keep
865+
the ctypes wrapper alive for the lifetime of the graph. Its
866+
declared prototype must match the driver's ``CUhostFn``
867+
(``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``,
868+
or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows.
867869

868870
.. warning::
869871

@@ -883,6 +885,14 @@ cdef class GraphBuilder:
883885
Only for ctypes function pointers. If ``int``, passed as a raw
884886
pointer (caller manages lifetime). If bytes-like, the data is
885887
copied and its lifetime is tied to the graph.
888+
889+
Raises
890+
------
891+
TypeError
892+
If ``fn`` is a ctypes function pointer whose declared prototype
893+
does not match ``CUhostFn``.
894+
ValueError
895+
If ``user_data`` is given for a Python callable.
886896
"""
887897
GB_callback(self, fn, user_data, False)
888898

cuda_core/cuda/core/graph/_graph_node.pyi

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -333,10 +333,12 @@ class GraphNode:
333333
- **Python callable**: Pass any callable. The GIL is acquired
334334
automatically. The callable must take no arguments; use closures
335335
or ``functools.partial`` to bind state.
336-
- **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance.
337-
The function receives a single ``void*`` argument (the
338-
``user_data``). The caller must keep the ctypes wrapper alive
339-
for the lifetime of the graph.
336+
- **ctypes function pointer**: The function receives a single
337+
``void*`` argument (the ``user_data``), and the caller must keep
338+
the ctypes wrapper alive for the lifetime of the graph. Its
339+
declared prototype must match the driver's ``CUhostFn``
340+
(``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``,
341+
or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows.
340342
341343
.. warning::
342344
@@ -361,6 +363,14 @@ class GraphNode:
361363
-------
362364
HostCallbackNode
363365
A new HostCallbackNode representing the callback.
366+
367+
Raises
368+
------
369+
TypeError
370+
If ``fn`` is a ctypes function pointer whose declared prototype
371+
does not match ``CUhostFn``.
372+
ValueError
373+
If ``user_data`` is given for a Python callable.
364374
"""
365375

366376
def if_then(self, condition: GraphCondition) -> IfNode:

cuda_core/cuda/core/graph/_graph_node.pyx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -490,10 +490,12 @@ cdef class GraphNode:
490490
- **Python callable**: Pass any callable. The GIL is acquired
491491
automatically. The callable must take no arguments; use closures
492492
or ``functools.partial`` to bind state.
493-
- **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance.
494-
The function receives a single ``void*`` argument (the
495-
``user_data``). The caller must keep the ctypes wrapper alive
496-
for the lifetime of the graph.
493+
- **ctypes function pointer**: The function receives a single
494+
``void*`` argument (the ``user_data``), and the caller must keep
495+
the ctypes wrapper alive for the lifetime of the graph. Its
496+
declared prototype must match the driver's ``CUhostFn``
497+
(``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``,
498+
or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows.
497499

498500
.. warning::
499501

@@ -518,6 +520,14 @@ cdef class GraphNode:
518520
-------
519521
HostCallbackNode
520522
A new HostCallbackNode representing the callback.
523+
524+
Raises
525+
------
526+
TypeError
527+
If ``fn`` is a ctypes function pointer whose declared prototype
528+
does not match ``CUhostFn``.
529+
ValueError
530+
If ``user_data`` is given for a Python callable.
521531
"""
522532
return GN_callback(self, fn, user_data)
523533
Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
11
# This file was generated by stubgen-pyx v0.2.6 from cuda_core/cuda/core/graph/_host_callback.pyx
22

3-
from __future__ import annotations
3+
from __future__ import annotations
4+
5+
import sys
6+
7+
_CUHOSTFN_HINT = 'ctypes.CFUNCTYPE(None, ctypes.c_void_p)' if sys.platform != 'win32' else 'ctypes.CFUNCTYPE(None, ctypes.c_void_p) or ctypes.WINFUNCTYPE(None, ctypes.c_void_p)'
8+
9+
def _cuhostfn_type_error(detail):
10+
"""Build the rejection message for a non-conforming ctypes callback."""
11+
12+
def _validate_ctypes_host_callback(fn):
13+
"""Reject ctypes callbacks whose declared prototype is not CUhostFn.
14+
15+
``restype`` and ``argtypes`` are the prototype the caller declared, and are
16+
what CUDA calls through. A function pointer taken from a shared library
17+
keeps ctypes' defaults -- a ``c_int`` result and unspecified arguments --
18+
until the caller declares otherwise, so it must be declared to be accepted.
19+
"""

cuda_core/cuda/core/graph/_host_callback.pyx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,47 @@ from cuda.core._resource_handles cimport (
1414
make_opaque_py,
1515
)
1616

17+
import sys
1718
import ctypes as ct
1819

1920

21+
# CUhostFn is `void (CUDA_CB *)(void*)`. CUDA_CB is __stdcall on Windows and
22+
# empty elsewhere, but ctypes only honors that distinction when it builds a
23+
# callback on 32-bit x86 Windows, which cuda.core does not support: on win-64
24+
# and ARM64 both CFUNCTYPE and WINFUNCTYPE produce a FFI_DEFAULT_ABI thunk. The
25+
# declared result and argument types are all that remain worth checking.
26+
_CUHOSTFN_HINT = (
27+
"ctypes.CFUNCTYPE(None, ctypes.c_void_p)"
28+
if sys.platform != "win32"
29+
else "ctypes.CFUNCTYPE(None, ctypes.c_void_p) or "
30+
"ctypes.WINFUNCTYPE(None, ctypes.c_void_p)"
31+
)
32+
33+
34+
def _cuhostfn_type_error(detail):
35+
"""Build the rejection message for a non-conforming ctypes callback."""
36+
return TypeError(
37+
f"host callback {detail}; CUDA requires a callback matching CUhostFn "
38+
f"(void (*)(void*)), declared as {_CUHOSTFN_HINT}. "
39+
"Alternatively, pass a Python callable."
40+
)
41+
42+
43+
def _validate_ctypes_host_callback(fn):
44+
"""Reject ctypes callbacks whose declared prototype is not CUhostFn.
45+
46+
``restype`` and ``argtypes`` are the prototype the caller declared, and are
47+
what CUDA calls through. A function pointer taken from a shared library
48+
keeps ctypes' defaults -- a ``c_int`` result and unspecified arguments --
49+
until the caller declares otherwise, so it must be declared to be accepted.
50+
"""
51+
restype = fn.restype
52+
argtypes = fn.argtypes
53+
if restype is not None or argtypes is None or tuple(argtypes) != (ct.c_void_p,):
54+
raise _cuhostfn_type_error(
55+
f"has prototype restype={restype!r}, argtypes={argtypes!r}")
56+
57+
2058
cdef void _py_host_trampoline(void* data) noexcept with gil:
2159
(<object>data)()
2260

@@ -36,8 +74,12 @@ cdef void _resolve_host_callback(
3674
``cuGraphAddHostNode`` or ``cuLaunchHostFunc``. ``*out_fn_owner`` owns the
3775
callback object; ``*out_data_owner`` owns a copied ``user_data`` buffer and
3876
is left null otherwise. The caller attaches both owners to the graph node.
77+
78+
ctypes callbacks are validated against the ``CUhostFn`` ABI before their
79+
address is passed to CUDA.
3980
"""
4081
if isinstance(fn, ct._CFuncPtr):
82+
_validate_ctypes_host_callback(fn)
4183
out_fn[0] = <cydriver.CUhostFn><uintptr_t>ct.cast(fn, ct.c_void_p).value
4284
if user_data is None:
4385
out_user_data[0] = NULL

cuda_core/cuda/core/graph/_subclasses.pyi

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,11 @@ class HostCallbackNode(GraphNode):
320320
def update(self, fn, *, user_data=None) -> None:
321321
"""Replace the callback and user-data binding for this node.
322322
323+
``fn`` accepts the same forms as :meth:`~graph.GraphNode.callback`: a
324+
Python callable, or a ctypes function pointer whose declared prototype
325+
matches ``CUhostFn`` (``void (*)(void*)``). A mismatched ctypes
326+
prototype raises ``TypeError``.
327+
323328
.. warning::
324329
325330
Callbacks must not call CUDA API functions. Doing so may
@@ -508,6 +513,10 @@ class ExecutableHostCallbackNode(ExecutableGraphNode):
508513
def update(self, fn, *, user_data=None) -> None:
509514
"""Replace the callback and user-data binding for future launches.
510515
516+
``fn`` may be a Python callable, or a ctypes function pointer whose
517+
declared prototype matches ``CUhostFn`` (``void (*)(void*)``); a
518+
mismatched prototype raises ``TypeError``.
519+
511520
.. warning::
512521
513522
Callbacks must not call CUDA API functions. Doing so may deadlock

cuda_core/cuda/core/graph/_subclasses.pyx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,11 @@ cdef class HostCallbackNode(GraphNode):
11721172
def update(self, fn, *, user_data=None) -> None:
11731173
"""Replace the callback and user-data binding for this node.
11741174

1175+
``fn`` accepts the same forms as :meth:`~graph.GraphNode.callback`: a
1176+
Python callable, or a ctypes function pointer whose declared prototype
1177+
matches ``CUhostFn`` (``void (*)(void*)``). A mismatched ctypes
1178+
prototype raises ``TypeError``.
1179+
11751180
.. warning::
11761181

11771182
Callbacks must not call CUDA API functions. Doing so may
@@ -1603,6 +1608,10 @@ cdef class ExecutableHostCallbackNode(ExecutableGraphNode):
16031608
def update(self, fn, *, user_data=None) -> None:
16041609
"""Replace the callback and user-data binding for future launches.
16051610

1611+
``fn`` may be a Python callable, or a ctypes function pointer whose
1612+
declared prototype matches ``CUhostFn`` (``void (*)(void*)``); a
1613+
mismatched prototype raises ``TypeError``.
1614+
16061615
.. warning::
16071616

16081617
Callbacks must not call CUDA API functions. Doing so may deadlock

cuda_core/docs/source/release/1.2.0-notes.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ Fixes and enhancements
5959
(`#2517 <https://github.com/NVIDIA/cuda-python/pull/2517>`__,
6060
closes `#2516 <https://github.com/NVIDIA/cuda-python/issues/2516>`__)
6161

62+
- ``cuda.core`` now checks ctypes host callbacks against the driver's
63+
``CUhostFn`` signature (``void (*)(void*)``) before passing the function
64+
pointer to CUDA. :meth:`graph.GraphNode.callback`,
65+
:meth:`graph.GraphBuilder.callback`, and the host-callback ``update()``
66+
methods raise ``TypeError`` for a mismatched prototype, rather than leaving
67+
the driver to call through an incompatible signature, which is undefined
68+
behavior. Declarations that previously reached the driver, such as
69+
``ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)``, are now rejected at the
70+
call site. A function pointer obtained from a shared library keeps ctypes'
71+
default ``c_int`` result type until it is declared, so set its ``restype``
72+
and ``argtypes`` (or cast it to the prototype above) before passing it. On
73+
Windows, both ``ctypes.CFUNCTYPE`` and ``ctypes.WINFUNCTYPE`` are accepted.
74+
(`#2439 <https://github.com/NVIDIA/cuda-python/issues/2439>`__)
75+
6276
Deprecation Notices
6377
-------------------
6478

cuda_core/tests/graph/test_graph_builder.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,21 @@ def read_byte(data):
306306
assert result[0] == 0xAB
307307

308308

309+
@pytest.mark.agent_authored(model="cursor-grok-4.5")
310+
def test_graph_capture_callback_ctypes_rejects_incompatible_signature(init_cuda):
311+
"""Stream-capture host callbacks use the same ctypes ABI check."""
312+
import ctypes
313+
314+
bad_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)
315+
launch_stream = Device().create_stream()
316+
gb = launch_stream.create_graph_builder().begin_building()
317+
try:
318+
with pytest.raises(TypeError, match="CUhostFn"):
319+
gb.callback(bad_type(0))
320+
finally:
321+
gb.end_building()
322+
323+
309324
@pytest.mark.agent_authored(model="claude-opus-4.8")
310325
def test_graph_capture_callback_python_survives_del(init_cuda):
311326
"""Captured callback is retained by its graph-node user object after del."""

0 commit comments

Comments
 (0)