diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index dc31d403b837ad..0012f8f421c233 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -1919,6 +1919,18 @@ def test_hash(self): with self.assertRaisesRegex(TypeError, "unhashable type: 'list'"): hash(fd) + def test_hash_deeply_nested(self): + # This should raise a RecursionError and not crash. + # Nesting through *values*: unlike keys, values are not hashed at + # construction time, so hashing recurses the full depth. + # See https://github.com/python/cpython/issues/154318. + fd = frozendict({0: 0}) + for _ in range(500_000): + fd = frozendict({0: fd}) + with support.infinite_recursion(): + with self.assertRaises(RecursionError): + hash(fd) + @support.cpython_only def test_hash_cpython(self): # Check that hash(frozendict) implementation is: diff --git a/Lib/test/test_tuple.py b/Lib/test/test_tuple.py index e533392b8cae94..1654dbc459d6a6 100644 --- a/Lib/test/test_tuple.py +++ b/Lib/test/test_tuple.py @@ -119,6 +119,16 @@ def check_one_exact(t, e32, e64): check_one_exact((0.5, (), (-2, 3, (4, 6))), 714642271, -1845940830829704396) + def test_hash_deeply_nested(self): + # This should raise a RecursionError and not crash. + # See https://github.com/python/cpython/issues/154318. + t = () + for _ in range(500_000): + t = (t,) + with support.infinite_recursion(): + with self.assertRaises(RecursionError): + hash(t) + # Various tests for hashing of tuples to check that we get few collisions. # Does something only if RUN_ALL_HASH_TESTS is true. # diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-21-10-42-15.gh-issue-154318.Kq3Wm7.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-21-10-42-15.gh-issue-154318.Kq3Wm7.rst new file mode 100644 index 00000000000000..b530d8cf06357b --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-21-10-42-15.gh-issue-154318.Kq3Wm7.rst @@ -0,0 +1,2 @@ +Fix a crash when hashing a deeply nested :class:`tuple` or :class:`frozendict`. +A :exc:`RecursionError` is now raised instead of overflowing the C stack. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index c650aa456d2cc9..567f30051a954b 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -8455,16 +8455,26 @@ frozendict_hash(PyObject *op) PyDictObject *mp = _PyAnyDict_CAST(op); Py_uhash_t hash = 0; + /* Hashing a value recurses back into frozendict_hash() when that value + is itself a frozendict, so guard the descent to raise RecursionError + rather than overflow the C stack on a deeply nested frozendict. Keys + are safe: their hashes are already computed at construction time. */ + if (_Py_EnterRecursiveCall(" while hashing a frozendict")) { + return -1; + } + PyObject *value; // borrowed ref Py_ssize_t pos = 0; Py_hash_t key_hash; while (_PyDict_Next(op, &pos, NULL, &value, &key_hash)) { Py_hash_t pair_hash = frozendict_pair_hash(key_hash, value); if (pair_hash == -1) { + _Py_LeaveRecursiveCall(); return -1; } hash ^= _shuffle_bits(pair_hash); } + _Py_LeaveRecursiveCall(); /* Factor in the number of active entries */ hash ^= ((Py_uhash_t)mp->ma_used + 1) * 1927868237UL; diff --git a/Objects/tupleobject.c b/Objects/tupleobject.c index bb5e18cb790acf..3156ed0711c68a 100644 --- a/Objects/tupleobject.c +++ b/Objects/tupleobject.c @@ -380,16 +380,26 @@ tuple_hash(PyObject *op) Py_ssize_t len = Py_SIZE(v); PyObject **item = v->ob_item; + + /* Hashing an item recurses back into tuple_hash() when that item is + itself a tuple, so guard the descent to raise RecursionError rather + than overflow the C stack on a deeply nested tuple. */ + if (_Py_EnterRecursiveCall(" while hashing a tuple")) { + return -1; + } + acc = _PyTuple_HASH_XXPRIME_5; for (Py_ssize_t i = 0; i < len; i++) { Py_uhash_t lane = PyObject_Hash(item[i]); if (lane == (Py_uhash_t)-1) { + _Py_LeaveRecursiveCall(); return -1; } acc += lane * _PyTuple_HASH_XXPRIME_2; acc = _PyTuple_HASH_XXROTATE(acc); acc *= _PyTuple_HASH_XXPRIME_1; } + _Py_LeaveRecursiveCall(); /* Add input length, mangled to keep the historical value of hash(()). */ acc += len ^ (_PyTuple_HASH_XXPRIME_5 ^ 3527539UL);