Skip to content

ctypes array cache can return distinct types when types are created concurrently #160978

Open

Description

@ngoldbaum

Bug report

On the free-threaded build, this fails with TypeError: expected c_int_Array_1 instance instead of c_int_Array_1:

from concurrent.futures import ThreadPoolExecutor
from ctypes import c_int
from threading import Barrier

barrier = Barrier(8)

def make_type(n):
    barrier.wait()
    return c_int * n

with ThreadPoolExecutor(8) as pool:
    for n in range(1, 1001):
        types = list(pool.map(make_type, [n] * 8))
        for t in types:
            types[0].from_param(t())

It passes on the GIL-enabled build because there isn't an opportunity for a GIL thread switch.

If I instead create a type that has a Python hook, I can deterministically trigger the same race on the GIL-enabled build as well:

import ctypes
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier

barrier = Barrier(2)

class Meta(type(ctypes.c_int)):
    def __set_name__(cls, owner, name):
        barrier.wait(timeout=5)

class C(ctypes.c_int, metaclass=Meta):
    pass

with ThreadPoolExecutor(2) as pool:
    a, b = pool.map(lambda _: C * 100, range(2))

print(a is b)
a.from_param(b())

This second script prints False and then TypeError: expected C_Array_100 instance instead of C_Array_100 on both builds, because defining __set_name__ makes python code run after the cache miss on the array type cache during creation of the array class.

I think the fix is to rewrite how PyCArrayType_from_ctype uses the array_cache:

PyObject *
PyCArrayType_from_ctype(ctypes_state *st, PyObject *itemtype, Py_ssize_t length)
{
PyObject *key;
char name[256];
PyObject *len;
assert(st->array_cache != NULL);
len = PyLong_FromSsize_t(length);
if (len == NULL)
return NULL;
key = _PyTuple_FromPairSteal(Py_NewRef(itemtype), len);
if (!key)
return NULL;
PyObject *result;
if (_PyDict_GetItemProxy(st->array_cache, key, &result) != 0) {
// found or error
Py_DECREF(key);
return result;
}
// not found
if (!PyType_Check(itemtype)) {
PyErr_SetString(PyExc_TypeError,
"Expected a type object");
Py_DECREF(key);
return NULL;
}
#ifdef MS_WIN64
sprintf(name, "%.200s_Array_%Id",
((PyTypeObject *)itemtype)->tp_name, length);
#else
sprintf(name, "%.200s_Array_%ld",
((PyTypeObject *)itemtype)->tp_name, (long)length);
#endif
result = PyObject_CallFunction((PyObject *)st->PyCArrayType_Type,
"s(O){s:n,s:O}",
name,
st->PyCArray_Type,
"_length_",
length,
"_type_",
itemtype
);
if (result == NULL) {
Py_DECREF(key);
return NULL;
}
if (PyDict_SetItemProxy(st, st->array_cache, key, result) < 0) {
Py_DECREF(key);
Py_DECREF(result);
return NULL;
}
Py_DECREF(key);
return result;

The current implementation has a check-then-insert TOCTOU bug.

I found this looking at the root cause of numpy/numpy#29943. See also #127945.

CPython versions tested on:

3.14, 3.15, 3.16, main

Operating systems tested on:

macOS (M1)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions