diff --git a/Lib/test/test_interpreters/test_api.py b/Lib/test/test_interpreters/test_api.py index 61beb317c37975..b9c0c45e0160bf 100644 --- a/Lib/test/test_interpreters/test_api.py +++ b/Lib/test/test_interpreters/test_api.py @@ -3,6 +3,7 @@ import sys from textwrap import dedent, indent import threading +import traceback import types import unittest @@ -1667,6 +1668,55 @@ def test_set___main___attrs(self): self.assertEqual(rc, 0) +class CaptureExceptionTests(unittest.TestCase): + + # Prevent crashes with incompatible TracebackException.format(). + # Regression test for https://github.com/python/cpython/issues/143377. + + def capture_with_formatter(self, exc, formatter): + with support.swap_attr(traceback.TracebackException, "format", formatter): + return _interpreters.capture_exception(exc) + + def test_capture_exception(self): + captured = _interpreters.capture_exception(ValueError("hello")) + + self.assertEqual(captured.type.__name__, "ValueError") + self.assertEqual(captured.type.__qualname__, "ValueError") + self.assertEqual(captured.type.__module__, "builtins") + + self.assertEqual(captured.msg, "hello") + self.assertEqual(captured.formatted, "ValueError: hello") + + def test_capture_exception_custom_format(self): + exc = ValueError("good bye!") + formatter = lambda self: ["hello\n", "world\n"] + captured = self.capture_with_formatter(exc, formatter) + self.assertEqual(captured.msg, "good bye!") + self.assertEqual(captured.formatted, "ValueError: good bye!") + self.assertEqual(captured.errdisplay, "hello\nworld") + + @support.subTests("exc_lines", ([], ["x-no-nl"], ["x-no-nl", "y-no-nl"])) + def test_capture_exception_invalid_format(self, exc_lines): + formatter = lambda self: exc_lines + captured = self.capture_with_formatter(ValueError(), formatter) + self.assertEqual(captured.msg, "") + self.assertEqual(captured.formatted, "ValueError: ") + self.assertEqual(captured.errdisplay, "".join(exc_lines)) + + @unittest.skipUnless( + support.Py_DEBUG, + "printing subinterpreter unraisable exceptions requires DEBUG build", + ) + def test_capture_exception_unraisable_exception(self): + formatter = lambda self: 1 + with support.catch_unraisable_exception() as cm: + captured = self.capture_with_formatter(ValueError(), formatter) + self.assertFalse(hasattr(captured, "errdisplay")) + self.assertEqual(cm.unraisable.exc_type, TypeError) + self.assertEqual(str(cm.unraisable.exc_value), + "can only join an iterable") + + if __name__ == '__main__': # Test needs to be a package, so we can do relative imports. unittest.main() diff --git a/Misc/NEWS.d/next/Core and Builtins/2026-01-04-16-56-17.gh-issue-143377.YJqMCa.rst b/Misc/NEWS.d/next/Core and Builtins/2026-01-04-16-56-17.gh-issue-143377.YJqMCa.rst new file mode 100644 index 00000000000000..fc58554781f0d3 --- /dev/null +++ b/Misc/NEWS.d/next/Core and Builtins/2026-01-04-16-56-17.gh-issue-143377.YJqMCa.rst @@ -0,0 +1,2 @@ +Fix a crash in :func:`!_interpreters.capture_exception` when +the exception is incorrectly formatted. Patch by Bénédikt Tran. diff --git a/Python/crossinterp.c b/Python/crossinterp.c index 2f6324d300dee0..caa1cb6702b8a6 100644 --- a/Python/crossinterp.c +++ b/Python/crossinterp.c @@ -337,7 +337,7 @@ _PyCrossInterpreterData_ReleaseAndRawFree(_PyCrossInterpreterData *data) /* convenience utilities */ /*************************/ -static const char * +static char * _copy_string_obj_raw(PyObject *strobj, Py_ssize_t *p_size) { Py_ssize_t size = -1; @@ -441,11 +441,16 @@ _format_TracebackException(PyObject *tbexc) } Py_ssize_t size = -1; - const char *formatted = _copy_string_obj_raw(formatted_obj, &size); + char *formatted = _copy_string_obj_raw(formatted_obj, &size); Py_DECREF(formatted_obj); - // We remove trailing the newline added by TracebackException.format(). - assert(formatted[size-1] == '\n'); - ((char *)formatted)[size-1] = '\0'; + if (formatted == NULL || size == 0) { + return formatted; + } + assert(formatted[size] == '\0'); + // Remove a trailing newline if needed. + if (formatted[size-1] == '\n') { + formatted[size-1] = '\0'; + } return formatted; }