From a950a1e9b0ee9a2e943f4e170e00a7ad01a4bb6c Mon Sep 17 00:00:00 2001 From: Daniel Schwartz-Narbone Date: Wed, 8 Apr 2026 13:42:29 -0400 Subject: [PATCH 1/3] gh-148925: Add PyUnstable_CollectCallStack and PyUnstable_PrintCallStack --- Doc/c-api/index.rst | 1 + Doc/c-api/traceback.rst | 96 ++++++ Include/cpython/traceback.h | 68 ++++ Include/internal/pycore_traceback.h | 3 + Lib/test/test_capi/test_traceback.py | 243 ++++++++++++++ ...-04-23-14-08-40.gh-issue-148925.qnLUh5.rst | 4 + Modules/Setup.stdlib.in | 2 +- Modules/_testcapi/parts.h | 1 + Modules/_testcapi/traceback.c | 176 +++++++++++ Modules/_testcapimodule.c | 3 + PCbuild/_testcapi.vcxproj | 1 + Python/traceback.c | 297 +++++++++++++----- 12 files changed, 820 insertions(+), 75 deletions(-) create mode 100644 Doc/c-api/traceback.rst create mode 100644 Lib/test/test_capi/test_traceback.py create mode 100644 Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst create mode 100644 Modules/_testcapi/traceback.c diff --git a/Doc/c-api/index.rst b/Doc/c-api/index.rst index eabe00f4004001..aca78f47d471db 100644 --- a/Doc/c-api/index.rst +++ b/Doc/c-api/index.rst @@ -17,6 +17,7 @@ document the API functions in detail. veryhigh.rst refcounting.rst exceptions.rst + traceback.rst extension-modules.rst utilities.rst abstract.rst diff --git a/Doc/c-api/traceback.rst b/Doc/c-api/traceback.rst new file mode 100644 index 00000000000000..394775d6452db7 --- /dev/null +++ b/Doc/c-api/traceback.rst @@ -0,0 +1,96 @@ +.. highlight:: c + + +.. _traceback: + +********** +Tracebacks +********** + +The functions below collect Python stack frames into a caller-supplied array of +:c:type:`PyUnstable_FrameInfo` structs. Because they do not acquire or release +the GIL or allocate heap memory, they can be called from signal handlers and +are suitable for low-overhead observability tools such as sampling profilers +and tracers. + +.. c:function:: int PyUnstable_CollectCallStack(PyThreadState *tstate, PyUnstable_FrameInfo *frames, int max_frames) + + Collect up to *max_frames* frames from *tstate* into the caller-supplied + *frames* array and return the number of frames written (0..*max_frames*). + Returns ``-1`` if *frames* is NULL, *tstate* is freed, or *tstate* has no + current Python frame. + + Filenames and function names are ASCII-encoded (non-ASCII characters are + backslash-escaped) and truncated to 500 characters; if truncated, the + corresponding ``filename_truncated`` or ``name_truncated`` field is set + to ``1``. + + In crash scenarios such as signal handlers for SIGSEGV, where the + interpreter may be in an inconsistent state, the function might produce + incomplete output or it may even crash itself. + + The caller does not need to hold an attached thread state, nor does *tstate* + need to be attached. + + This function does not acquire or release the GIL, modify reference counts, + or allocate heap memory. + + .. versionadded:: next + +.. c:function:: void PyUnstable_PrintCallStack(int fd, const PyUnstable_FrameInfo *frames, int n_frames, int write_header) + + Write a traceback collected by :c:func:`PyUnstable_CollectCallStack` to + *fd*. The format looks like:: + + Stack (most recent call first): + File "foo/bar.py", line 42 in myfunc + File "foo/bar.py", line 99 in caller + + Pass *write_header* as ``1`` to emit the ``Stack (most recent call first):`` + header line, or ``0`` to omit it. Truncated filenames and function names + are followed by ``...``. + + This function only reads the caller-supplied *frames* array and does not + access interpreter state. It is async-signal-safe: it does not acquire or + release the GIL, modify reference counts, or allocate heap memory, and its + only I/O is via :c:func:`!write`. + + .. versionadded:: next + +.. c:type:: PyUnstable_FrameInfo + + A plain-data struct representing a single Python stack frame, suitable for + use in crash-handling code. Populated by + :c:func:`PyUnstable_CollectCallStack`. + + .. c:member:: int lineno + + The line number, or ``-1`` if unknown. + + .. c:member:: int filename_truncated + + ``1`` if :c:member:`filename` was truncated, ``0`` otherwise. + + .. c:member:: int name_truncated + + ``1`` if :c:member:`name` was truncated, ``0`` otherwise. + + .. c:member:: char filename[Py_UNSTABLE_FRAMEINFO_STRSIZE] + + The source filename, ASCII-encoded with ``backslashreplace`` and + null-terminated. Empty string if unavailable. + + .. c:member:: char name[Py_UNSTABLE_FRAMEINFO_STRSIZE] + + The function name, ASCII-encoded with ``backslashreplace`` and + null-terminated. Empty string if unavailable. + + .. versionadded:: next + +.. c:macro:: Py_UNSTABLE_FRAMEINFO_STRSIZE + + The size in bytes of the :c:member:`PyUnstable_FrameInfo.filename` and + :c:member:`PyUnstable_FrameInfo.name` character arrays (``501``): up to + 500 content bytes plus a null terminator. + + .. versionadded:: next diff --git a/Include/cpython/traceback.h b/Include/cpython/traceback.h index 81c51944f136f2..dcbd490fcbf0af 100644 --- a/Include/cpython/traceback.h +++ b/Include/cpython/traceback.h @@ -11,3 +11,71 @@ struct _traceback { int tb_lasti; int tb_lineno; }; + +/* Buffer size for the filename and name fields in PyUnstable_FrameInfo: + up to 500 content bytes plus '\0' (1). */ +#define Py_UNSTABLE_FRAMEINFO_STRSIZE 501 + +/* Structured, plain-data representation of a single Python frame. + PyUnstable_CollectCallStack and PyUnstable_PrintCallStack() do not + acquire or release the GIL or allocate heap memory, so they can be called + from signal handlers and are suitable for low-overhead observability tools + such as sampling profilers and tracers. + + Populated by PyUnstable_CollectCallStack. filename and name are + ASCII-encoded (non-ASCII characters are backslash-escaped) and + null-terminated; they are empty strings if the corresponding code + attribute is missing or not a unicode object. lineno is -1 when it + cannot be determined. filename_truncated and name_truncated are 1 if + the respective string was longer than Py_UNSTABLE_FRAMEINFO_STRSIZE-1 + bytes and was truncated. */ +typedef struct { + int lineno; + int filename_truncated; + int name_truncated; + char filename[Py_UNSTABLE_FRAMEINFO_STRSIZE]; + char name[Py_UNSTABLE_FRAMEINFO_STRSIZE]; +} PyUnstable_FrameInfo; + +/* Collect up to max_frames frames from tstate into the caller-supplied + frames array and return the number of frames written (0..max_frames). + Returns -1 if frames is NULL, tstate is freed, or tstate has no current + Python frame. + + The filename and function names are encoded to ASCII with backslashreplace + and truncated to 500 characters; when truncated, the corresponding + filename_truncated or name_truncated field is set to 1. + + In crash scenarios such as signal handlers for SIGSEGV, where the + interpreter may be in an inconsistent state, the function might produce + incomplete output or it may even crash itself. + + The caller does not need to hold an attached thread state, nor does tstate + need to be attached. + + This function does not acquire or release the GIL, modify reference counts, + or allocate heap memory. */ +PyAPI_FUNC(int) PyUnstable_CollectCallStack( + PyThreadState *tstate, + PyUnstable_FrameInfo *frames, + int max_frames); + +/* Write a traceback collected by PyUnstable_CollectCallStack to fd. + The format looks like: + + Stack (most recent call first): + File "foo/bar.py", line 42 in myfunc + File "foo/bar.py", line 99 in caller + + Pass write_header=1 to emit the "Stack (most recent call first):" header + line, or write_header=0 to omit it. + + This function only reads the caller-supplied frames array and does not + access interpreter state. It is async-signal-safe: it does not acquire or + release the GIL, modify reference counts, or allocate heap memory, and its + only I/O is via write(2). */ +PyAPI_FUNC(void) PyUnstable_PrintCallStack( + int fd, + const PyUnstable_FrameInfo *frames, + int n_frames, + int write_header); diff --git a/Include/internal/pycore_traceback.h b/Include/internal/pycore_traceback.h index 6b5e24979d5321..052dd35618affa 100644 --- a/Include/internal/pycore_traceback.h +++ b/Include/internal/pycore_traceback.h @@ -14,6 +14,9 @@ PyAPI_FUNC(int) _Py_DisplaySourceLine(PyObject *, PyObject *, int, int, int *, P // Export for 'pyexact' shared extension PyAPI_FUNC(void) _PyTraceback_Add(const char *, const char *, int); +#include "traceback.h" /* PyUnstable_FrameInfo, PyUnstable_CollectCallStack, + PyUnstable_PrintCallStack */ + /* Write the Python traceback into the file 'fd'. For example: Traceback (most recent call first): diff --git a/Lib/test/test_capi/test_traceback.py b/Lib/test/test_capi/test_traceback.py new file mode 100644 index 00000000000000..b603c4a4b73da2 --- /dev/null +++ b/Lib/test/test_capi/test_traceback.py @@ -0,0 +1,243 @@ +"""Tests for PyUnstable_CollectCallStack and PyUnstable_PrintCallStack.""" + +import os +import sys +import unittest +from test.support import import_helper + +_testcapi = import_helper.import_module('_testcapi') + + +def _read_pipe(fd): + """Read all available bytes from a pipe file descriptor.""" + chunks = [] + while True: + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + # Non-blocking pipes (e.g. Emscripten) raise BlockingIOError + # instead of returning b'' at EOF. + break + if not chunk: + break + chunks.append(chunk) + return b''.join(chunks).decode() + + +# Path to this source file as stored in code objects (.pyc -> .py). +_THIS_FILE = __file__.removesuffix('c') + + +class TestCollectTraceback(unittest.TestCase): + + def test_returns_list(self): + frames = _testcapi.collect_call_stack() + self.assertIsInstance(frames, list) + self.assertGreater(len(frames), 0) + + def test_frame_tuple_structure(self): + # Each element is (filename, lineno, name, filename_truncated, + # name_truncated). + frames = _testcapi.collect_call_stack() + for filename, lineno, name, filename_truncated, name_truncated in frames: + self.assertIsInstance(filename, str) + self.assertTrue(lineno is None or isinstance(lineno, int)) + self.assertIsInstance(name, str) + self.assertIsInstance(filename_truncated, int) + self.assertIsInstance(name_truncated, int) + + def test_innermost_frame_name_and_caller(self): + # frames[0] is the direct Python caller; frames[1] is its caller. + def inner(): + return _testcapi.collect_call_stack() + + frames = inner() + self.assertEqual(frames[0][2], 'inner') + self.assertEqual(frames[1][2], 'test_innermost_frame_name_and_caller') + + def test_call_stack_order(self): + # Frames are most-recent-first. + def level2(): + return _testcapi.collect_call_stack() + + def level1(): + return level2() + + frames = level1() + names = [f[2] for f in frames] + self.assertEqual(names[0], 'level2') + self.assertEqual(names[1], 'level1') + self.assertEqual(names[2], 'test_call_stack_order') + + def test_filename_and_lineno_accuracy(self): + # The innermost frame should reference this file at the call site line. + def inner(): + call_line = sys._getframe().f_lineno + 1 + frames = _testcapi.collect_call_stack() + return call_line, frames + + call_line, frames = inner() + filename0, lineno0, name0, *_ = frames[0] + self.assertEqual(name0, 'inner') + self.assertEqual(filename0, _THIS_FILE) + self.assertEqual(lineno0, call_line) + + filename1, lineno1, name1, *_ = frames[1] + self.assertEqual(name1, 'test_filename_and_lineno_accuracy') + self.assertEqual(filename1, _THIS_FILE) + + def test_truncation_flags_false_for_normal_frames(self): + frames = _testcapi.collect_call_stack() + for filename, lineno, name, filename_truncated, name_truncated in frames: + self.assertEqual(filename_truncated, 0, msg=f"filename truncated: {filename!r}") + self.assertEqual(name_truncated, 0, msg=f"name truncated: {name!r}") + + def test_max_frames_limits_collection(self): + def level2(): + def level1(): + return _testcapi.collect_call_stack(2) + return level1() + + frames = level2() + self.assertEqual(len(frames), 2) + self.assertEqual(frames[0][2], 'level1') + self.assertEqual(frames[1][2], 'level2') + + def test_frameinfo_strsize_constant(self): + # 500 content bytes + '\0' (1) = 501. + self.assertEqual(_testcapi.FRAMEINFO_STRSIZE, 501) + + +@unittest.skipUnless(hasattr(os, 'pipe'), 'requires os.pipe') +class TestPrintTraceback(unittest.TestCase): + + def _print(self, frames, write_header=True): + r, w = os.pipe() + try: + _testcapi.print_call_stack(w, frames, write_header) + os.close(w) + w = -1 + return _read_pipe(r) + finally: + os.close(r) + if w >= 0: + os.close(w) + + def test_header_present(self): + out = self._print([('/a.py', 1, 'f')], write_header=True) + self.assertTrue(out.startswith('Stack (most recent call first):\n')) + + def test_header_absent(self): + out = self._print([('/a.py', 1, 'f')], write_header=False) + self.assertNotIn('Stack', out) + + def test_frame_format(self): + out = self._print([('/some/module.py', 42, 'myfunc')], write_header=False) + self.assertEqual(out, ' File "/some/module.py", line 42 in myfunc\n') + + def test_multiple_frames(self): + frames = [('/a.py', 10, 'inner'), ('/b.py', 20, 'outer')] + out = self._print(frames, write_header=False) + lines = out.splitlines() + self.assertEqual(len(lines), 2) + self.assertIn('inner', lines[0]) + self.assertIn('outer', lines[1]) + + def test_unknown_filename_prints_question_marks(self): + out = self._print([('', 1, 'f')], write_header=False) + self.assertIn('???', out) + self.assertNotIn('""', out) + + def test_unknown_name_prints_question_marks(self): + out = self._print([('/a.py', 1, '')], write_header=False) + self.assertIn('???', out) + + def test_unknown_lineno_prints_question_marks(self): + out = self._print([('/a.py', -1, 'f')], write_header=False) + self.assertIn('???', out) + self.assertNotIn('line -1', out) + + def test_empty_frame_list(self): + out = self._print([], write_header=True) + self.assertEqual(out, 'Stack (most recent call first):\n') + + def test_truncated_filename_appends_ellipsis(self): + out = self._print([('/long/path.py', 1, 'f', 1, 0)], write_header=False) + self.assertIn('"/long/path.py..."', out) + + def test_truncated_name_appends_ellipsis(self): + out = self._print([('/a.py', 1, 'long_func', 0, 1)], write_header=False) + self.assertIn('long_func...', out) + + def test_not_truncated_has_no_ellipsis(self): + out = self._print([('/a.py', 1, 'f', 0, 0)], write_header=False) + self.assertNotIn('...', out) + + +@unittest.skipUnless(hasattr(os, 'pipe'), 'requires os.pipe') +class TestEndToEnd(unittest.TestCase): + + def test_output_contains_caller(self): + def inner(): + r, w = os.pipe() + try: + _testcapi.collect_and_print_call_stack(w) + os.close(w) + w = -1 + return _read_pipe(r) + finally: + os.close(r) + if w >= 0: + os.close(w) + + output = inner() + self.assertIn('Stack (most recent call first):', output) + self.assertIn('inner', output) + self.assertIn('test_output_contains_caller', output) + + def test_output_filename_and_lineno(self): + # The innermost frame should reference this file and the correct line. + def inner(): + r, w = os.pipe() + try: + call_line = sys._getframe().f_lineno + 1 + _testcapi.collect_and_print_call_stack(w) + os.close(w) + w = -1 + return call_line, _read_pipe(r) + finally: + os.close(r) + if w >= 0: + os.close(w) + + call_line, output = inner() + file_lines = [l for l in output.splitlines() if l.startswith(' File')] + self.assertTrue(file_lines) + first = file_lines[0] + self.assertIn(os.path.basename(_THIS_FILE), first) + self.assertIn(f'line {call_line}', first) + self.assertIn('inner', first) + + def test_max_frames_limits_output(self): + def level2(): + def level1(): + r, w = os.pipe() + try: + _testcapi.collect_and_print_call_stack(w, 1) + os.close(w) + w = -1 + return _read_pipe(r) + finally: + os.close(r) + if w >= 0: + os.close(w) + return level1() + + output = level2() + file_lines = [l for l in output.splitlines() if l.startswith(' File')] + self.assertEqual(len(file_lines), 1) + self.assertIn('level1', file_lines[0]) + + +if __name__ == '__main__': + unittest.main() diff --git a/Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst b/Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst new file mode 100644 index 00000000000000..7e21b9defdabfd --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst @@ -0,0 +1,4 @@ +Add :c:func:`PyUnstable_CollectCallStack` and +:c:func:`PyUnstable_PrintCallStack`, a new signal-safe C API for collecting +and printing Python stack frames without acquiring the GIL or allocating +heap memory. diff --git a/Modules/Setup.stdlib.in b/Modules/Setup.stdlib.in index 0d520684c795d6..aac39acf36e444 100644 --- a/Modules/Setup.stdlib.in +++ b/Modules/Setup.stdlib.in @@ -175,7 +175,7 @@ @MODULE__XXTESTFUZZ_TRUE@_xxtestfuzz _xxtestfuzz/_xxtestfuzz.c _xxtestfuzz/fuzzer.c @MODULE__TESTBUFFER_TRUE@_testbuffer _testbuffer.c @MODULE__TESTINTERNALCAPI_TRUE@_testinternalcapi _testinternalcapi.c _testinternalcapi/test_lock.c _testinternalcapi/pytime.c _testinternalcapi/set.c _testinternalcapi/test_critical_sections.c _testinternalcapi/complex.c _testinternalcapi/interpreter.c _testinternalcapi/tuple.c -@MODULE__TESTCAPI_TRUE@_testcapi _testcapimodule.c _testcapi/vectorcall.c _testcapi/heaptype.c _testcapi/abstract.c _testcapi/unicode.c _testcapi/dict.c _testcapi/set.c _testcapi/list.c _testcapi/tuple.c _testcapi/getargs.c _testcapi/datetime.c _testcapi/docstring.c _testcapi/mem.c _testcapi/watchers.c _testcapi/long.c _testcapi/float.c _testcapi/complex.c _testcapi/numbers.c _testcapi/structmember.c _testcapi/exceptions.c _testcapi/code.c _testcapi/buffer.c _testcapi/pyatomic.c _testcapi/run.c _testcapi/file.c _testcapi/codec.c _testcapi/immortal.c _testcapi/gc.c _testcapi/hash.c _testcapi/time.c _testcapi/bytes.c _testcapi/object.c _testcapi/modsupport.c _testcapi/monitoring.c _testcapi/config.c _testcapi/import.c _testcapi/frame.c _testcapi/type.c _testcapi/function.c _testcapi/module.c +@MODULE__TESTCAPI_TRUE@_testcapi _testcapimodule.c _testcapi/vectorcall.c _testcapi/heaptype.c _testcapi/abstract.c _testcapi/unicode.c _testcapi/dict.c _testcapi/set.c _testcapi/list.c _testcapi/tuple.c _testcapi/getargs.c _testcapi/datetime.c _testcapi/docstring.c _testcapi/mem.c _testcapi/watchers.c _testcapi/long.c _testcapi/float.c _testcapi/complex.c _testcapi/numbers.c _testcapi/structmember.c _testcapi/exceptions.c _testcapi/code.c _testcapi/buffer.c _testcapi/pyatomic.c _testcapi/run.c _testcapi/file.c _testcapi/codec.c _testcapi/immortal.c _testcapi/gc.c _testcapi/hash.c _testcapi/time.c _testcapi/bytes.c _testcapi/object.c _testcapi/modsupport.c _testcapi/monitoring.c _testcapi/config.c _testcapi/import.c _testcapi/frame.c _testcapi/traceback.c _testcapi/type.c _testcapi/function.c _testcapi/module.c @MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/sys.c _testlimitedcapi/threadstate.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c _testlimitedcapi/file.c @MODULE__TESTCLINIC_TRUE@_testclinic _testclinic.c @MODULE__TESTCLINIC_LIMITED_TRUE@_testclinic_limited _testclinic_limited.c diff --git a/Modules/_testcapi/parts.h b/Modules/_testcapi/parts.h index a7feca5bd96070..da7b208104a0e6 100644 --- a/Modules/_testcapi/parts.h +++ b/Modules/_testcapi/parts.h @@ -64,6 +64,7 @@ int _PyTestCapi_Init_Object(PyObject *module); int _PyTestCapi_Init_Config(PyObject *mod); int _PyTestCapi_Init_Import(PyObject *mod); int _PyTestCapi_Init_Frame(PyObject *mod); +int _PyTestCapi_Init_Traceback(PyObject *mod); int _PyTestCapi_Init_Type(PyObject *mod); int _PyTestCapi_Init_Function(PyObject *mod); int _PyTestCapi_Init_Module(PyObject *mod); diff --git a/Modules/_testcapi/traceback.c b/Modules/_testcapi/traceback.c new file mode 100644 index 00000000000000..cfbf973ae6e9f1 --- /dev/null +++ b/Modules/_testcapi/traceback.c @@ -0,0 +1,176 @@ +#include "parts.h" + +#include "traceback.h" // PyUnstable_CollectCallStack, PyUnstable_PrintCallStack + + +/* collect_call_stack([max_frames]) -> + * list of (filename, lineno, name, filename_truncated, name_truncated) + * | None + * + * Calls PyUnstable_CollectCallStack() on the current tstate and returns the + * collected frames as a list of 5-tuples. lineno is an int or None if + * unknown; filename_truncated and name_truncated are 0 or 1. Returns None + * if the tstate has no Python frame (i.e. PyUnstable_CollectCallStack() + * returned -1). */ +static PyObject * +traceback_collect(PyObject *self, PyObject *args) +{ + int max_frames = 10; + if (!PyArg_ParseTuple(args, "|i", &max_frames)) { + return NULL; + } + if (max_frames <= 0) { + PyErr_SetString(PyExc_ValueError, "max_frames must be positive"); + return NULL; + } + + PyUnstable_FrameInfo *frames = PyMem_Malloc( + sizeof(PyUnstable_FrameInfo) * max_frames); + if (frames == NULL) { + return PyErr_NoMemory(); + } + + PyThreadState *tstate = PyThreadState_Get(); + int n = PyUnstable_CollectCallStack(tstate, frames, max_frames); + + if (n < 0) { + PyMem_Free(frames); + Py_RETURN_NONE; + } + + PyObject *result = PyList_New(n); + if (result == NULL) { + PyMem_Free(frames); + return NULL; + } + + for (int i = 0; i < n; i++) { + PyObject *lineno = frames[i].lineno >= 0 + ? PyLong_FromLong(frames[i].lineno) + : Py_NewRef(Py_None); + if (lineno == NULL) { + Py_DECREF(result); + PyMem_Free(frames); + return NULL; + } + PyObject *item = Py_BuildValue("(sNsii)", + frames[i].filename, lineno, frames[i].name, + frames[i].filename_truncated, + frames[i].name_truncated); + if (item == NULL) { + Py_DECREF(result); + PyMem_Free(frames); + return NULL; + } + PyList_SET_ITEM(result, i, item); + } + + PyMem_Free(frames); + return result; +} + + +/* print_call_stack(fd, [(filename, lineno, name[, filename_truncated, + * name_truncated]), ...][, write_header]) -> None + * + * Constructs a PyUnstable_FrameInfo array from a Python list of tuples and + * calls PyUnstable_PrintCallStack(). Used to test the print format in + * isolation from collection. The optional filename_truncated and + * name_truncated fields allow testing the truncation display path directly. */ +static PyObject * +traceback_print(PyObject *self, PyObject *args) +{ + int fd; + PyObject *frame_list; + int write_header = 1; + if (!PyArg_ParseTuple(args, "iO!|i", + &fd, &PyList_Type, &frame_list, &write_header)) { + return NULL; + } + + Py_ssize_t n = PyList_GET_SIZE(frame_list); + PyUnstable_FrameInfo *frames = PyMem_Malloc( + sizeof(PyUnstable_FrameInfo) * (n ? n : 1)); + if (frames == NULL) { + return PyErr_NoMemory(); + } + + for (Py_ssize_t i = 0; i < n; i++) { + PyObject *item = PyList_GET_ITEM(frame_list, i); + const char *filename, *name; + int lineno; + int filename_truncated = 0, name_truncated = 0; + if (!PyArg_ParseTuple(item, "sis|ii", + &filename, &lineno, &name, + &filename_truncated, &name_truncated)) { + PyMem_Free(frames); + return NULL; + } + PyOS_snprintf(frames[i].filename, Py_UNSTABLE_FRAMEINFO_STRSIZE, + "%s", filename); + frames[i].lineno = lineno; + PyOS_snprintf(frames[i].name, Py_UNSTABLE_FRAMEINFO_STRSIZE, + "%s", name); + frames[i].filename_truncated = filename_truncated; + frames[i].name_truncated = name_truncated; + } + + PyUnstable_PrintCallStack(fd, frames, (int)n, write_header); + PyMem_Free(frames); + Py_RETURN_NONE; +} + + +/* collect_and_print_call_stack(fd[, max_frames[, write_header]]) -> None + * + * Calls PyUnstable_CollectCallStack() + PyUnstable_PrintCallStack() in one + * step. Used to test the end-to-end path with a real Python call stack. */ +static PyObject * +traceback_collect_and_print(PyObject *self, PyObject *args) +{ + int fd; + int max_frames = 10; + int write_header = 1; + if (!PyArg_ParseTuple(args, "i|ii", &fd, &max_frames, &write_header)) { + return NULL; + } + if (max_frames <= 0) { + PyErr_SetString(PyExc_ValueError, "max_frames must be positive"); + return NULL; + } + + PyUnstable_FrameInfo *frames = PyMem_Malloc( + sizeof(PyUnstable_FrameInfo) * max_frames); + if (frames == NULL) { + return PyErr_NoMemory(); + } + + PyThreadState *tstate = PyThreadState_Get(); + int n = PyUnstable_CollectCallStack(tstate, frames, max_frames); + if (n >= 0) { + PyUnstable_PrintCallStack(fd, frames, n, write_header); + } + PyMem_Free(frames); + Py_RETURN_NONE; +} + + +static PyMethodDef traceback_methods[] = { + {"collect_call_stack", traceback_collect, METH_VARARGS}, + {"print_call_stack", traceback_print, METH_VARARGS}, + {"collect_and_print_call_stack", traceback_collect_and_print, METH_VARARGS}, + {NULL}, +}; + +int +_PyTestCapi_Init_Traceback(PyObject *mod) +{ + if (PyModule_AddIntConstant(mod, "FRAMEINFO_STRSIZE", + Py_UNSTABLE_FRAMEINFO_STRSIZE) < 0) { + return -1; + } + if (PyModule_AddFunctions(mod, traceback_methods) < 0) { + return -1; + } + return 0; +} diff --git a/Modules/_testcapimodule.c b/Modules/_testcapimodule.c index 3ebe4ceea6a72e..9e0daa82a93010 100644 --- a/Modules/_testcapimodule.c +++ b/Modules/_testcapimodule.c @@ -3564,6 +3564,9 @@ _testcapi_exec(PyObject *m) if (_PyTestCapi_Init_Frame(m) < 0) { return -1; } + if (_PyTestCapi_Init_Traceback(m) < 0) { + return -1; + } if (_PyTestCapi_Init_Type(m) < 0) { return -1; } diff --git a/PCbuild/_testcapi.vcxproj b/PCbuild/_testcapi.vcxproj index 68707a54ff6b87..628c388fcdecfa 100644 --- a/PCbuild/_testcapi.vcxproj +++ b/PCbuild/_testcapi.vcxproj @@ -131,6 +131,7 @@ + diff --git a/Python/traceback.c b/Python/traceback.c index 1e8c9c879f9aac..d41605f494e40e 100644 --- a/Python/traceback.c +++ b/Python/traceback.c @@ -1029,88 +1029,242 @@ _Py_DumpWideString(int fd, wchar_t *str) #endif -/* Write a frame into the file fd: "File "xxx", line xxx in xxx". - This function is signal safe. +static bool _Py_NO_SANITIZE_THREAD +tstate_is_freed(PyThreadState *tstate) +{ + return (_PyMem_IsPtrFreed(tstate) || + _PyMem_IsPtrFreed(tstate->interp) || + _PyMem_IsULongFreed(tstate->thread_id)); +} + + +static bool _Py_NO_SANITIZE_THREAD +interp_is_freed(PyInterpreterState *interp) +{ + return _PyMem_IsPtrFreed(interp); +} - Return 0 on success. Return -1 if the frame is invalid. */ +/* Write the ASCII (backslash-escaped) representation of text into buf, + null-terminating the result. buf must be at least Py_UNSTABLE_FRAMEINFO_STRSIZE + bytes. At most Py_UNSTABLE_FRAMEINFO_STRSIZE-1 content bytes are written. + Returns 1 if the output was truncated, 0 if it fit, or -1 if text is NULL + or not a unicode object (in which case buf is set to an empty string). + + This function does not acquire or release the GIL, modify reference counts, + or allocate heap memory. */ static int _Py_NO_SANITIZE_THREAD -dump_frame(int fd, _PyInterpreterFrame *frame) +format_ascii(char *buf, PyObject *text) { - if (frame->owner == FRAME_OWNED_BY_INTERPRETER) { - /* Ignore trampoline frames and base frame sentinel */ - return 0; - } + static const char hex[] = "0123456789abcdef"; - PyCodeObject *code = _PyFrame_SafeGetCode(frame); - if (code == NULL) { + if (text == NULL || !PyUnicode_Check(text)) { + buf[0] = '\0'; return -1; } - int res = 0; - PUTS(fd, " File "); - if (code->co_filename != NULL - && PyUnicode_Check(code->co_filename)) - { - PUTS(fd, "\""); - _Py_DumpASCII(fd, code->co_filename); - PUTS(fd, "\""); + PyASCIIObject *ascii = _PyASCIIObject_CAST(text); + Py_ssize_t srclen = ascii->length; + int kind = ascii->state.kind; + void *data; + + if (ascii->state.compact) { + data = ascii->state.ascii ? (void *)(ascii + 1) + : (void *)(_PyCompactUnicodeObject_CAST(text) + 1); } else { - PUTS(fd, "???"); - res = -1; + data = _PyUnicodeObject_CAST(text)->data.any; + if (data == NULL) { + buf[0] = '\0'; + return -1; + } } - PUTS(fd, ", line "); - int lasti = _PyFrame_SafeGetLasti(frame); - int lineno = -1; - if (lasti >= 0) { - lineno = _PyCode_SafeAddr2Line(code, lasti); - } - if (lineno >= 0) { - _Py_DumpDecimal(fd, (size_t)lineno); - } - else { - PUTS(fd, "???"); - res = -1; + char *out = buf; + /* Reserve 1 byte at the end for '\0'. */ + char *limit = buf + Py_UNSTABLE_FRAMEINFO_STRSIZE - 1; + int truncated = 0; + + for (Py_ssize_t i = 0; i < srclen; i++) { + Py_UCS4 ch = PyUnicode_READ(kind, data, i); + if (' ' <= ch && ch <= 126) { + if (out >= limit) { truncated = 1; break; } + *out++ = (char)ch; + } + else if (ch <= 0xff) { + if (out + 4 > limit) { truncated = 1; break; } + out[0] = '\\'; out[1] = 'x'; + out[2] = hex[(ch >> 4) & 0xf]; out[3] = hex[ch & 0xf]; + out += 4; + } + else if (ch <= 0xffff) { + if (out + 6 > limit) { truncated = 1; break; } + out[0] = '\\'; out[1] = 'u'; + out[2] = hex[(ch >> 12) & 0xf]; out[3] = hex[(ch >> 8) & 0xf]; + out[4] = hex[(ch >> 4) & 0xf]; out[5] = hex[ch & 0xf]; + out += 6; + } + else { + if (out + 10 > limit) { truncated = 1; break; } + out[0] = '\\'; out[1] = 'U'; + out[2] = hex[(ch >> 28) & 0xf]; out[3] = hex[(ch >> 24) & 0xf]; + out[4] = hex[(ch >> 20) & 0xf]; out[5] = hex[(ch >> 16) & 0xf]; + out[6] = hex[(ch >> 12) & 0xf]; out[7] = hex[(ch >> 8) & 0xf]; + out[8] = hex[(ch >> 4) & 0xf]; out[9] = hex[ch & 0xf]; + out += 10; + } } + *out = '\0'; + return truncated; +} + - PUTS(fd, " in "); - if (code->co_name != NULL && PyUnicode_Check(code->co_name)) { - _Py_DumpASCII(fd, code->co_name); +/* Collect info from a single frame into *out. + Returns 1 if *out was filled, 0 if the frame should be skipped + (trampoline/sentinel), -1 if the frame is invalid. + + This function is intended for use in crash scenarios such as signal handlers + for SIGSEGV, where the interpreter may be in an inconsistent state. Given + that it reads interpreter data structures that may be partially modified, the + function might produce incomplete output or it may even crash itself. + + This function does not acquire or release the GIL, modify reference counts, + or allocate heap memory. */ +static int _Py_NO_SANITIZE_THREAD +collect_frame(_PyInterpreterFrame *frame, PyUnstable_FrameInfo *out) +{ + if (frame->owner == FRAME_OWNED_BY_INTERPRETER) { + /* Trampoline frames and the base-frame sentinel carry no Python + code object; skip them silently. */ + return 0; } - else { - PUTS(fd, "???"); - res = -1; + PyCodeObject *code = _PyFrame_SafeGetCode(frame); + if (code == NULL) { + return -1; } - PUTS(fd, "\n"); - return res; + out->filename_truncated = format_ascii(out->filename, code->co_filename) > 0; + int lasti = _PyFrame_SafeGetLasti(frame); + out->lineno = (lasti >= 0) ? _PyCode_SafeAddr2Line(code, lasti) : -1; + out->name_truncated = format_ascii(out->name, code->co_name) > 0; + return 1; } -static int _Py_NO_SANITIZE_THREAD -tstate_is_freed(PyThreadState *tstate) + +/* Collect up to max_frames frames from tstate into the caller-supplied frames + array. Returns the number of frames written (0..max_frames), or -1 if + tstate is freed or has no current Python frame. + + The caller does not need to hold an attached thread state, nor does tstate + need to be attached. + + This function is intended for use in crash scenarios such as signal handlers + for SIGSEGV, where the interpreter may be in an inconsistent state. Given + that it reads interpreter data structures that may be partially modified, the + function might produce incomplete output or it may even crash itself. + + This function does not acquire or release the GIL, modify reference counts, + or allocate heap memory. */ +int _Py_NO_SANITIZE_THREAD +PyUnstable_CollectCallStack(PyThreadState *tstate, PyUnstable_FrameInfo *frames, + int max_frames) { - if (_PyMem_IsPtrFreed(tstate)) { - return 1; + if (frames == NULL) { + return -1; } - if (_PyMem_IsPtrFreed(tstate->interp)) { - return 1; + if (tstate_is_freed(tstate)) { + return -1; } - if (_PyMem_IsULongFreed(tstate->thread_id)) { - return 1; + _PyInterpreterFrame *frame = tstate->current_frame; + if (frame == NULL) { + return -1; } - return 0; + int n = 0; + int hops = 0; + /* hops bounds total frame pointer traversals to guard against cycles in + corrupted memory, where trampolines (r==0) could prevent n from + advancing. */ + int max_hops = max_frames + MAX_FRAME_DEPTH; + while (frame != NULL && n < max_frames && hops < max_hops) { + hops++; + if (_PyMem_IsPtrFreed(frame)) { + break; + } + /* Read frame->previous before touching frame fields in case memory + is freed during collect_frame(). */ + _PyInterpreterFrame *previous = frame->previous; + int r = collect_frame(frame, &frames[n]); + if (r > 0) { + n++; + } + else if (r < 0) { + break; + } + frame = previous; + } + return n; } -static int _Py_NO_SANITIZE_THREAD -interp_is_freed(PyInterpreterState *interp) +/* Write a previously-collected traceback to fd. n_frames is the value + returned by PyUnstable_CollectCallStack(); pass write_header=1 to emit the + "Stack (most recent call first):" header line. + + This function only reads the caller-supplied frames array and does not + access interpreter state. It is async-signal-safe: it does not acquire or + release the GIL, modify reference counts, or allocate heap memory, and its + only I/O is via write(2). */ +void _Py_NO_SANITIZE_THREAD +PyUnstable_PrintCallStack(int fd, const PyUnstable_FrameInfo *frames, + int n_frames, int write_header) { - return _PyMem_IsPtrFreed(interp); + if (write_header) { + PUTS(fd, "Stack (most recent call first):\n"); + } + if (frames == NULL) { + return; + } + for (int i = 0; i < n_frames; i++) { + const PyUnstable_FrameInfo *fi = &frames[i]; + PUTS(fd, " File "); + if (fi->filename[0] != '\0') { + PUTS(fd, "\""); + PUTS(fd, fi->filename); + if (fi->filename_truncated) { + PUTS(fd, "..."); + } + PUTS(fd, "\""); + } + else { + PUTS(fd, "???"); + } + PUTS(fd, ", line "); + if (fi->lineno >= 0) { + _Py_DumpDecimal(fd, (size_t)fi->lineno); + } + else { + PUTS(fd, "???"); + } + PUTS(fd, " in "); + if (fi->name[0] != '\0') { + PUTS(fd, fi->name); + if (fi->name_truncated) { + PUTS(fd, "..."); + } + } + else { + PUTS(fd, "???"); + } + PUTS(fd, "\n"); + } } +/* Dump the Python traceback for tstate to fd. Called from signal handlers + and faulthandler; tstate may belong to any thread and need not be attached. + + This function does not acquire or release the GIL, modify reference counts, + or allocate heap memory. */ static void _Py_NO_SANITIZE_THREAD dump_traceback(int fd, PyThreadState *tstate, int write_header) { @@ -1129,35 +1283,30 @@ dump_traceback(int fd, PyThreadState *tstate, int write_header) return; } - unsigned int depth = 0; - while (1) { - if (MAX_FRAME_DEPTH <= depth) { - if (MAX_FRAME_DEPTH < depth) { - PUTS(fd, "plus "); - _Py_DumpDecimal(fd, depth); - PUTS(fd, " frames\n"); - } - break; - } - + /* Process one frame at a time to keep stack usage bounded: a stack array + of MAX_FRAME_DEPTH PyUnstable_FrameInfo structs would overflow the alternate + signal stack. */ + int depth = 0; + while (frame != NULL && depth < MAX_FRAME_DEPTH) { if (_PyMem_IsPtrFreed(frame)) { PUTS(fd, " \n"); - break; + return; } - // Read frame->previous early since memory can be freed during - // dump_frame() _PyInterpreterFrame *previous = frame->previous; - - if (dump_frame(fd, frame) < 0) { + PyUnstable_FrameInfo fi; + int r = collect_frame(frame, &fi); + if (r > 0) { + PyUnstable_PrintCallStack(fd, &fi, 1, 0); + depth++; + } + else if (r < 0) { PUTS(fd, " \n"); - break; + return; } - frame = previous; - if (frame == NULL) { - break; - } - depth++; + } + if (frame != NULL) { + PUTS(fd, " ...\n"); } } From c56f3357ea7f294a558a8b78e1feb52378104a7f Mon Sep 17 00:00:00 2001 From: Daniel Schwartz-Narbonne Date: Mon, 11 May 2026 16:39:10 -0400 Subject: [PATCH 2/3] Undo changes in preperation for a simpler API. --- Doc/c-api/index.rst | 1 - Doc/c-api/traceback.rst | 96 ------ Include/cpython/traceback.h | 68 ---- Include/internal/pycore_traceback.h | 3 - Lib/test/test_capi/test_traceback.py | 243 -------------- ...-04-23-14-08-40.gh-issue-148925.qnLUh5.rst | 4 - Modules/Setup.stdlib.in | 2 +- Modules/_testcapi/parts.h | 1 - Modules/_testcapi/traceback.c | 176 ----------- Modules/_testcapimodule.c | 3 - PCbuild/_testcapi.vcxproj | 1 - Python/traceback.c | 297 +++++------------- 12 files changed, 75 insertions(+), 820 deletions(-) delete mode 100644 Doc/c-api/traceback.rst delete mode 100644 Lib/test/test_capi/test_traceback.py delete mode 100644 Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst delete mode 100644 Modules/_testcapi/traceback.c diff --git a/Doc/c-api/index.rst b/Doc/c-api/index.rst index aca78f47d471db..eabe00f4004001 100644 --- a/Doc/c-api/index.rst +++ b/Doc/c-api/index.rst @@ -17,7 +17,6 @@ document the API functions in detail. veryhigh.rst refcounting.rst exceptions.rst - traceback.rst extension-modules.rst utilities.rst abstract.rst diff --git a/Doc/c-api/traceback.rst b/Doc/c-api/traceback.rst deleted file mode 100644 index 394775d6452db7..00000000000000 --- a/Doc/c-api/traceback.rst +++ /dev/null @@ -1,96 +0,0 @@ -.. highlight:: c - - -.. _traceback: - -********** -Tracebacks -********** - -The functions below collect Python stack frames into a caller-supplied array of -:c:type:`PyUnstable_FrameInfo` structs. Because they do not acquire or release -the GIL or allocate heap memory, they can be called from signal handlers and -are suitable for low-overhead observability tools such as sampling profilers -and tracers. - -.. c:function:: int PyUnstable_CollectCallStack(PyThreadState *tstate, PyUnstable_FrameInfo *frames, int max_frames) - - Collect up to *max_frames* frames from *tstate* into the caller-supplied - *frames* array and return the number of frames written (0..*max_frames*). - Returns ``-1`` if *frames* is NULL, *tstate* is freed, or *tstate* has no - current Python frame. - - Filenames and function names are ASCII-encoded (non-ASCII characters are - backslash-escaped) and truncated to 500 characters; if truncated, the - corresponding ``filename_truncated`` or ``name_truncated`` field is set - to ``1``. - - In crash scenarios such as signal handlers for SIGSEGV, where the - interpreter may be in an inconsistent state, the function might produce - incomplete output or it may even crash itself. - - The caller does not need to hold an attached thread state, nor does *tstate* - need to be attached. - - This function does not acquire or release the GIL, modify reference counts, - or allocate heap memory. - - .. versionadded:: next - -.. c:function:: void PyUnstable_PrintCallStack(int fd, const PyUnstable_FrameInfo *frames, int n_frames, int write_header) - - Write a traceback collected by :c:func:`PyUnstable_CollectCallStack` to - *fd*. The format looks like:: - - Stack (most recent call first): - File "foo/bar.py", line 42 in myfunc - File "foo/bar.py", line 99 in caller - - Pass *write_header* as ``1`` to emit the ``Stack (most recent call first):`` - header line, or ``0`` to omit it. Truncated filenames and function names - are followed by ``...``. - - This function only reads the caller-supplied *frames* array and does not - access interpreter state. It is async-signal-safe: it does not acquire or - release the GIL, modify reference counts, or allocate heap memory, and its - only I/O is via :c:func:`!write`. - - .. versionadded:: next - -.. c:type:: PyUnstable_FrameInfo - - A plain-data struct representing a single Python stack frame, suitable for - use in crash-handling code. Populated by - :c:func:`PyUnstable_CollectCallStack`. - - .. c:member:: int lineno - - The line number, or ``-1`` if unknown. - - .. c:member:: int filename_truncated - - ``1`` if :c:member:`filename` was truncated, ``0`` otherwise. - - .. c:member:: int name_truncated - - ``1`` if :c:member:`name` was truncated, ``0`` otherwise. - - .. c:member:: char filename[Py_UNSTABLE_FRAMEINFO_STRSIZE] - - The source filename, ASCII-encoded with ``backslashreplace`` and - null-terminated. Empty string if unavailable. - - .. c:member:: char name[Py_UNSTABLE_FRAMEINFO_STRSIZE] - - The function name, ASCII-encoded with ``backslashreplace`` and - null-terminated. Empty string if unavailable. - - .. versionadded:: next - -.. c:macro:: Py_UNSTABLE_FRAMEINFO_STRSIZE - - The size in bytes of the :c:member:`PyUnstable_FrameInfo.filename` and - :c:member:`PyUnstable_FrameInfo.name` character arrays (``501``): up to - 500 content bytes plus a null terminator. - - .. versionadded:: next diff --git a/Include/cpython/traceback.h b/Include/cpython/traceback.h index dcbd490fcbf0af..81c51944f136f2 100644 --- a/Include/cpython/traceback.h +++ b/Include/cpython/traceback.h @@ -11,71 +11,3 @@ struct _traceback { int tb_lasti; int tb_lineno; }; - -/* Buffer size for the filename and name fields in PyUnstable_FrameInfo: - up to 500 content bytes plus '\0' (1). */ -#define Py_UNSTABLE_FRAMEINFO_STRSIZE 501 - -/* Structured, plain-data representation of a single Python frame. - PyUnstable_CollectCallStack and PyUnstable_PrintCallStack() do not - acquire or release the GIL or allocate heap memory, so they can be called - from signal handlers and are suitable for low-overhead observability tools - such as sampling profilers and tracers. - - Populated by PyUnstable_CollectCallStack. filename and name are - ASCII-encoded (non-ASCII characters are backslash-escaped) and - null-terminated; they are empty strings if the corresponding code - attribute is missing or not a unicode object. lineno is -1 when it - cannot be determined. filename_truncated and name_truncated are 1 if - the respective string was longer than Py_UNSTABLE_FRAMEINFO_STRSIZE-1 - bytes and was truncated. */ -typedef struct { - int lineno; - int filename_truncated; - int name_truncated; - char filename[Py_UNSTABLE_FRAMEINFO_STRSIZE]; - char name[Py_UNSTABLE_FRAMEINFO_STRSIZE]; -} PyUnstable_FrameInfo; - -/* Collect up to max_frames frames from tstate into the caller-supplied - frames array and return the number of frames written (0..max_frames). - Returns -1 if frames is NULL, tstate is freed, or tstate has no current - Python frame. - - The filename and function names are encoded to ASCII with backslashreplace - and truncated to 500 characters; when truncated, the corresponding - filename_truncated or name_truncated field is set to 1. - - In crash scenarios such as signal handlers for SIGSEGV, where the - interpreter may be in an inconsistent state, the function might produce - incomplete output or it may even crash itself. - - The caller does not need to hold an attached thread state, nor does tstate - need to be attached. - - This function does not acquire or release the GIL, modify reference counts, - or allocate heap memory. */ -PyAPI_FUNC(int) PyUnstable_CollectCallStack( - PyThreadState *tstate, - PyUnstable_FrameInfo *frames, - int max_frames); - -/* Write a traceback collected by PyUnstable_CollectCallStack to fd. - The format looks like: - - Stack (most recent call first): - File "foo/bar.py", line 42 in myfunc - File "foo/bar.py", line 99 in caller - - Pass write_header=1 to emit the "Stack (most recent call first):" header - line, or write_header=0 to omit it. - - This function only reads the caller-supplied frames array and does not - access interpreter state. It is async-signal-safe: it does not acquire or - release the GIL, modify reference counts, or allocate heap memory, and its - only I/O is via write(2). */ -PyAPI_FUNC(void) PyUnstable_PrintCallStack( - int fd, - const PyUnstable_FrameInfo *frames, - int n_frames, - int write_header); diff --git a/Include/internal/pycore_traceback.h b/Include/internal/pycore_traceback.h index 052dd35618affa..6b5e24979d5321 100644 --- a/Include/internal/pycore_traceback.h +++ b/Include/internal/pycore_traceback.h @@ -14,9 +14,6 @@ PyAPI_FUNC(int) _Py_DisplaySourceLine(PyObject *, PyObject *, int, int, int *, P // Export for 'pyexact' shared extension PyAPI_FUNC(void) _PyTraceback_Add(const char *, const char *, int); -#include "traceback.h" /* PyUnstable_FrameInfo, PyUnstable_CollectCallStack, - PyUnstable_PrintCallStack */ - /* Write the Python traceback into the file 'fd'. For example: Traceback (most recent call first): diff --git a/Lib/test/test_capi/test_traceback.py b/Lib/test/test_capi/test_traceback.py deleted file mode 100644 index b603c4a4b73da2..00000000000000 --- a/Lib/test/test_capi/test_traceback.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Tests for PyUnstable_CollectCallStack and PyUnstable_PrintCallStack.""" - -import os -import sys -import unittest -from test.support import import_helper - -_testcapi = import_helper.import_module('_testcapi') - - -def _read_pipe(fd): - """Read all available bytes from a pipe file descriptor.""" - chunks = [] - while True: - try: - chunk = os.read(fd, 4096) - except BlockingIOError: - # Non-blocking pipes (e.g. Emscripten) raise BlockingIOError - # instead of returning b'' at EOF. - break - if not chunk: - break - chunks.append(chunk) - return b''.join(chunks).decode() - - -# Path to this source file as stored in code objects (.pyc -> .py). -_THIS_FILE = __file__.removesuffix('c') - - -class TestCollectTraceback(unittest.TestCase): - - def test_returns_list(self): - frames = _testcapi.collect_call_stack() - self.assertIsInstance(frames, list) - self.assertGreater(len(frames), 0) - - def test_frame_tuple_structure(self): - # Each element is (filename, lineno, name, filename_truncated, - # name_truncated). - frames = _testcapi.collect_call_stack() - for filename, lineno, name, filename_truncated, name_truncated in frames: - self.assertIsInstance(filename, str) - self.assertTrue(lineno is None or isinstance(lineno, int)) - self.assertIsInstance(name, str) - self.assertIsInstance(filename_truncated, int) - self.assertIsInstance(name_truncated, int) - - def test_innermost_frame_name_and_caller(self): - # frames[0] is the direct Python caller; frames[1] is its caller. - def inner(): - return _testcapi.collect_call_stack() - - frames = inner() - self.assertEqual(frames[0][2], 'inner') - self.assertEqual(frames[1][2], 'test_innermost_frame_name_and_caller') - - def test_call_stack_order(self): - # Frames are most-recent-first. - def level2(): - return _testcapi.collect_call_stack() - - def level1(): - return level2() - - frames = level1() - names = [f[2] for f in frames] - self.assertEqual(names[0], 'level2') - self.assertEqual(names[1], 'level1') - self.assertEqual(names[2], 'test_call_stack_order') - - def test_filename_and_lineno_accuracy(self): - # The innermost frame should reference this file at the call site line. - def inner(): - call_line = sys._getframe().f_lineno + 1 - frames = _testcapi.collect_call_stack() - return call_line, frames - - call_line, frames = inner() - filename0, lineno0, name0, *_ = frames[0] - self.assertEqual(name0, 'inner') - self.assertEqual(filename0, _THIS_FILE) - self.assertEqual(lineno0, call_line) - - filename1, lineno1, name1, *_ = frames[1] - self.assertEqual(name1, 'test_filename_and_lineno_accuracy') - self.assertEqual(filename1, _THIS_FILE) - - def test_truncation_flags_false_for_normal_frames(self): - frames = _testcapi.collect_call_stack() - for filename, lineno, name, filename_truncated, name_truncated in frames: - self.assertEqual(filename_truncated, 0, msg=f"filename truncated: {filename!r}") - self.assertEqual(name_truncated, 0, msg=f"name truncated: {name!r}") - - def test_max_frames_limits_collection(self): - def level2(): - def level1(): - return _testcapi.collect_call_stack(2) - return level1() - - frames = level2() - self.assertEqual(len(frames), 2) - self.assertEqual(frames[0][2], 'level1') - self.assertEqual(frames[1][2], 'level2') - - def test_frameinfo_strsize_constant(self): - # 500 content bytes + '\0' (1) = 501. - self.assertEqual(_testcapi.FRAMEINFO_STRSIZE, 501) - - -@unittest.skipUnless(hasattr(os, 'pipe'), 'requires os.pipe') -class TestPrintTraceback(unittest.TestCase): - - def _print(self, frames, write_header=True): - r, w = os.pipe() - try: - _testcapi.print_call_stack(w, frames, write_header) - os.close(w) - w = -1 - return _read_pipe(r) - finally: - os.close(r) - if w >= 0: - os.close(w) - - def test_header_present(self): - out = self._print([('/a.py', 1, 'f')], write_header=True) - self.assertTrue(out.startswith('Stack (most recent call first):\n')) - - def test_header_absent(self): - out = self._print([('/a.py', 1, 'f')], write_header=False) - self.assertNotIn('Stack', out) - - def test_frame_format(self): - out = self._print([('/some/module.py', 42, 'myfunc')], write_header=False) - self.assertEqual(out, ' File "/some/module.py", line 42 in myfunc\n') - - def test_multiple_frames(self): - frames = [('/a.py', 10, 'inner'), ('/b.py', 20, 'outer')] - out = self._print(frames, write_header=False) - lines = out.splitlines() - self.assertEqual(len(lines), 2) - self.assertIn('inner', lines[0]) - self.assertIn('outer', lines[1]) - - def test_unknown_filename_prints_question_marks(self): - out = self._print([('', 1, 'f')], write_header=False) - self.assertIn('???', out) - self.assertNotIn('""', out) - - def test_unknown_name_prints_question_marks(self): - out = self._print([('/a.py', 1, '')], write_header=False) - self.assertIn('???', out) - - def test_unknown_lineno_prints_question_marks(self): - out = self._print([('/a.py', -1, 'f')], write_header=False) - self.assertIn('???', out) - self.assertNotIn('line -1', out) - - def test_empty_frame_list(self): - out = self._print([], write_header=True) - self.assertEqual(out, 'Stack (most recent call first):\n') - - def test_truncated_filename_appends_ellipsis(self): - out = self._print([('/long/path.py', 1, 'f', 1, 0)], write_header=False) - self.assertIn('"/long/path.py..."', out) - - def test_truncated_name_appends_ellipsis(self): - out = self._print([('/a.py', 1, 'long_func', 0, 1)], write_header=False) - self.assertIn('long_func...', out) - - def test_not_truncated_has_no_ellipsis(self): - out = self._print([('/a.py', 1, 'f', 0, 0)], write_header=False) - self.assertNotIn('...', out) - - -@unittest.skipUnless(hasattr(os, 'pipe'), 'requires os.pipe') -class TestEndToEnd(unittest.TestCase): - - def test_output_contains_caller(self): - def inner(): - r, w = os.pipe() - try: - _testcapi.collect_and_print_call_stack(w) - os.close(w) - w = -1 - return _read_pipe(r) - finally: - os.close(r) - if w >= 0: - os.close(w) - - output = inner() - self.assertIn('Stack (most recent call first):', output) - self.assertIn('inner', output) - self.assertIn('test_output_contains_caller', output) - - def test_output_filename_and_lineno(self): - # The innermost frame should reference this file and the correct line. - def inner(): - r, w = os.pipe() - try: - call_line = sys._getframe().f_lineno + 1 - _testcapi.collect_and_print_call_stack(w) - os.close(w) - w = -1 - return call_line, _read_pipe(r) - finally: - os.close(r) - if w >= 0: - os.close(w) - - call_line, output = inner() - file_lines = [l for l in output.splitlines() if l.startswith(' File')] - self.assertTrue(file_lines) - first = file_lines[0] - self.assertIn(os.path.basename(_THIS_FILE), first) - self.assertIn(f'line {call_line}', first) - self.assertIn('inner', first) - - def test_max_frames_limits_output(self): - def level2(): - def level1(): - r, w = os.pipe() - try: - _testcapi.collect_and_print_call_stack(w, 1) - os.close(w) - w = -1 - return _read_pipe(r) - finally: - os.close(r) - if w >= 0: - os.close(w) - return level1() - - output = level2() - file_lines = [l for l in output.splitlines() if l.startswith(' File')] - self.assertEqual(len(file_lines), 1) - self.assertIn('level1', file_lines[0]) - - -if __name__ == '__main__': - unittest.main() diff --git a/Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst b/Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst deleted file mode 100644 index 7e21b9defdabfd..00000000000000 --- a/Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst +++ /dev/null @@ -1,4 +0,0 @@ -Add :c:func:`PyUnstable_CollectCallStack` and -:c:func:`PyUnstable_PrintCallStack`, a new signal-safe C API for collecting -and printing Python stack frames without acquiring the GIL or allocating -heap memory. diff --git a/Modules/Setup.stdlib.in b/Modules/Setup.stdlib.in index aac39acf36e444..0d520684c795d6 100644 --- a/Modules/Setup.stdlib.in +++ b/Modules/Setup.stdlib.in @@ -175,7 +175,7 @@ @MODULE__XXTESTFUZZ_TRUE@_xxtestfuzz _xxtestfuzz/_xxtestfuzz.c _xxtestfuzz/fuzzer.c @MODULE__TESTBUFFER_TRUE@_testbuffer _testbuffer.c @MODULE__TESTINTERNALCAPI_TRUE@_testinternalcapi _testinternalcapi.c _testinternalcapi/test_lock.c _testinternalcapi/pytime.c _testinternalcapi/set.c _testinternalcapi/test_critical_sections.c _testinternalcapi/complex.c _testinternalcapi/interpreter.c _testinternalcapi/tuple.c -@MODULE__TESTCAPI_TRUE@_testcapi _testcapimodule.c _testcapi/vectorcall.c _testcapi/heaptype.c _testcapi/abstract.c _testcapi/unicode.c _testcapi/dict.c _testcapi/set.c _testcapi/list.c _testcapi/tuple.c _testcapi/getargs.c _testcapi/datetime.c _testcapi/docstring.c _testcapi/mem.c _testcapi/watchers.c _testcapi/long.c _testcapi/float.c _testcapi/complex.c _testcapi/numbers.c _testcapi/structmember.c _testcapi/exceptions.c _testcapi/code.c _testcapi/buffer.c _testcapi/pyatomic.c _testcapi/run.c _testcapi/file.c _testcapi/codec.c _testcapi/immortal.c _testcapi/gc.c _testcapi/hash.c _testcapi/time.c _testcapi/bytes.c _testcapi/object.c _testcapi/modsupport.c _testcapi/monitoring.c _testcapi/config.c _testcapi/import.c _testcapi/frame.c _testcapi/traceback.c _testcapi/type.c _testcapi/function.c _testcapi/module.c +@MODULE__TESTCAPI_TRUE@_testcapi _testcapimodule.c _testcapi/vectorcall.c _testcapi/heaptype.c _testcapi/abstract.c _testcapi/unicode.c _testcapi/dict.c _testcapi/set.c _testcapi/list.c _testcapi/tuple.c _testcapi/getargs.c _testcapi/datetime.c _testcapi/docstring.c _testcapi/mem.c _testcapi/watchers.c _testcapi/long.c _testcapi/float.c _testcapi/complex.c _testcapi/numbers.c _testcapi/structmember.c _testcapi/exceptions.c _testcapi/code.c _testcapi/buffer.c _testcapi/pyatomic.c _testcapi/run.c _testcapi/file.c _testcapi/codec.c _testcapi/immortal.c _testcapi/gc.c _testcapi/hash.c _testcapi/time.c _testcapi/bytes.c _testcapi/object.c _testcapi/modsupport.c _testcapi/monitoring.c _testcapi/config.c _testcapi/import.c _testcapi/frame.c _testcapi/type.c _testcapi/function.c _testcapi/module.c @MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/sys.c _testlimitedcapi/threadstate.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c _testlimitedcapi/file.c @MODULE__TESTCLINIC_TRUE@_testclinic _testclinic.c @MODULE__TESTCLINIC_LIMITED_TRUE@_testclinic_limited _testclinic_limited.c diff --git a/Modules/_testcapi/parts.h b/Modules/_testcapi/parts.h index da7b208104a0e6..a7feca5bd96070 100644 --- a/Modules/_testcapi/parts.h +++ b/Modules/_testcapi/parts.h @@ -64,7 +64,6 @@ int _PyTestCapi_Init_Object(PyObject *module); int _PyTestCapi_Init_Config(PyObject *mod); int _PyTestCapi_Init_Import(PyObject *mod); int _PyTestCapi_Init_Frame(PyObject *mod); -int _PyTestCapi_Init_Traceback(PyObject *mod); int _PyTestCapi_Init_Type(PyObject *mod); int _PyTestCapi_Init_Function(PyObject *mod); int _PyTestCapi_Init_Module(PyObject *mod); diff --git a/Modules/_testcapi/traceback.c b/Modules/_testcapi/traceback.c deleted file mode 100644 index cfbf973ae6e9f1..00000000000000 --- a/Modules/_testcapi/traceback.c +++ /dev/null @@ -1,176 +0,0 @@ -#include "parts.h" - -#include "traceback.h" // PyUnstable_CollectCallStack, PyUnstable_PrintCallStack - - -/* collect_call_stack([max_frames]) -> - * list of (filename, lineno, name, filename_truncated, name_truncated) - * | None - * - * Calls PyUnstable_CollectCallStack() on the current tstate and returns the - * collected frames as a list of 5-tuples. lineno is an int or None if - * unknown; filename_truncated and name_truncated are 0 or 1. Returns None - * if the tstate has no Python frame (i.e. PyUnstable_CollectCallStack() - * returned -1). */ -static PyObject * -traceback_collect(PyObject *self, PyObject *args) -{ - int max_frames = 10; - if (!PyArg_ParseTuple(args, "|i", &max_frames)) { - return NULL; - } - if (max_frames <= 0) { - PyErr_SetString(PyExc_ValueError, "max_frames must be positive"); - return NULL; - } - - PyUnstable_FrameInfo *frames = PyMem_Malloc( - sizeof(PyUnstable_FrameInfo) * max_frames); - if (frames == NULL) { - return PyErr_NoMemory(); - } - - PyThreadState *tstate = PyThreadState_Get(); - int n = PyUnstable_CollectCallStack(tstate, frames, max_frames); - - if (n < 0) { - PyMem_Free(frames); - Py_RETURN_NONE; - } - - PyObject *result = PyList_New(n); - if (result == NULL) { - PyMem_Free(frames); - return NULL; - } - - for (int i = 0; i < n; i++) { - PyObject *lineno = frames[i].lineno >= 0 - ? PyLong_FromLong(frames[i].lineno) - : Py_NewRef(Py_None); - if (lineno == NULL) { - Py_DECREF(result); - PyMem_Free(frames); - return NULL; - } - PyObject *item = Py_BuildValue("(sNsii)", - frames[i].filename, lineno, frames[i].name, - frames[i].filename_truncated, - frames[i].name_truncated); - if (item == NULL) { - Py_DECREF(result); - PyMem_Free(frames); - return NULL; - } - PyList_SET_ITEM(result, i, item); - } - - PyMem_Free(frames); - return result; -} - - -/* print_call_stack(fd, [(filename, lineno, name[, filename_truncated, - * name_truncated]), ...][, write_header]) -> None - * - * Constructs a PyUnstable_FrameInfo array from a Python list of tuples and - * calls PyUnstable_PrintCallStack(). Used to test the print format in - * isolation from collection. The optional filename_truncated and - * name_truncated fields allow testing the truncation display path directly. */ -static PyObject * -traceback_print(PyObject *self, PyObject *args) -{ - int fd; - PyObject *frame_list; - int write_header = 1; - if (!PyArg_ParseTuple(args, "iO!|i", - &fd, &PyList_Type, &frame_list, &write_header)) { - return NULL; - } - - Py_ssize_t n = PyList_GET_SIZE(frame_list); - PyUnstable_FrameInfo *frames = PyMem_Malloc( - sizeof(PyUnstable_FrameInfo) * (n ? n : 1)); - if (frames == NULL) { - return PyErr_NoMemory(); - } - - for (Py_ssize_t i = 0; i < n; i++) { - PyObject *item = PyList_GET_ITEM(frame_list, i); - const char *filename, *name; - int lineno; - int filename_truncated = 0, name_truncated = 0; - if (!PyArg_ParseTuple(item, "sis|ii", - &filename, &lineno, &name, - &filename_truncated, &name_truncated)) { - PyMem_Free(frames); - return NULL; - } - PyOS_snprintf(frames[i].filename, Py_UNSTABLE_FRAMEINFO_STRSIZE, - "%s", filename); - frames[i].lineno = lineno; - PyOS_snprintf(frames[i].name, Py_UNSTABLE_FRAMEINFO_STRSIZE, - "%s", name); - frames[i].filename_truncated = filename_truncated; - frames[i].name_truncated = name_truncated; - } - - PyUnstable_PrintCallStack(fd, frames, (int)n, write_header); - PyMem_Free(frames); - Py_RETURN_NONE; -} - - -/* collect_and_print_call_stack(fd[, max_frames[, write_header]]) -> None - * - * Calls PyUnstable_CollectCallStack() + PyUnstable_PrintCallStack() in one - * step. Used to test the end-to-end path with a real Python call stack. */ -static PyObject * -traceback_collect_and_print(PyObject *self, PyObject *args) -{ - int fd; - int max_frames = 10; - int write_header = 1; - if (!PyArg_ParseTuple(args, "i|ii", &fd, &max_frames, &write_header)) { - return NULL; - } - if (max_frames <= 0) { - PyErr_SetString(PyExc_ValueError, "max_frames must be positive"); - return NULL; - } - - PyUnstable_FrameInfo *frames = PyMem_Malloc( - sizeof(PyUnstable_FrameInfo) * max_frames); - if (frames == NULL) { - return PyErr_NoMemory(); - } - - PyThreadState *tstate = PyThreadState_Get(); - int n = PyUnstable_CollectCallStack(tstate, frames, max_frames); - if (n >= 0) { - PyUnstable_PrintCallStack(fd, frames, n, write_header); - } - PyMem_Free(frames); - Py_RETURN_NONE; -} - - -static PyMethodDef traceback_methods[] = { - {"collect_call_stack", traceback_collect, METH_VARARGS}, - {"print_call_stack", traceback_print, METH_VARARGS}, - {"collect_and_print_call_stack", traceback_collect_and_print, METH_VARARGS}, - {NULL}, -}; - -int -_PyTestCapi_Init_Traceback(PyObject *mod) -{ - if (PyModule_AddIntConstant(mod, "FRAMEINFO_STRSIZE", - Py_UNSTABLE_FRAMEINFO_STRSIZE) < 0) { - return -1; - } - if (PyModule_AddFunctions(mod, traceback_methods) < 0) { - return -1; - } - return 0; -} diff --git a/Modules/_testcapimodule.c b/Modules/_testcapimodule.c index 9e0daa82a93010..3ebe4ceea6a72e 100644 --- a/Modules/_testcapimodule.c +++ b/Modules/_testcapimodule.c @@ -3564,9 +3564,6 @@ _testcapi_exec(PyObject *m) if (_PyTestCapi_Init_Frame(m) < 0) { return -1; } - if (_PyTestCapi_Init_Traceback(m) < 0) { - return -1; - } if (_PyTestCapi_Init_Type(m) < 0) { return -1; } diff --git a/PCbuild/_testcapi.vcxproj b/PCbuild/_testcapi.vcxproj index 628c388fcdecfa..68707a54ff6b87 100644 --- a/PCbuild/_testcapi.vcxproj +++ b/PCbuild/_testcapi.vcxproj @@ -131,7 +131,6 @@ - diff --git a/Python/traceback.c b/Python/traceback.c index d41605f494e40e..1e8c9c879f9aac 100644 --- a/Python/traceback.c +++ b/Python/traceback.c @@ -1029,242 +1029,88 @@ _Py_DumpWideString(int fd, wchar_t *str) #endif +/* Write a frame into the file fd: "File "xxx", line xxx in xxx". -static bool _Py_NO_SANITIZE_THREAD -tstate_is_freed(PyThreadState *tstate) -{ - return (_PyMem_IsPtrFreed(tstate) || - _PyMem_IsPtrFreed(tstate->interp) || - _PyMem_IsULongFreed(tstate->thread_id)); -} - - -static bool _Py_NO_SANITIZE_THREAD -interp_is_freed(PyInterpreterState *interp) -{ - return _PyMem_IsPtrFreed(interp); -} + This function is signal safe. + Return 0 on success. Return -1 if the frame is invalid. */ -/* Write the ASCII (backslash-escaped) representation of text into buf, - null-terminating the result. buf must be at least Py_UNSTABLE_FRAMEINFO_STRSIZE - bytes. At most Py_UNSTABLE_FRAMEINFO_STRSIZE-1 content bytes are written. - Returns 1 if the output was truncated, 0 if it fit, or -1 if text is NULL - or not a unicode object (in which case buf is set to an empty string). - - This function does not acquire or release the GIL, modify reference counts, - or allocate heap memory. */ static int _Py_NO_SANITIZE_THREAD -format_ascii(char *buf, PyObject *text) +dump_frame(int fd, _PyInterpreterFrame *frame) { - static const char hex[] = "0123456789abcdef"; + if (frame->owner == FRAME_OWNED_BY_INTERPRETER) { + /* Ignore trampoline frames and base frame sentinel */ + return 0; + } - if (text == NULL || !PyUnicode_Check(text)) { - buf[0] = '\0'; + PyCodeObject *code = _PyFrame_SafeGetCode(frame); + if (code == NULL) { return -1; } - PyASCIIObject *ascii = _PyASCIIObject_CAST(text); - Py_ssize_t srclen = ascii->length; - int kind = ascii->state.kind; - void *data; - - if (ascii->state.compact) { - data = ascii->state.ascii ? (void *)(ascii + 1) - : (void *)(_PyCompactUnicodeObject_CAST(text) + 1); + int res = 0; + PUTS(fd, " File "); + if (code->co_filename != NULL + && PyUnicode_Check(code->co_filename)) + { + PUTS(fd, "\""); + _Py_DumpASCII(fd, code->co_filename); + PUTS(fd, "\""); } else { - data = _PyUnicodeObject_CAST(text)->data.any; - if (data == NULL) { - buf[0] = '\0'; - return -1; - } + PUTS(fd, "???"); + res = -1; } - char *out = buf; - /* Reserve 1 byte at the end for '\0'. */ - char *limit = buf + Py_UNSTABLE_FRAMEINFO_STRSIZE - 1; - int truncated = 0; - - for (Py_ssize_t i = 0; i < srclen; i++) { - Py_UCS4 ch = PyUnicode_READ(kind, data, i); - if (' ' <= ch && ch <= 126) { - if (out >= limit) { truncated = 1; break; } - *out++ = (char)ch; - } - else if (ch <= 0xff) { - if (out + 4 > limit) { truncated = 1; break; } - out[0] = '\\'; out[1] = 'x'; - out[2] = hex[(ch >> 4) & 0xf]; out[3] = hex[ch & 0xf]; - out += 4; - } - else if (ch <= 0xffff) { - if (out + 6 > limit) { truncated = 1; break; } - out[0] = '\\'; out[1] = 'u'; - out[2] = hex[(ch >> 12) & 0xf]; out[3] = hex[(ch >> 8) & 0xf]; - out[4] = hex[(ch >> 4) & 0xf]; out[5] = hex[ch & 0xf]; - out += 6; - } - else { - if (out + 10 > limit) { truncated = 1; break; } - out[0] = '\\'; out[1] = 'U'; - out[2] = hex[(ch >> 28) & 0xf]; out[3] = hex[(ch >> 24) & 0xf]; - out[4] = hex[(ch >> 20) & 0xf]; out[5] = hex[(ch >> 16) & 0xf]; - out[6] = hex[(ch >> 12) & 0xf]; out[7] = hex[(ch >> 8) & 0xf]; - out[8] = hex[(ch >> 4) & 0xf]; out[9] = hex[ch & 0xf]; - out += 10; - } + PUTS(fd, ", line "); + int lasti = _PyFrame_SafeGetLasti(frame); + int lineno = -1; + if (lasti >= 0) { + lineno = _PyCode_SafeAddr2Line(code, lasti); + } + if (lineno >= 0) { + _Py_DumpDecimal(fd, (size_t)lineno); + } + else { + PUTS(fd, "???"); + res = -1; } - *out = '\0'; - return truncated; -} - - -/* Collect info from a single frame into *out. - Returns 1 if *out was filled, 0 if the frame should be skipped - (trampoline/sentinel), -1 if the frame is invalid. - - This function is intended for use in crash scenarios such as signal handlers - for SIGSEGV, where the interpreter may be in an inconsistent state. Given - that it reads interpreter data structures that may be partially modified, the - function might produce incomplete output or it may even crash itself. - This function does not acquire or release the GIL, modify reference counts, - or allocate heap memory. */ -static int _Py_NO_SANITIZE_THREAD -collect_frame(_PyInterpreterFrame *frame, PyUnstable_FrameInfo *out) -{ - if (frame->owner == FRAME_OWNED_BY_INTERPRETER) { - /* Trampoline frames and the base-frame sentinel carry no Python - code object; skip them silently. */ - return 0; + PUTS(fd, " in "); + if (code->co_name != NULL && PyUnicode_Check(code->co_name)) { + _Py_DumpASCII(fd, code->co_name); } - PyCodeObject *code = _PyFrame_SafeGetCode(frame); - if (code == NULL) { - return -1; + else { + PUTS(fd, "???"); + res = -1; } - out->filename_truncated = format_ascii(out->filename, code->co_filename) > 0; - int lasti = _PyFrame_SafeGetLasti(frame); - out->lineno = (lasti >= 0) ? _PyCode_SafeAddr2Line(code, lasti) : -1; - out->name_truncated = format_ascii(out->name, code->co_name) > 0; - return 1; + PUTS(fd, "\n"); + return res; } - -/* Collect up to max_frames frames from tstate into the caller-supplied frames - array. Returns the number of frames written (0..max_frames), or -1 if - tstate is freed or has no current Python frame. - - The caller does not need to hold an attached thread state, nor does tstate - need to be attached. - - This function is intended for use in crash scenarios such as signal handlers - for SIGSEGV, where the interpreter may be in an inconsistent state. Given - that it reads interpreter data structures that may be partially modified, the - function might produce incomplete output or it may even crash itself. - - This function does not acquire or release the GIL, modify reference counts, - or allocate heap memory. */ -int _Py_NO_SANITIZE_THREAD -PyUnstable_CollectCallStack(PyThreadState *tstate, PyUnstable_FrameInfo *frames, - int max_frames) +static int _Py_NO_SANITIZE_THREAD +tstate_is_freed(PyThreadState *tstate) { - if (frames == NULL) { - return -1; + if (_PyMem_IsPtrFreed(tstate)) { + return 1; } - if (tstate_is_freed(tstate)) { - return -1; + if (_PyMem_IsPtrFreed(tstate->interp)) { + return 1; } - _PyInterpreterFrame *frame = tstate->current_frame; - if (frame == NULL) { - return -1; + if (_PyMem_IsULongFreed(tstate->thread_id)) { + return 1; } - int n = 0; - int hops = 0; - /* hops bounds total frame pointer traversals to guard against cycles in - corrupted memory, where trampolines (r==0) could prevent n from - advancing. */ - int max_hops = max_frames + MAX_FRAME_DEPTH; - while (frame != NULL && n < max_frames && hops < max_hops) { - hops++; - if (_PyMem_IsPtrFreed(frame)) { - break; - } - /* Read frame->previous before touching frame fields in case memory - is freed during collect_frame(). */ - _PyInterpreterFrame *previous = frame->previous; - int r = collect_frame(frame, &frames[n]); - if (r > 0) { - n++; - } - else if (r < 0) { - break; - } - frame = previous; - } - return n; + return 0; } -/* Write a previously-collected traceback to fd. n_frames is the value - returned by PyUnstable_CollectCallStack(); pass write_header=1 to emit the - "Stack (most recent call first):" header line. - - This function only reads the caller-supplied frames array and does not - access interpreter state. It is async-signal-safe: it does not acquire or - release the GIL, modify reference counts, or allocate heap memory, and its - only I/O is via write(2). */ -void _Py_NO_SANITIZE_THREAD -PyUnstable_PrintCallStack(int fd, const PyUnstable_FrameInfo *frames, - int n_frames, int write_header) +static int _Py_NO_SANITIZE_THREAD +interp_is_freed(PyInterpreterState *interp) { - if (write_header) { - PUTS(fd, "Stack (most recent call first):\n"); - } - if (frames == NULL) { - return; - } - for (int i = 0; i < n_frames; i++) { - const PyUnstable_FrameInfo *fi = &frames[i]; - PUTS(fd, " File "); - if (fi->filename[0] != '\0') { - PUTS(fd, "\""); - PUTS(fd, fi->filename); - if (fi->filename_truncated) { - PUTS(fd, "..."); - } - PUTS(fd, "\""); - } - else { - PUTS(fd, "???"); - } - PUTS(fd, ", line "); - if (fi->lineno >= 0) { - _Py_DumpDecimal(fd, (size_t)fi->lineno); - } - else { - PUTS(fd, "???"); - } - PUTS(fd, " in "); - if (fi->name[0] != '\0') { - PUTS(fd, fi->name); - if (fi->name_truncated) { - PUTS(fd, "..."); - } - } - else { - PUTS(fd, "???"); - } - PUTS(fd, "\n"); - } + return _PyMem_IsPtrFreed(interp); } -/* Dump the Python traceback for tstate to fd. Called from signal handlers - and faulthandler; tstate may belong to any thread and need not be attached. - - This function does not acquire or release the GIL, modify reference counts, - or allocate heap memory. */ static void _Py_NO_SANITIZE_THREAD dump_traceback(int fd, PyThreadState *tstate, int write_header) { @@ -1283,30 +1129,35 @@ dump_traceback(int fd, PyThreadState *tstate, int write_header) return; } - /* Process one frame at a time to keep stack usage bounded: a stack array - of MAX_FRAME_DEPTH PyUnstable_FrameInfo structs would overflow the alternate - signal stack. */ - int depth = 0; - while (frame != NULL && depth < MAX_FRAME_DEPTH) { + unsigned int depth = 0; + while (1) { + if (MAX_FRAME_DEPTH <= depth) { + if (MAX_FRAME_DEPTH < depth) { + PUTS(fd, "plus "); + _Py_DumpDecimal(fd, depth); + PUTS(fd, " frames\n"); + } + break; + } + if (_PyMem_IsPtrFreed(frame)) { PUTS(fd, " \n"); - return; + break; } + // Read frame->previous early since memory can be freed during + // dump_frame() _PyInterpreterFrame *previous = frame->previous; - PyUnstable_FrameInfo fi; - int r = collect_frame(frame, &fi); - if (r > 0) { - PyUnstable_PrintCallStack(fd, &fi, 1, 0); - depth++; - } - else if (r < 0) { + + if (dump_frame(fd, frame) < 0) { PUTS(fd, " \n"); - return; + break; } + frame = previous; - } - if (frame != NULL) { - PUTS(fd, " ...\n"); + if (frame == NULL) { + break; + } + depth++; } } From 4448ac8cf4ed995ab621b81965b109b661a5332b Mon Sep 17 00:00:00 2001 From: Daniel Schwartz-Narbonne Date: Mon, 4 May 2026 17:10:58 -0400 Subject: [PATCH 3/3] gh-148925: Add signal-safe PyUnstable_* APIs for call-stack iteration Add four new public APIs for walking the Python call stack without allocating memory, changing reference counts, or acquiring the GIL, making them safe to call from signal handlers and custom memory allocator hooks: - PyUnstable_ThreadState_GetInterpreterFrame(tstate): returns the innermost complete interpreter frame, skipping entry trampolines and pre-RESUME frames automatically. - PyUnstable_InterpreterFrame_GetNextComplete(frame): returns the next complete calling frame, skipping incomplete frames. Mirrors PyFrame_GetBack semantics without allocating. - PyUnstable_InterpreterFrame_GetCodeSafe(frame): returns a borrowed reference to the code object, or NULL if freed memory is detected. - PyUnstable_InterpreterFrame_GetLineSafe(frame): returns the current line number, validating the instruction offset rather than asserting, safe when the frame may be partially torn down. All four use _Py_NO_SANITIZE_THREAD to suppress intentional racy reads and heuristics (_PyMem_IsPtrFreed) to detect freed memory. Switch tracemalloc and the fatal error traceback printer to use the new APIs instead of internal helpers. Add tests in Lib/test/test_capi/test_misc.py and test helpers in Modules/_testinternalcapi.c. --- Doc/c-api/frame.rst | 72 +++++++- Include/cpython/pyframe.h | 43 ++++- Lib/test/test_capi/test_misc.py | 80 +++++++++ ...-04-23-14-08-40.gh-issue-148925.qnLUh5.rst | 7 + Modules/_testinternalcapi.c | 168 ++++++++++++++++++ Python/frame.c | 50 ++++++ Python/traceback.c | 21 +-- Python/tracemalloc.c | 15 +- 8 files changed, 430 insertions(+), 26 deletions(-) create mode 100644 Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst diff --git a/Doc/c-api/frame.rst b/Doc/c-api/frame.rst index 4159ff6e5965fb..8d1dbb2c3fadd1 100644 --- a/Doc/c-api/frame.rst +++ b/Doc/c-api/frame.rst @@ -226,11 +226,31 @@ Unless using :pep:`523`, you will not need this. .. c:function:: PyObject* PyUnstable_InterpreterFrame_GetCode(struct _PyInterpreterFrame *frame); - Return a :term:`strong reference` to the code object for the frame. + Return a :term:`strong reference` to the code object for the frame. + Does not raise an exception. + + If allocation and reference count changes are not permitted (for example, + from a signal handler or a custom memory allocator), use + :c:func:`PyUnstable_InterpreterFrame_GetCodeSafe` instead. .. versionadded:: 3.12 +.. c:function:: PyObject* PyUnstable_InterpreterFrame_GetCodeSafe(struct _PyInterpreterFrame *frame); + + Return a :term:`borrowed reference` to the code object for the frame. + The reference is valid as long as the frame is alive. + + Use this instead of :c:func:`PyUnstable_InterpreterFrame_GetCode` when + allocation and reference count changes are not permitted (for example, + from a signal handler or a custom memory allocator). Does not allocate + memory, does not change any reference counts, does not acquire or release + the GIL, and does not raise an exception. Uses heuristics to detect freed + memory — not 100% reliable in the presence of concurrent deallocation. + + .. versionadded:: 3.15 + + .. c:function:: int PyUnstable_InterpreterFrame_GetLasti(struct _PyInterpreterFrame *frame); Return the byte offset into the last executed instruction. @@ -243,3 +263,53 @@ Unless using :pep:`523`, you will not need this. Return the currently executing line number, or -1 if there is no line number. .. versionadded:: 3.12 + + +.. c:function:: int PyUnstable_InterpreterFrame_GetLineSafe(struct _PyInterpreterFrame *frame) + + Return the currently executing line number, or ``-1`` if there is no line + number or the frame is invalid. Does not raise an exception. + + Unlike :c:func:`PyUnstable_InterpreterFrame_GetLine`, validates the code + object and instruction offset before accessing the line table rather than + asserting them, making it safe to call when the frame state may be + partially torn down. + + .. versionadded:: 3.15 + + +.. c:function:: struct _PyInterpreterFrame* PyUnstable_ThreadState_GetInterpreterFrame(PyThreadState *tstate) + + Return the innermost complete interpreter frame of *tstate*, or ``NULL`` if + the thread has no complete frame or freed memory is detected. Incomplete + frames (interpreter entry trampolines and frames that have not yet begun + executing) are skipped automatically. + + Does not allocate memory, does not raise an exception, and does not acquire + or release the GIL. Safe to call from a signal handler; racy reads from + other threads are intentional. Uses heuristics to detect freed memory — + not 100% reliable in the presence of concurrent deallocation. + + To iterate over the full call stack, call + :c:func:`PyUnstable_InterpreterFrame_GetNextComplete` repeatedly on the + returned frame until it returns ``NULL``. + + .. versionadded:: 3.15 + + +.. c:function:: struct _PyInterpreterFrame* PyUnstable_InterpreterFrame_GetNextComplete(struct _PyInterpreterFrame *frame) + + Return the next (calling) complete frame, or ``NULL`` if *frame* is the + outermost complete frame or freed memory is detected. Incomplete frames are + skipped automatically. + + Does not allocate memory, does not raise an exception, and does not acquire + or release the GIL. Safe to call from a signal handler; racy reads from + other threads are intentional. Uses heuristics to detect freed memory — + not 100% reliable in the presence of concurrent deallocation. + + Unlike :c:func:`PyFrame_GetBack`, this function never allocates memory, + making it safe to call from a custom memory allocator hook without risking + re-entrant allocation. + + .. versionadded:: 3.15 diff --git a/Include/cpython/pyframe.h b/Include/cpython/pyframe.h index 51529763923ec3..93d1889cd76fd0 100644 --- a/Include/cpython/pyframe.h +++ b/Include/cpython/pyframe.h @@ -25,9 +25,20 @@ PyAPI_FUNC(PyObject*) PyFrame_GetVarString(PyFrameObject *frame, const char *nam struct _PyInterpreterFrame; /* Returns the code object of the frame (strong reference). - * Does not raise an exception. */ + * Does not raise an exception. + * If allocation and reference count changes are not permitted, use + * PyUnstable_InterpreterFrame_GetCodeSafe instead. */ PyAPI_FUNC(PyObject *) PyUnstable_InterpreterFrame_GetCode(struct _PyInterpreterFrame *frame); +/* Returns the code object of the frame as a borrowed reference. + * The reference is valid as long as the frame is alive. + * Use instead of PyUnstable_InterpreterFrame_GetCode when allocation and + * reference count changes are not permitted (e.g. from a signal handler or + * a custom memory allocator). Does not allocate, does not change any + * reference counts, does not acquire or release the GIL, does not raise an + * exception. Uses heuristics to detect freed memory; not 100% reliable. */ +PyAPI_FUNC(PyObject *) PyUnstable_InterpreterFrame_GetCodeSafe(struct _PyInterpreterFrame *frame); + /* Returns a byte offset into the last executed instruction. * Does not raise an exception. */ PyAPI_FUNC(int) PyUnstable_InterpreterFrame_GetLasti(struct _PyInterpreterFrame *frame); @@ -36,6 +47,36 @@ PyAPI_FUNC(int) PyUnstable_InterpreterFrame_GetLasti(struct _PyInterpreterFrame * Does not raise an exception. */ PyAPI_FUNC(int) PyUnstable_InterpreterFrame_GetLine(struct _PyInterpreterFrame *frame); +/* Returns the currently executing line number, or -1 if there is no line + * number or the frame is invalid. + * Unlike PyUnstable_InterpreterFrame_GetLine, validates the code object and + * instruction offset before accessing the line table rather than asserting + * them, making it safe to call when the frame state may be partially torn + * down. Does not raise an exception. */ +PyAPI_FUNC(int) PyUnstable_InterpreterFrame_GetLineSafe(struct _PyInterpreterFrame *frame); + + +/* Returns the innermost complete interpreter frame of the thread state, or + * NULL if the thread has no complete frame or freed memory is detected. + * Skips over incomplete frames (interpreter entry trampolines and frames that + * have not yet begun executing) automatically. + * Does not allocate memory, does not acquire or release the GIL, does not + * raise an exception. Safe to call from signal handlers; racy reads from + * other threads are intentional and suppressed (_Py_NO_SANITIZE_THREAD). + * Uses heuristics to detect freed memory; not 100% reliable. */ +PyAPI_FUNC(struct _PyInterpreterFrame *) +PyUnstable_ThreadState_GetInterpreterFrame(PyThreadState *tstate); + +/* Returns the next (calling) complete frame, or NULL if frame is the + * outermost complete frame or freed memory is detected. + * Skips over incomplete frames automatically. + * Does not allocate memory, does not acquire or release the GIL, does not + * raise an exception. Safe to call from signal handlers; racy reads from + * other threads are intentional and suppressed (_Py_NO_SANITIZE_THREAD). + * Uses heuristics to detect freed memory; not 100% reliable. */ +PyAPI_FUNC(struct _PyInterpreterFrame *) +PyUnstable_InterpreterFrame_GetNextComplete(struct _PyInterpreterFrame *frame); + #define PyUnstable_EXECUTABLE_KIND_SKIP 0 #define PyUnstable_EXECUTABLE_KIND_PY_FUNCTION 1 #define PyUnstable_EXECUTABLE_KIND_BUILTIN_FUNCTION 3 diff --git a/Lib/test/test_capi/test_misc.py b/Lib/test/test_capi/test_misc.py index 4c16bbd4cb0acf..3e1c71bc5effb4 100644 --- a/Lib/test/test_capi/test_misc.py +++ b/Lib/test/test_capi/test_misc.py @@ -2789,6 +2789,86 @@ def test_line(self): firstline = self.func.__code__.co_firstlineno self.assertEqual(line, firstline + 2) + def test_tstate_getframe_is_current(self): + # PyUnstable_ThreadState_GetInterpreterFrame must return the same + # PyFrameObject as sys._getframe(0) when called at the same level. + frame_from_c = _testinternalcapi.tstate_getframe() + self.assertIs(frame_from_c, sys._getframe(0)) + + def test_getnextcomplete_matches_f_back(self): + # GetNextComplete must match f_back at every step, all the way to None. + c_frame = _testinternalcapi.tstate_getframe() + py_frame = sys._getframe(0) + while c_frame is not None: + self.assertIs(c_frame, py_frame) + c_frame = _testinternalcapi.iframe_getnextcomplete(c_frame) + py_frame = py_frame.f_back + self.assertIsNone(py_frame) + + def test_stack_to_yaml(self): + # stack_to_yaml uses only signal-safe operations for the walk and + # emission (no allocation, no refcount changes, no GIL release). + # Verify the output is well-formed and contains the expected frames. + def inner(): + return _testinternalcapi.stack_to_yaml() + + yaml_str = inner() + self.assertIsInstance(yaml_str, str) + + # Parse the YAML manually to avoid a PyYAML dependency. + # Format is blocks of: + # - filename: + # name: + # lineno: + frames = [] + current = {} + for line in yaml_str.splitlines(): + if line.startswith('- filename: '): + if current: + frames.append(current) + current = {'filename': line[len('- filename: '):]} + elif line.startswith(' name: '): + current['name'] = line[len(' name: '):] + elif line.startswith(' lineno: '): + current['lineno'] = int(line[len(' lineno: '):]) + if current: + frames.append(current) + + self.assertGreater(len(frames), 1) + # Innermost frame is the Python function that called stack_to_yaml. + self.assertEqual(frames[0]['name'], 'inner') + # Next frame is this test method. + self.assertEqual(frames[1]['name'], 'test_stack_to_yaml') + # Every frame must have a non-empty filename and a valid lineno. + for f in frames: + self.assertTrue(f.get('filename'), f) + self.assertIsInstance(f.get('lineno'), int, f) + + def test_getcodesafe_matches_fcode(self): + # GetCodeSafe must return the same code object as frame.f_code. + frame = _testinternalcapi.tstate_getframe() + self.assertIs(_testinternalcapi.iframe_getcodesafe(frame), frame.f_code) + + + def test_iframe_getlinesafe(self): + # Use a generator frame frozen at a yield point so that iframe_getlasti + # and iframe_getlinesafe both read the same (stable) instruction pointer. + def gen(): + yield + g = gen() + next(g) + frame = g.gi_frame + lasti = _testinternalcapi.iframe_getlasti(frame) + lineno = _testinternalcapi.iframe_getlinesafe(frame) + if lasti >= 0: + expected = None + for start, end, ln in frame.f_code.co_lines(): + if ln is not None and start <= lasti < end: + expected = ln + break + if expected is not None: + self.assertEqual(lineno, expected) + SUFFICIENT_TO_DEOPT_AND_SPECIALIZE = 100 diff --git a/Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst b/Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst new file mode 100644 index 00000000000000..cbaa68e9a35edc --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-04-23-14-08-40.gh-issue-148925.qnLUh5.rst @@ -0,0 +1,7 @@ +Add four new ``PyUnstable_*`` C API functions for signal-safe, allocation-free +call-stack iteration: :c:func:`PyUnstable_ThreadState_GetInterpreterFrame`, +:c:func:`PyUnstable_InterpreterFrame_GetNextComplete`, +:c:func:`PyUnstable_InterpreterFrame_GetCodeSafe`, and +:c:func:`PyUnstable_InterpreterFrame_GetLineSafe`. These functions do not +allocate memory, do not change reference counts, and do not acquire the GIL, +making them safe to call from signal handlers and custom memory allocator hooks. diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c index deac8570fe3241..b4e80fb5f5b578 100644 --- a/Modules/_testinternalcapi.c +++ b/Modules/_testinternalcapi.c @@ -1307,6 +1307,168 @@ iframe_getlasti(PyObject *self, PyObject *frame) return PyLong_FromLong(PyUnstable_InterpreterFrame_GetLasti(f)); } +/* ---- signal-safe buffer helpers ---------------------------------------- + * Both helpers use only memcpy and arithmetic — no libc printf, no locale, + * unambiguously async-signal-safe on all POSIX platforms. + * ------------------------------------------------------------------------- */ + +/* Append `len` bytes from `src` into buf[pos..cap-1]. + * Returns the new position, or cap (saturated) on overflow. */ +static int +_yaml_write(char *buf, int pos, int cap, const char *src, int len) +{ + if (pos + len >= cap) { return cap; } + memcpy(buf + pos, src, len); + return pos + len; +} + +/* Append a string literal whose length is known at compile time. */ +#define _yaml_lit(buf, pos, cap, lit) \ + _yaml_write((buf), (pos), (cap), (lit), (int)(sizeof(lit) - 1)) + +/* Append a decimal integer. */ +static int +_yaml_decimal(char *buf, int pos, int cap, int value) +{ + char tmp[12]; + int len = 0; + int neg = value < 0; + if (neg) { value = -value; } + do { tmp[len++] = '0' + value % 10; value /= 10; } while (value); + if (neg) { tmp[len++] = '-'; } + for (int i = 0, j = len - 1; i < j; i++, j--) { + char t = tmp[i]; tmp[i] = tmp[j]; tmp[j] = t; + } + return _yaml_write(buf, pos, cap, tmp, len); +} + +/* ---- signal-safe stack-to-YAML ----------------------------------------- */ + +#define STACK_YAML_BUFSZ 8192 + +/* Walk the call stack and write a YAML sequence into buf using only + * public PyUnstable_* APIs and signal-safe operations: no allocation, + * no refcount changes, no GIL release. + * Returns bytes written (excluding NUL), or -1 on overflow. */ +static int _Py_NO_SANITIZE_THREAD +_emit_stack_yaml_nosignal(char *buf, int cap, PyThreadState *tstate) +{ + int pos = 0; + struct _PyInterpreterFrame *frame = + PyUnstable_ThreadState_GetInterpreterFrame(tstate); + while (frame != NULL && pos < cap) { + PyCodeObject *code = + (PyCodeObject *)PyUnstable_InterpreterFrame_GetCodeSafe(frame); + if (code == NULL) { break; } + int lineno = PyUnstable_InterpreterFrame_GetLineSafe(frame); + + PyObject *filename = code->co_filename; + PyObject *name = code->co_name; + + pos = _yaml_lit(buf, pos, cap, "- filename: "); + if (filename && PyUnicode_IS_ASCII(filename)) { + pos = _yaml_write(buf, pos, cap, + (const char *)PyUnicode_DATA(filename), + (int)PyUnicode_GET_LENGTH(filename)); + } else { pos = _yaml_lit(buf, pos, cap, "???"); } + + pos = _yaml_lit(buf, pos, cap, "\n name: "); + if (name && PyUnicode_IS_ASCII(name)) { + pos = _yaml_write(buf, pos, cap, + (const char *)PyUnicode_DATA(name), + (int)PyUnicode_GET_LENGTH(name)); + } else { pos = _yaml_lit(buf, pos, cap, "???"); } + + pos = _yaml_lit(buf, pos, cap, "\n lineno: "); + pos = _yaml_decimal(buf, pos, cap, lineno); + pos = _yaml_lit(buf, pos, cap, "\n"); + + frame = PyUnstable_InterpreterFrame_GetNextComplete(frame); + } + if (pos >= cap) { return -1; } + buf[pos] = '\0'; + return pos; +} + +/* Return the current call stack as a YAML string. + * The walk and emission are entirely signal-safe (no allocation, no refcount + * changes, no GIL release). The only non-signal-safe step is the final + * PyUnicode_FromStringAndSize call after the walk completes. */ +static PyObject * +stack_to_yaml(PyObject *self, PyObject *Py_UNUSED(ignored)) +{ + char buf[STACK_YAML_BUFSZ]; + PyThreadState *tstate = _PyThreadState_GET(); + int len = _emit_stack_yaml_nosignal(buf, sizeof(buf), tstate); + if (len < 0) { + PyErr_SetString(PyExc_OverflowError, + "stack YAML output exceeded buffer"); + return NULL; + } + return PyUnicode_FromStringAndSize(buf, len); +} + +static PyObject * +iframe_getnextcomplete(PyObject *self, PyObject *frame) +{ + if (!PyFrame_Check(frame)) { + PyErr_SetString(PyExc_TypeError, "argument must be a frame"); + return NULL; + } + struct _PyInterpreterFrame *f = ((PyFrameObject *)frame)->f_frame; + struct _PyInterpreterFrame *next = PyUnstable_InterpreterFrame_GetNextComplete(f); + if (next == NULL) { + Py_RETURN_NONE; + } + PyObject *result = (PyObject *)_PyFrame_GetFrameObject(next); + if (result == NULL) { + return NULL; + } + return Py_NewRef(result); +} + +static PyObject * +tstate_getframe(PyObject *self, PyObject *Py_UNUSED(ignored)) +{ + PyThreadState *tstate = _PyThreadState_GET(); + struct _PyInterpreterFrame *f = PyUnstable_ThreadState_GetInterpreterFrame(tstate); + if (f == NULL) { + Py_RETURN_NONE; + } + PyObject *result = (PyObject *)_PyFrame_GetFrameObject(f); + if (result == NULL) { + return NULL; + } + return Py_NewRef(result); +} + +static PyObject * +iframe_getcodesafe(PyObject *self, PyObject *frame) +{ + if (!PyFrame_Check(frame)) { + PyErr_SetString(PyExc_TypeError, "argument must be a frame"); + return NULL; + } + struct _PyInterpreterFrame *f = ((PyFrameObject *)frame)->f_frame; + PyObject *code = PyUnstable_InterpreterFrame_GetCodeSafe(f); + if (code == NULL) { + Py_RETURN_NONE; + } + return Py_NewRef(code); +} + + +static PyObject * +iframe_getlinesafe(PyObject *self, PyObject *frame) +{ + if (!PyFrame_Check(frame)) { + PyErr_SetString(PyExc_TypeError, "argument must be a frame"); + return NULL; + } + struct _PyInterpreterFrame *f = ((PyFrameObject *)frame)->f_frame; + return PyLong_FromLong(PyUnstable_InterpreterFrame_GetLineSafe(f)); +} + static PyObject * code_returns_only_none(PyObject *self, PyObject *arg) { @@ -2929,6 +3091,12 @@ static PyMethodDef module_functions[] = { {"iframe_getcode", iframe_getcode, METH_O, NULL}, {"iframe_getline", iframe_getline, METH_O, NULL}, {"iframe_getlasti", iframe_getlasti, METH_O, NULL}, + {"iframe_getnextcomplete", iframe_getnextcomplete, METH_O, NULL}, + {"iframe_getcodesafe", iframe_getcodesafe, METH_O, NULL}, + {"tstate_getframe", tstate_getframe, METH_NOARGS, NULL}, + {"stack_to_yaml", stack_to_yaml, METH_NOARGS, NULL}, + + {"iframe_getlinesafe", iframe_getlinesafe, METH_O, NULL}, {"code_returns_only_none", code_returns_only_none, METH_O, NULL}, {"get_co_framesize", get_co_framesize, METH_O, NULL}, {"get_co_localskinds", get_co_localskinds, METH_O, NULL}, diff --git a/Python/frame.c b/Python/frame.c index ff81eb0b3020c7..8bc44685417c79 100644 --- a/Python/frame.c +++ b/Python/frame.c @@ -150,6 +150,56 @@ PyUnstable_InterpreterFrame_GetLine(_PyInterpreterFrame *frame) return PyCode_Addr2Line(_PyFrame_GetCode(frame), addr); } +PyObject * _Py_NO_SANITIZE_THREAD +PyUnstable_InterpreterFrame_GetCodeSafe(struct _PyInterpreterFrame *frame) +{ + return (PyObject *)_PyFrame_SafeGetCode(frame); +} + +int _Py_NO_SANITIZE_THREAD +PyUnstable_InterpreterFrame_GetLineSafe(struct _PyInterpreterFrame *frame) +{ + PyCodeObject *code = _PyFrame_SafeGetCode(frame); + if (code == NULL) { + return -1; + } + int addr = _PyInterpreterFrame_LASTI(frame) * sizeof(_Py_CODEUNIT); + return _PyCode_SafeAddr2Line(code, addr); +} + + +struct _PyInterpreterFrame * _Py_NO_SANITIZE_THREAD +PyUnstable_ThreadState_GetInterpreterFrame(PyThreadState *tstate) +{ + _PyInterpreterFrame *frame = tstate->current_frame; + while (frame != NULL) { + if (_PyMem_IsPtrFreed(frame)) { + return NULL; + } + if (!_PyFrame_IsIncomplete(frame)) { + return frame; + } + frame = frame->previous; + } + return NULL; +} + +struct _PyInterpreterFrame * _Py_NO_SANITIZE_THREAD +PyUnstable_InterpreterFrame_GetNextComplete(struct _PyInterpreterFrame *frame) +{ + _PyInterpreterFrame *previous = frame->previous; + while (previous != NULL) { + if (_PyMem_IsPtrFreed(previous)) { + return NULL; + } + if (!_PyFrame_IsIncomplete(previous)) { + return previous; + } + previous = previous->previous; + } + return NULL; +} + const PyTypeObject *const PyUnstable_ExecutableKinds[PyUnstable_EXECUTABLE_KINDS+1] = { [PyUnstable_EXECUTABLE_KIND_SKIP] = &_PyNone_Type, [PyUnstable_EXECUTABLE_KIND_PY_FUNCTION] = &PyCode_Type, diff --git a/Python/traceback.c b/Python/traceback.c index 1e8c9c879f9aac..f3eb0b90b546e3 100644 --- a/Python/traceback.c +++ b/Python/traceback.c @@ -1038,12 +1038,7 @@ _Py_DumpWideString(int fd, wchar_t *str) static int _Py_NO_SANITIZE_THREAD dump_frame(int fd, _PyInterpreterFrame *frame) { - if (frame->owner == FRAME_OWNED_BY_INTERPRETER) { - /* Ignore trampoline frames and base frame sentinel */ - return 0; - } - - PyCodeObject *code = _PyFrame_SafeGetCode(frame); + PyCodeObject *code = (PyCodeObject *)PyUnstable_InterpreterFrame_GetCodeSafe(frame); if (code == NULL) { return -1; } @@ -1063,11 +1058,7 @@ dump_frame(int fd, _PyInterpreterFrame *frame) } PUTS(fd, ", line "); - int lasti = _PyFrame_SafeGetLasti(frame); - int lineno = -1; - if (lasti >= 0) { - lineno = _PyCode_SafeAddr2Line(code, lasti); - } + int lineno = PyUnstable_InterpreterFrame_GetLineSafe(frame); if (lineno >= 0) { _Py_DumpDecimal(fd, (size_t)lineno); } @@ -1123,7 +1114,7 @@ dump_traceback(int fd, PyThreadState *tstate, int write_header) return; } - _PyInterpreterFrame *frame = tstate->current_frame; + _PyInterpreterFrame *frame = PyUnstable_ThreadState_GetInterpreterFrame(tstate); if (frame == NULL) { PUTS(fd, " \n"); return; @@ -1144,9 +1135,9 @@ dump_traceback(int fd, PyThreadState *tstate, int write_header) PUTS(fd, " \n"); break; } - // Read frame->previous early since memory can be freed during - // dump_frame() - _PyInterpreterFrame *previous = frame->previous; + // Read frame->previous early: memory may be freed during dump_frame(), + // so advance to the next frame while frame is still live. + _PyInterpreterFrame *previous = PyUnstable_InterpreterFrame_GetNextComplete(frame); if (dump_frame(fd, frame) < 0) { PUTS(fd, " \n"); diff --git a/Python/tracemalloc.c b/Python/tracemalloc.c index 0afc84e021817c..44a29d1a959f63 100644 --- a/Python/tracemalloc.c +++ b/Python/tracemalloc.c @@ -224,14 +224,11 @@ tracemalloc_get_frame(_PyInterpreterFrame *pyframe, frame_t *frame) assert(PyStackRef_CodeCheck(pyframe->f_executable)); frame->filename = &_Py_STR(anon_unknown); - int lineno = -1; - PyCodeObject *code = _PyFrame_GetCode(pyframe); - // PyUnstable_InterpreterFrame_GetLine() cannot but used, since it uses - // a critical section which can trigger a deadlock. - int lasti = _PyFrame_SafeGetLasti(pyframe); - if (lasti >= 0) { - lineno = _PyCode_SafeAddr2Line(code, lasti); + PyCodeObject *code = (PyCodeObject *)PyUnstable_InterpreterFrame_GetCodeSafe(pyframe); + if (code == NULL) { + return; } + int lineno = PyUnstable_InterpreterFrame_GetLineSafe(pyframe); if (lineno < 0) { lineno = 0; } @@ -308,7 +305,7 @@ traceback_get_frames(traceback_t *traceback) PyThreadState *tstate = _PyThreadState_GET(); assert(tstate != NULL); - _PyInterpreterFrame *pyframe = _PyThreadState_GetFrame(tstate); + _PyInterpreterFrame *pyframe = PyUnstable_ThreadState_GetInterpreterFrame(tstate); while (pyframe) { if (traceback->nframe < tracemalloc_config.max_nframe) { tracemalloc_get_frame(pyframe, &traceback->frames[traceback->nframe]); @@ -318,7 +315,7 @@ traceback_get_frames(traceback_t *traceback) if (traceback->total_nframe < UINT16_MAX) { traceback->total_nframe++; } - pyframe = _PyFrame_GetFirstComplete(pyframe->previous); + pyframe = PyUnstable_InterpreterFrame_GetNextComplete(pyframe); } }