Skip to content

gh-154318: Guard C recursion when hashing nested tuples and frozendicts#154362

Open
gianghungtien wants to merge 2 commits into
python:mainfrom
gianghungtien:gh-154318-guard-recursive-hash
Open

gh-154318: Guard C recursion when hashing nested tuples and frozendicts#154362
gianghungtien wants to merge 2 commits into
python:mainfrom
gianghungtien:gh-154318-guard-recursive-hash

Conversation

@gianghungtien

@gianghungtien gianghungtien commented Jul 21, 2026

Copy link
Copy Markdown

tuple_hash() and frozendict_hash() fold each contained object's hash into an accumulator with PyObject_Hash() inside a loop that has no recursion guard. For a deeply nested tuple or frozendict this recurses back into the same function once per level and overflows the C stack instead of raising RecursionError.

Both loops are now wrapped in _Py_EnterRecursiveCall() / _Py_LeaveRecursiveCall(), matching what the sibling comparison (do_richcompare) and repr() paths on these types already do.

Before / after

t = ()
for _ in range(300_000):
    t = (t,)
hash(t)
before after
hash(t) crash RecursionError: Stack overflow (used 1952 kB) while hashing a tuple
hash(d) (nested frozendict) crash RecursionError: ... while hashing a frozendict
hash(list[list[...]]) crash RecursionError: ... while hashing a tuple

Verified on Windows x64 / MSVC 19.44, Release build. The unpatched build exits with 0xC00000FD (STATUS_STACK_OVERFLOW); the patched build raises a catchable RecursionError and continues.

The indirect paths noted in the issue are covered too — {t: 1}, {t}, frozenset({t}), {d: 1} and {d} all raise RecursionError rather than crashing, since they reach the same tp_hash.

types.GenericAlias needed no separate change: ga_hash() hashes its __args__ tuple, so it is fixed through tuple_hash() — which is why the third row above reports "while hashing a tuple".

Placement

The guard sits after the cached-hash early return in both functions, so re-hashing an already-hashed tuple or frozendict (the hottest case, e.g. a tuple reused as a dict key) does not touch the guard at all.

In frozendict_hash() the guard covers the value side only, which is where the recursion is: keys are hashed at construction time and their hashes are cached in the entry, so nesting through keys never recursed in the first place.

Performance

The issue flagged tuple_hash as a hot path where the guard's cost should be measured, so I A/B'd two builds of this same commit (with and without the C change), run interleaved, 5 rounds each, best-of-15 per round, hashing driven from inside C via set() / dict.fromkeys() over prebuilt lists of fresh tuples so the numbers are not swamped by interpreter dispatch:

case before after delta
set() of fresh 1-tuples 69.00 69.81 +1.2%
set() of fresh 2-tuples 94.07 95.56 +1.6%
set() of fresh 5-tuples 107.80 111.51 +3.4%
set() of fresh nested 117.99 118.71 +0.6%
dict.fromkeys 2-tuples 116.51 113.26 −2.8%

(ns/op, median of 5 rounds)

I read this as below the noise floor rather than a measured regression: the run-to-run spread within a single variant is ~3.5%, one case comes out faster (which a fixed per-call check cannot cause), and the apparent overhead is largest on 5-tuples and smallest on 1-tuples — the opposite of how a once-per-call cost would distribute.

That is consistent with what the guard compiles to: _Py_EnterRecursiveCall() is a thread-state load plus a stack-pointer compare, and _Py_LeaveRecursiveCall() is empty.

Caveat: this is one Windows/MSVC machine with a hand-rolled harness, not pyperf on a tuned Linux box. If someone wants to confirm on the standard benchmarking setup before this lands, that seems worth doing.

Tests

test_hash_deeply_nested added to TupleTest and FrozenDictTests, following the existing pattern from gh-148801 / gh-145986.

Both were checked to actually catch the bug: run against an unpatched core they die with Windows fatal exception: stack overflow pointing at the new test line, and pass against the patched core.

Hash values are unchanged — the existing test_hash_exact (hardcoded expected values) and frozendict's test_hash_cpython both still pass.

Full suite on Windows x64: 436 modules OK, 49,393 tests. The one failure, test_shutil.TestWhich{,Bytes}.test_environ_path_cwd, reproduces identically on an unpatched build of the same tree and is unrelated to this change.

…zendicts

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().
@python-cla-bot

python-cla-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR prevents C stack overflows when hashing deeply nested tuple and frozendict objects by adding recursion protection to their tp_hash implementations, turning previously-fatal crashes into catchable RecursionErrors. It also adds regression tests and a NEWS entry documenting the behavior change.

Changes:

  • Add _Py_EnterRecursiveCall() / _Py_LeaveRecursiveCall() around the hash loops in tuple_hash() and frozendict_hash().
  • Add regression tests asserting hash() raises RecursionError rather than crashing for deeply nested tuples and frozendicts.
  • Add a NEWS fragment documenting the crash fix.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Objects/tupleobject.c Adds C recursion guard around tuple hashing to avoid stack overflow on deep nesting.
Objects/dictobject.c Adds C recursion guard around frozendict hashing to avoid stack overflow on deep nesting.
Lib/test/test_tuple.py Adds a regression test for deeply nested tuple hashing.
Lib/test/test_dict.py Adds a regression test for deeply nested frozendict hashing.
Misc/NEWS.d/next/Core_and_Builtins/2026-07-21-10-42-15.gh-issue-154318.Kq3Wm7.rst Documents the crash-to-RecursionError behavior change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Lib/test/test_tuple.py
Comment on lines +122 to +131
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)

Comment thread Lib/test/test_dict.py
Comment on lines +1922 to +1933
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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants