From d9255a1f01b4642fff7f4ae70af7dfe7faebd249 Mon Sep 17 00:00:00 2001 From: Andy Jost Date: Thu, 6 Aug 2026 14:36:39 -0700 Subject: [PATCH 1/3] 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/cuda/core/graph/_graph_builder.pyi | 18 +++-- cuda_core/cuda/core/graph/_graph_builder.pyx | 18 +++-- cuda_core/cuda/core/graph/_graph_node.pyi | 18 +++-- cuda_core/cuda/core/graph/_graph_node.pyx | 18 +++-- cuda_core/cuda/core/graph/_host_callback.pyx | 65 +++++++++++++++++++ cuda_core/cuda/core/graph/_subclasses.pyi | 9 +++ cuda_core/cuda/core/graph/_subclasses.pyx | 9 +++ cuda_core/docs/source/release/1.2.0-notes.rst | 12 ++++ cuda_core/tests/graph/test_graph_builder.py | 15 +++++ .../tests/graph/test_graph_definition.py | 56 ++++++++++++++++ 10 files changed, 222 insertions(+), 16 deletions(-) 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.pyx b/cuda_core/cuda/core/graph/_host_callback.pyx index 5fd71f8653f..6cbd90ee427 100644 --- a/cuda_core/cuda/core/graph/_host_callback.pyx +++ b/cuda_core/cuda/core/graph/_host_callback.pyx @@ -14,9 +14,70 @@ from cuda.core._resource_handles cimport ( make_opaque_py, ) +import sys +import _ctypes import ctypes as ct +# CUhostFn is `void (CUDA_CB *)(void*)`. CUDA_CB is __stdcall on Windows and +# empty elsewhere, so ctypes.WINFUNCTYPE (stdcall) is the literal match on +# Windows and ctypes.CFUNCTYPE (cdecl) is the match everywhere else. Modern +# Windows (x64 and ARM64) has a single calling convention, where the two are +# interchangeable, and cuda.core callers already pass CFUNCTYPE; Windows +# therefore accepts either. +_FUNCFLAG_CDECL = ct._FUNCFLAG_CDECL +_FUNCFLAG_STDCALL = getattr(_ctypes, "FUNCFLAG_STDCALL", 0x2) +_FUNCFLAG_PYTHONAPI = ct._FUNCFLAG_PYTHONAPI + +_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. + + Only the ctypes type's ``_restype_`` / ``_argtypes_`` / ``_flags_`` are + checked: that is the ABI the wrapper claims. Instance ``restype`` / + ``argtypes`` overrides are ignored because CUDA invokes the function + pointer directly. + """ + proto = type(fn) + restype = getattr(proto, "_restype_", None) + argtypes = getattr(proto, "_argtypes_", None) + flags = int(getattr(proto, "_flags_", 0)) + + if restype is not None or argtypes != (ct.c_void_p,): + raise _cuhostfn_type_error( + f"has prototype restype={restype!r}, argtypes={argtypes!r}") + + if flags & _FUNCFLAG_PYTHONAPI: + raise _cuhostfn_type_error( + "was declared with ctypes.PYFUNCTYPE, which uses the Python-API " + "calling convention") + + if sys.platform == "win32": + if not (flags & (_FUNCFLAG_CDECL | _FUNCFLAG_STDCALL)): + raise _cuhostfn_type_error("uses an unrecognized calling convention") + elif flags & _FUNCFLAG_STDCALL: + raise _cuhostfn_type_error( + "uses the stdcall calling convention, which applies only to Windows") + elif not (flags & _FUNCFLAG_CDECL): + raise _cuhostfn_type_error("uses an unrecognized calling convention") + + cdef void _py_host_trampoline(void* data) noexcept with gil: (data)() @@ -36,8 +97,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 4bb81cec759..6c1f14246ac 100644 --- a/cuda_core/docs/source/release/1.2.0-notes.rst +++ b/cuda_core/docs/source/release/1.2.0-notes.rst @@ -53,6 +53,18 @@ Fixes and enhancements (`#2409 `__, closes `#2408 `__) +- ``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. 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..d3a63b3d40f 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,60 @@ 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"), + 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="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() From fa0ec9c6245c10844078eaa1897421b3fa4c7227 Mon Sep 17 00:00:00 2001 From: Andy Jost Date: Thu, 6 Aug 2026 14:41:27 -0700 Subject: [PATCH 2/3] 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/cuda/core/graph/_host_callback.pyi | 24 +++++++++++++++++++- cuda_core/cuda/core/graph/_host_callback.pyx | 4 ++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/cuda_core/cuda/core/graph/_host_callback.pyi b/cuda_core/cuda/core/graph/_host_callback.pyi index 6c9d0ead317..93254f7d1a3 100644 --- a/cuda_core/cuda/core/graph/_host_callback.pyi +++ b/cuda_core/cuda/core/graph/_host_callback.pyi @@ -1,3 +1,25 @@ # 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 ctypes as ct +import sys + +import _ctypes + +_FUNCFLAG_CDECL = getattr(ct, '_FUNCFLAG_CDECL', 1) +_FUNCFLAG_STDCALL = getattr(_ctypes, 'FUNCFLAG_STDCALL', 2) +_FUNCFLAG_PYTHONAPI = getattr(ct, '_FUNCFLAG_PYTHONAPI', 4) +_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. + + Only the ctypes type's ``_restype_`` / ``_argtypes_`` / ``_flags_`` are + checked: that is the ABI the wrapper claims. Instance ``restype`` / + ``argtypes`` overrides are ignored because CUDA invokes the function + pointer directly. + """ \ 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 6cbd90ee427..cf9db7eed79 100644 --- a/cuda_core/cuda/core/graph/_host_callback.pyx +++ b/cuda_core/cuda/core/graph/_host_callback.pyx @@ -25,9 +25,9 @@ import ctypes as ct # Windows (x64 and ARM64) has a single calling convention, where the two are # interchangeable, and cuda.core callers already pass CFUNCTYPE; Windows # therefore accepts either. -_FUNCFLAG_CDECL = ct._FUNCFLAG_CDECL +_FUNCFLAG_CDECL = getattr(ct, "_FUNCFLAG_CDECL", 0x1) _FUNCFLAG_STDCALL = getattr(_ctypes, "FUNCFLAG_STDCALL", 0x2) -_FUNCFLAG_PYTHONAPI = ct._FUNCFLAG_PYTHONAPI +_FUNCFLAG_PYTHONAPI = getattr(ct, "_FUNCFLAG_PYTHONAPI", 0x4) _CUHOSTFN_HINT = ( "ctypes.CFUNCTYPE(None, ctypes.c_void_p)" From d502a25f9effd7a9514bb7100d58de186fb9cf35 Mon Sep 17 00:00:00 2001 From: Andy Jost Date: Fri, 7 Aug 2026 11:34:27 -0700 Subject: [PATCH 3/3] 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. --- cuda_core/cuda/core/graph/_host_callback.pyi | 14 ++---- cuda_core/cuda/core/graph/_host_callback.pyx | 45 +++++-------------- cuda_core/docs/source/release/1.2.0-notes.rst | 6 ++- .../tests/graph/test_graph_definition.py | 28 ++++++++++++ 4 files changed, 47 insertions(+), 46 deletions(-) diff --git a/cuda_core/cuda/core/graph/_host_callback.pyi b/cuda_core/cuda/core/graph/_host_callback.pyi index 93254f7d1a3..1c642abf501 100644 --- a/cuda_core/cuda/core/graph/_host_callback.pyi +++ b/cuda_core/cuda/core/graph/_host_callback.pyi @@ -2,14 +2,8 @@ from __future__ import annotations -import ctypes as ct import sys -import _ctypes - -_FUNCFLAG_CDECL = getattr(ct, '_FUNCFLAG_CDECL', 1) -_FUNCFLAG_STDCALL = getattr(_ctypes, 'FUNCFLAG_STDCALL', 2) -_FUNCFLAG_PYTHONAPI = getattr(ct, '_FUNCFLAG_PYTHONAPI', 4) _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): @@ -18,8 +12,8 @@ def _cuhostfn_type_error(detail): def _validate_ctypes_host_callback(fn): """Reject ctypes callbacks whose declared prototype is not CUhostFn. - Only the ctypes type's ``_restype_`` / ``_argtypes_`` / ``_flags_`` are - checked: that is the ABI the wrapper claims. Instance ``restype`` / - ``argtypes`` overrides are ignored because CUDA invokes the function - pointer directly. + ``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 cf9db7eed79..4fb48f0d6ec 100644 --- a/cuda_core/cuda/core/graph/_host_callback.pyx +++ b/cuda_core/cuda/core/graph/_host_callback.pyx @@ -15,20 +15,14 @@ from cuda.core._resource_handles cimport ( ) import sys -import _ctypes import ctypes as ct # CUhostFn is `void (CUDA_CB *)(void*)`. CUDA_CB is __stdcall on Windows and -# empty elsewhere, so ctypes.WINFUNCTYPE (stdcall) is the literal match on -# Windows and ctypes.CFUNCTYPE (cdecl) is the match everywhere else. Modern -# Windows (x64 and ARM64) has a single calling convention, where the two are -# interchangeable, and cuda.core callers already pass CFUNCTYPE; Windows -# therefore accepts either. -_FUNCFLAG_CDECL = getattr(ct, "_FUNCFLAG_CDECL", 0x1) -_FUNCFLAG_STDCALL = getattr(_ctypes, "FUNCFLAG_STDCALL", 0x2) -_FUNCFLAG_PYTHONAPI = getattr(ct, "_FUNCFLAG_PYTHONAPI", 0x4) - +# 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" @@ -49,34 +43,17 @@ def _cuhostfn_type_error(detail): def _validate_ctypes_host_callback(fn): """Reject ctypes callbacks whose declared prototype is not CUhostFn. - Only the ctypes type's ``_restype_`` / ``_argtypes_`` / ``_flags_`` are - checked: that is the ABI the wrapper claims. Instance ``restype`` / - ``argtypes`` overrides are ignored because CUDA invokes the function - pointer directly. + ``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. """ - proto = type(fn) - restype = getattr(proto, "_restype_", None) - argtypes = getattr(proto, "_argtypes_", None) - flags = int(getattr(proto, "_flags_", 0)) - - if restype is not None or argtypes != (ct.c_void_p,): + 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}") - if flags & _FUNCFLAG_PYTHONAPI: - raise _cuhostfn_type_error( - "was declared with ctypes.PYFUNCTYPE, which uses the Python-API " - "calling convention") - - if sys.platform == "win32": - if not (flags & (_FUNCFLAG_CDECL | _FUNCFLAG_STDCALL)): - raise _cuhostfn_type_error("uses an unrecognized calling convention") - elif flags & _FUNCFLAG_STDCALL: - raise _cuhostfn_type_error( - "uses the stdcall calling convention, which applies only to Windows") - elif not (flags & _FUNCFLAG_CDECL): - raise _cuhostfn_type_error("uses an unrecognized calling convention") - cdef void _py_host_trampoline(void* data) noexcept with gil: (data)() 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 6c1f14246ac..0c85b609fbb 100644 --- a/cuda_core/docs/source/release/1.2.0-notes.rst +++ b/cuda_core/docs/source/release/1.2.0-notes.rst @@ -61,8 +61,10 @@ Fixes and enhancements 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. On Windows, both ``ctypes.CFUNCTYPE`` and ``ctypes.WINFUNCTYPE`` - are accepted. + 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_definition.py b/cuda_core/tests/graph/test_graph_definition.py index d3a63b3d40f..0aeb5a9d527 100644 --- a/cuda_core/tests/graph/test_graph_definition.py +++ b/cuda_core/tests/graph/test_graph_definition.py @@ -1166,6 +1166,13 @@ def test_host_callback_user_data_rejected_for_python_callable(sample_graphdef): 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"), ] @@ -1193,6 +1200,27 @@ def good(data): 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):