From 2da81857ac5b4f572f91519223547d957f1062af Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 18 Sep 2026 10:33:05 +0800 Subject: [PATCH 1/7] gh-49960: add ctypes test helpers for struct-returning callbacks --- Modules/_ctypes/_ctypes_test.c | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/Modules/_ctypes/_ctypes_test.c b/Modules/_ctypes/_ctypes_test.c index 991ff0d675c2f1..f43cf25a6e2ba5 100644 --- a/Modules/_ctypes/_ctypes_test.c +++ b/Modules/_ctypes/_ctypes_test.c @@ -72,6 +72,79 @@ _testfunc_cbk_large_struct(Test in, void (*func)(Test)) func(in); } +/* + * gh-49960: callbacks returning structs and unions by value. + * + * Each of these exercises a different ABI return class, since struct + * return conventions are highly platform-specific: + * - SmallRet: small integer struct, returned in registers + * - Test: >8 bytes (reused from above), returned via hidden pointer + * - FloatRet: all-float struct, SSE class on x86-64 / HFA on AArch64 + * - UnionRet: union + * - PtrRet: struct containing a pointer + */ + +typedef struct { + int a; + int b; +} SmallRet; + +EXPORT(SmallRet) +_testfunc_cbk_ret_small_struct(SmallRet (*func)(void)) +{ + return func(); +} + +/* Returns a scalar derived from the struct, so a test can prove the bytes + actually reached the C caller rather than only round-tripping in Python. */ +EXPORT(long) +_testfunc_cbk_ret_small_struct_sum(SmallRet (*func)(void)) +{ + SmallRet s = func(); + return (long)s.a + (long)s.b; +} + +EXPORT(Test) +_testfunc_cbk_ret_large_struct(Test (*func)(void)) +{ + return func(); +} + +typedef struct { + double x; + double y; +} FloatRet; + +EXPORT(FloatRet) +_testfunc_cbk_ret_float_struct(FloatRet (*func)(void)) +{ + return func(); +} + +typedef union { + int i; + float f; +} UnionRet; + +EXPORT(UnionRet) +_testfunc_cbk_ret_union(UnionRet (*func)(void)) +{ + return func(); +} + +/* Struct containing a pointer, for the documented pointer-lifetime contract: + the struct is copied by value, so the pointed-to memory must outlive the + callback. */ +typedef struct { + const char *s; +} PtrRet; + +EXPORT(PtrRet) +_testfunc_cbk_ret_ptr_struct(PtrRet (*func)(void)) +{ + return func(); +} + /* * See issue 29565. Update a structure passed by value; * the caller should not see any change. From 5484950a0ee180b1c125d115e77b263cc9edae78 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 18 Sep 2026 10:34:22 +0800 Subject: [PATCH 2/7] gh-49960: add failing tests for struct/union callback returns --- Lib/test/test_ctypes/test_callbacks.py | 152 ++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_ctypes/test_callbacks.py b/Lib/test/test_ctypes/test_callbacks.py index 6c7c2e5270736e..1beff244196e41 100644 --- a/Lib/test/test_ctypes/test_callbacks.py +++ b/Lib/test/test_ctypes/test_callbacks.py @@ -4,10 +4,11 @@ import math import sys import unittest +import warnings from _ctypes import CTYPES_MAX_ARGCOUNT -from ctypes import (CDLL, cdll, Structure, CFUNCTYPE, +from ctypes import (CDLL, cdll, Structure, Union, CFUNCTYPE, ArgumentError, POINTER, sizeof, - c_byte, c_ubyte, c_char, + c_byte, c_ubyte, c_char, c_char_p, c_short, c_ushort, c_int, c_uint, c_long, c_longlong, c_ulonglong, c_ulong, c_float, c_double, c_longdouble, py_object) @@ -293,6 +294,153 @@ def callback(check, s): self.assertEqual(s.second, check.second) self.assertEqual(s.third, check.third) + # gh-49960: callbacks may return structures and unions by value. + + def _dll(self): + return CDLL(_ctypes_test.__file__) + + def test_callback_return_small_struct(self): + class SmallRet(Structure): + _fields_ = [("a", c_int), ("b", c_int)] + + CALLBACK = CFUNCTYPE(SmallRet) + func = self._dll()._testfunc_cbk_ret_small_struct + func.argtypes = (CALLBACK,) + func.restype = SmallRet + + result = func(CALLBACK(lambda: SmallRet(17, 42))) + self.assertEqual((result.a, result.b), (17, 42)) + + def test_callback_return_struct_reaches_c(self): + # The C helper sums the fields itself, proving the bytes actually + # reached the C caller rather than only round-tripping in Python. + class SmallRet(Structure): + _fields_ = [("a", c_int), ("b", c_int)] + + CALLBACK = CFUNCTYPE(SmallRet) + func = self._dll()._testfunc_cbk_ret_small_struct_sum + func.argtypes = (CALLBACK,) + func.restype = c_long + + self.assertEqual(func(CALLBACK(lambda: SmallRet(300, 45))), 345) + + def test_callback_return_large_struct(self): + # Mirrors `Test` in Modules/_ctypes/_ctypes_test.c: >8 bytes, so it + # is returned via a hidden pointer rather than in registers. + class X(Structure): + _fields_ = [("first", c_ulong), + ("second", c_ulong), + ("third", c_ulong)] + + CALLBACK = CFUNCTYPE(X) + func = self._dll()._testfunc_cbk_ret_large_struct + func.argtypes = (CALLBACK,) + func.restype = X + + result = func(CALLBACK(lambda: X(0xdeadbeef, 0xcafebabe, 0x0bad1dea))) + self.assertEqual(result.first, 0xdeadbeef) + self.assertEqual(result.second, 0xcafebabe) + self.assertEqual(result.third, 0x0bad1dea) + + def test_callback_return_float_struct(self): + # All-float struct: SSE class on x86-64, HFA on AArch64. + class FloatRet(Structure): + _fields_ = [("x", c_double), ("y", c_double)] + + CALLBACK = CFUNCTYPE(FloatRet) + func = self._dll()._testfunc_cbk_ret_float_struct + func.argtypes = (CALLBACK,) + func.restype = FloatRet + + result = func(CALLBACK(lambda: FloatRet(1.5, -2.25))) + self.assertEqual((result.x, result.y), (1.5, -2.25)) + + def test_callback_return_union(self): + class UnionRet(Union): + _fields_ = [("i", c_int), ("f", c_float)] + + CALLBACK = CFUNCTYPE(UnionRet) + func = self._dll()._testfunc_cbk_ret_union + func.argtypes = (CALLBACK,) + func.restype = UnionRet + + result = func(CALLBACK(lambda: UnionRet(i=0x41424344))) + self.assertEqual(result.i, 0x41424344) + + def test_callback_return_struct_repeatedly(self): + # Guards against per-call leaks and against regressing into the + # "memory leak in callback function" RuntimeWarning path. + class SmallRet(Structure): + _fields_ = [("a", c_int), ("b", c_int)] + + CALLBACK = CFUNCTYPE(SmallRet) + func = self._dll()._testfunc_cbk_ret_small_struct_sum + func.argtypes = (CALLBACK,) + func.restype = c_long + + cb = CALLBACK(lambda: SmallRet(1, 2)) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + for _ in range(100): + self.assertEqual(func(cb), 3) + + def test_callback_return_struct_subclass(self): + # isinstance semantics: a subclass instance is acceptable. + class SmallRet(Structure): + _fields_ = [("a", c_int), ("b", c_int)] + + class SubRet(SmallRet): + pass + + CALLBACK = CFUNCTYPE(SmallRet) + func = self._dll()._testfunc_cbk_ret_small_struct_sum + func.argtypes = (CALLBACK,) + func.restype = c_long + + self.assertEqual(func(CALLBACK(lambda: SubRet(4, 5))), 9) + + def test_callback_return_struct_wrong_type(self): + class SmallRet(Structure): + _fields_ = [("a", c_int), ("b", c_int)] + + class Other(Structure): + _fields_ = [("q", c_int)] + + CALLBACK = CFUNCTYPE(SmallRet) + func = self._dll()._testfunc_cbk_ret_small_struct_sum + func.argtypes = (CALLBACK,) + func.restype = c_long + + for bad in (None, 42, Other(1)): + with self.subTest(bad=bad): + def cb(bad=bad): + return bad + with support.catch_unraisable_exception() as cm: + # The buffer is zeroed on failure, so the sum is 0. + self.assertEqual(func(CALLBACK(cb)), 0) + self.assertIsInstance(cm.unraisable.exc_value, TypeError) + self.assertEqual( + cm.unraisable.err_msg, + f"Exception ignored while converting result " + f"of ctypes callback function {cb!r}") + + def test_callback_return_struct_with_pointer(self): + # gh-49960 / bpo-5710 discussion: the struct is copied by value, so + # any pointer it contains must reference memory the caller keeps + # alive. Here `keepalive` does exactly that. + class WithPtr(Structure): + _fields_ = [("s", c_char_p)] + + keepalive = b"hello" + + CALLBACK = CFUNCTYPE(WithPtr) + func = self._dll()._testfunc_cbk_ret_ptr_struct + func.argtypes = (CALLBACK,) + func.restype = WithPtr + + result = func(CALLBACK(lambda: WithPtr(keepalive))) + self.assertEqual(result.s, b"hello") + def test_callback_too_many_args(self): def func(*args): return len(args) From 63ee2fb133bc87f03a4ec0c84b4d7bcbe877cfbf Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 18 Sep 2026 10:35:33 +0800 Subject: [PATCH 3/7] gh-49960: allow struct and union restypes for ctypes callbacks --- Modules/_ctypes/callbacks.c | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Modules/_ctypes/callbacks.c b/Modules/_ctypes/callbacks.c index fd508ae61f2e04..8296dc55c61d3e 100644 --- a/Modules/_ctypes/callbacks.c +++ b/Modules/_ctypes/callbacks.c @@ -371,10 +371,22 @@ CThunkObject *_ctypes_alloc_callback(ctypes_state *st, goto error; } - if (info == NULL || info->setfunc == NULL) { - PyErr_SetString(PyExc_TypeError, - "invalid result type for callback function"); - goto error; + if (info == NULL) { + PyErr_SetString(PyExc_TypeError, + "invalid result type for callback function"); + goto error; + } + /* gh-49960: structs and unions have no setfunc (that is reserved for + "simple" types), but can still be returned by value. Leaving + p->setfunc as NULL signals the struct-return path in + _CallPythonObject. */ + if (info->setfunc == NULL + && !PyCStructTypeObject_Check(st, restype) + && !PyObject_TypeCheck(restype, st->UnionType_Type)) + { + PyErr_SetString(PyExc_TypeError, + "invalid result type for callback function"); + goto error; } p->setfunc = info->setfunc; p->ffi_restype = &info->ffi_type_pointer; From d2363f19cea423a1774182ac97677ef568025e9e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 18 Sep 2026 10:37:53 +0800 Subject: [PATCH 4/7] gh-49960: copy struct/union results from ctypes callbacks --- Modules/_ctypes/callbacks.c | 97 ++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 28 deletions(-) diff --git a/Modules/_ctypes/callbacks.c b/Modules/_ctypes/callbacks.c index 8296dc55c61d3e..7e8f89737f27f7 100644 --- a/Modules/_ctypes/callbacks.c +++ b/Modules/_ctypes/callbacks.c @@ -111,6 +111,7 @@ TryAddRef(PyObject *cnv, CDataObject *obj) static void _CallPythonObject(ctypes_state *st, void *mem, ffi_type *restype, + PyObject *restype_obj, SETFUNC setfunc, PyObject *callable, PyObject *converters, @@ -220,47 +221,86 @@ static void _CallPythonObject(ctypes_state *st, Py_XDECREF(error_object); if (restype != &ffi_type_void && result) { - assert(setfunc); #ifdef WORDS_BIGENDIAN /* See the corresponding code in _ctypes_callproc(): - in callproc.c, around line 1219. */ - if (restype->type != FFI_TYPE_FLOAT && restype->size < sizeof(ffi_arg)) { + in callproc.c, around line 1330. */ + if (restype->type != FFI_TYPE_FLOAT + && restype->type != FFI_TYPE_STRUCT + && restype->size < sizeof(ffi_arg)) + { mem = (char *)mem + sizeof(ffi_arg) - restype->size; } #endif - /* keep is an object we have to keep alive so that the result - stays valid. If there is no such object, the setfunc will - have returned Py_None. - - If there is such an object, we have no choice than to keep - it alive forever - but a refcount and/or memory leak will - be the result. EXCEPT when restype is py_object - Python - itself knows how to manage the refcount of these objects. - */ - PyObject *keep = setfunc(mem, result, restype->size); - - if (keep == NULL) { - /* Could not convert callback result. */ - PyErr_FormatUnraisable( - "Exception ignored while converting result " - "of ctypes callback function %R", - callable); - } - else if (setfunc != _ctypes_get_fielddesc("O")->setfunc) { - if (keep == Py_None) { - /* Nothing to keep */ - Py_DECREF(keep); + if (setfunc == NULL) { + /* gh-49960: struct/union return. There is no setfunc for these + types, so copy the bytes out of the CData object directly. + The struct is copied by value and no object is kept alive, so + any pointer it contains must reference memory the caller keeps + alive - the same contract C imposes. */ + int ok = 0; + if (CDataObject_Check(st, result)) { + int is_inst = PyObject_IsInstance(result, restype_obj); + if (is_inst < 0) { + /* Discard this failure; the TypeError raised below is the + more useful report and only one can be shown. */ + PyErr_Clear(); + } + else if (is_inst) { + CDataObject *cd = (CDataObject *)result; + Py_BEGIN_CRITICAL_SECTION(cd); + memcpy(mem, cd->b_ptr, restype->size); + Py_END_CRITICAL_SECTION(); + ok = 1; + } + } + if (!ok) { + /* Zero the buffer so the C caller sees deterministic zeros + rather than uninitialised memory. */ + memset(mem, 0, restype->size); + PyErr_Format(PyExc_TypeError, + "ctypes callback function returned unexpected " + "type %T", result); + PyErr_FormatUnraisable( + "Exception ignored while converting result " + "of ctypes callback function %R", + callable); } - else if (PyErr_WarnEx(PyExc_RuntimeWarning, - "memory leak in callback function.", - 1) == -1) { + } + else { + /* keep is an object we have to keep alive so that the result + stays valid. If there is no such object, the setfunc will + have returned Py_None. + + If there is such an object, we have no choice than to keep + it alive forever - but a refcount and/or memory leak will + be the result. EXCEPT when restype is py_object - Python + itself knows how to manage the refcount of these objects. + */ + PyObject *keep = setfunc(mem, result, restype->size); + + if (keep == NULL) { + /* Could not convert callback result. */ PyErr_FormatUnraisable( "Exception ignored while converting result " "of ctypes callback function %R", callable); } + else if (setfunc != _ctypes_get_fielddesc("O")->setfunc) { + if (keep == Py_None) { + /* Nothing to keep */ + Py_DECREF(keep); + } + else if (PyErr_WarnEx(PyExc_RuntimeWarning, + "memory leak in callback function.", + 1) == -1) { + PyErr_FormatUnraisable( + "Exception ignored while converting result " + "of ctypes callback function %R", + callable); + } + } } } @@ -293,6 +333,7 @@ static void closure_fcn(ffi_cif *cif, _CallPythonObject(st, resp, p->ffi_restype, + p->restype, p->setfunc, p->callable, p->converters, From 94c0f6084c56eda46702f7cbcffee6ed1de87672 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 18 Sep 2026 10:41:22 +0800 Subject: [PATCH 5/7] gh-49960: document struct and union returns from ctypes callbacks --- Doc/library/ctypes.rst | 25 +++++++++++++++++++ ...6-09-18-00-00-00.gh-issue-49960.MDT3jE.rst | 3 +++ 2 files changed, 28 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-09-18-00-00-00.gh-issue-49960.MDT3jE.rst diff --git a/Doc/library/ctypes.rst b/Doc/library/ctypes.rst index 1d33f593fffe94..0102ae16fb892d 100644 --- a/Doc/library/ctypes.rst +++ b/Doc/library/ctypes.rst @@ -1322,6 +1322,31 @@ write:: :class:`threading.local` will *not* survive across different callbacks, even when those calls are made from the same C thread. +Callback functions can return structures and unions by value, in addition to +the simple types: + +.. code-block:: python + + class Point(Structure): + _fields_ = [("x", c_int), ("y", c_int)] + + @CFUNCTYPE(Point) + def get_origin(): + return Point(0, 0) + +.. note:: + + A structure or union returned from a callback is copied *by value*, and + :mod:`!ctypes` does not keep the returned object alive after the callback + returns. If the structure contains a pointer field (such as + :class:`c_char_p` or a :func:`POINTER` type), you must ensure that the + memory it refers to stays valid for as long as the C code uses it -- the + same requirement C itself imposes. Returning a pointer to memory owned by + a temporary Python object will leave the C caller with a dangling pointer. + +.. versionchanged:: next + Callback functions can now return structures and unions. + .. _ctypes-accessing-values-exported-from-dlls: Accessing values exported from dlls diff --git a/Misc/NEWS.d/next/Library/2026-09-18-00-00-00.gh-issue-49960.MDT3jE.rst b/Misc/NEWS.d/next/Library/2026-09-18-00-00-00.gh-issue-49960.MDT3jE.rst new file mode 100644 index 00000000000000..9793c1143c7d0d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-18-00-00-00.gh-issue-49960.MDT3jE.rst @@ -0,0 +1,3 @@ +:mod:`ctypes` callback functions can now return structures and unions by +value. Previously only "simple" types could be returned, and any other result +type raised :exc:`TypeError`. From 58943768ae9d7dc10c2bea5626e839d850a7682d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 23 Sep 2026 10:25:22 +0800 Subject: [PATCH 6/7] Clean up test cases for consistency. --- Lib/test/test_ctypes/test_callbacks.py | 30 +++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/Lib/test/test_ctypes/test_callbacks.py b/Lib/test/test_ctypes/test_callbacks.py index 1beff244196e41..7968d5bdce5d50 100644 --- a/Lib/test/test_ctypes/test_callbacks.py +++ b/Lib/test/test_ctypes/test_callbacks.py @@ -296,15 +296,13 @@ def callback(check, s): # gh-49960: callbacks may return structures and unions by value. - def _dll(self): - return CDLL(_ctypes_test.__file__) - def test_callback_return_small_struct(self): class SmallRet(Structure): _fields_ = [("a", c_int), ("b", c_int)] CALLBACK = CFUNCTYPE(SmallRet) - func = self._dll()._testfunc_cbk_ret_small_struct + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_small_struct func.argtypes = (CALLBACK,) func.restype = SmallRet @@ -318,7 +316,8 @@ class SmallRet(Structure): _fields_ = [("a", c_int), ("b", c_int)] CALLBACK = CFUNCTYPE(SmallRet) - func = self._dll()._testfunc_cbk_ret_small_struct_sum + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_small_struct_sum func.argtypes = (CALLBACK,) func.restype = c_long @@ -333,7 +332,8 @@ class X(Structure): ("third", c_ulong)] CALLBACK = CFUNCTYPE(X) - func = self._dll()._testfunc_cbk_ret_large_struct + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_large_struct func.argtypes = (CALLBACK,) func.restype = X @@ -348,7 +348,8 @@ class FloatRet(Structure): _fields_ = [("x", c_double), ("y", c_double)] CALLBACK = CFUNCTYPE(FloatRet) - func = self._dll()._testfunc_cbk_ret_float_struct + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_float_struct func.argtypes = (CALLBACK,) func.restype = FloatRet @@ -360,7 +361,8 @@ class UnionRet(Union): _fields_ = [("i", c_int), ("f", c_float)] CALLBACK = CFUNCTYPE(UnionRet) - func = self._dll()._testfunc_cbk_ret_union + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_union func.argtypes = (CALLBACK,) func.restype = UnionRet @@ -374,7 +376,8 @@ class SmallRet(Structure): _fields_ = [("a", c_int), ("b", c_int)] CALLBACK = CFUNCTYPE(SmallRet) - func = self._dll()._testfunc_cbk_ret_small_struct_sum + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_small_struct_sum func.argtypes = (CALLBACK,) func.restype = c_long @@ -393,7 +396,8 @@ class SubRet(SmallRet): pass CALLBACK = CFUNCTYPE(SmallRet) - func = self._dll()._testfunc_cbk_ret_small_struct_sum + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_small_struct_sum func.argtypes = (CALLBACK,) func.restype = c_long @@ -407,7 +411,8 @@ class Other(Structure): _fields_ = [("q", c_int)] CALLBACK = CFUNCTYPE(SmallRet) - func = self._dll()._testfunc_cbk_ret_small_struct_sum + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_small_struct_sum func.argtypes = (CALLBACK,) func.restype = c_long @@ -434,7 +439,8 @@ class WithPtr(Structure): keepalive = b"hello" CALLBACK = CFUNCTYPE(WithPtr) - func = self._dll()._testfunc_cbk_ret_ptr_struct + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_ptr_struct func.argtypes = (CALLBACK,) func.restype = WithPtr From bf97330255302e55256eaaa3a4b53932cd55342b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 22 Oct 2026 09:20:51 -0700 Subject: [PATCH 7/7] Address review feedback. - Check the callback result with PyObject_TypeCheck rather than PyObject_IsInstance, so a metaclass __instancecheck__ can't cause a smaller buffer to be accepted (an out-of-bounds read). - Mark the struct/union result type's _fields_ final when the callback is created, since the libffi cif depends on its layout. - Don't zero the result buffer on conversion failure, for consistency with other result types. - Detect unraisable exceptions in the repeated-call test. - Use ctypes.util.struct for test structures. - Add a What's New entry. --- Doc/whatsnew/3.16.rst | 4 + Lib/test/test_ctypes/test_callbacks.py | 113 +++++++++++++++++++------ Modules/_ctypes/callbacks.c | 36 ++++---- 3 files changed, 109 insertions(+), 44 deletions(-) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 925f425aba5869..7995da01285824 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -244,6 +244,10 @@ ctypes implicitly converted when passed for a :func:`~ctypes.POINTER` argument. (Contributed by Peter Bierma in :gh:`71990`.) +* :ref:`Callback functions ` can now return + :class:`~ctypes.Structure` and :class:`~ctypes.Union` instances by value. + (Contributed by Russell Keith-Magee in :gh:`49960`.) + curses ------ diff --git a/Lib/test/test_ctypes/test_callbacks.py b/Lib/test/test_ctypes/test_callbacks.py index b9e7060fd91ee5..c7f287d9fb6293 100644 --- a/Lib/test/test_ctypes/test_callbacks.py +++ b/Lib/test/test_ctypes/test_callbacks.py @@ -12,7 +12,7 @@ c_short, c_ushort, c_int, c_uint, c_long, c_longlong, c_ulonglong, c_ulong, c_float, c_double, c_longdouble, py_object) -from ctypes.util import find_library, wrap_dll_function +from ctypes.util import find_library, struct, wrap_dll_function from test import support from test.support import import_helper _ctypes_test = import_helper.import_module("_ctypes_test") @@ -297,8 +297,10 @@ def callback(check, s): # gh-49960: callbacks may return structures and unions by value. def test_callback_return_small_struct(self): - class SmallRet(Structure): - _fields_ = [("a", c_int), ("b", c_int)] + @struct + class SmallRet: + a: c_int + b: c_int CALLBACK = CFUNCTYPE(SmallRet) dll = CDLL(_ctypes_test.__file__) @@ -312,8 +314,10 @@ class SmallRet(Structure): def test_callback_return_struct_reaches_c(self): # The C helper sums the fields itself, proving the bytes actually # reached the C caller rather than only round-tripping in Python. - class SmallRet(Structure): - _fields_ = [("a", c_int), ("b", c_int)] + @struct + class SmallRet: + a: c_int + b: c_int CALLBACK = CFUNCTYPE(SmallRet) dll = CDLL(_ctypes_test.__file__) @@ -326,10 +330,11 @@ class SmallRet(Structure): def test_callback_return_large_struct(self): # Mirrors `Test` in Modules/_ctypes/_ctypes_test.c: >8 bytes, so it # is returned via a hidden pointer rather than in registers. - class X(Structure): - _fields_ = [("first", c_ulong), - ("second", c_ulong), - ("third", c_ulong)] + @struct + class X: + first: c_ulong + second: c_ulong + third: c_ulong CALLBACK = CFUNCTYPE(X) dll = CDLL(_ctypes_test.__file__) @@ -344,8 +349,10 @@ class X(Structure): def test_callback_return_float_struct(self): # All-float struct: SSE class on x86-64, HFA on AArch64. - class FloatRet(Structure): - _fields_ = [("x", c_double), ("y", c_double)] + @struct + class FloatRet: + x: c_double + y: c_double CALLBACK = CFUNCTYPE(FloatRet) dll = CDLL(_ctypes_test.__file__) @@ -372,8 +379,10 @@ class UnionRet(Union): def test_callback_return_struct_repeatedly(self): # Guards against per-call leaks and against regressing into the # "memory leak in callback function" RuntimeWarning path. - class SmallRet(Structure): - _fields_ = [("a", c_int), ("b", c_int)] + @struct + class SmallRet: + a: c_int + b: c_int CALLBACK = CFUNCTYPE(SmallRet) dll = CDLL(_ctypes_test.__file__) @@ -382,15 +391,21 @@ class SmallRet(Structure): func.restype = c_long cb = CALLBACK(lambda: SmallRet(1, 2)) + # A RuntimeWarning raised inside the callback is reported as an + # unraisable exception rather than propagated, so check for those. with warnings.catch_warnings(): warnings.simplefilter("error", RuntimeWarning) - for _ in range(100): - self.assertEqual(func(cb), 3) + with support.catch_unraisable_exception() as cm: + for _ in range(100): + self.assertEqual(func(cb), 3) + self.assertIsNone(cm.unraisable) def test_callback_return_struct_subclass(self): # isinstance semantics: a subclass instance is acceptable. - class SmallRet(Structure): - _fields_ = [("a", c_int), ("b", c_int)] + @struct + class SmallRet: + a: c_int + b: c_int class SubRet(SmallRet): pass @@ -404,11 +419,14 @@ class SubRet(SmallRet): self.assertEqual(func(CALLBACK(lambda: SubRet(4, 5))), 9) def test_callback_return_struct_wrong_type(self): - class SmallRet(Structure): - _fields_ = [("a", c_int), ("b", c_int)] + @struct + class SmallRet: + a: c_int + b: c_int - class Other(Structure): - _fields_ = [("q", c_int)] + @struct + class Other: + q: c_int CALLBACK = CFUNCTYPE(SmallRet) dll = CDLL(_ctypes_test.__file__) @@ -421,8 +439,9 @@ class Other(Structure): def cb(bad=bad): return bad with support.catch_unraisable_exception() as cm: - # The buffer is zeroed on failure, so the sum is 0. - self.assertEqual(func(CALLBACK(cb)), 0) + # As with other result types, the value returned to C + # after a conversion failure is undefined; don't test it. + func(CALLBACK(cb)) self.assertIsInstance(cm.unraisable.exc_value, TypeError) self.assertEqual( cm.unraisable.err_msg, @@ -433,8 +452,9 @@ def test_callback_return_struct_with_pointer(self): # gh-49960 / bpo-5710 discussion: the struct is copied by value, so # any pointer it contains must reference memory the caller keeps # alive. Here `keepalive` does exactly that. - class WithPtr(Structure): - _fields_ = [("s", c_char_p)] + @struct + class WithPtr: + s: c_char_p keepalive = b"hello" @@ -447,6 +467,49 @@ class WithPtr(Structure): result = func(CALLBACK(lambda: WithPtr(keepalive))) self.assertEqual(result.s, b"hello") + def test_callback_return_struct_ignores_instancecheck(self): + # The result type is checked without running Python code, so a + # metaclass __instancecheck__ cannot cause a smaller structure to be + # accepted (which would read past the end of its buffer). + class Meta(type(Structure)): + def __instancecheck__(cls, inst): + return True + + # Not @struct: it would rebuild the class and drop the metaclass. + class SmallRet(Structure, metaclass=Meta): + _fields_ = [("a", c_int), ("b", c_int)] + + @struct + class Tiny: + x: c_byte + + CALLBACK = CFUNCTYPE(SmallRet) + dll = CDLL(_ctypes_test.__file__) + func = dll._testfunc_cbk_ret_small_struct_sum + func.argtypes = (CALLBACK,) + func.restype = c_long + + def cb(): + return Tiny(5) + with support.catch_unraisable_exception() as cm: + func(CALLBACK(cb)) + self.assertIsInstance(cm.unraisable.exc_value, TypeError) + + def test_callback_return_struct_fields_final(self): + # Once a structure is used as a callback's result type, its layout + # is fixed: the callback's libffi signature depends on it. + @struct + class Base: + a: c_int + b: c_int + + class Sub(Base): + pass + + CFUNCTYPE(Sub)(lambda: Sub(1, 2)) + with self.assertRaisesRegex(AttributeError, "_fields_ is final"): + Sub._fields_ = [("c", c_double)] + def test_callback_too_many_args(self): def func(*args): return len(args) diff --git a/Modules/_ctypes/callbacks.c b/Modules/_ctypes/callbacks.c index 680dd4aa44f46a..8395e9eaf3505e 100644 --- a/Modules/_ctypes/callbacks.c +++ b/Modules/_ctypes/callbacks.c @@ -262,26 +262,19 @@ static void _CallPythonObject(ctypes_state *st, any pointer it contains must reference memory the caller keeps alive - the same contract C imposes. */ assert(!narrow); - int ok = 0; - if (CDataObject_Check(st, result)) { - int is_inst = PyObject_IsInstance(result, restype_obj); - if (is_inst < 0) { - /* Discard this failure; the TypeError raised below is the - more useful report and only one can be shown. */ - PyErr_Clear(); - } - else if (is_inst) { - CDataObject *cd = (CDataObject *)result; - Py_BEGIN_CRITICAL_SECTION(cd); - memcpy(mem, cd->b_ptr, restype->size); - Py_END_CRITICAL_SECTION(); - ok = 1; - } + /* Use a real subtype check rather than PyObject_IsInstance(): + a metaclass __instancecheck__ could otherwise accept an object + whose buffer is smaller than restype->size. */ + if (PyObject_TypeCheck(result, (PyTypeObject *)restype_obj)) { + /* restype_obj is a struct or union type (enforced in + _ctypes_alloc_callback), so its instances are CData. */ + assert(CDataObject_Check(st, result)); + CDataObject *cd = (CDataObject *)result; + Py_BEGIN_CRITICAL_SECTION(cd); + memcpy(mem, cd->b_ptr, restype->size); + Py_END_CRITICAL_SECTION(); } - if (!ok) { - /* Zero the buffer so the C caller sees deterministic zeros - rather than uninitialised memory. */ - memset(mem, 0, restype->size); + else { PyErr_Format(PyExc_TypeError, "ctypes callback function returned unexpected " "type %T", result); @@ -477,6 +470,11 @@ CThunkObject *_ctypes_alloc_callback(ctypes_state *st, "invalid result type for callback function"); goto error; } + if (info->setfunc == NULL) { + /* The cif built below depends on the struct/union layout, so + _fields_ must not be (re)assigned after this point. */ + stginfo_set_dict_final(info); + } p->setfunc = info->setfunc; p->ffi_restype = &info->ffi_type_pointer; }