From 419bd3cc9a61835f1ef09f63ae1b4c4862374736 Mon Sep 17 00:00:00 2001 From: An Long Date: Wed, 5 Aug 2026 00:03:28 +0900 Subject: [PATCH] gh-155176: Don't track frozendicts whose contents can never be tracked by the GC --- Lib/test/test_dict.py | 20 +++++++ ...-08-05-00-02-51.gh-issue-155176.6Gz2aR.rst | 3 + Objects/dictobject.c | 59 +++++++++++++++++-- 3 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-08-05-00-02-51.gh-issue-155176.6Gz2aR.rst diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index 1e665c86303078c..464bb7bacbd7362 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -1869,6 +1869,26 @@ def test_copy(self): self.assertEqual(d2, frozendict(x=1, y=2)) self.assertEqual(type(d2), frozendict) + def test_gc_tracking(self): + self.assertFalse(gc.is_tracked(frozendict())) + self.assertFalse(gc.is_tracked(frozendict({1: 2}))) + self.assertFalse(gc.is_tracked(frozendict.fromkeys('ab', 1))) + self.assertFalse(gc.is_tracked(frozendict({1: 2}) | {3: 4})) + + self.assertTrue(gc.is_tracked(frozendict({1: [2]}))) + # subclasses can create reference cycles, they are always tracked + self.assertTrue(gc.is_tracked(FrozenDict({1: 2}))) + + # a reference cycle through a tracked frozendict is collectable + class Obj: + pass + obj = Obj() + obj.fd = frozendict({1: obj}) + ref = weakref.ref(obj) + del obj + gc.collect() + self.assertIsNone(ref()) + def test_merge(self): # test "a | b" operator self.assertEqual(frozendict(x=1) | frozendict(y=2), diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-05-00-02-51.gh-issue-155176.6Gz2aR.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-05-00-02-51.gh-issue-155176.6Gz2aR.rst new file mode 100644 index 000000000000000..e2247d6920c786c --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-05-00-02-51.gh-issue-155176.6Gz2aR.rst @@ -0,0 +1,3 @@ +:class:`frozendict` objects whose keys and values can never be tracked by +the garbage collector are no longer tracked, like :class:`frozenset` +objects. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 74b6d5d779a064c..0741871a2a81edd 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -139,6 +139,7 @@ As a consequence of this, split keys have a maximum size of 16. static PyObject* frozendict_new(PyTypeObject *type, PyObject *args, PyObject *kwds); static PyObject* frozendict_new_untracked(PyTypeObject *type); +static void frozendict_maybe_track(PyObject *op); static PyObject* dict_new(PyTypeObject *type, PyObject *args, PyObject *kwds); static PyObject* dict_new_untracked(PyTypeObject *type); static int dict_merge(PyObject *a, PyObject *b, int override, PyObject **dupkey); @@ -3479,11 +3480,18 @@ _PyDict_FromKeys(PyObject *cls, PyObject *iterable, PyObject *value) // gh-151722: If cls constructor returns a frozendict which is tracked by // the GC, create a frozendict copy which is not tracked by the GC. // + // gh-155176: Since frozendicts whose contents can never be tracked by the + // GC are left untracked, being untracked no longer implies that d is + // private: also make a copy if d is not uniquely referenced, otherwise + // inserting keys below would mutate an object visible elsewhere. + // // At the function exit, return cls(fd) where fd is a frozendict. // // Untracking the frozendict requires tracking again the frozendict on // error which is more complicated. It's easier to work on a copy. - if (PyFrozenDict_Check(d) && _PyObject_GC_IS_TRACKED(d)) { + if (PyFrozenDict_Check(d) + && (_PyObject_GC_IS_TRACKED(d) + || !_PyObject_IsUniquelyReferenced(d))) { need_copy = 1; PyObject *copy = frozendict_new_untracked(&PyFrozenDict_Type); @@ -3637,7 +3645,12 @@ _PyDict_FromKeys(PyObject *cls, PyObject *iterable, PyObject *value) Py_SETREF(d, copy); } else if (!_PyObject_GC_IS_TRACKED(d)) { - _PyObject_GC_TRACK(d); + if (PyFrozenDict_Check(d)) { + frozendict_maybe_track(d); + } + else { + _PyObject_GC_TRACK(d); + } } return d; } @@ -5202,7 +5215,12 @@ _PyDict_Or(PyObject *self, PyObject *other) Py_DECREF(new); return NULL; } - _PyObject_GC_TRACK(new); + if (PyFrozenDict_Check(new)) { + frozendict_maybe_track(new); + } + else { + _PyObject_GC_TRACK(new); + } return new; } @@ -5472,7 +5490,7 @@ frozendict_vectorcall(PyObject *type, PyObject * const*args, } } - _PyObject_GC_TRACK(self); + frozendict_maybe_track(self); return self; } @@ -8502,6 +8520,35 @@ frozendict_new_untracked(PyTypeObject *type) return d; } +/* Track a fully built frozendict in the GC, unless it can never be part of + a reference cycle: an exact frozendict whose keys and values are all + guaranteed to never be tracked by the GC. Subclasses can create reference + cycles, so they are always tracked. */ +static void +frozendict_maybe_track(PyObject *op) +{ + assert(PyFrozenDict_Check(op)); + assert(!_PyObject_GC_IS_TRACKED(op)); + + if (PyFrozenDict_CheckExact(op)) { + PyObject *key, *value; + Py_ssize_t pos = 0; + int track = 0; + while (_PyDict_Next(op, &pos, &key, &value, NULL)) { + if (_PyObject_GC_MAY_BE_TRACKED(key) + || _PyObject_GC_MAY_BE_TRACKED(value)) + { + track = 1; + break; + } + } + if (!track) { + return; + } + } + _PyObject_GC_TRACK(op); +} + static PyObject * frozendict_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { @@ -8520,7 +8567,7 @@ frozendict_new(PyTypeObject *type, PyObject *args, PyObject *kwds) assert(kwds == NULL); } - _PyObject_GC_TRACK(d); + frozendict_maybe_track(d); return d; } @@ -8566,7 +8613,7 @@ frozendict_copy_impl(PyFrozenDictObject *self) PyObject *copy = anydict_copy_untracked((PyObject*)self); if (copy != NULL) { - _PyObject_GC_TRACK(copy); + frozendict_maybe_track(copy); } return copy; }