From f1c951d98fd65debe3f4640b557c3f795cf3a761 Mon Sep 17 00:00:00 2001 From: awolverp Date: Wed, 30 Sep 2026 12:16:38 +0330 Subject: [PATCH 1/4] Changing the value of an existing key while iterating was not a problem (except for LRUCache), and this caused a segfault in free-threaded versions. From now on, in all free-threaded versions, this will raise a RuntimeError in all cache implementations. --- src/policies/fifopolicy.rs | 8 ++++++-- src/policies/lfupolicy.rs | 8 ++++++-- src/policies/nopolicy.rs | 7 +++++++ src/policies/rrpolicy.rs | 7 +++++++ src/policies/ttlpolicy.rs | 8 ++++++-- src/policies/vttlpolicy.rs | 8 ++++++-- tests/mixins.py | 13 +++++++------ uv.lock | 4 ++++ 8 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/policies/fifopolicy.rs b/src/policies/fifopolicy.rs index 4696b4d..0199c86 100644 --- a/src/policies/fifopolicy.rs +++ b/src/policies/fifopolicy.rs @@ -38,8 +38,12 @@ impl traits::OccupiedExt for Occupied<'_> { #[inline] fn replace(self, new: Self::Handle) -> Self::Handle { - // In update we don't need to increment this; because this does not change the memory address ranges - // self.shared.generation_version().increment(); + // The replace operation does not modify memory range here in the normal case, + // but when the GIL is disabled, concurrent modification can cause a segfault. + // Therefore, in free-threaded versions we have to change the generation version + // before manipulating memory. + #[cfg(Py_GIL_DISABLED)] + self.shared.generation_version().increment(); let item = unsafe { get_handle!(&mut self.policy, *self.bucket.as_ref()) }; diff --git a/src/policies/lfupolicy.rs b/src/policies/lfupolicy.rs index 5e7fc01..0742d4f 100644 --- a/src/policies/lfupolicy.rs +++ b/src/policies/lfupolicy.rs @@ -147,8 +147,12 @@ impl traits::OccupiedExt for Occupied<'_> { type Shared = Shared; fn replace(self, new: Self::Handle) -> Self::Handle { - // Here we don't need to increment generation version - // self.shared.generation_version().increment(); + // The replace operation does not modify memory range here in the normal case, + // but when the GIL is disabled, concurrent modification can cause a segfault. + // Therefore, in free-threaded versions we have to change the generation version + // before manipulating memory. + #[cfg(Py_GIL_DISABLED)] + self.shared.generation_version().increment(); unsafe { let cursor = self.bucket.as_mut(); diff --git a/src/policies/nopolicy.rs b/src/policies/nopolicy.rs index fdd4558..7fd1abe 100644 --- a/src/policies/nopolicy.rs +++ b/src/policies/nopolicy.rs @@ -33,6 +33,13 @@ impl traits::OccupiedExt for Occupied<'_> { } fn replace(self, new: Self::Handle) -> Self::Handle { + // The replace operation does not modify memory range here in the normal case, + // but when the GIL is disabled, concurrent modification can cause a segfault. + // Therefore, in free-threaded versions we have to change the generation version + // before manipulating memory. + #[cfg(Py_GIL_DISABLED)] + self.shared.generation_version().increment(); + self.policy.currsize = self.policy.currsize.saturating_add(new.size()); let old = unsafe { std::mem::replace(self.bucket.as_mut(), new) }; self.policy.currsize = self.policy.currsize.saturating_sub(old.size()); diff --git a/src/policies/rrpolicy.rs b/src/policies/rrpolicy.rs index 80b5216..b0ad113 100644 --- a/src/policies/rrpolicy.rs +++ b/src/policies/rrpolicy.rs @@ -35,6 +35,13 @@ impl traits::OccupiedExt for Occupied<'_> { #[inline] fn replace(self, new: Self::Handle) -> Self::Handle { + // The replace operation does not modify memory range here in the normal case, + // but when the GIL is disabled, concurrent modification can cause a segfault. + // Therefore, in free-threaded versions we have to change the generation version + // before manipulating memory. + #[cfg(Py_GIL_DISABLED)] + self.shared.generation_version().increment(); + self.policy.currsize = self.policy.currsize.saturating_add(new.size()); let old = unsafe { std::mem::replace(self.bucket.as_mut(), new) }; self.policy.currsize = self.policy.currsize.saturating_sub(old.size()); diff --git a/src/policies/ttlpolicy.rs b/src/policies/ttlpolicy.rs index 0c2fe6f..db44a2a 100644 --- a/src/policies/ttlpolicy.rs +++ b/src/policies/ttlpolicy.rs @@ -151,8 +151,12 @@ impl traits::OccupiedExt for Occupied<'_> { #[inline] fn replace(self, new: Self::Handle) -> Self::Handle { - // In update we don't need to increment this; because this does not change the memory address ranges - // self.shared.generation_version().increment(); + // The replace operation does not modify memory range here in the normal case, + // but when the GIL is disabled, concurrent modification can cause a segfault. + // Therefore, in free-threaded versions we have to change the generation version + // before manipulating memory. + #[cfg(Py_GIL_DISABLED)] + self.shared.generation_version().increment(); let item = unsafe { get_handle!(&mut self.policy, *self.bucket.as_ref()) }; diff --git a/src/policies/vttlpolicy.rs b/src/policies/vttlpolicy.rs index adc9a71..3b923f5 100644 --- a/src/policies/vttlpolicy.rs +++ b/src/policies/vttlpolicy.rs @@ -152,8 +152,12 @@ impl traits::OccupiedExt for Occupied<'_> { type Shared = Shared; fn replace(self, new: Self::Handle) -> Self::Handle { - // Here we don't need to increment generation version - // self.shared.generation_version().increment(); + // The replace operation does not modify memory range here in the normal case, + // but when the GIL is disabled, concurrent modification can cause a segfault. + // Therefore, in free-threaded versions we have to change the generation version + // before manipulating memory. + #[cfg(Py_GIL_DISABLED)] + self.shared.generation_version().increment(); unsafe { let cursor = self.bucket.as_mut(); diff --git a/tests/mixins.py b/tests/mixins.py index cb64dea..81ed2ed 100644 --- a/tests/mixins.py +++ b/tests/mixins.py @@ -669,13 +669,14 @@ def test_generation_version_on_insert(self): for _ in cache.items(): cache.insert("C", 1) - if isinstance(cache, cachebox.LRUCache): - return + if hasattr(sys, "_is_gil_enabled") and sys._is_gil_enabled(): + for i in cache: + cache.insert(i, "hello") - for i in cache: - # It should not increment the generation version - # in replacing value - cache.insert(i, "hello") + else: + with pytest.raises(RuntimeError): + for i in cache: + cache.insert(i, "hello") def test_generation_version_on_shrink_to_fit(self): cache = self.create_cache(10, {i: i for i in range(3)}) diff --git a/uv.lock b/uv.lock index 2e654d1..8a7ff87 100644 --- a/uv.lock +++ b/uv.lock @@ -122,9 +122,11 @@ dev = [ { name = "maturin", extra = ["patchelf"], marker = "sys_platform == 'linux'" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, + { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-xdist" }, + { name = "ruff" }, { name = "typos" }, { name = "zizmor" }, ] @@ -170,9 +172,11 @@ dev = [ { name = "maturin", extras = ["patchelf"], marker = "sys_platform == 'linux'", specifier = "==1.14.1" }, { name = "mkdocs-material", specifier = "==9.7.7" }, { name = "mkdocstrings", extras = ["python"], specifier = "==1.0.6" }, + { name = "mypy", specifier = ">=2.3.1" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio", specifier = "==1.4.0" }, { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "ruff", specifier = ">=0.16.9" }, { name = "typos", specifier = "==1.48.0" }, { name = "zizmor", specifier = "==1.29.0" }, ] From a156a2be8a820498e05f69cb3ef21ea8d4953b57 Mon Sep 17 00:00:00 2001 From: awolverp Date: Wed, 30 Sep 2026 12:21:33 +0330 Subject: [PATCH 2/4] Fix tests --- tests/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mixins.py b/tests/mixins.py index 81ed2ed..b5d4062 100644 --- a/tests/mixins.py +++ b/tests/mixins.py @@ -669,7 +669,7 @@ def test_generation_version_on_insert(self): for _ in cache.items(): cache.insert("C", 1) - if hasattr(sys, "_is_gil_enabled") and sys._is_gil_enabled(): + if not hasattr(sys, "_is_gil_enabled") or sys._is_gil_enabled(): for i in cache: cache.insert(i, "hello") From c7a46d76788326a066210e215d09875852e40aa0 Mon Sep 17 00:00:00 2001 From: awolverp Date: Wed, 30 Sep 2026 12:35:15 +0330 Subject: [PATCH 3/4] Fix tests --- tests/mixins.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/mixins.py b/tests/mixins.py index b5d4062..e9b9ef3 100644 --- a/tests/mixins.py +++ b/tests/mixins.py @@ -5,6 +5,7 @@ import platform import subprocess import sys +import sysconfig import threading import time import typing @@ -669,7 +670,10 @@ def test_generation_version_on_insert(self): for _ in cache.items(): cache.insert("C", 1) - if not hasattr(sys, "_is_gil_enabled") or sys._is_gil_enabled(): + if ( + not isinstance(cache, cachebox.LRUCache) + and sysconfig.get_config_var("Py_GIL_DISABLED") == 0 + ): for i in cache: cache.insert(i, "hello") From 7a149c5e8e56ab1b681ece62ae308a4fc24310d1 Mon Sep 17 00:00:00 2001 From: awolverp Date: Thu, 1 Oct 2026 09:57:13 +0330 Subject: [PATCH 4/4] Fix tests --- tests/mixins.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/mixins.py b/tests/mixins.py index e9b9ef3..9fa294e 100644 --- a/tests/mixins.py +++ b/tests/mixins.py @@ -670,9 +670,8 @@ def test_generation_version_on_insert(self): for _ in cache.items(): cache.insert("C", 1) - if ( - not isinstance(cache, cachebox.LRUCache) - and sysconfig.get_config_var("Py_GIL_DISABLED") == 0 + if not isinstance(cache, cachebox.LRUCache) and not sysconfig.get_config_var( + "Py_GIL_DISABLED" ): for i in cache: cache.insert(i, "hello")