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");
}
}