Repository: cython/cython
Issue category: Bug Report

Issue:
[BUG] No matching signature found on free-threaded Python code generation
### Describe the bug

Whilst testing SciPy for issues against free-threaded CPython 3.13 https://github.com/scipy/scipy/pull/21496, we have observed several instances of errors on functions that fuse dtypes, for some reason, these errors are being flagged when running tests under parallel conditions, and not under single threading.

```
__________________________________________________________________ test_label03[numpy] __________________________________________________________________
scipy/ndimage/tests/test_measurements.py:169: in test_label03
    out, n = ndimage.label(data)
        data       = array([1.])
        xp         = <module 'numpy' from '/home/rgommers/code/pixi-dev-scipystack/scipy/.pixi/envs/free-threading/lib/python3.13t/site-packages/numpy/__init__.py'>
scipy/ndimage/_support_alternative_backends.py:37: in wrapper
    result = func(*args, **kwds)
        args       = (array([1.]),)
        delegator  = <function binary_fill_holes_signature at 0x385421dfa00>
        func       = <function label at 0x385421de9c0>
        kwds       = {}
        module_name = 'ndimage'
        xp         = <module 'scipy._lib.array_api_compat.numpy' from '/home/rgommers/code/pixi-dev-scipystack/scipy/scipy/build-nogil-install/lib/python3.13t/site-packages/scipy/_lib/array_api_compat/numpy/__init__.py'>
scipy/ndimage/_measurements.py:218: in label
    max_label = _ni_label._label(input, structure, output)
        caller_provided_output = False
        ii         = 3
        input      = array([1.])
        need_64bits = False
        output     = array([-889028608], dtype=int32)
        structure  = array([ True,  True,  True])
_ni_label.pyx:200: in _ni_label._label
    ???
_ni_label.pyx:237: in _ni_label._label
    ???
_ni_label.pyx:93: in _ni_label.__pyx_fused_cpdef
    ???
E   TypeError: No matching signature found
```

The generated C code for the Cython instance is described in the following comment: https://github.com/scipy/scipy/pull/21496#discussion_r1850947457

I will provide a minimal reproducible example soon. 

cc @rgommers 

### Code to reproduce the behaviour:

_No response_

### Expected behaviour

_No response_

### OS

Linux

### Python version

3.13t

### Cython version

_No response_

### Additional context

_No response_


Hints:
There's definitely some caching inside the fused dispatch function (so it's quicker to match on subsequent calls). On a quick look, I'm not convinced that caching is currently thread-safe. I'll look into it. 

(There's also some code that imports numpy and gets the dtype. That's less likely to be an issue, but probably still needs a close look)
> The generated C code for the Cython instance is described in the following comment: [...]

That's probably actually a lower level than it's worth looking at. In this case it goes "fused function"->"generated Cython code"->"generated C code" and the issue is in the intermediate Cython code.

I think this should apply to any cpdef or def fused function so there isn't too much value trying to prepare a specific reproducible example.

Patch:
diff --git a/Cython/Compiler/FusedNode.py b/Cython/Compiler/FusedNode.py
index 1b0ceb72ca0..9cb5f4e9059 100644
--- a/Cython/Compiler/FusedNode.py
+++ b/Cython/Compiler/FusedNode.py
@@ -643,11 +643,25 @@ def _fused_signature_index(self, pyx_code):
         Generate Cython code for constructing a persistent nested dictionary index of
         fused type specialization signatures.
         """
+        # Note on thread-safety:
+        # Filling in "fused_sigindex" should only happen once. However, in a multi-threaded
+        # environment it's possible that multiple threads can all start to fill it in
+        # independently (especially on freehtreading builds).
+        # Therefore:
+        # * "_fused_sigindex_ref" is a list of length 1 where the first element is either None,
+        #   or a dictionary of signatures to lookup.
+        # * We rely on being able to get/set list elements atomically (which is true on
+        #   freethreading and regular Python).
+        # * It doesn't really matter if multiple threads start generating their own version
+        #   of this - the contents will end up the same. The main point is that no thread
+        #   sees a half filled-in sigindex
         pyx_code.put_chunk(
             """
-                if not _fused_sigindex:
+                fused_sigindex = <dict> _fused_sigindex_ref[0]
+                if fused_sigindex is None:
+                    fused_sigindex = {}
                     for sig in <dict> signatures:
-                        sigindex_node = <dict> _fused_sigindex
+                        sigindex_node = fused_sigindex
                         *sig_series, last_type = sig.strip('()').split('|')
                         for sig_type in sig_series:
                             if sig_type not in sigindex_node:
@@ -655,6 +669,7 @@ def _fused_signature_index(self, pyx_code):
                             else:
                                 sigindex_node = <dict> sigindex_node[sig_type]
                         sigindex_node[last_type] = sig
+                    _fused_sigindex_ref[0] = fused_sigindex
             """
         )
 
@@ -694,7 +709,7 @@ def make_fused_cpdef(self, orig_py_func, env, is_def):
 
         pyx_code.put_chunk(
             """
-                def __pyx_fused_cpdef(signatures, args, kwargs, defaults, _fused_sigindex={}):
+                def __pyx_fused_cpdef(signatures, args, kwargs, defaults, _fused_sigindex_ref=[None]):
                     # FIXME: use a typed signature - currently fails badly because
                     #        default arguments inherit the types we specify here!
 
@@ -773,7 +788,7 @@ def __pyx_fused_cpdef(signatures, args, kwargs, defaults, _fused_sigindex={}):
         pyx_code.put_chunk(
             """
                 sigindex_matches = []
-                sigindex_candidates = [_fused_sigindex]
+                sigindex_candidates = [fused_sigindex]
 
                 for dst_type in dest_sig:
                     found_matches = []


Test patch:
diff --git a/tests/run/fused_def.pyx b/tests/run/fused_def.pyx
index 96fcb93ea2d..2da9ddb89cb 100644
--- a/tests/run/fused_def.pyx
+++ b/tests/run/fused_def.pyx
@@ -461,3 +461,53 @@ cdef class HasBound:
     func = bind_me[float]
 
     func_fused = bind_me
+
+
+
+ctypedef fused IntOrFloat1:
+    int
+    float
+
+ctypedef fused IntOrFloat2:
+    int
+    float
+
+ctypedef fused IntOrFloat3:
+    int
+    float
+
+def really_simple_fused_function(IntOrFloat1 a, IntOrFloat2 b, IntOrFloat3 c):
+    # Don't use this function for anything except the thread safety stress test.
+    # The first call should be from that.
+    return (a + 1) * 2 + (b*c)
+
+def run_really_simple_fused_function(start_barrier, n_iters, failed_list):
+    # Maximize the chance of failure by waiting until all threads are ready to start
+    args = [ n if n % 2 else float(n) for n in range(n_iters) ]
+    try:
+        start_barrier.wait()
+        for a in args:
+            really_simple_fused_function(a, a, a)
+    except:
+        failed_list.append(True)
+
+
+def stress_test_thread_safety(n_threads):
+    """
+    >>> stress_test_thread_safety(20)
+    """
+    from threading import Barrier, Thread
+    start_barrier = Barrier(n_threads)
+
+    failed_list = []
+
+    threads = [
+        Thread(
+            target=run_really_simple_fused_function,
+            args=[start_barrier, 30, failed_list]
+        ) for _ in range(n_threads) ]
+    for t in threads:
+        t.start()
+    for t in threads:
+        t.join()
+    assert not failed_list, len(failed_list)