Skip to content

setdefault_with freezes the whole process when the factory triggers a GC pass #67

Description

@Malkiz223

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions