https://github.com/python/cpython/commit/08500b05e1526f2d840a716e332aaf4e58c56b6a
commit: 08500b05e1526f2d840a716e332aaf4e58c56b6a
branch: main
author: Jason G <[email protected]>
committer: gpshead <[email protected]>
date: 2026-10-21T15:10:17-07:00
summary:
gh-154318: Guard C recursion when hashing nested tuples and frozendicts
(GH-154362)
* gh-154318: Guard C recursion when hashing nested tuples and frozendicts
tuple_hash() and frozendict_hash() fold each contained object's hash into
an accumulator with PyObject_Hash() inside an unguarded loop. For a deeply
nested tuple or frozendict this recurses back into the same function once
per level and overflows the C stack (SIGSEGV / STATUS_STACK_OVERFLOW)
instead of raising RecursionError.
Wrap both loops in _Py_EnterRecursiveCall() / _Py_LeaveRecursiveCall(), as
the sibling comparison and repr paths already do. The guard is placed after
the cached-hash early return, so repeated hashing of an already-hashed
tuple is unaffected.
This also covers types.GenericAlias, whose ga_hash() hashes its __args__
tuple and so crashed through tuple_hash().
files:
A
Misc/NEWS.d/next/Core_and_Builtins/2026-07-21-10-42-15.gh-issue-154318.Kq3Wm7.rst
M Lib/test/test_dict.py
M Lib/test/test_tuple.py
M Objects/dictobject.c
M Objects/tupleobject.c
diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py
index 9b1f8d5b8db394..9a9e738a7cf76e 100644
--- a/Lib/test/test_dict.py
+++ b/Lib/test/test_dict.py
@@ -1990,6 +1990,21 @@ def test_hash(self):
with self.assertRaisesRegex(TypeError, "unhashable type: 'list'"):
hash(fd)
+ @support.skip_if_huge_c_stack(500_000)
+ @support.skip_wasi_stack_overflow()
+ @support.skip_emscripten_stack_overflow()
+ 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 self.assertRaisesRegex(RecursionError,
+ "while hashing a frozendict"):
+ 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 7e5e57f09449c0..5650dec09adef4 100644
--- a/Lib/test/test_tuple.py
+++ b/Lib/test/test_tuple.py
@@ -123,6 +123,18 @@ def check_one_exact(t, e32, e64):
check_one_exact((0.5, (), (-2, 3, (4, 6))), 714642271,
-1845940830829704396)
+ @support.skip_if_huge_c_stack(500_000)
+ @support.skip_wasi_stack_overflow()
+ @support.skip_emscripten_stack_overflow()
+ 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 self.assertRaisesRegex(RecursionError, "while hashing a tuple"):
+ 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 b3e6323fb79335..41b90f9277d0f2 100644
--- a/Objects/dictobject.c
+++ b/Objects/dictobject.c
@@ -8474,16 +8474,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 1ca28379f328cf..627147e585c718 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);
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]