Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Doc/c-api/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -854,7 +854,12 @@ Exception Objects
.. c:function:: int PyException_SetTraceback(PyObject *ex, PyObject *tb)

Set the traceback associated with the exception to *tb*. Use ``Py_None`` to
clear it.
clear it. A traceback set through this function is not treated as
user-defined: a later ``raise`` of *ex* will discard *tb* unless its
origin frame is on the current call chain (see :ref:`raise`). To attach
a traceback that always survives a subsequent ``raise``, assign it from
Python via :attr:`~BaseException.__traceback__` or
:meth:`~BaseException.with_traceback`.


.. c:function:: PyObject* PyException_GetContext(PyObject *ex)
Expand Down
5 changes: 4 additions & 1 deletion Doc/library/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,10 @@ The following exceptions are used mostly as base classes for other exceptions.

A writable field that holds the
:ref:`traceback object <traceback-objects>` associated with this
exception. See also: :ref:`raise`.
exception. A traceback assigned through this attribute is treated
as user-defined and is preserved across a later ``raise`` of this
exception. Assigning ``None`` clears the traceback. See also:
:ref:`raise`.

.. method:: add_note(note)

Expand Down
11 changes: 11 additions & 0 deletions Doc/reference/simple_stmts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,17 @@ same exception instance, with its traceback set to its argument), like so::

raise Exception("foo occurred").with_traceback(tracebackobj)

When ``raise`` *expression* receives an exception that already carries a
traceback, the resulting traceback depends on how that traceback was set.
A traceback assigned from Python (via
:meth:`~BaseException.with_traceback` or assignment to
:attr:`~BaseException.__traceback__`) is always preserved. Otherwise the
traceback is kept only when its origin frame is on the current call chain,
so it continues coherently from the new raise site; if not, it is replaced
by a fresh traceback rooted at the current frame. Assigning ``None`` to
:attr:`~BaseException.__traceback__` clears both the traceback and its
user-set status.

.. index:: pair: exception; chaining
__cause__ (exception attribute)
__context__ (exception attribute)
Expand Down
3 changes: 2 additions & 1 deletion Include/cpython/pyerrors.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
#define PyException_HEAD PyObject_HEAD PyObject *dict;\
PyObject *args; PyObject *notes; PyObject *traceback;\
PyObject *context; PyObject *cause;\
char suppress_context;
char suppress_context;\
char user_defined_traceback;

typedef struct {
PyException_HEAD
Expand Down
90 changes: 90 additions & 0 deletions Lib/test/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1955,6 +1955,96 @@ def test_exec_set_nomemory_hang(self):
self.assertIn(b"MemoryError", output)


class CapturedExceptionReraiseTests(unittest.TestCase):
"""Raising a captured exception from a different call chain renders
a traceback rooted at the actual raise site, not a blended stack
stitched from two unrelated call chains. See gh-116862.

These tests inspect __traceback__ via a plain try/except because
unittest's assertRaises clears it on the captured exception (to
break refcount cycles in Lib/unittest/case.py)."""

@staticmethod
def _tb_frame_names(exc):
names = []
cursor = exc.__traceback__
while cursor is not None:
names.append(cursor.tb_frame.f_code.co_name)
cursor = cursor.tb_next
return names

def test_stored_exception_clears_foreign_traceback(self):
def capture():
try:
raise ValueError('captured')
except ValueError as e:
return e

err = capture()
try:
raise err
except ValueError as caught:
names = self._tb_frame_names(caught)
# capture()'s frame had already returned at the point of the
# re-raise, so it must not appear in the rendered chain.
self.assertNotIn('capture', names)
# The chain reflects the actual raise site only.
self.assertEqual(
names[-1], 'test_stored_exception_clears_foreign_traceback')

def test_bare_raise_preserves_user_defined_traceback(self):
# Bare `raise` re-raises the active exception unchanged, so a
# user-defined __traceback__ attached inside the except block
# must survive the re-raise (it forms the tail of the rendered
# tb, with the propagating frames prepended on top).
def make_custom_tb():
try:
raise ValueError('orig')
except ValueError as e:
return e.__traceback__

custom_tb = make_custom_tb()
try:
try:
raise RuntimeError('initial')
except RuntimeError as e:
e.__traceback__ = custom_tb
raise
except RuntimeError as caught:
names = self._tb_frame_names(caught)
# The custom tb's frame (make_custom_tb) must appear in the trace.
self.assertIn('make_custom_tb', names)

def test_clearing_traceback_disarms_user_defined_marker(self):
# Assigning __traceback__ marks the tb as user-defined so a
# later raise preserves it. Assigning None must clear that mark
# so the foreign-tb heuristic re-engages on the next raise.
def capture():
try:
raise ValueError('orig')
except ValueError as e:
return e

err = capture()
# Sanity: setting a user-defined tb survives the raise.
explicit_tb = err.__traceback__
err.__traceback__ = explicit_tb
try:
raise err
except ValueError as caught:
names_with_marker = self._tb_frame_names(caught)
self.assertIn('capture', names_with_marker)

# Now disarm: setting to None must drop the explicit marker.
err.__traceback__ = None
try:
raise err
except ValueError as caught:
names_after_clear = self._tb_frame_names(caught)
# Heuristic re-engages: capture()'s foreign frame stays out.
self.assertNotIn('capture', names_after_clear)


class NameErrorTests(unittest.TestCase):
def test_name_error_has_name(self):
try:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Re-raising a previously captured exception with ``raise expr`` from a
different call chain no longer renders a traceback that blends two
unrelated stacks. ``do_raise`` now drops the stored traceback when the
exception's head frame is not on the current call chain, so the
rendered trace reflects the actual raise site. Tracebacks explicitly
attached via ``__traceback__ = tb`` or :meth:`BaseException.with_traceback`
are preserved.
42 changes: 32 additions & 10 deletions Objects/exceptions.c
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ BaseException_vectorcall(PyObject *type_obj, PyObject * const*args,
self->cause = NULL;
self->context = NULL;
self->suppress_context = 0;
self->user_defined_traceback = 0;

self->args = PyTuple_FromArray(args, PyVectorcall_NARGS(nargsf));
if (!self->args) {
Expand Down Expand Up @@ -380,16 +381,12 @@ BaseException___traceback___get_impl(PyBaseExceptionObject *self)
}


/*[clinic input]
@critical_section
@setter
BaseException.__traceback__
[clinic start generated code]*/

/* Shared by the Python-level __traceback__ setter and the C API
PyException_SetTraceback. Type-checks `value` and assigns it to
self->traceback (or clears it on None). Does NOT touch the
user_defined_traceback flag; callers do that if they need it. */
static int
BaseException___traceback___set_impl(PyBaseExceptionObject *self,
PyObject *value)
/*[clinic end generated code: output=a82c86d9f29f48f0 input=12676035676badad]*/
set_traceback_field(PyBaseExceptionObject *self, PyObject *value)
{
if (value == NULL) {
PyErr_SetString(PyExc_TypeError, "__traceback__ may not be deleted");
Expand All @@ -409,6 +406,28 @@ BaseException___traceback___set_impl(PyBaseExceptionObject *self,
return 0;
}

/*[clinic input]
@critical_section
@setter
BaseException.__traceback__
[clinic start generated code]*/

static int
BaseException___traceback___set_impl(PyBaseExceptionObject *self,
PyObject *value)
/*[clinic end generated code: output=a82c86d9f29f48f0 input=12676035676badad]*/
{
int res = set_traceback_field(self, value);
if (res < 0) {
return res;
}
/* A real tb assigned from Python is user-defined and survives a
subsequent raise; assigning None clears the mark so the next raise
falls back to the normal heuristic. */
self->user_defined_traceback = (value != Py_None);
return 0;
}

/*[clinic input]
@critical_section
@getter
Expand Down Expand Up @@ -521,9 +540,12 @@ PyException_GetTraceback(PyObject *self)
int
PyException_SetTraceback(PyObject *self, PyObject *tb)
{
/* C-API tb assignment is not user-defined: this is the same path
the internal auto-stitcher (PyTraceBack_Here) takes, and stitched
frames must not be marked as user-defined. */
int res;
Py_BEGIN_CRITICAL_SECTION(self);
res = BaseException___traceback___set_impl(PyBaseExceptionObject_CAST(self), tb);
res = set_traceback_field(PyBaseExceptionObject_CAST(self), tb);
Py_END_CRITICAL_SECTION();
return res;
}
Expand Down
29 changes: 29 additions & 0 deletions Python/ceval.h
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,35 @@ do_raise(PyThreadState *tstate, PyObject *exc, PyObject *cause)
assert(type != NULL);
assert(value != NULL);

/* gh-116862: if `value` carries a traceback that was stitched in a
call chain unrelated to where `raise` is now happening, drop it so
the rendered trace reflects the actual raise site rather than
blending two unrelated stacks. A user-defined tb is always
preserved (see user_defined_traceback). */
{
PyBaseExceptionObject *e = (PyBaseExceptionObject *)value;
_PyErr_StackItem *exc_info = _PyErr_GetTopmostException(tstate);
PyObject *active = exc_info ? exc_info->exc_value : NULL;
if (!e->user_defined_traceback && active != value && e->traceback != NULL) {
/* The existing tb is "foreign" unless its head frame appears
somewhere on the current call chain, in which case the tb
and the current frames compose into a coherent stack. */
PyTracebackObject *tb_head = (PyTracebackObject *)e->traceback;
PyFrameObject *tb_frame = tb_head->tb_frame;
int in_chain = 0;
for (_PyInterpreterFrame *f = tstate->current_frame;
f != NULL; f = f->previous) {
if (f->frame_obj == tb_frame) {
in_chain = 1;
break;
}
}
if (!in_chain) {
Py_CLEAR(e->traceback);
}
}
}

if (cause) {
PyObject *fixed_cause;
if (PyExceptionClass_Check(cause)) {
Expand Down
Loading