gh-154318: Guard C recursion when hashing nested tuples and frozendicts#154362
Open
gianghungtien wants to merge 2 commits into
Open
gh-154318: Guard C recursion when hashing nested tuples and frozendicts#154362gianghungtien wants to merge 2 commits into
gianghungtien wants to merge 2 commits into
Conversation
…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().
methane
approved these changes
Jul 21, 2026
There was a problem hiding this comment.
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 intuple_hash()andfrozendict_hash(). - Add regression tests asserting
hash()raisesRecursionErrorrather 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 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 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) | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
tuple_hash()andfrozendict_hash()fold each contained object's hash into an accumulator withPyObject_Hash()inside a loop that has no recursion guard. For a deeply nestedtupleorfrozendictthis recurses back into the same function once per level and overflows the C stack instead of raisingRecursionError.Both loops are now wrapped in
_Py_EnterRecursiveCall()/_Py_LeaveRecursiveCall(), matching what the sibling comparison (do_richcompare) andrepr()paths on these types already do.Before / after
hash(t)RecursionError: Stack overflow (used 1952 kB) while hashing a tuplehash(d)(nested frozendict)RecursionError: ... while hashing a frozendicthash(list[list[...]])RecursionError: ... while hashing a tupleVerified on Windows x64 / MSVC 19.44, Release build. The unpatched build exits with
0xC00000FD(STATUS_STACK_OVERFLOW); the patched build raises a catchableRecursionErrorand continues.The indirect paths noted in the issue are covered too —
{t: 1},{t},frozenset({t}),{d: 1}and{d}all raiseRecursionErrorrather than crashing, since they reach the sametp_hash.types.GenericAliasneeded no separate change:ga_hash()hashes its__args__tuple, so it is fixed throughtuple_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_hashas 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 viaset()/dict.fromkeys()over prebuilt lists of fresh tuples so the numbers are not swamped by interpreter dispatch:set()of fresh 1-tuplesset()of fresh 2-tuplesset()of fresh 5-tuplesset()of fresh nesteddict.fromkeys2-tuples(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_nestedadded toTupleTestandFrozenDictTests, 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 overflowpointing at the new test line, and pass against the patched core.Hash values are unchanged — the existing
test_hash_exact(hardcoded expected values) andfrozendict'stest_hash_cpythonboth 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.