diff --git a/cuda_core/cuda/core/graph/_graph_builder.pyi b/cuda_core/cuda/core/graph/_graph_builder.pyi index 6689082b10b..d238b419be1 100644 --- a/cuda_core/cuda/core/graph/_graph_builder.pyi +++ b/cuda_core/cuda/core/graph/_graph_builder.pyi @@ -409,10 +409,12 @@ class GraphBuilder: - **Python callable**: Pass any callable. The GIL is acquired automatically. The callable must take no arguments; use closures or ``functools.partial`` to bind state. - - **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance. - The function receives a single ``void*`` argument (the - ``user_data``). The caller must keep the ctypes wrapper alive - for the lifetime of the graph. + - **ctypes function pointer**: The function receives a single + ``void*`` argument (the ``user_data``), and the caller must keep + the ctypes wrapper alive for the lifetime of the graph. Its + declared prototype must match the driver's ``CUhostFn`` + (``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``, + or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows. .. warning:: @@ -432,6 +434,14 @@ class GraphBuilder: Only for ctypes function pointers. If ``int``, passed as a raw pointer (caller manages lifetime). If bytes-like, the data is copied and its lifetime is tied to the graph. + + Raises + ------ + TypeError + If ``fn`` is a ctypes function pointer whose declared prototype + does not match ``CUhostFn``. + ValueError + If ``user_data`` is given for a Python callable. """ class Graph: diff --git a/cuda_core/cuda/core/graph/_graph_builder.pyx b/cuda_core/cuda/core/graph/_graph_builder.pyx index 1115e3023df..d3053a7261e 100644 --- a/cuda_core/cuda/core/graph/_graph_builder.pyx +++ b/cuda_core/cuda/core/graph/_graph_builder.pyx @@ -860,10 +860,12 @@ cdef class GraphBuilder: - **Python callable**: Pass any callable. The GIL is acquired automatically. The callable must take no arguments; use closures or ``functools.partial`` to bind state. - - **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance. - The function receives a single ``void*`` argument (the - ``user_data``). The caller must keep the ctypes wrapper alive - for the lifetime of the graph. + - **ctypes function pointer**: The function receives a single + ``void*`` argument (the ``user_data``), and the caller must keep + the ctypes wrapper alive for the lifetime of the graph. Its + declared prototype must match the driver's ``CUhostFn`` + (``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``, + or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows. .. warning:: @@ -883,6 +885,14 @@ cdef class GraphBuilder: Only for ctypes function pointers. If ``int``, passed as a raw pointer (caller manages lifetime). If bytes-like, the data is copied and its lifetime is tied to the graph. + + Raises + ------ + TypeError + If ``fn`` is a ctypes function pointer whose declared prototype + does not match ``CUhostFn``. + ValueError + If ``user_data`` is given for a Python callable. """ GB_callback(self, fn, user_data, False) diff --git a/cuda_core/cuda/core/graph/_graph_node.pyi b/cuda_core/cuda/core/graph/_graph_node.pyi index ab503183f63..0e3cac045d2 100644 --- a/cuda_core/cuda/core/graph/_graph_node.pyi +++ b/cuda_core/cuda/core/graph/_graph_node.pyi @@ -333,10 +333,12 @@ class GraphNode: - **Python callable**: Pass any callable. The GIL is acquired automatically. The callable must take no arguments; use closures or ``functools.partial`` to bind state. - - **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance. - The function receives a single ``void*`` argument (the - ``user_data``). The caller must keep the ctypes wrapper alive - for the lifetime of the graph. + - **ctypes function pointer**: The function receives a single + ``void*`` argument (the ``user_data``), and the caller must keep + the ctypes wrapper alive for the lifetime of the graph. Its + declared prototype must match the driver's ``CUhostFn`` + (``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``, + or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows. .. warning:: @@ -361,6 +363,14 @@ class GraphNode: ------- HostCallbackNode A new HostCallbackNode representing the callback. + + Raises + ------ + TypeError + If ``fn`` is a ctypes function pointer whose declared prototype + does not match ``CUhostFn``. + ValueError + If ``user_data`` is given for a Python callable. """ def if_then(self, condition: GraphCondition) -> IfNode: diff --git a/cuda_core/cuda/core/graph/_graph_node.pyx b/cuda_core/cuda/core/graph/_graph_node.pyx index 37411c857b5..2c9c07e6b3a 100644 --- a/cuda_core/cuda/core/graph/_graph_node.pyx +++ b/cuda_core/cuda/core/graph/_graph_node.pyx @@ -490,10 +490,12 @@ cdef class GraphNode: - **Python callable**: Pass any callable. The GIL is acquired automatically. The callable must take no arguments; use closures or ``functools.partial`` to bind state. - - **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance. - The function receives a single ``void*`` argument (the - ``user_data``). The caller must keep the ctypes wrapper alive - for the lifetime of the graph. + - **ctypes function pointer**: The function receives a single + ``void*`` argument (the ``user_data``), and the caller must keep + the ctypes wrapper alive for the lifetime of the graph. Its + declared prototype must match the driver's ``CUhostFn`` + (``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``, + or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows. .. warning:: @@ -518,6 +520,14 @@ cdef class GraphNode: ------- HostCallbackNode A new HostCallbackNode representing the callback. + + Raises + ------ + TypeError + If ``fn`` is a ctypes function pointer whose declared prototype + does not match ``CUhostFn``. + ValueError + If ``user_data`` is given for a Python callable. """ return GN_callback(self, fn, user_data) diff --git a/cuda_core/cuda/core/graph/_host_callback.pyi b/cuda_core/cuda/core/graph/_host_callback.pyi index 6c9d0ead317..1c642abf501 100644 --- a/cuda_core/cuda/core/graph/_host_callback.pyi +++ b/cuda_core/cuda/core/graph/_host_callback.pyi @@ -1,3 +1,19 @@ # This file was generated by stubgen-pyx v0.2.6 from cuda_core/cuda/core/graph/_host_callback.pyx -from __future__ import annotations \ No newline at end of file +from __future__ import annotations + +import sys + +_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)' + +def _cuhostfn_type_error(detail): + """Build the rejection message for a non-conforming ctypes callback.""" + +def _validate_ctypes_host_callback(fn): + """Reject ctypes callbacks whose declared prototype is not CUhostFn. + + ``restype`` and ``argtypes`` are the prototype the caller declared, and are + what CUDA calls through. A function pointer taken from a shared library + keeps ctypes' defaults -- a ``c_int`` result and unspecified arguments -- + until the caller declares otherwise, so it must be declared to be accepted. + """ \ No newline at end of file diff --git a/cuda_core/cuda/core/graph/_host_callback.pyx b/cuda_core/cuda/core/graph/_host_callback.pyx index 5fd71f8653f..4fb48f0d6ec 100644 --- a/cuda_core/cuda/core/graph/_host_callback.pyx +++ b/cuda_core/cuda/core/graph/_host_callback.pyx @@ -14,9 +14,47 @@ from cuda.core._resource_handles cimport ( make_opaque_py, ) +import sys import ctypes as ct +# CUhostFn is `void (CUDA_CB *)(void*)`. CUDA_CB is __stdcall on Windows and +# empty elsewhere, but ctypes only honors that distinction when it builds a +# callback on 32-bit x86 Windows, which cuda.core does not support: on win-64 +# and ARM64 both CFUNCTYPE and WINFUNCTYPE produce a FFI_DEFAULT_ABI thunk. The +# declared result and argument types are all that remain worth checking. +_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)" +) + + +def _cuhostfn_type_error(detail): + """Build the rejection message for a non-conforming ctypes callback.""" + return TypeError( + f"host callback {detail}; CUDA requires a callback matching CUhostFn " + f"(void (*)(void*)), declared as {_CUHOSTFN_HINT}. " + "Alternatively, pass a Python callable." + ) + + +def _validate_ctypes_host_callback(fn): + """Reject ctypes callbacks whose declared prototype is not CUhostFn. + + ``restype`` and ``argtypes`` are the prototype the caller declared, and are + what CUDA calls through. A function pointer taken from a shared library + keeps ctypes' defaults -- a ``c_int`` result and unspecified arguments -- + until the caller declares otherwise, so it must be declared to be accepted. + """ + restype = fn.restype + argtypes = fn.argtypes + if restype is not None or argtypes is None or tuple(argtypes) != (ct.c_void_p,): + raise _cuhostfn_type_error( + f"has prototype restype={restype!r}, argtypes={argtypes!r}") + + cdef void _py_host_trampoline(void* data) noexcept with gil: (data)() @@ -36,8 +74,12 @@ cdef void _resolve_host_callback( ``cuGraphAddHostNode`` or ``cuLaunchHostFunc``. ``*out_fn_owner`` owns the callback object; ``*out_data_owner`` owns a copied ``user_data`` buffer and is left null otherwise. The caller attaches both owners to the graph node. + + ctypes callbacks are validated against the ``CUhostFn`` ABI before their + address is passed to CUDA. """ if isinstance(fn, ct._CFuncPtr): + _validate_ctypes_host_callback(fn) out_fn[0] = ct.cast(fn, ct.c_void_p).value if user_data is None: out_user_data[0] = NULL diff --git a/cuda_core/cuda/core/graph/_subclasses.pyi b/cuda_core/cuda/core/graph/_subclasses.pyi index ebe0adb01c9..a68e500f7f2 100644 --- a/cuda_core/cuda/core/graph/_subclasses.pyi +++ b/cuda_core/cuda/core/graph/_subclasses.pyi @@ -320,6 +320,11 @@ class HostCallbackNode(GraphNode): def update(self, fn, *, user_data=None) -> None: """Replace the callback and user-data binding for this node. + ``fn`` accepts the same forms as :meth:`~graph.GraphNode.callback`: a + Python callable, or a ctypes function pointer whose declared prototype + matches ``CUhostFn`` (``void (*)(void*)``). A mismatched ctypes + prototype raises ``TypeError``. + .. warning:: Callbacks must not call CUDA API functions. Doing so may @@ -508,6 +513,10 @@ class ExecutableHostCallbackNode(ExecutableGraphNode): def update(self, fn, *, user_data=None) -> None: """Replace the callback and user-data binding for future launches. + ``fn`` may be a Python callable, or a ctypes function pointer whose + declared prototype matches ``CUhostFn`` (``void (*)(void*)``); a + mismatched prototype raises ``TypeError``. + .. warning:: Callbacks must not call CUDA API functions. Doing so may deadlock diff --git a/cuda_core/cuda/core/graph/_subclasses.pyx b/cuda_core/cuda/core/graph/_subclasses.pyx index b0630165671..79e302d59d5 100644 --- a/cuda_core/cuda/core/graph/_subclasses.pyx +++ b/cuda_core/cuda/core/graph/_subclasses.pyx @@ -1172,6 +1172,11 @@ cdef class HostCallbackNode(GraphNode): def update(self, fn, *, user_data=None) -> None: """Replace the callback and user-data binding for this node. + ``fn`` accepts the same forms as :meth:`~graph.GraphNode.callback`: a + Python callable, or a ctypes function pointer whose declared prototype + matches ``CUhostFn`` (``void (*)(void*)``). A mismatched ctypes + prototype raises ``TypeError``. + .. warning:: Callbacks must not call CUDA API functions. Doing so may @@ -1603,6 +1608,10 @@ cdef class ExecutableHostCallbackNode(ExecutableGraphNode): def update(self, fn, *, user_data=None) -> None: """Replace the callback and user-data binding for future launches. + ``fn`` may be a Python callable, or a ctypes function pointer whose + declared prototype matches ``CUhostFn`` (``void (*)(void*)``); a + mismatched prototype raises ``TypeError``. + .. warning:: Callbacks must not call CUDA API functions. Doing so may deadlock diff --git a/cuda_core/docs/source/release/1.2.0-notes.rst b/cuda_core/docs/source/release/1.2.0-notes.rst index ffd82331f39..120d2c2a253 100644 --- a/cuda_core/docs/source/release/1.2.0-notes.rst +++ b/cuda_core/docs/source/release/1.2.0-notes.rst @@ -59,6 +59,20 @@ Fixes and enhancements (`#2517 `__, closes `#2516 `__) +- ``cuda.core`` now checks ctypes host callbacks against the driver's + ``CUhostFn`` signature (``void (*)(void*)``) before passing the function + pointer to CUDA. :meth:`graph.GraphNode.callback`, + :meth:`graph.GraphBuilder.callback`, and the host-callback ``update()`` + methods raise ``TypeError`` for a mismatched prototype, rather than leaving + the driver to call through an incompatible signature, which is undefined + behavior. Declarations that previously reached the driver, such as + ``ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)``, are now rejected at the + call site. A function pointer obtained from a shared library keeps ctypes' + default ``c_int`` result type until it is declared, so set its ``restype`` + and ``argtypes`` (or cast it to the prototype above) before passing it. On + Windows, both ``ctypes.CFUNCTYPE`` and ``ctypes.WINFUNCTYPE`` are accepted. + (`#2439 `__) + Deprecation Notices ------------------- diff --git a/cuda_core/tests/graph/test_graph_builder.py b/cuda_core/tests/graph/test_graph_builder.py index 6c7c9ef7d64..a730c2bd83a 100644 --- a/cuda_core/tests/graph/test_graph_builder.py +++ b/cuda_core/tests/graph/test_graph_builder.py @@ -306,6 +306,21 @@ def read_byte(data): assert result[0] == 0xAB +@pytest.mark.agent_authored(model="cursor-grok-4.5") +def test_graph_capture_callback_ctypes_rejects_incompatible_signature(init_cuda): + """Stream-capture host callbacks use the same ctypes ABI check.""" + import ctypes + + bad_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p) + launch_stream = Device().create_stream() + gb = launch_stream.create_graph_builder().begin_building() + try: + with pytest.raises(TypeError, match="CUhostFn"): + gb.callback(bad_type(0)) + finally: + gb.end_building() + + @pytest.mark.agent_authored(model="claude-opus-4.8") def test_graph_capture_callback_python_survives_del(init_cuda): """Captured callback is retained by its graph-node user object after del.""" diff --git a/cuda_core/tests/graph/test_graph_definition.py b/cuda_core/tests/graph/test_graph_definition.py index 9459cfb4e95..0aeb5a9d527 100644 --- a/cuda_core/tests/graph/test_graph_definition.py +++ b/cuda_core/tests/graph/test_graph_definition.py @@ -3,6 +3,8 @@ """Tests for GraphDefinition topology, node types, instantiation, and execution.""" +import ctypes +import sys from collections.abc import Callable from dataclasses import dataclass, field @@ -1159,6 +1161,88 @@ def test_host_callback_user_data_rejected_for_python_callable(sample_graphdef): sample_graphdef.callback(lambda: None, user_data=b"hello") +_INCOMPATIBLE_CTYPES_HOST_CALLBACKS = [ + pytest.param(ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p), id="bad-restype"), + pytest.param(ctypes.CFUNCTYPE(None, ctypes.c_int), id="bad-argtype"), + pytest.param(ctypes.CFUNCTYPE(None), id="missing-arg"), + pytest.param(ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_void_p), id="extra-arg"), +] + +# Prototypes that declare CUhostFn but differ in ctypes bookkeeping. ctypes +# builds the same thunk for all of them, so all must be accepted. +_COMPATIBLE_CTYPES_HOST_CALLBACKS = [ + pytest.param(ctypes.CFUNCTYPE(None, ctypes.c_void_p), id="cfunctype"), + pytest.param(ctypes.CFUNCTYPE(None, ctypes.c_void_p, use_errno=True), id="use-errno"), + pytest.param(ctypes.PYFUNCTYPE(None, ctypes.c_void_p), id="pyfunctype"), +] + + +@pytest.mark.agent_authored(model="cursor-grok-4.5") +@pytest.mark.parametrize("callback_type", _INCOMPATIBLE_CTYPES_HOST_CALLBACKS) +def test_host_callback_ctypes_rejects_incompatible_signature(sample_graphdef, callback_type): + """Incompatible ctypes prototypes are rejected before CUDA sees them.""" + with pytest.raises(TypeError, match="CUhostFn"): + sample_graphdef.callback(callback_type(0)) + + +@pytest.mark.agent_authored(model="cursor-grok-4.5") +def test_host_callback_ctypes_update_rejects_incompatible_signature(sample_graphdef): + """HostCallbackNode.update applies the same ctypes ABI check.""" + good_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p) + bad_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p) + + @good_type + def good(data): + pass + + node = sample_graphdef.callback(good) + with pytest.raises(TypeError, match="CUhostFn"): + node.update(bad_type(0)) + + +@pytest.mark.agent_authored(model="claude-opus-5") +@pytest.mark.parametrize("callback_type", _COMPATIBLE_CTYPES_HOST_CALLBACKS) +def test_host_callback_ctypes_accepts_equivalent_prototypes(sample_graphdef, callback_type): + """Prototypes that declare CUhostFn are accepted and run.""" + called = [False] + + @callback_type + def raw_fn(data): + called[0] = True + + sample_graphdef.callback(raw_fn) + graph = sample_graphdef.instantiate() + + stream = Device().create_stream() + graph.upload(stream) + graph.launch(stream) + stream.sync() + + assert called[0] + + +@pytest.mark.agent_authored(model="cursor-grok-4.5") +@pytest.mark.skipif(sys.platform != "win32", reason="WINFUNCTYPE is Windows-only") +def test_host_callback_ctypes_accepts_winfunctype(sample_graphdef): + """On Windows, WINFUNCTYPE matches CUDA_CB (__stdcall) and is accepted.""" + callback_type = ctypes.WINFUNCTYPE(None, ctypes.c_void_p) + called = [False] + + @callback_type + def raw_fn(data): + called[0] = True + + sample_graphdef.callback(raw_fn) + graph = sample_graphdef.instantiate() + + stream = Device().create_stream() + graph.upload(stream) + graph.launch(stream) + stream.sync() + + assert called[0] + + def test_instantiate_and_execute_event_record_wait(sample_graphdef): """Graph with event record and wait nodes can be executed.""" event = Device().create_event()