Skip to content

Commit e369a81

Browse files
committed
improve robustness of tz cache under free-threading
1 parent 35097b8 commit e369a81

File tree

5 files changed

+56
-20
lines changed

5 files changed

+56
-20
lines changed

pysrc/whenever/_pywhenever.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6359,7 +6359,10 @@ def _get_tz(key: str) -> TimeZone:
63596359
with _tzcache_lru_lock:
63606360
_tzcache_lru[key] = _tzcache_lru.pop(key, instance)
63616361
if len(_tzcache_lru) > _TZCACHE_LRU_SIZE:
6362-
_tzcache_lru.popitem(last=False)
6362+
try:
6363+
_tzcache_lru.popitem(last=False)
6364+
except KeyError: # pragma: no cover
6365+
pass # theoretically possible if other threads are clearing too
63636366

63646367
return instance
63656368

scripts/smoketest_refcounting.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
This test can surface refcounting issues when many timezones are loaded and unloaded.
55
"""
66

7-
from whenever import PlainDateTime
7+
import os
8+
9+
from whenever import PlainDateTime, reset_system_tz
810

911
f = PlainDateTime(2023, 10, 1, 12, 0, 0)
1012

@@ -34,7 +36,23 @@ def main():
3436
f.assume_tz("Europe/Amsterdam")
3537
f.assume_tz("Europe/Amsterdam")
3638
f.assume_tz("Europe/Amsterdam")
39+
40+
reset_system_tz()
3741
f.assume_system_tz()
42+
os.environ["TZ"] = "America/New_York"
43+
f.assume_system_tz()
44+
f.assume_tz("Europe/Amsterdam")
45+
46+
# A posix timezone
47+
os.environ["TZ"] = "IST-5:30"
48+
f.assume_system_tz()
49+
50+
# A path timezone
51+
path = os.environ["TZ"] = "/usr/share/zoneinfo/Asia/Tokyo"
52+
if os.path.exists(path):
53+
reset_system_tz()
54+
else:
55+
print("Path timezone not found, skipping that part of the test")
3856

3957

4058
if __name__ == "__main__":

scripts/smoketest_threading.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ def touch_timezones(tzs):
5353
"""A minimal function that triggers a timezone lookup"""
5454
for tz in tzs:
5555
zdt = PLAIN_DT.assume_tz(tz)
56-
# zdt = ZoneInfo(tz)
5756
del zdt
5857

5958

src/tz/store.rs

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,20 @@ use std::{
1818
/// meant to be used in a single-threaded context, it's safe to share and copy
1919
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
2020
pub(crate) struct TzPtr {
21-
inner: NonNull<Inner>,
21+
inner: NonNull<TzRefCounted>,
2222
}
2323

2424
#[derive(Debug)]
25-
struct Inner {
25+
struct TzRefCounted {
2626
value: TimeZone,
2727
refcnt: AtomicRefCount,
2828
}
2929

3030
impl TzPtr {
3131
/// Creates a new instance with specified refcount.
32-
fn new(value: TimeZone) -> Self {
33-
let inner = Box::new(Inner {
34-
// We start with refcount of 2:
35-
// - one to share outside of the tzstore
36-
// - one to keep the pointer alive in the LRU
37-
refcnt: AtomicRefCount::new(2),
32+
fn new(value: TimeZone, refcount: usize) -> Self {
33+
let inner = Box::new(TzRefCounted {
34+
refcnt: AtomicRefCount::new(refcount),
3835
value,
3936
});
4037
Self {
@@ -210,7 +207,10 @@ impl Cache {
210207
}
211208

212209
// Cache miss: load outside the lock (may do file I/O)
213-
let loaded = TzPtr::new(load()?);
210+
// We start with refcount of 2:
211+
// - one to return to the caller
212+
// - one to keep the pointer alive in the LRU
213+
let loaded = TzPtr::new(load()?, 2);
214214

215215
// Re-acquire lock to insert. Another thread may have loaded the same key.
216216
self.inner.with_mut(|CacheInner { lookup, lru }| {
@@ -333,6 +333,10 @@ impl Drop for Cache {
333333
// the only strong references are in the LRU.
334334
let mut lru = std::mem::take(lru);
335335
for tz in lru.drain(..) {
336+
debug_assert!(
337+
tz.ref_count() == 1,
338+
"Dropping cache with strong references still present"
339+
);
336340
Self::decref(tz, || {
337341
// Remove the weak reference too
338342
lookup.remove(tz.key.as_ref().expect("LRU entries always have a key"));
@@ -387,7 +391,7 @@ pub(crate) struct TzStore {
387391
// We cache the system timezone here, since it's expensive to determine.
388392
// Uses SwapPtr for lock-free reads with atomic swaps for writes.
389393
// This holds a strong reference to a TzPtr.
390-
system_tz_cache: SwapPtr<Inner>,
394+
system_tz_cache: SwapPtr<TzRefCounted>,
391395
// This reference is borrowed from the module, which outlives this store.
392396
exc_notfound: PyObj,
393397
}
@@ -503,7 +507,7 @@ impl TzStore {
503507
}
504508

505509
/// Get a pointer to what is currently considered the system timezone.
506-
/// The pointer is already a strong reference.
510+
/// The pointer represents a strong reference.
507511
fn determine_system_tz(&self) -> PyResult<TzPtr> {
508512
const ERR_MSG: &str = "get_tz() gave unexpected result";
509513
let tz_tuple = import(c"whenever._tz.system")?
@@ -540,15 +544,15 @@ impl TzStore {
540544
.ok_or_else_raise(self.exc_notfound.as_ptr(), || {
541545
format!("No time zone found at path {path:?}")
542546
})?;
543-
Ok(TzPtr::new(tzif))
547+
Ok(TzPtr::new(tzif, 1))
544548
}
545549
// type 2: zoneinfo key OR posix TZ string (we're unsure which)
546550
2 => {
547551
self.cache
548552
// Try to load it as a zoneinfo key first.
549553
.get_or_insert_with(tz_value, || self.load_tzif(tz_value))
550554
// If this fails, try to parse it as a posix TZ string.
551-
.or_else(|| TimeZone::parse_posix(tz_value).map(TzPtr::new))
555+
.or_else(|| TimeZone::parse_posix(tz_value).map(|tz| TzPtr::new(tz, 1)))
552556
.ok_or_else_raise(self.exc_notfound.as_ptr(), || {
553557
format!("No time zone found with key or posix TZ string {tz_value}")
554558
})
@@ -567,6 +571,20 @@ impl Drop for TzStore {
567571
// Clear the system timezone cache
568572
if let Some(inner) = self.system_tz_cache.swap(None) {
569573
let ptr = TzPtr { inner };
574+
// When dropping, ensure the refcount is what we expect.
575+
#[cfg(debug_assertions)]
576+
{
577+
let mut expect_refcnt = 1; // only held by system_tz_cache
578+
self.cache.inner.with_mut(|CacheInner { lru, .. }| {
579+
if lru.contains(&ptr) {
580+
expect_refcnt += 1; // also held by LRU
581+
}
582+
});
583+
assert!(
584+
ptr.ref_count() == expect_refcnt,
585+
"Dangling references to system timezone on TzStore drop"
586+
);
587+
}
570588
ptr.decref_with_cleanup(|| self);
571589
}
572590
// The rest of the fields will be dropped automatically

src/tz/sync.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,8 @@ mod free_threaded {
204204
impl<T> Drop for SwapCell<T> {
205205
fn drop(&mut self) {
206206
let ptr = *self.0.get_mut();
207-
if !ptr.is_null() {
208-
// SAFETY: ptr was created by Box::into_raw
209-
drop(unsafe { Box::from_raw(ptr) });
210-
}
207+
// SAFETY: ptr was created by Box::into_raw
208+
drop(unsafe { Box::from_raw(ptr) });
211209
}
212210
}
213211

0 commit comments

Comments
 (0)