From 70a3de4600291c92b20783d5f0b64cc2ca629da1 Mon Sep 17 00:00:00 2001 From: Tekin Ertekin Date: Thu, 6 Aug 2026 13:36:08 +0300 Subject: [PATCH] gh-155151: Check the recursion limit in CALL_EX_PY and CALL_KW_BOUND_METHOD The generic CALL_FUNCTION_EX and CALL_KW opcodes reach start_frame, which calls _Py_EnterRecursivePy() and raises RecursionError before the callee runs. Their specialized forms end in _PUSH_FRAME, which decrements py_recursion_remaining without checking it, so once a call site had been warmed up the callee was entered and returned normally where the unspecialized instruction raised. Warming a call site should not change whether the target function executes. CALL_KW_PY and CALL_BOUND_METHOD_EXACT_ARGS already guard their frame push with _CHECK_RECURSION_REMAINING; add it to these two as well. It deopts, so it goes where a deopt is already safe: after the existing flush in CALL_KW_BOUND_METHOD, following the pattern of CALL_BOUND_METHOD_EXACT_ARGS, and after _CHECK_IS_PY_CALLABLE_EX in CALL_EX_PY, which already exits at that point. --- Include/internal/pycore_opcode_metadata.h | 4 +- Lib/test/test_opcache.py | 82 ++++++++++++++++++- ...-08-06-14-40-18.gh-issue-155151.Wq8mN3.rst | 5 ++ Modules/_testinternalcapi/test_cases.c.h | 16 ++++ Python/bytecodes.c | 2 + Python/generated_cases.c.h | 16 ++++ 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-08-06-14-40-18.gh-issue-155151.Wq8mN3.rst diff --git a/Include/internal/pycore_opcode_metadata.h b/Include/internal/pycore_opcode_metadata.h index 457e5c5bf20d2b9..3b675b03906a92b 100644 --- a/Include/internal/pycore_opcode_metadata.h +++ b/Include/internal/pycore_opcode_metadata.h @@ -1389,11 +1389,11 @@ _PyOpcode_macro_expansion[256] = { [CALL_BUILTIN_FAST_WITH_KEYWORDS] = { .nuops = 6, .uops = { { _RECORD_CALLABLE, OPARG_SIMPLE, 0 }, { _GUARD_CALLABLE_BUILTIN_FAST_WITH_KEYWORDS, OPARG_SIMPLE, 3 }, { _CALL_BUILTIN_FAST_WITH_KEYWORDS, OPARG_SIMPLE, 3 }, { _POP_TOP_OPARG, OPARG_SIMPLE, 3 }, { _POP_TOP, OPARG_SIMPLE, 3 }, { _CHECK_PERIODIC_AT_END, OPARG_REPLACED, 3 } } }, [CALL_BUILTIN_O] = { .nuops = 7, .uops = { { _RECORD_CALLABLE, OPARG_SIMPLE, 0 }, { _GUARD_CALLABLE_BUILTIN_O, OPARG_SIMPLE, 3 }, { _CHECK_RECURSION_LIMIT, OPARG_SIMPLE, 3 }, { _CALL_BUILTIN_O, OPARG_SIMPLE, 3 }, { _POP_TOP, OPARG_SIMPLE, 3 }, { _POP_TOP, OPARG_SIMPLE, 3 }, { _CHECK_PERIODIC_AT_END, OPARG_REPLACED, 3 } } }, [CALL_EX_NON_PY_GENERAL] = { .nuops = 4, .uops = { { _CHECK_IS_NOT_PY_CALLABLE_EX, OPARG_SIMPLE, 1 }, { _MAKE_CALLARGS_A_TUPLE, OPARG_SIMPLE, 1 }, { _CALL_FUNCTION_EX_NON_PY_GENERAL, OPARG_SIMPLE, 1 }, { _CHECK_PERIODIC_AT_END, OPARG_REPLACED, 1 } } }, - [CALL_EX_PY] = { .nuops = 7, .uops = { { _RECORD_4OS, OPARG_SIMPLE, 0 }, { _CHECK_PEP_523, OPARG_SIMPLE, 1 }, { _MAKE_CALLARGS_A_TUPLE, OPARG_SIMPLE, 1 }, { _CHECK_IS_PY_CALLABLE_EX, OPARG_SIMPLE, 1 }, { _PY_FRAME_EX, OPARG_SIMPLE, 1 }, { _SAVE_RETURN_OFFSET, OPARG_SAVE_RETURN_OFFSET, 1 }, { _PUSH_FRAME, OPARG_SIMPLE, 1 } } }, + [CALL_EX_PY] = { .nuops = 8, .uops = { { _RECORD_4OS, OPARG_SIMPLE, 0 }, { _CHECK_PEP_523, OPARG_SIMPLE, 1 }, { _MAKE_CALLARGS_A_TUPLE, OPARG_SIMPLE, 1 }, { _CHECK_IS_PY_CALLABLE_EX, OPARG_SIMPLE, 1 }, { _CHECK_RECURSION_REMAINING, OPARG_SIMPLE, 1 }, { _PY_FRAME_EX, OPARG_SIMPLE, 1 }, { _SAVE_RETURN_OFFSET, OPARG_SAVE_RETURN_OFFSET, 1 }, { _PUSH_FRAME, OPARG_SIMPLE, 1 } } }, [CALL_INTRINSIC_1] = { .nuops = 2, .uops = { { _CALL_INTRINSIC_1, OPARG_SIMPLE, 0 }, { _POP_TOP, OPARG_SIMPLE, 0 } } }, [CALL_INTRINSIC_2] = { .nuops = 3, .uops = { { _CALL_INTRINSIC_2, OPARG_SIMPLE, 0 }, { _POP_TOP, OPARG_SIMPLE, 0 }, { _POP_TOP, OPARG_SIMPLE, 0 } } }, [CALL_ISINSTANCE] = { .nuops = 3, .uops = { { _GUARD_THIRD_NULL, OPARG_SIMPLE, 3 }, { _GUARD_CALLABLE_ISINSTANCE, OPARG_SIMPLE, 3 }, { _CALL_ISINSTANCE, OPARG_SIMPLE, 3 } } }, - [CALL_KW_BOUND_METHOD] = { .nuops = 7, .uops = { { _RECORD_CALLABLE_KW, OPARG_SIMPLE, 0 }, { _CHECK_PEP_523, OPARG_SIMPLE, 1 }, { _CHECK_METHOD_VERSION_KW, 2, 1 }, { _EXPAND_METHOD_KW, OPARG_SIMPLE, 3 }, { _PY_FRAME_KW, OPARG_SIMPLE, 3 }, { _SAVE_RETURN_OFFSET, OPARG_SAVE_RETURN_OFFSET, 3 }, { _PUSH_FRAME, OPARG_SIMPLE, 3 } } }, + [CALL_KW_BOUND_METHOD] = { .nuops = 8, .uops = { { _RECORD_CALLABLE_KW, OPARG_SIMPLE, 0 }, { _CHECK_PEP_523, OPARG_SIMPLE, 1 }, { _CHECK_METHOD_VERSION_KW, 2, 1 }, { _EXPAND_METHOD_KW, OPARG_SIMPLE, 3 }, { _CHECK_RECURSION_REMAINING, OPARG_SIMPLE, 3 }, { _PY_FRAME_KW, OPARG_SIMPLE, 3 }, { _SAVE_RETURN_OFFSET, OPARG_SAVE_RETURN_OFFSET, 3 }, { _PUSH_FRAME, OPARG_SIMPLE, 3 } } }, [CALL_KW_NON_PY] = { .nuops = 3, .uops = { { _CHECK_IS_NOT_PY_CALLABLE_KW, OPARG_SIMPLE, 3 }, { _CALL_KW_NON_PY, OPARG_SIMPLE, 3 }, { _CHECK_PERIODIC_AT_END, OPARG_REPLACED, 3 } } }, [CALL_KW_PY] = { .nuops = 7, .uops = { { _RECORD_CALLABLE_KW, OPARG_SIMPLE, 0 }, { _CHECK_PEP_523, OPARG_SIMPLE, 1 }, { _CHECK_FUNCTION_VERSION_KW, 2, 1 }, { _CHECK_RECURSION_REMAINING, OPARG_SIMPLE, 3 }, { _PY_FRAME_KW, OPARG_SIMPLE, 3 }, { _SAVE_RETURN_OFFSET, OPARG_SAVE_RETURN_OFFSET, 3 }, { _PUSH_FRAME, OPARG_SIMPLE, 3 } } }, [CALL_LEN] = { .nuops = 5, .uops = { { _GUARD_NOS_NULL, OPARG_SIMPLE, 3 }, { _GUARD_CALLABLE_LEN, OPARG_SIMPLE, 3 }, { _CALL_LEN, OPARG_SIMPLE, 3 }, { _POP_TOP, OPARG_SIMPLE, 3 }, { _POP_TOP, OPARG_SIMPLE, 3 } } }, diff --git a/Lib/test/test_opcache.py b/Lib/test/test_opcache.py index 7946550ec0db637..243fc571b170385 100644 --- a/Lib/test/test_opcache.py +++ b/Lib/test/test_opcache.py @@ -6,7 +6,7 @@ import types import unittest from test.support import (threading_helper, check_impl_detail, - requires_specialization, + infinite_recursion, requires_specialization, cpython_only, requires_jit_disabled, reset_code) from test.support.import_helper import import_module @@ -568,6 +568,86 @@ def test(default=None): with self.assertRaises(RecursionError): test() + # gh-155151: a specialized call opcode must not enter the callee where the + # generic one raises RecursionError. Warming a call site must not change + # whether the target runs. The probes below drive the recursion limit and + # then make the specialized call from the deepest live frame; the target + # bumps the counter with plain bytecode, so reaching it cannot itself be + # stopped by a second recursion check. + + @requires_jit_disabled + @requires_specialization + def test_recursion_check_for_call_ex_py(self): + hits = 0 + deepest_frame_claimed = False + + def target(*args): + nonlocal hits + hits += 1 + return 42 + + def probe(at_limit): + nonlocal deepest_frame_claimed + while not at_limit: + try: + return probe(False) + except RecursionError: + # Only the deepest live frame makes the call. Outer frames + # re-raise, so the count is not taken after a frame has + # unwound and freed recursion budget. + if deepest_frame_claimed: + raise + deepest_frame_claimed = True + at_limit = True + args = () + return target(*args) + + for _ in range(_testinternalcapi.SPECIALIZATION_THRESHOLD): + probe(True) + self.assert_specialized(probe, "CALL_EX_PY") + + hits_before = hits + with infinite_recursion(50): + with self.assertRaises(RecursionError): + probe(False) + self.assertEqual(hits, hits_before) + + @requires_jit_disabled + @requires_specialization + def test_recursion_check_for_call_kw_bound_method(self): + hits = 0 + deepest_frame_claimed = False + + class Receiver: + def target(self, *, value): + nonlocal hits + hits += 1 + return value + + bound_target = Receiver().target + + def probe(at_limit): + nonlocal deepest_frame_claimed + while not at_limit: + try: + return probe(False) + except RecursionError: + if deepest_frame_claimed: + raise + deepest_frame_claimed = True + at_limit = True + return bound_target(value=43) + + for _ in range(_testinternalcapi.SPECIALIZATION_THRESHOLD): + probe(True) + self.assert_specialized(probe, "CALL_KW_BOUND_METHOD") + + hits_before = hits + with infinite_recursion(50): + with self.assertRaises(RecursionError): + probe(False) + self.assertEqual(hits, hits_before) + def test_dont_specialize_custom_vectorcall(self): def f(): raise Exception("no way") diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-06-14-40-18.gh-issue-155151.Wq8mN3.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-06-14-40-18.gh-issue-155151.Wq8mN3.rst new file mode 100644 index 000000000000000..83feec9a8a7200c --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-06-14-40-18.gh-issue-155151.Wq8mN3.rst @@ -0,0 +1,5 @@ +Fix the specialized ``CALL_EX_PY`` and ``CALL_KW_BOUND_METHOD`` instructions +entering the callee at the recursion limit, where the generic +``CALL_FUNCTION_EX`` and ``CALL_KW`` raise :exc:`RecursionError`. Both now +check the remaining recursion depth before pushing the new frame, so warming +a call site no longer changes whether the target is executed. diff --git a/Modules/_testinternalcapi/test_cases.c.h b/Modules/_testinternalcapi/test_cases.c.h index a17648a33d4fe4a..0195d80d545af8a 100644 --- a/Modules/_testinternalcapi/test_cases.c.h +++ b/Modules/_testinternalcapi/test_cases.c.h @@ -2940,6 +2940,14 @@ JUMP_TO_PREDICTED(CALL_FUNCTION_EX); } } + // _CHECK_RECURSION_REMAINING + { + if (tstate->py_recursion_remaining <= 1) { + UPDATE_MISS_STATS(CALL_FUNCTION_EX); + assert(_PyOpcode_Deopt[opcode] == (CALL_FUNCTION_EX)); + JUMP_TO_PREDICTED(CALL_FUNCTION_EX); + } + } // _PY_FRAME_EX { kwargs_st = stack_pointer[-1]; @@ -3566,6 +3574,14 @@ _PyFrame_StackPointerInvalidate(frame); } // flush + // _CHECK_RECURSION_REMAINING + { + if (tstate->py_recursion_remaining <= 1) { + UPDATE_MISS_STATS(CALL_KW); + assert(_PyOpcode_Deopt[opcode] == (CALL_KW)); + JUMP_TO_PREDICTED(CALL_KW); + } + } // _PY_FRAME_KW { kwnames = stack_pointer[-1]; diff --git a/Python/bytecodes.c b/Python/bytecodes.c index 4d7b338e2dbd4c3..9bc7ae1bdcab906 100644 --- a/Python/bytecodes.c +++ b/Python/bytecodes.c @@ -5537,6 +5537,7 @@ dummy_func( _CHECK_METHOD_VERSION_KW + _EXPAND_METHOD_KW + flush + // so that self is in the argument array + _CHECK_RECURSION_REMAINING + _PY_FRAME_KW + _SAVE_RETURN_OFFSET + _PUSH_FRAME; @@ -5757,6 +5758,7 @@ dummy_func( _CHECK_PEP_523 + _MAKE_CALLARGS_A_TUPLE + _CHECK_IS_PY_CALLABLE_EX + + _CHECK_RECURSION_REMAINING + _PY_FRAME_EX + _SAVE_RETURN_OFFSET + _PUSH_FRAME; diff --git a/Python/generated_cases.c.h b/Python/generated_cases.c.h index 6178dc70c1b80e7..853d206a421276d 100644 --- a/Python/generated_cases.c.h +++ b/Python/generated_cases.c.h @@ -2940,6 +2940,14 @@ JUMP_TO_PREDICTED(CALL_FUNCTION_EX); } } + // _CHECK_RECURSION_REMAINING + { + if (tstate->py_recursion_remaining <= 1) { + UPDATE_MISS_STATS(CALL_FUNCTION_EX); + assert(_PyOpcode_Deopt[opcode] == (CALL_FUNCTION_EX)); + JUMP_TO_PREDICTED(CALL_FUNCTION_EX); + } + } // _PY_FRAME_EX { kwargs_st = stack_pointer[-1]; @@ -3566,6 +3574,14 @@ _PyFrame_StackPointerInvalidate(frame); } // flush + // _CHECK_RECURSION_REMAINING + { + if (tstate->py_recursion_remaining <= 1) { + UPDATE_MISS_STATS(CALL_KW); + assert(_PyOpcode_Deopt[opcode] == (CALL_KW)); + JUMP_TO_PREDICTED(CALL_KW); + } + } // _PY_FRAME_KW { kwnames = stack_pointer[-1];