Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a3292c2
Do not track frozenset objects with immutables
eendebakpt Oct 16, 2025
cd294a6
cleanup
eendebakpt Oct 16, 2025
7e28cf2
cleanup
eendebakpt Oct 16, 2025
30057a5
Merge branch 'main' into frozenset_immutable_tracking
eendebakpt Oct 16, 2025
c4deb03
fix test
eendebakpt Oct 16, 2025
607237a
📜🤖 Added by blurb_it.
blurb-it[bot] Oct 16, 2025
2735a71
Update Objects/setobject.c
eendebakpt Oct 17, 2025
c05db54
make sure PySet_Add tracks frozensets if needed
eendebakpt Oct 24, 2025
7f6bc4b
Merge branch 'frozenset_immutable_tracking' of github.com:eendebakpt/…
eendebakpt Oct 24, 2025
0b97604
review comment
eendebakpt Oct 24, 2025
948daed
Merge branch 'main' into frozenset_immutable_tracking
eendebakpt Oct 24, 2025
08e22c3
use _testcapi for testing
eendebakpt Oct 26, 2025
62afc76
whitespace
eendebakpt Oct 26, 2025
eab653e
Merge branch 'main' into frozenset_immutable_tracking
eendebakpt Oct 26, 2025
37fc61d
Apply suggestions from code review
eendebakpt Oct 26, 2025
4f8bda7
review comment
eendebakpt Oct 26, 2025
e9d42b4
Merge branch 'frozenset_immutable_tracking' of github.com:eendebakpt/…
eendebakpt Oct 26, 2025
4b39149
Apply suggestions from code review
eendebakpt Oct 28, 2025
2859802
review comments
eendebakpt Oct 28, 2025
08f43c5
review comments
eendebakpt Oct 28, 2025
d12102c
Merge branch 'main' into frozenset_immutable_tracking
eendebakpt Jan 18, 2026
5f8b1f2
Update Objects/setobject.c
eendebakpt Jan 18, 2026
ae5cc7f
review comments
eendebakpt Jan 23, 2026
786019d
Update Lib/test/test_set.py
eendebakpt Jan 23, 2026
f37f46e
Update Modules/_testcapimodule.c
eendebakpt Jan 23, 2026
afd8a5b
Merge branch 'main' into frozenset_immutable_tracking
eendebakpt Jan 23, 2026
377e6d8
Apply suggestions from code review
eendebakpt Jan 23, 2026
c62fa9a
revert to fastcall
eendebakpt Jan 24, 2026
69b728f
Merge branch 'frozenset_immutable_tracking' of github.com:eendebakpt/…
eendebakpt Jan 24, 2026
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
27 changes: 27 additions & 0 deletions Lib/test/test_capi/test_set.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import gc
import unittest

from test.support import import_helper
Expand Down Expand Up @@ -220,6 +221,32 @@ def test_clear(self):
# CRASHES: clear(NULL)


class TestPySet_Add(unittest.TestCase):
def test_set(self):
# Test the PySet_Add c-api for set objects
s = set()
self.assertEqual(_testlimitedcapi.pyset_add(s, 1), {1})
self.assertRaises(TypeError, _testlimitedcapi.pyset_add, s, [])

def test_frozenset(self):
# Test the PySet_Add c-api for frozenset objects
self.assertEqual(_testlimitedcapi.pyset_add(frozenset(), 1), frozenset([1]))
frozen_set = frozenset()
# if the argument to PySet_Add is a frozenset that is not uniquely references an error is generated
self.assertRaises(SystemError, _testlimitedcapi.pyset_add, frozen_set, 1)

def test_frozenset_gc_tracking(self):
# see gh-140234
class TrackedHashableClass():
pass

a = TrackedHashableClass()
result_set = _testlimitedcapi.pyset_add(frozenset(), 1)
self.assertFalse(gc.is_tracked(result_set))
result_set = _testlimitedcapi.pyset_add(frozenset(), a)
self.assertTrue(gc.is_tracked(result_set))


class TestInternalCAPI(BaseSetTests, unittest.TestCase):
def test_set_update(self):
update = _testinternalcapi.set_update
Expand Down
5 changes: 4 additions & 1 deletion Lib/test/test_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -1880,7 +1880,10 @@ class S(set):
check(S(), set(), '3P')
class FS(frozenset):
__slots__ = 'a', 'b', 'c'
check(FS(), frozenset(), '3P')

class mytuple(tuple):
pass
check(FS([mytuple()]), frozenset([mytuple()]), '3P')
from collections import OrderedDict
class OD(OrderedDict):
__slots__ = 'a', 'b', 'c'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Frozenset objects with immutable elements are no longer tracked by the garbage collector.
2 changes: 1 addition & 1 deletion Modules/_testcapimodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -2658,7 +2658,7 @@ static PyMethodDef TestMethods[] = {
{"return_null_without_error", return_null_without_error, METH_NOARGS},
{"return_result_with_error", return_result_with_error, METH_NOARGS},
{"getitem_with_error", getitem_with_error, METH_VARARGS},
{"Py_CompileString", pycompilestring, METH_O},
{"Py_CompileString", pycompilestring, METH_O},
{"raise_SIGINT_then_send_None", raise_SIGINT_then_send_None, METH_VARARGS},
{"stack_pointer", stack_pointer, METH_NOARGS},
#ifdef W_STOPCODE
Expand Down
26 changes: 26 additions & 0 deletions Modules/_testlimitedcapi/set.c
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
#if !defined(Py_GIL_DISABLED) && !defined(Py_LIMITED_API)
// Need limited C API for METH_FASTCALL
#define Py_LIMITED_API 0x030d0000
#endif

#include "parts.h"
#include "util.h"


static PyObject *
set_check(PyObject *self, PyObject *obj)
{
Expand Down Expand Up @@ -200,6 +206,25 @@ test_set_contains_does_not_convert_unhashable_key(PyObject *self, PyObject *Py_U
return NULL;
}

// Interface to PySet_Add, returning the set
static PyObject *
pyset_add(PyObject *self, PyObject *const *args, Py_ssize_t nargs)
{
if (nargs != 2) {
PyErr_SetString(PyExc_TypeError,
"pyset_add requires exactly 2 arguments");
return NULL;
}
PyObject *set = args[0];
PyObject *item = args[1];

int return_value = PySet_Add(set, item);
if (return_value < 0) {
return NULL;
}
return Py_NewRef(set);
}

static PyMethodDef test_methods[] = {
{"set_check", set_check, METH_O},
{"set_checkexact", set_checkexact, METH_O},
Expand All @@ -221,6 +246,7 @@ static PyMethodDef test_methods[] = {
{"test_frozenset_add_in_capi", test_frozenset_add_in_capi, METH_NOARGS},
{"test_set_contains_does_not_convert_unhashable_key",
test_set_contains_does_not_convert_unhashable_key, METH_NOARGS},
{"pyset_add", _PyCFunction_CAST(pyset_add), METH_FASTCALL},

{NULL},
};
Expand Down
36 changes: 34 additions & 2 deletions Objects/setobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,26 @@ make_new_set_basetype(PyTypeObject *type, PyObject *iterable)
return make_new_set(type, iterable);
}

void
// gh-140232: check whether a frozenset can be untracked from the GC
_PyFrozenSet_MaybeUntrack(PyObject *op)
{
assert(op != NULL);
// subclasses of a frozenset can generate reference cycles, so do not untrack
if (!PyFrozenSet_CheckExact(op)) {
return;
}
// if no elements of a frozenset are tracked by the GC, we untrack the object
Py_ssize_t pos = 0;
setentry *entry;
while (set_next((PySetObject *)op, &pos, &entry)) {
if (_PyObject_GC_MAY_BE_TRACKED(entry->key)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should use faster _PyType_IS_GC(Py_TYPE(entry->key)) as in maybe_tracked from Objects/tupleobject.c?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure performance matters a lot, but I would prefer to have it consistent with what is used in tupleobject.c. Unless there are objections, I will change the implementation to use the maybe_tracked.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, leaving this as it is. The maybe_tracked has a comment mentioning it is a temporary workaround.

return;
}
}
_PyObject_GC_UNTRACK(op);
}

static PyObject *
make_new_frozenset(PyTypeObject *type, PyObject *iterable)
{
Expand All @@ -1379,7 +1399,11 @@ make_new_frozenset(PyTypeObject *type, PyObject *iterable)
/* frozenset(f) is idempotent */
return Py_NewRef(iterable);
}
return make_new_set(type, iterable);
PyObject *obj = make_new_set(type, iterable);
if (obj != NULL) {
_PyFrozenSet_MaybeUntrack(obj);
}
return obj;
}

static PyObject *
Expand Down Expand Up @@ -2932,7 +2956,11 @@ PySet_New(PyObject *iterable)
PyObject *
PyFrozenSet_New(PyObject *iterable)
{
return make_new_set(&PyFrozenSet_Type, iterable);
PyObject *result = make_new_set(&PyFrozenSet_Type, iterable);
if (result != 0) {
_PyFrozenSet_MaybeUntrack(result);
}
return result;
}

Py_ssize_t
Expand Down Expand Up @@ -3010,6 +3038,10 @@ PySet_Add(PyObject *anyset, PyObject *key)
// API limits the usage of `PySet_Add` to "fill in the values of brand
// new frozensets before they are exposed to other code". In this case,
// this can be done without a lock.
// since another key is added to the set, we must track the frozenset if needed
if (PyFrozenSet_CheckExact(anyset) && PyObject_GC_IsTracked(key) && !PyObject_GC_IsTracked(anyset) ) {
_PyObject_GC_TRACK(anyset);
}
return set_add_key((PySetObject *)anyset, key);
}

Expand Down
Loading