diff --git a/docs/docs/tips.md b/docs/docs/tips.md index f357558..994e2df 100644 --- a/docs/docs/tips.md +++ b/docs/docs/tips.md @@ -370,4 +370,9 @@ for k in it: ``` Do not modify the cache while an iterator is alive — every method on that iterator raises -`RuntimeError` if the cache changed. \ No newline at end of file +`RuntimeError` if the cache changed. + +Iterator methods take the cache's lock, so another thread can iterate safely while the cache +is being modified (it gets the `RuntimeError`). Don't call them from code that runs *inside* a +cache operation on the same cache, such as a key's `__eq__` or a value's `__del__`: that waits +for the lock it is already holding and never returns, the same as calling `cache[key]` there. diff --git a/src/macro_rules.rs b/src/macro_rules.rs index 8640cec..c50e337 100644 --- a/src/macro_rules.rs +++ b/src/macro_rules.rs @@ -24,22 +24,37 @@ macro_rules! implement_pyclass { }; } -/// Implements the generation-version guard shared by every cache view type. +/// Implements the guard shared by every cache view type. +/// +/// Views hold raw positions into the policy storage. A position is valid only while +/// the cache is unchanged, and every mutation bumps the generation version while +/// holding the cache lock. So a view must take the cache lock **first**, then compare +/// the generation, and keep the lock while it reads through its position. Checking +/// without the lock races with other threads on free-threaded builds (#94). +/// +/// Lock order: cache lock, then the view's own iterator mutex. /// /// # Example /// /// ```ignore -/// implement_view_guard!(PyCacheKeys); +/// implement_view_guard!(PyCacheKeys, nopolicy::NoPolicy); /// ``` #[macro_export] macro_rules! implement_view_guard { - ($name:ident) => { + ($name:ident, $policy:ty) => { impl $name { - /// Fails if the cache changed after this view was created. + /// Locks the cache and fails if it changed after this view was created. + /// + /// Hold the returned guard for as long as entries are read. #[inline] - fn check_generation(&self) -> pyo3::PyResult<()> { + fn lock_and_check<'a>( + &'a self, + py: pyo3::Python<'_>, + ) -> pyo3::PyResult> { + let policy = self.cache.get().0.get()?.policy_py_attached(py); + if self.initial_gv == self.gv.get() { - return Ok(()); + return Ok(policy); } Err($crate::new_py_error!( diff --git a/src/pyclasses/cache.rs b/src/pyclasses/cache.rs index 131d364..a8a757a 100644 --- a/src/pyclasses/cache.rs +++ b/src/pyclasses/cache.rs @@ -556,7 +556,7 @@ impl PyCache { let initial_gv = gv.get(); let result = PyCacheItems { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] iter: parking_lot::Mutex::new(unsafe { inner.policy_py_attached(slf.py()).table().iter() @@ -574,7 +574,7 @@ impl PyCache { let initial_gv = gv.get(); let result = PyCacheValues { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] iter: parking_lot::Mutex::new(unsafe { inner.policy_py_attached(slf.py()).table().iter() @@ -591,7 +591,7 @@ impl PyCache { let initial_gv = gv.get(); let result = PyCacheKeys { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] iter: parking_lot::Mutex::new(unsafe { inner.policy_py_attached(slf.py()).table().iter() @@ -698,14 +698,14 @@ macro_rules! implement_iterator { $( implement_pyclass! { [generic, frozen] $name as $pyname { - cache: pyo3::Py, + cache: pyo3::Py, initial_gv: u32, gv: utils::GenerationVersion, iter: parking_lot::Mutex>, } } - implement_view_guard!($name); + implement_view_guard!($name, nopolicy::NoPolicy); #[pyo3::pymethods] impl $name { @@ -719,7 +719,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let mut iter = slf.iter.lock(); @@ -735,7 +735,7 @@ macro_rules! implement_iterator { /// Returns how many items are left to yield. fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; Ok(slf.iter.lock().len()) } diff --git a/src/pyclasses/fifocache.rs b/src/pyclasses/fifocache.rs index da3a015..f76d625 100644 --- a/src/pyclasses/fifocache.rs +++ b/src/pyclasses/fifocache.rs @@ -559,7 +559,7 @@ impl PyFIFOCache { // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyFIFOCacheItems { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(inner.policy_py_attached(slf.py()).iter()), gv, initial_gv, @@ -574,7 +574,7 @@ impl PyFIFOCache { // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyFIFOCacheValues { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(inner.policy_py_attached(slf.py()).iter()), gv, initial_gv, @@ -589,7 +589,7 @@ impl PyFIFOCache { // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyFIFOCacheKeys { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(inner.policy_py_attached(slf.py()).iter()), gv, initial_gv, @@ -716,14 +716,14 @@ macro_rules! implement_iterator { $( implement_pyclass! { [generic, frozen] $name as $pyname { - cache: pyo3::Py, + cache: pyo3::Py, initial_gv: u32, gv: utils::GenerationVersion, iter: parking_lot::Mutex>, } } - implement_view_guard!($name); + implement_view_guard!($name, fifopolicy::FIFOPolicy); #[pyo3::pymethods] impl $name { @@ -737,7 +737,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let mut iter = slf.iter.lock(); @@ -753,7 +753,7 @@ macro_rules! implement_iterator { /// Returns how many items are left to yield. fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; Ok(slf.iter.lock().len()) } diff --git a/src/pyclasses/lfucache.rs b/src/pyclasses/lfucache.rs index 879ff69..1f4b6ce 100644 --- a/src/pyclasses/lfucache.rs +++ b/src/pyclasses/lfucache.rs @@ -579,7 +579,7 @@ impl PyLFUCache { let iter = policy.iter(gv); let result = PyLFUCacheItems { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv: gv.clone(), initial_gv: gv.get(), @@ -595,7 +595,7 @@ impl PyLFUCache { let iter = policy.iter(gv); let result = PyLFUCacheValues { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv: gv.clone(), initial_gv: gv.get(), @@ -611,7 +611,7 @@ impl PyLFUCache { let iter = policy.iter(gv); let result = PyLFUCacheKeys { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv: gv.clone(), initial_gv: gv.get(), @@ -634,7 +634,7 @@ impl PyLFUCache { let iter = policy.iter(gv); let result = PyLFUCacheItemsWithFrequency { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv: gv.clone(), initial_gv: gv.get(), @@ -783,14 +783,14 @@ macro_rules! implement_iterator { $( implement_pyclass! { [generic, frozen] $name as $pyname { - cache: pyo3::Py, + cache: pyo3::Py, initial_gv: u32, gv: utils::GenerationVersion, iter: parking_lot::Mutex>, } } - implement_view_guard!($name); + implement_view_guard!($name, lfupolicy::LFUPolicy); #[pyo3::pymethods] impl $name { @@ -804,7 +804,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let mut iter = slf.iter.lock(); @@ -820,7 +820,7 @@ macro_rules! implement_iterator { /// Returns how many items are left to yield. fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; Ok(slf.iter.lock().len()) } diff --git a/src/pyclasses/lrucache.rs b/src/pyclasses/lrucache.rs index 08541b0..4f5eb5c 100644 --- a/src/pyclasses/lrucache.rs +++ b/src/pyclasses/lrucache.rs @@ -586,7 +586,7 @@ impl PyLRUCache { // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyLRUCacheItems { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(unsafe { inner.policy_py_attached(slf.py()).list().iter() }), @@ -603,7 +603,7 @@ impl PyLRUCache { // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyLRUCacheValues { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(unsafe { inner.policy_py_attached(slf.py()).list().iter() }), @@ -620,7 +620,7 @@ impl PyLRUCache { // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyLRUCacheKeys { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(unsafe { inner.policy_py_attached(slf.py()).list().iter() }), @@ -770,14 +770,14 @@ macro_rules! implement_iterator { $( implement_pyclass! { [generic, frozen] $name as $pyname { - cache: pyo3::Py, + cache: pyo3::Py, initial_gv: u32, gv: utils::GenerationVersion, iter: parking_lot::Mutex>, } } - implement_view_guard!($name); + implement_view_guard!($name, lrupolicy::LRUPolicy); #[pyo3::pymethods] impl $name { @@ -791,7 +791,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let mut iter = slf.iter.lock(); @@ -807,7 +807,7 @@ macro_rules! implement_iterator { /// Returns how many items are left to yield. fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; Ok(slf.iter.lock().len()) } diff --git a/src/pyclasses/rrcache.rs b/src/pyclasses/rrcache.rs index 3eb9984..60a8527 100644 --- a/src/pyclasses/rrcache.rs +++ b/src/pyclasses/rrcache.rs @@ -559,7 +559,7 @@ impl PyRRCache { // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyRRCacheItems { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(unsafe { inner.policy_py_attached(slf.py()).table().iter() }), @@ -576,7 +576,7 @@ impl PyRRCache { // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyRRCacheValues { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(unsafe { inner.policy_py_attached(slf.py()).table().iter() }), @@ -593,7 +593,7 @@ impl PyRRCache { // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyRRCacheKeys { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(unsafe { inner.policy_py_attached(slf.py()).table().iter() }), @@ -712,14 +712,14 @@ macro_rules! implement_iterator { $( implement_pyclass! { [generic, frozen] $name as $pyname { - cache: pyo3::Py, + cache: pyo3::Py, initial_gv: u32, gv: utils::GenerationVersion, iter: parking_lot::Mutex>, } } - implement_view_guard!($name); + implement_view_guard!($name, rrpolicy::RRPolicy); #[pyo3::pymethods] impl $name { @@ -733,7 +733,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let mut iter = slf.iter.lock(); @@ -749,7 +749,7 @@ macro_rules! implement_iterator { /// Returns how many items are left to yield. fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; Ok(slf.iter.lock().len()) } diff --git a/src/pyclasses/ttlcache.rs b/src/pyclasses/ttlcache.rs index 6c1b83f..f6d36a6 100644 --- a/src/pyclasses/ttlcache.rs +++ b/src/pyclasses/ttlcache.rs @@ -560,14 +560,18 @@ impl PyTTLCache { fn items(slf: pyo3::Bound<'_, Self>) -> pyo3::PyResult> { let inner = slf.get().0.get()?; - let iter = inner.policy_py_attached(slf.py()).iter(inner.shared()); + // Create the iterator and read the generation under one lock, or a mutation in + // between would make stale positions look current. + let mut policy = inner.policy_py_attached(slf.py()); + let iter = policy.iter(inner.shared()); let gv = inner.shared().generation_version().clone(); let initial_gv = gv.get(); + drop(policy); // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyTTLCacheItems { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv, initial_gv, @@ -578,14 +582,18 @@ impl PyTTLCache { fn values(slf: pyo3::Bound<'_, Self>) -> pyo3::PyResult> { let inner = slf.get().0.get()?; - let iter = inner.policy_py_attached(slf.py()).iter(inner.shared()); + // Create the iterator and read the generation under one lock, or a mutation in + // between would make stale positions look current. + let mut policy = inner.policy_py_attached(slf.py()); + let iter = policy.iter(inner.shared()); let gv = inner.shared().generation_version().clone(); let initial_gv = gv.get(); + drop(policy); // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyTTLCacheValues { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv, initial_gv, @@ -596,14 +604,18 @@ impl PyTTLCache { fn keys(slf: pyo3::Bound<'_, Self>) -> pyo3::PyResult> { let inner = slf.get().0.get()?; - let iter = inner.policy_py_attached(slf.py()).iter(inner.shared()); + // Create the iterator and read the generation under one lock, or a mutation in + // between would make stale positions look current. + let mut policy = inner.policy_py_attached(slf.py()); + let iter = policy.iter(inner.shared()); let gv = inner.shared().generation_version().clone(); let initial_gv = gv.get(); + drop(policy); // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyTTLCacheKeys { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv, initial_gv, @@ -802,14 +814,18 @@ impl PyTTLCache { ) -> pyo3::PyResult> { let inner = slf.get().0.get()?; - let iter = inner.policy_py_attached(slf.py()).iter(inner.shared()); + // Create the iterator and read the generation under one lock, or a mutation in + // between would make stale positions look current. + let mut policy = inner.policy_py_attached(slf.py()); + let iter = policy.iter(inner.shared()); let gv = inner.shared().generation_version().clone(); let initial_gv = gv.get(); + drop(policy); // SAFETY: We cannot use lifetimes here, but we're tracking changes using [`GenerationVersion`] let result = PyTTLCacheItemsWithExpire { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv, initial_gv, @@ -854,14 +870,14 @@ macro_rules! implement_iterator { $( implement_pyclass! { [generic, frozen] $name as $pyname { - cache: pyo3::Py, + cache: pyo3::Py, initial_gv: u32, gv: utils::GenerationVersion, iter: parking_lot::Mutex>, } } - implement_view_guard!($name); + implement_view_guard!($name, ttlpolicy::TTLPolicy); #[pyo3::pymethods] impl $name { @@ -875,7 +891,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let now = std::time::SystemTime::now(); let mut iter = slf.iter.lock(); @@ -895,7 +911,7 @@ macro_rules! implement_iterator { /// Returns how many not-expired items are left to yield. fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let now = std::time::SystemTime::now(); let iter = slf.iter.lock().clone(); @@ -905,7 +921,7 @@ macro_rules! implement_iterator { /// Returns whether any not-expired item is left, without counting them all. fn __bool__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let now = std::time::SystemTime::now(); let mut iter = slf.iter.lock().clone(); diff --git a/src/pyclasses/vttlcache.rs b/src/pyclasses/vttlcache.rs index e96b9f8..e2d3584 100644 --- a/src/pyclasses/vttlcache.rs +++ b/src/pyclasses/vttlcache.rs @@ -539,7 +539,7 @@ impl PyVTTLCache { let iter = policy.iter(gv); let result = PyVTTLCacheItems { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv: gv.clone(), initial_gv: gv.get(), @@ -555,7 +555,7 @@ impl PyVTTLCache { let iter = policy.iter(gv); let result = PyVTTLCacheValues { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv: gv.clone(), initial_gv: gv.get(), @@ -571,7 +571,7 @@ impl PyVTTLCache { let iter = policy.iter(gv); let result = PyVTTLCacheKeys { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv: gv.clone(), initial_gv: gv.get(), @@ -769,7 +769,7 @@ impl PyVTTLCache { let iter = policy.iter(gv); let result = PyVTTLCacheItemsWithExpire { - cache: slf.as_any().clone().unbind(), + cache: slf.clone().unbind(), iter: parking_lot::Mutex::new(iter), gv: gv.clone(), initial_gv: gv.get(), @@ -816,14 +816,14 @@ macro_rules! implement_iterator { $( implement_pyclass! { [generic, frozen] $name as $pyname { - cache: pyo3::Py, + cache: pyo3::Py, initial_gv: u32, gv: utils::GenerationVersion, iter: parking_lot::Mutex>, } } - implement_view_guard!($name); + implement_view_guard!($name, vttlpolicy::VTTLPolicy); #[pyo3::pymethods] impl $name { @@ -837,7 +837,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let now = std::time::SystemTime::now(); let mut iter = slf.iter.lock(); @@ -857,7 +857,7 @@ macro_rules! implement_iterator { /// Returns how many not-expired items are left to yield. fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let now = std::time::SystemTime::now(); let iter = slf.iter.lock().clone(); @@ -867,7 +867,7 @@ macro_rules! implement_iterator { /// Returns whether any not-expired item is left, without counting them all. fn __bool__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - slf.check_generation()?; + let _policy = slf.lock_and_check(slf.py())?; let now = std::time::SystemTime::now(); let mut iter = slf.iter.lock().clone(); diff --git a/tests/mixins.py b/tests/mixins.py index f76e867..811d918 100644 --- a/tests/mixins.py +++ b/tests/mixins.py @@ -535,6 +535,83 @@ def make(): print("ok") """ +ITERATING_WHILE_OTHER_THREADS_MUTATE = """ +import faulthandler +import random +import sys +import threading +import time + +import cachebox + +name = sys.argv[1] +cls = getattr(cachebox, name) +if name == "TTLCache": + cache = cls(500, global_ttl=60) +elif name == "Cache": + cache = cls(0) +else: + cache = cls(500) + +if name == "VTTLCache": + def put(key): + cache.insert(key, key, 60) +else: + def put(key): + cache[key] = key + +stop = threading.Event() +errors = [] +# a hang prints every thread's stack before the parent's timeout +faulthandler.dump_traceback_later(50, exit=True) + + +def worker(seed): + rng = random.Random(seed) + while not stop.is_set(): + key = rng.randrange(2000) + op = rng.random() + try: + if op < 0.25: + cache.get(key) + elif op < 0.55: + put(key) + elif op < 0.7: + cache.pop(key, None) + else: + if rng.random() < 0.5: + view = rng.choice((cache.keys, cache.values))() + bool(view), len(view) + for _ in view: + pass + else: + # every entry is key -> key: a torn or reused-node read shows up here + view = cache.items() + bool(view), len(view) + seen = set() + for k, v in view: + if k != v or k in seen: + raise AssertionError(f"items() yielded {k!r} -> {v!r}") + seen.add(k) + except RuntimeError: + pass # the cache changed during iteration: expected + except Exception as e: + errors.append(repr(e)) + return + + +threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)] +for t in threads: + t.start() +time.sleep(2) +stop.set() +for t in threads: + t.join() + +assert not errors, errors[:3] +print("ok") +""" + class IterationMixin(BaseMixin): def test_keys_returns_all_keys(self): @@ -577,6 +654,33 @@ def test_walking_a_cache_nothing_else_holds(self): done.stderr or f"exit code {done.returncode}" ) + def test_iterating_while_other_threads_mutate(self): + # Free-threaded builds: an iterator must not read entries another thread is + # changing. Reading freed memory faults instead of failing, hence a child process. + name = type(self.create_cache()).__name__ + + try: + done = subprocess.run( + [ + sys.executable, + "-c", + ITERATING_WHILE_OTHER_THREADS_MUTATE, + name, + ], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except subprocess.TimeoutExpired: + pytest.fail( + f"{name}: iterating while other threads mutate never finished" + ) + + assert done.stdout.strip() == "ok", ( + done.stderr or f"exit code {done.returncode}" + ) + @pytest.mark.skipif( platform.python_implementation() == "PyPy", reason="PyPy's GC does not collect cycles through the cache objects: "