From df43d6aba34c955a72613b8ca7fd9afa5ab0314d Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Fri, 21 Aug 2026 18:57:27 +0000 Subject: [PATCH 1/7] gh-156204: Guard recursion in PyErr_GivenExceptionMatches --- Lib/test/test_exceptions.py | 16 ++++++++++++++++ ...26-08-21-18-49-12.gh-issue-156204.ZusA7e.rst | 3 +++ Python/errors.c | 17 +++++++++++------ 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index c34cf44d722456c..1aebdd5ba38fd5e 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -2778,6 +2778,22 @@ def test_except_star_invalid_exception_type(self): except (ValueError, 42): pass + def test_given_exception_matches_deeply_nested_tuple(self): + import ctypes + lib = ctypes.pythonapi + lib.PyErr_GivenExceptionMatches.argtypes = [ctypes.py_object, ctypes.py_object] + lib.PyErr_GivenExceptionMatches.restype = ctypes.c_int + + tup = (1, ValueError) + for _ in range(50_000): + tup = (1, tup) + + # PyErr_GivenExceptionMatches should handle deep recursion safely without SIGSEGV + res = lib.PyErr_GivenExceptionMatches(TypeError(), tup) + self.assertEqual(res, 0) + if lib.PyErr_Occurred(): + lib.PyErr_Clear() + class PEP626Tests(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst new file mode 100644 index 000000000000000..9c8d1efbd761a00 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst @@ -0,0 +1,3 @@ +Fix unhandled recursion in :c:func:`PyErr_GivenExceptionMatches` when +evaluating deeply nested exception tuples, preventing crashes caused by +stack exhaustion. diff --git a/Python/errors.c b/Python/errors.c index 48b03e5fd714b18..6f16107c2385f84 100644 --- a/Python/errors.c +++ b/Python/errors.c @@ -335,16 +335,21 @@ PyErr_GivenExceptionMatches(PyObject *err, PyObject *exc) return 0; } if (PyTuple_Check(exc)) { + if (Py_EnterRecursiveCall(" in PyErr_GivenExceptionMatches")) { + return 0; + } Py_ssize_t i, n; - n = PyTuple_Size(exc); + n = PyTuple_GET_SIZE(exc); for (i = 0; i < n; i++) { /* Test recursively */ - if (PyErr_GivenExceptionMatches( - err, PyTuple_GET_ITEM(exc, i))) - { - return 1; - } + if (PyErr_GivenExceptionMatches( + err, PyTuple_GET_ITEM(exc, i))) + { + Py_LeaveRecursiveCall(); + return 1; + } } + Py_LeaveRecursiveCall(); return 0; } /* err might be an instance, so check its class. */ From d0d85956659696e101e3c5d0645395ab197faee9 Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Fri, 21 Aug 2026 19:27:01 +0000 Subject: [PATCH 2/7] gh-156204: Safely skip ctypes test on mobile/wasm and reduce tuple depth --- Lib/test/test_exceptions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 1aebdd5ba38fd5e..f77c02c442bc2c7 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -2778,8 +2778,11 @@ def test_except_star_invalid_exception_type(self): except (ValueError, 42): pass + @cpython_only + @support.skip_emscripten_stack_overflow() + @support.skip_wasi_stack_overflow() def test_given_exception_matches_deeply_nested_tuple(self): - import ctypes + ctypes = import_module('ctypes') lib = ctypes.pythonapi lib.PyErr_GivenExceptionMatches.argtypes = [ctypes.py_object, ctypes.py_object] lib.PyErr_GivenExceptionMatches.restype = ctypes.c_int From b6be6c5d9015c774bc82cadf63e2901894f3d54e Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Fri, 21 Aug 2026 20:16:21 +0000 Subject: [PATCH 3/7] gh-156204: address review feedback --- Doc/c-api/exceptions.rst | 7 ++++ Lib/test/test_exceptions.py | 33 +++++++++++-------- ...-08-21-18-49-12.gh-issue-156204.ZusA7e.rst | 2 +- Modules/_testcapi/exceptions.c | 19 +++++++++++ Python/errors.c | 16 ++++----- 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/Doc/c-api/exceptions.rst b/Doc/c-api/exceptions.rst index 40522f8c7b13756..a4d450424594ba0 100644 --- a/Doc/c-api/exceptions.rst +++ b/Doc/c-api/exceptions.rst @@ -486,6 +486,13 @@ Querying the error indicator of a subclass. If *exc* is a tuple, all exception types in the tuple (and recursively in subtuples) are searched for a match. + If the subtuples are nested deeply enough to risk exhausting the C stack, + the search is abandoned, a :exc:`RecursionError` is set, and the function + returns false. + + .. versionchanged:: next + Deeply nested subtuples previously crashed the interpreter. + .. c:function:: PyObject *PyErr_GetRaisedException(void) diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index f77c02c442bc2c7..923e0ff9ec6dc84 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -2779,23 +2779,28 @@ def test_except_star_invalid_exception_type(self): pass @cpython_only + @unittest.skipIf(_testcapi is None, "requires _testcapi") + def test_given_exception_matches_nested_tuple(self): + # Nested tuples are searched recursively. + self.assertTrue( + _testcapi.err_givenexceptionmatches(ValueError(), ((ValueError,),))) + self.assertFalse( + _testcapi.err_givenexceptionmatches(TypeError(), ((ValueError,),))) + + @cpython_only + @unittest.skipIf(_testcapi is None, "requires _testcapi") @support.skip_emscripten_stack_overflow() @support.skip_wasi_stack_overflow() + @support.run_with_limited_c_stack(depth=500_000) def test_given_exception_matches_deeply_nested_tuple(self): - ctypes = import_module('ctypes') - lib = ctypes.pythonapi - lib.PyErr_GivenExceptionMatches.argtypes = [ctypes.py_object, ctypes.py_object] - lib.PyErr_GivenExceptionMatches.restype = ctypes.c_int - - tup = (1, ValueError) - for _ in range(50_000): - tup = (1, tup) - - # PyErr_GivenExceptionMatches should handle deep recursion safely without SIGSEGV - res = lib.PyErr_GivenExceptionMatches(TypeError(), tup) - self.assertEqual(res, 0) - if lib.PyErr_Occurred(): - lib.PyErr_Clear() + # gh-156204: PyErr_GivenExceptionMatches() used to exhaust the C stack + # and crash the interpreter on deeply nested tuples of exception types. + tup = (ValueError,) + for _ in range(500_000): + tup = (tup,) + + with self.assertRaises(RecursionError): + _testcapi.err_givenexceptionmatches(TypeError(), tup) class PEP626Tests(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst index 9c8d1efbd761a00..da40d41bc4115a6 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst @@ -1,3 +1,3 @@ -Fix unhandled recursion in :c:func:`PyErr_GivenExceptionMatches` when +Fix unhandled recursion error in :c:func:`PyErr_GivenExceptionMatches` when evaluating deeply nested exception tuples, preventing crashes caused by stack exhaustion. diff --git a/Modules/_testcapi/exceptions.c b/Modules/_testcapi/exceptions.c index c0254e044bc2d5f..29a2dcf37fdffd7 100644 --- a/Modules/_testcapi/exceptions.c +++ b/Modules/_testcapi/exceptions.c @@ -54,6 +54,24 @@ err_restore(PyObject *self, PyObject *args) { return NULL; } +static PyObject * +err_givenexceptionmatches(PyObject *Py_UNUSED(module), PyObject *args) +{ + PyObject *err, *exc; + if (!PyArg_ParseTuple(args, "OO", &err, &exc)) { + return NULL; + } + assert(!PyErr_Occurred()); + int res = PyErr_GivenExceptionMatches(err, exc); + /* PyErr_GivenExceptionMatches() has no failure return value, but it can + * set RecursionError on a deeply nested tuple; report that to the caller. + */ + if (res == 0 && PyErr_Occurred()) { + return NULL; + } + return PyBool_FromLong(res); +} + /*[clinic input] _testcapi.exception_print exception as exc: object @@ -544,6 +562,7 @@ static PyTypeObject PyRecursingInfinitelyError_Type = { static PyMethodDef test_methods[] = { {"err_restore", err_restore, METH_VARARGS}, + {"err_givenexceptionmatches", err_givenexceptionmatches, METH_VARARGS}, {"err_writeunraisable", err_writeunraisable, METH_VARARGS}, {"err_formatunraisable", err_formatunraisable, METH_VARARGS}, _TESTCAPI_ERR_SET_RAISED_METHODDEF diff --git a/Python/errors.c b/Python/errors.c index 6f16107c2385f84..2455fb07688d79c 100644 --- a/Python/errors.c +++ b/Python/errors.c @@ -338,19 +338,17 @@ PyErr_GivenExceptionMatches(PyObject *err, PyObject *exc) if (Py_EnterRecursiveCall(" in PyErr_GivenExceptionMatches")) { return 0; } - Py_ssize_t i, n; - n = PyTuple_GET_SIZE(exc); - for (i = 0; i < n; i++) { + int res = 0; + Py_ssize_t n = PyTuple_GET_SIZE(exc); + for (Py_ssize_t i = 0; i < n; i++) { /* Test recursively */ - if (PyErr_GivenExceptionMatches( - err, PyTuple_GET_ITEM(exc, i))) - { - Py_LeaveRecursiveCall(); - return 1; + if (PyErr_GivenExceptionMatches(err, PyTuple_GET_ITEM(exc, i))) { + res = 1; + break; } } Py_LeaveRecursiveCall(); - return 0; + return res; } /* err might be an instance, so check its class. */ if (PyExceptionInstance_Check(err)) From 170d3c961eaf6fa7193ef697b78e2a0479871982 Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Fri, 21 Aug 2026 20:28:44 +0000 Subject: [PATCH 4/7] gh-156204: drop docs change --- Doc/c-api/exceptions.rst | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Doc/c-api/exceptions.rst b/Doc/c-api/exceptions.rst index a4d450424594ba0..40522f8c7b13756 100644 --- a/Doc/c-api/exceptions.rst +++ b/Doc/c-api/exceptions.rst @@ -486,13 +486,6 @@ Querying the error indicator of a subclass. If *exc* is a tuple, all exception types in the tuple (and recursively in subtuples) are searched for a match. - If the subtuples are nested deeply enough to risk exhausting the C stack, - the search is abandoned, a :exc:`RecursionError` is set, and the function - returns false. - - .. versionchanged:: next - Deeply nested subtuples previously crashed the interpreter. - .. c:function:: PyObject *PyErr_GetRaisedException(void) From 12ce58fd9bf59b29a3eee285f2bf7d074d9f777d Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Thu, 24 Sep 2026 14:08:22 +0000 Subject: [PATCH 5/7] gh-156204: convert test helper to Argument Clinic --- Modules/_testcapi/clinic/exceptions.c.h | 33 ++++++++++++++++++++++++- Modules/_testcapi/exceptions.c | 23 ++++++++++------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/Modules/_testcapi/clinic/exceptions.c.h b/Modules/_testcapi/clinic/exceptions.c.h index cac5d288f437b72..89b78923bdb2d0c 100644 --- a/Modules/_testcapi/clinic/exceptions.c.h +++ b/Modules/_testcapi/clinic/exceptions.c.h @@ -16,6 +16,37 @@ PyDoc_STRVAR(_testcapi_err_set_raised__doc__, #define _TESTCAPI_ERR_SET_RAISED_METHODDEF \ {"err_set_raised", (PyCFunction)_testcapi_err_set_raised, METH_O, _testcapi_err_set_raised__doc__}, +PyDoc_STRVAR(_testcapi_err_givenexceptionmatches__doc__, +"err_givenexceptionmatches($module, err, exc, /)\n" +"--\n" +"\n" +"Test PyErr_GivenExceptionMatches()."); + +#define _TESTCAPI_ERR_GIVENEXCEPTIONMATCHES_METHODDEF \ + {"err_givenexceptionmatches", _PyCFunction_CAST(_testcapi_err_givenexceptionmatches), METH_FASTCALL, _testcapi_err_givenexceptionmatches__doc__}, + +static PyObject * +_testcapi_err_givenexceptionmatches_impl(PyObject *module, PyObject *err, + PyObject *exc); + +static PyObject * +_testcapi_err_givenexceptionmatches(PyObject *module, PyObject *const *args, Py_ssize_t nargs) +{ + PyObject *return_value = NULL; + PyObject *err; + PyObject *exc; + + if (!_PyArg_CheckPositional("err_givenexceptionmatches", nargs, 2, 2)) { + goto exit; + } + err = args[0]; + exc = args[1]; + return_value = _testcapi_err_givenexceptionmatches_impl(module, err, exc); + +exit: + return return_value; +} + PyDoc_STRVAR(_testcapi_exception_print__doc__, "exception_print($module, exception, legacy=False, /)\n" "--\n" @@ -459,4 +490,4 @@ _testcapi_unstable_exc_prep_reraise_star(PyObject *module, PyObject *const *args exit: return return_value; } -/*[clinic end generated code: output=357caea020348789 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=a21ce5554900dba1 input=a9049054013a1b77]*/ diff --git a/Modules/_testcapi/exceptions.c b/Modules/_testcapi/exceptions.c index 29a2dcf37fdffd7..f80a9c53e681e4c 100644 --- a/Modules/_testcapi/exceptions.c +++ b/Modules/_testcapi/exceptions.c @@ -54,18 +54,23 @@ err_restore(PyObject *self, PyObject *args) { return NULL; } +/*[clinic input] +_testcapi.err_givenexceptionmatches + err: object + exc: object + / + +Test PyErr_GivenExceptionMatches(). +[clinic start generated code]*/ + static PyObject * -err_givenexceptionmatches(PyObject *Py_UNUSED(module), PyObject *args) +_testcapi_err_givenexceptionmatches_impl(PyObject *module, PyObject *err, + PyObject *exc) +/*[clinic end generated code: output=e40994ab6dd75001 input=7b8ef542df07575b]*/ { - PyObject *err, *exc; - if (!PyArg_ParseTuple(args, "OO", &err, &exc)) { - return NULL; - } assert(!PyErr_Occurred()); int res = PyErr_GivenExceptionMatches(err, exc); - /* PyErr_GivenExceptionMatches() has no failure return value, but it can - * set RecursionError on a deeply nested tuple; report that to the caller. - */ + /* The function has no failure return, but it can set RecursionError. */ if (res == 0 && PyErr_Occurred()) { return NULL; } @@ -562,7 +567,6 @@ static PyTypeObject PyRecursingInfinitelyError_Type = { static PyMethodDef test_methods[] = { {"err_restore", err_restore, METH_VARARGS}, - {"err_givenexceptionmatches", err_givenexceptionmatches, METH_VARARGS}, {"err_writeunraisable", err_writeunraisable, METH_VARARGS}, {"err_formatunraisable", err_formatunraisable, METH_VARARGS}, _TESTCAPI_ERR_SET_RAISED_METHODDEF @@ -571,6 +575,7 @@ static PyMethodDef test_methods[] = { _TESTCAPI_MAKE_EXCEPTION_WITH_DOC_METHODDEF _TESTCAPI_EXC_SET_OBJECT_METHODDEF _TESTCAPI_EXC_SET_OBJECT_FETCH_METHODDEF + _TESTCAPI_ERR_GIVENEXCEPTIONMATCHES_METHODDEF _TESTCAPI_ERR_SETSTRING_METHODDEF _TESTCAPI_ERR_SETFROMERRNOWITHFILENAME_METHODDEF _TESTCAPI_RAISE_EXCEPTION_METHODDEF From e7c80b9cbc2b4187de6e5b5c90a89583dbce159f Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Thu, 24 Sep 2026 17:01:19 +0000 Subject: [PATCH 6/7] gh-156204: remove comment --- Modules/_testcapi/exceptions.c | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/_testcapi/exceptions.c b/Modules/_testcapi/exceptions.c index f80a9c53e681e4c..8c233e64ec3a354 100644 --- a/Modules/_testcapi/exceptions.c +++ b/Modules/_testcapi/exceptions.c @@ -70,7 +70,6 @@ _testcapi_err_givenexceptionmatches_impl(PyObject *module, PyObject *err, { assert(!PyErr_Occurred()); int res = PyErr_GivenExceptionMatches(err, exc); - /* The function has no failure return, but it can set RecursionError. */ if (res == 0 && PyErr_Occurred()) { return NULL; } From 141b18193ba4e7aa54ce6a290da4a52eb451c2ed Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Sun, 27 Sep 2026 18:26:41 +0000 Subject: [PATCH 7/7] gh-156204: report recursion overflow as unraisable --- Lib/test/test_exceptions.py | 6 ++++-- .../2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst | 6 +++--- Modules/_testcapi/exceptions.c | 4 +--- Python/errors.c | 12 ++++++++++-- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index c3a655554361f92..963da1b0eae27ce 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -2827,8 +2827,10 @@ def test_given_exception_matches_deeply_nested_tuple(self): for _ in range(500_000): tup = (tup,) - with self.assertRaises(RecursionError): - _testcapi.err_givenexceptionmatches(TypeError(), tup) + with support.catch_unraisable_exception() as cm: + self.assertFalse( + _testcapi.err_givenexceptionmatches(ValueError(), tup)) + self.assertIsInstance(cm.unraisable.exc_value, RecursionError) class PEP626Tests(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst index da40d41bc4115a6..fade87c2d09ae1b 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-21-18-49-12.gh-issue-156204.ZusA7e.rst @@ -1,3 +1,3 @@ -Fix unhandled recursion error in :c:func:`PyErr_GivenExceptionMatches` when -evaluating deeply nested exception tuples, preventing crashes caused by -stack exhaustion. +Fix a crash in :c:func:`PyErr_GivenExceptionMatches` when evaluating deeply +nested exception tuples. The recursion is now bounded, and exceeding the +limit is reported as an unraisable exception. diff --git a/Modules/_testcapi/exceptions.c b/Modules/_testcapi/exceptions.c index 8c233e64ec3a354..cc62b47c99414a0 100644 --- a/Modules/_testcapi/exceptions.c +++ b/Modules/_testcapi/exceptions.c @@ -70,9 +70,7 @@ _testcapi_err_givenexceptionmatches_impl(PyObject *module, PyObject *err, { assert(!PyErr_Occurred()); int res = PyErr_GivenExceptionMatches(err, exc); - if (res == 0 && PyErr_Occurred()) { - return NULL; - } + assert(!PyErr_Occurred()); return PyBool_FromLong(res); } diff --git a/Python/errors.c b/Python/errors.c index 09e8d8f6832087a..edb1557e23f63bc 100644 --- a/Python/errors.c +++ b/Python/errors.c @@ -4,6 +4,7 @@ #include "Python.h" #include "pycore_audit.h" // _PySys_Audit() #include "pycore_call.h" // _PyObject_CallNoArgs() +#include "pycore_ceval.h" // _Py_ReachedRecursionLimitWithMargin() #include "pycore_fileutils.h" // _PyFile_Flush #include "pycore_initconfig.h" // _PyStatus_ERR() #include "pycore_pyerrors.h" // _PyErr_Format() @@ -337,7 +338,15 @@ PyErr_GivenExceptionMatches(PyObject *err, PyObject *exc) return 0; } if (PyTuple_Check(exc)) { - if (Py_EnterRecursiveCall(" in PyErr_GivenExceptionMatches")) { + PyThreadState *tstate = _PyThreadState_GET(); + if (_Py_ReachedRecursionLimitWithMargin(tstate, 2)) { + PyObject *exc_value = _PyErr_GetRaisedException(tstate); + _PyErr_SetString(tstate, PyExc_RecursionError, + "maximum recursion depth exceeded while " + "checking exception tuple"); + PyErr_FormatUnraisable("Exception ignored while " + "checking exception tuple"); + _PyErr_SetRaisedException(tstate, exc_value); return 0; } int res = 0; @@ -349,7 +358,6 @@ PyErr_GivenExceptionMatches(PyObject *err, PyObject *exc) break; } } - Py_LeaveRecursiveCall(); return res; } /* err might be an instance, so check its class. */