Repository navigation
functools.lru_cache critical section locks self but _LockHeld dict APIs assert self->cache is locked #148180
Description
Crash report
What happened?
On a debug free-threaded build, functools.lru_cache crashes with a critical section assertion failure when gc.get_objects() is called between cache uses.
bounded_lru_cache_wrapper (line 1498) acquires Py_BEGIN_CRITICAL_SECTION(self), locking the lru_cache object's mutex. It then calls bounded_lru_cache_get_lock_held (line 1325) which calls _PyDict_GetItemRef_KnownHash_LockHeld on self->cache. The _LockHeld variant asserts that the dict's per-object mutex is held (_Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(mp) at dictobject.c:1254), but only self's mutex is held — self and self->cache have different mutexes.
In 3.15 we have this abort message:
Assertion "(cs != ((void*)0) && cs->_cs_mutex == mutex)" failed: Critical section of object is not held
This was introduced in gh-131757 (PR #131758), which split the critical section to allow the cached function to execute concurrently. The same pattern was extended in gh-132641 (PR #133787) which added more _LockHeld calls following the same assumption.
On non-debug builds the assertion is compiled out and the code happens to work in practice (no actual contention in the reproducer), but the lock contract is violated.
Reproducer
Requires a debug free-threaded build (e.g., --with-pydebug --disable-gil).
from functools import lru_cache
import gc
@lru_cache(maxsize=32)
def get_pep(num):
return type('', (), {})()
for n in 8, 290, 308, 320, 8, 218, 320, 279, 289, 320, 9991:
pep = get_pep(n)
print(n, pep)
objs = gc.get_objects()
print(dir(get_pep))
for n in 8, 290, 308, 320, 8, 218, 320, 279, 289, 320, 9991:
pep = get_pep(n)
print(n, pep)Backtrace on 3.14 (trimmed):
python: ./Include/internal/pycore_critical_section.h:226: void _PyCriticalSection_AssertHeld(PyMutex *): Assertion `cs != NULL && cs->_cs_mutex == mutex' failed.
[...]
#5 _PyCriticalSection_AssertHeld (mutex=...) at pycore_critical_section.h:226
#6 _Py_dict_lookup (mp=...) at Objects/dictobject.c:1254
#7 _PyDict_GetItemRef_KnownHash_LockHeld (...) at Objects/dictobject.c:2420
#8 bounded_lru_cache_get_lock_held (...) at Modules/_functoolsmodule.c:1325
#9 bounded_lru_cache_wrapper (...) at Modules/_functoolsmodule.c:1499
Backtrace on 3.15:
./Include/internal/pycore_critical_section.h:249: _PyCriticalSection_AssertHeldObj: Assertion "(cs != ((void*)0) && cs->_cs_mutex == mutex)" failed: Critical section of object is not held
Enable tracemalloc to get the memory block allocation traceback
object address : 0x7bffb670ccd0
object refcount : 2
object type : 0x555556749840
object type name: dict
object repr : {8: <functools._lru_list_elem object at 0x7bffb65701d0>, 290: <functools._lru_list_elem object at 0x7bffb6570240>, 308: <functools._lru_list_elem object at 0x7bffb65702b0>, 320: <functools._lru_list_elem object at 0x7bffb6570320>, 218: <functools._lru_list_elem object at 0x7bffb6570390>, 279: <functools._lru_list_elem object at 0x7bffb6570400>, 289: <functools._lru_list_elem object at 0x7bffb6570470>, 9991: <functools._lru_list_elem object at 0x7bffb65704e0>}
Fatal Python error: _PyObject_AssertFailed: _PyObject_AssertFailed
Python runtime state: initialized
Stack (most recent call first):
File "/home/danzin/crashers/yifei_01.py", line 15 in <module>
Program received signal SIGABRT, Aborted.
Download failed: Invalid argument. Continuing without source file ./nptl/./nptl/pthread_kill.c.
__pthread_kill_implementation (threadid=<optimized out>, signo=6, no_tid=0) at ./nptl/pthread_kill.c:44
warning: 44 ./nptl/pthread_kill.c: No such file or directory
(gdb) bt
#0 __pthread_kill_implementation (threadid=<optimized out>, signo=6, no_tid=0) at ./nptl/pthread_kill.c:44
#1 __pthread_kill_internal (threadid=<optimized out>, signo=6) at ./nptl/pthread_kill.c:89
#2 __GI___pthread_kill (threadid=<optimized out>, signo=signo@entry=6) at ./nptl/pthread_kill.c:100
#3 0x00007ffff7c45e2e in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#4 0x00007ffff7c28888 in __GI_abort () at ./stdlib/abort.c:77
#5 0x000055555606dd54 in fatal_error_exit (status=status@entry=-1) at Python/pylifecycle.c:3389
#6 0x000055555606da1b in fatal_error (fd=fd@entry=2, header=header@entry=1, prefix=prefix@entry=0x555556405360 <__func__._PyObject_AssertFailed> "_PyObject_AssertFailed",
msg=msg@entry=0x555556405360 <__func__._PyObject_AssertFailed> "_PyObject_AssertFailed", status=status@entry=-1) at Python/pylifecycle.c:3612
#7 0x0000555556069730 in _Py_FatalErrorFunc (func=0x555556405360 <__func__._PyObject_AssertFailed> "_PyObject_AssertFailed",
msg=0x555556405360 <__func__._PyObject_AssertFailed> "_PyObject_AssertFailed") at Python/pylifecycle.c:3635
#8 0x00005555557fc43b in _PyObject_AssertFailed (obj=obj@entry=0x7bffb670ccd0, expr=<optimized out>, msg=<optimized out>, file=<optimized out>, line=line@entry=249,
function=<optimized out>) at Objects/object.c:3259
#9 0x0000555555bb7711 in _PyCriticalSection_AssertHeldObj (op=0x7bffb670ccd0) at ./Include/internal/pycore_critical_section.h:247
#10 _Py_dict_lookup (mp=0x7bffb670ccd0, key=key@entry=0x55555693c4f8 <_PyRuntime+30776>, hash=hash@entry=8, value_addr=value_addr@entry=0x7fffffff9d00) at Objects/dictobject.c:1305
#11 0x0000555555bbbc61 in _PyDict_GetItemRef_KnownHash_LockHeld (op=0x196f39, key=0x196f39, key@entry=0x55555693c4f8 <_PyRuntime+30776>, hash=6, hash@entry=8,
result=result@entry=0x7fffffff9e80) at Objects/dictobject.c:2480
#12 0x00005555562ed221 in bounded_lru_cache_get_lock_held (self=0x7bffb6826c90, args=0x7bffb69bb290, kwds=0x0, result=<optimized out>, key=<optimized out>, hash=<optimized out>)
at ./Modules/_functoolsmodule.c:1416
#13 bounded_lru_cache_wrapper (self=0x7bffb6826c90, args=0x7bffb69bb290, kwds=0x0) at ./Modules/_functoolsmodule.c:1590
#14 0x0000555555ac6b5d in _PyObject_MakeTpCall (tstate=0x555556997d60 <_PyRuntime+405664>, callable=0x7bffb6826c90, args=0x7fffffffa048, nargs=1, keywords=0x0) at Objects/call.c:242
#15 0x0000555555ea9e3b in _Py_VectorCallInstrumentation_StackRefSteal (callable=..., arguments=arguments@entry=0x7e8ff6fe0290, total_args=total_args@entry=1, kwnames=kwnames@entry=...,
call_instrumentation=false, frame=frame@entry=0x7e8ff6fe0220, this_instr=0x7bffb6405394, tstate=0x555556997d60 <_PyRuntime+405664>) at Python/ceval.c:775
#16 0x0000555555ee754d in _PyEval_EvalFrameDefault (tstate=<optimized out>, frame=<optimized out>, throwflag=<optimized out>) at Python/generated_cases.c.h:1841
#17 0x0000555555ea8d04 in _PyEval_EvalFrame (tstate=0x555556997d60 <_PyRuntime+405664>, frame=0x7e8ff6fe0220, throwflag=0) at ./Include/internal/pycore_ceval.h:118
#18 _PyEval_Vector (tstate=tstate@entry=0x555556997d60 <_PyRuntime+405664>, func=func@entry=0x7bffb64c3e30, locals=locals@entry=0x7bffb670ca90, args=args@entry=0x0,
argcount=argcount@entry=0, kwnames=kwnames@entry=0x0) at Python/ceval.c:2179
#19 0x0000555555ea80db in PyEval_EvalCode (co=co@entry=0x7bffb6405210, globals=globals@entry=0x7bffb670ca90, locals=locals@entry=0x7bffb670ca90) at Python/ceval.c:686
#20 0x00005555560c6ca0 in run_eval_code_obj (tstate=tstate@entry=0x555556997d60 <_PyRuntime+405664>, co=co@entry=0x7bffb6405210, globals=0x7bffb670ca90, locals=locals@entry=0x7bffb670ca90)
at Python/pythonrun.c:1369
Possible fix
Either lock both objects:
// Line 1498, 1511: lock both self and self->cache
Py_BEGIN_CRITICAL_SECTION2(self, self->cache);Or use the regular (self-locking) dict APIs instead of _LockHeld variants for the get path.
The initial crash and reproducer were found by @zhuyifei1999. Root cause analysis and issue draft done with assistance from Claude Code.
CPython versions tested on:
3.15, CPython main branch, 3.14
Operating systems tested on:
Linux
Output from running 'python -VV' on the command line:
Python 3.15.0a7+ free-threading build (heads/main:4d0e8ee649c, Mar 29 2026, 23:20:29) [Clang 21.1.2 (2ubuntu6)]
Activity
colesbury commentedon Apr 6, 2026
I don't think we should fuzz for
gc.get_objectsorgc.get_referrersbugs.devdanzin commentedon Apr 6, 2026
Yes, I avoid doing that.
This was found when trying to implement free threading in a C extension, the code looked wrong, but the only way we found to trigger it is with
gc.get_objectsorgc.get_referrers.Should I close this, since the bug isn't triggerable from other paths?
colesbury commentedon Apr 6, 2026
Ah, okay. I jumped to that conclusion.
I'm not sure what we can do about it, but let's leave this open for now. I'm open to suggestions on how we should handle this.
zhuyifei1999 commentedon Apr 7, 2026
What's your thoughts on the
Py_BEGIN_CRITICAL_SECTION2(self, self->cache);workaround suggested by the LLM? Nothing else should be locking the cache so it should be painless I think?For context, I hit this during zhuyifei1999/guppy3#52 (comment). This is a heap-traversing memory debugging tool I maintain, that answers the question "what objects are there and why are they not being freed". Because I need to track objects in order to look at them more closely, the library obtains refcounts to nearly everything in the heap after a heap traversal, which includes this
functools.lru_cache->cachedict.And because I had a refcounting bug in my library while porting to freethreading, I popped open a
Py_DEBUGbuild to find what the refcounting bug was, only for Python to hit this bug and crash.robertsdotpm commentedon Jun 28, 2026
I hit what looks like the same root cause from an ordinary,
gc-free path, which may be relevant to the "should we close this since it's only triggerable viagc.get_objects/get_referrers?" question above.Concurrent use of a single
functools.lru_cacheon a free-threaded build can make the cache exceed itsmaxsize— nogcintrospection involved, just normal lookups under eviction churn. The over-count is a real, persistent structural state (read quiescently after all threads join viacache_info().currsize, which isPyDict_GET_SIZE(self->cache)), not a transient counter skew.Reproducer (pure stdlib, no
gccalls) -- on Mac / ARM64On a free-threaded arm64 build this prints e.g.
CacheInfo(hits=..., misses=..., maxsize=128, currsize=129)within a few trials.Platform / GIL matrix (Python 3.13.13, same machine)
PYTHON_GIL=0)currsize=129(maxsize=128)PYTHON_GIL=1)So it's a free-threading bug (the GIL-on control is clean), reproducible without any heap-introspection API.
Where it surfaces
In a stock build instrumented only with an
fprintfafter eachlru_cache_append_link(no--with-pydebug, to avoid perturbing timing), the size first passesmaxsizeat the evict-path append inbounded_lru_cache_wrapper(Modules/_functoolsmodule.c, ~L1205 in 3.13.13); a "grow branch taken while already full" probe never fired. That's consistent with two wrapper invocations interleaving whileself's critical section is suspended (e.g. across thePyObject_Callto the user function), making the size-check-then-insert non-atomic — i.e. the same "wrapper guardsself, notself->cache" issue described here, but observable as amaxsizeviolation rather than only the_LockHeldassertion.I'm not certain whether this is identical to the assertion failure above or a sibling of it (and whether the
Py_BEGIN_CRITICAL_SECTION2(self, self->cache)suggestion would close this particular TOCTOU) — you'd know far better than I would. Posting it mainly as evidence that the defect is reachable from normal concurrent use with a user-visible consequence.big_100: skip p303 lru_cache over-grow test (upstream CPython gh-148180)