diff --git a/Lib/test/test_crossinterp.py b/Lib/test/test_crossinterp.py index 2fa0077a09bbbb..4e536211168747 100644 --- a/Lib/test/test_crossinterp.py +++ b/Lib/test/test_crossinterp.py @@ -1,10 +1,12 @@ import contextlib import itertools import sys +import traceback import types import unittest import warnings +from test import support from test.support import import_helper _testinternalcapi = import_helper.import_module('_testinternalcapi') @@ -1491,5 +1493,54 @@ def test_builtin_objects(self): ]) +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.assertNotHasAttr(captured, "errdisplay") + self.assertEqual(cm.unraisable.exc_type, TypeError) + self.assertEqual(str(cm.unraisable.exc_value), + "can only join an iterable") + + if __name__ == '__main__': 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 b43f33fdf97771..6365b995a0d3f7 100644 --- a/Python/crossinterp.c +++ b/Python/crossinterp.c @@ -1038,7 +1038,7 @@ _PyXIData_ReleaseAndRawFree(_PyXIData_t *xidata) /* convenience utilities */ /*************************/ -static const char * +static char * _copy_string_obj_raw(PyObject *strobj, Py_ssize_t *p_size) { Py_ssize_t size = -1; @@ -1139,11 +1139,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; }