setdefault_with calls the factory while the cache's internal lock is held, and __traverse__ takes that same lock. A garbage collection pass inside the factory therefore deadlocks. The factory does not have to do anything unusual: any allocation can start a pass.
It is worse than one stuck thread. The frozen thread keeps the GIL, so the whole interpreter stops and no watchdog thread can report it.
import gc
import cachebox
def factory():
"""Any allocation can start a GC pass; this call just makes it certain."""
gc.collect()
return 'value'
cache = cachebox.Cache(0)
print('cachebox', cachebox.__version__)
print('calling setdefault_with ...', flush=True)
cache.setdefault_with('key', factory)
print('returned:', cache['key'])
On 6.2.3 this prints the two lines and never returns. Ctrl-C does not stop it, the process has to be killed. A factory that only reads the cache (return cache['other']) freezes in the same way. All seven cache types have the same code.
The doc string warns that the factory must not call back into the cache, but a GC pass needs no cooperation from user code, so the warning cannot be followed.
I think this also hits cachebox itself. The async @cached wrapper calls locks.setdefault_with(key, lambda: _AsyncLock(lock_type())) on every miss (cachebox/_wrappers.py:351), and that factory allocates an asyncio.Lock. On a clean 6.2.3 checkout (Windows, CPython 3.12) pytest froze in 4 runs out of 9, always in the async tests at the end of tests/test_utils.py.
A fix that works: call the factory with the lock released, then take the lock again and re-check the key before inserting. The price is that "the factory is called exactly once" becomes "it may run more than once when two threads race for the same missing key"; the value inserted first still wins and both callers get it. I have this patch with a regression test and can send it as a PR.
setdefault_withcalls the factory while the cache's internal lock is held, and__traverse__takes that same lock. A garbage collection pass inside the factory therefore deadlocks. The factory does not have to do anything unusual: any allocation can start a pass.It is worse than one stuck thread. The frozen thread keeps the GIL, so the whole interpreter stops and no watchdog thread can report it.
On 6.2.3 this prints the two lines and never returns. Ctrl-C does not stop it, the process has to be killed. A factory that only reads the cache (
return cache['other']) freezes in the same way. All seven cache types have the same code.The doc string warns that the factory must not call back into the cache, but a GC pass needs no cooperation from user code, so the warning cannot be followed.
I think this also hits cachebox itself. The async
@cachedwrapper callslocks.setdefault_with(key, lambda: _AsyncLock(lock_type()))on every miss (cachebox/_wrappers.py:351), and that factory allocates anasyncio.Lock. On a clean 6.2.3 checkout (Windows, CPython 3.12)pytestfroze in 4 runs out of 9, always in the async tests at the end oftests/test_utils.py.A fix that works: call the factory with the lock released, then take the lock again and re-check the key before inserting. The price is that "the factory is called exactly once" becomes "it may run more than once when two threads race for the same missing key"; the value inserted first still wins and both callers get it. I have this patch with a regression test and can send it as a PR.