Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/policies/wrapped.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ impl<P: PolicyExt> Wrapped<P> {
pub fn policy(&self) -> parking_lot::MutexGuard<'_, P> {
self.inner.lock()
}

/// Acquires the mutex only if it is free, returning `None` otherwise.
///
/// For callers that must never wait for the lock, such as `__traverse__`:
/// the thread holding the lock may be running Python code, and a garbage
/// collection pass landing there would deadlock the whole process.
#[inline(always)]
pub fn try_policy(&self) -> Option<parking_lot::MutexGuard<'_, P>> {
self.inner.try_lock()
}
}

#[inline(always)]
Expand Down
4 changes: 3 additions & 1 deletion src/pyclasses/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,9 @@ impl PyCache {
}

let inner = self.0.get();
let policy = inner.policy();
let Some(policy) = inner.try_policy() else {
return Ok(());
};

for handle_ref in unsafe { policy.table().iter() } {
let handle = unsafe { handle_ref.as_ref() };
Expand Down
4 changes: 3 additions & 1 deletion src/pyclasses/fifocache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,9 @@ impl PyFIFOCache {
}

let inner = self.0.get();
let policy = inner.policy();
let Some(policy) = inner.try_policy() else {
return Ok(());
};

for handle in policy.entries().iter() {
visit.call(handle.key().as_ref())?;
Expand Down
4 changes: 3 additions & 1 deletion src/pyclasses/lfucache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,9 @@ impl PyLFUCache {
}

let inner = self.0.get();
let policy = inner.policy();
let Some(policy) = inner.try_policy() else {
return Ok(());
};

for cursor in unsafe { policy.table().iter() } {
let handle = unsafe { cursor.as_ref().element() };
Expand Down
4 changes: 3 additions & 1 deletion src/pyclasses/lrucache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,9 @@ impl PyLRUCache {
}

let inner = self.0.get();
let policy = inner.policy();
let Some(policy) = inner.try_policy() else {
return Ok(());
};

for cursor in unsafe { policy.list().iter() } {
let handle = unsafe { cursor.element() };
Expand Down
4 changes: 3 additions & 1 deletion src/pyclasses/rrcache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,9 @@ impl PyRRCache {
}

let inner = self.0.get();
let policy = inner.policy();
let Some(policy) = inner.try_policy() else {
return Ok(());
};

for handle_ref in unsafe { policy.table().iter() } {
let handle = unsafe { handle_ref.as_ref() };
Expand Down
4 changes: 3 additions & 1 deletion src/pyclasses/ttlcache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,9 @@ impl PyTTLCache {
}

let inner = self.0.get();
let policy = inner.policy();
let Some(policy) = inner.try_policy() else {
return Ok(());
};

for handle in policy.entries().iter() {
visit.call(handle.key().as_ref())?;
Expand Down
4 changes: 3 additions & 1 deletion src/pyclasses/vttlcache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,9 @@ impl PyVTTLCache {
}

let inner = self.0.get();
let policy = inner.policy();
let Some(policy) = inner.try_policy() else {
return Ok(());
};

for cursor in unsafe { policy.table().iter() } {
let handle = unsafe { cursor.as_ref().element() };
Expand Down
82 changes: 82 additions & 0 deletions tests/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ def test_gc_traverse_clear(self):


class InsertAndGetMixin(BaseMixin):
def test_key_eq_may_trigger_the_gc(self):
# __eq__ runs in the middle of a probe, with the lock held; a deadlock
# here would keep the GIL, so the call runs in a child process
name = type(self.create_cache()).__name__

try:
done = subprocess.run(
[sys.executable, "-c", EQ_TRIGGERING_GC, name],
capture_output=True,
text=True,
timeout=60,
)
except subprocess.TimeoutExpired:
pytest.fail(f"{name}.get() with a colliding key never returned")

assert done.stdout.strip() == "ok", done.stderr

def test_insert_returns_none_on_new_key(self):
cache = self.create_cache()

Expand Down Expand Up @@ -205,6 +222,55 @@ def test_setdefault_returns_existing_value(self):
assert cache.get("k") == "existing"


DROPPED_VALUE_TRIGGERING_GC = """
import gc
import sys

import cachebox

name = sys.argv[1]
cls = getattr(cachebox, name)
cache = cls(10, global_ttl=60) if name == "TTLCache" else cls(10)


class Boom:
def __del__(self):
gc.collect()


cache.insert("k", Boom())
cache.clear()
print("ok")
"""

EQ_TRIGGERING_GC = """
import gc
import sys

import cachebox

name = sys.argv[1]
cls = getattr(cachebox, name)
cache = cls(10, global_ttl=60) if name == "TTLCache" else cls(10)


class Key:
def __init__(self, name):
self.name = name

def __hash__(self):
return 42 # same hash for every key, so lookups have to call __eq__

def __eq__(self, other):
gc.collect()
return self.name == other.name


cache.insert(Key("a"), 1)
assert cache.get(Key("b")) is None
print("ok")
"""

FACTORY_TOUCHING_CACHE = """
import gc
import sys
Expand Down Expand Up @@ -635,6 +701,22 @@ def test_generation_version_on_popitem(self):


class DrainClearShrinkMixin(BaseMixin):
def test_dropping_a_value_may_trigger_the_gc(self):
# a deadlock here would keep the GIL, so the call runs in a child process
name = type(self.create_cache()).__name__

try:
done = subprocess.run(
[sys.executable, "-c", DROPPED_VALUE_TRIGGERING_GC, name],
capture_output=True,
text=True,
timeout=60,
)
except subprocess.TimeoutExpired:
pytest.fail(f"{name}.clear() never returned")

assert done.stdout.strip() == "ok", done.stderr

def test_clear_removes_all_items(self):
cache = self.create_cache()

Expand Down