Skip to content

Commit 297d764

Browse files
pdepetrometa-codesync[bot]
authored andcommitted
Add Stacktrace.reconstruct_exception()
Summary: Adds a public `Stacktrace.reconstruct_exception()` method to the tintype API that reconstructs a real `BaseException` (with `__traceback__` and a wired `__cause__`/`__context__` chain) from a snapshot stacktrace. The name uses the verb `reconstruct` to make clear it synthesizes a new exception from the snapshot rather than returning the originally-thrown object. This consolidates exception-reconstruction logic that consumers (e.g. the python_postmortem agent) previously hand-rolled. - New pure-Python helper `tintype/_exception.py::reconstruct_exception`, with a `_MAX_CHAIN_DEPTH` cap + `id()` cycle guard (mirroring `tintype/dap/exceptions.py`) so a cyclic/deep chain can't spin forever. - New C++ pybind `.def("reconstruct_exception", ...)` in `_snapshot.cpp` that delegates to the helper, mirroring how `get_traceback` delegates to `tintype/_traceback.py`. - `_snapshot.pyi` stub + `PYTHON_API.md` row. - Caveat (documented): the reconstructed class is always `Exception` because `exception_object` is a serialized object — the original class isn't recoverable; the message is preserved. Reviewed By: aperez Differential Revision: D115205582 fbshipit-source-id: c1dfbbead2adb9418ef097b6fc3787259565dba7
1 parent bb41b08 commit 297d764

5 files changed

Lines changed: 245 additions & 1 deletion

File tree

PYTHON_API.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ Represents a stacktrace within a snapshot. For single-thread snapshots (`take_sn
412412
| `get_cause()` | `Stacktrace \| None` | Get the `__cause__` stacktrace, or `None` if there is no cause. Caches the result. |
413413
| `get_context()` | `Stacktrace \| None` | Get the `__context__` stacktrace, or `None` if there is no context. Caches the result. |
414414
| `get_traceback()` | `TracebackType \| None` | Generate a synthetic Python traceback object from this stacktrace's frames. Useful for feeding into debuggers. |
415+
| `reconstruct_exception()` | `BaseException \| None` | Reconstruct a `BaseException` from this stacktrace with `__traceback__` and a wired `__cause__`/`__context__` chain, or `None` if it has no exception. At most 10 chained exceptions are wired. Nodes with a wired `__cause__` have `__suppress_context__` set. The reconstructed class is always `Exception` (the original class is not recoverable from a snapshot — read it from the message or `exception_object`). |
415416

416417
#### Internal Attributes
417418

_exception.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# This source code is licensed under the MIT license found in the
3+
# LICENSE file in the root directory of this source tree.
4+
5+
# pyre-strict
6+
7+
"""Reconstruct a ``BaseException`` from a snapshot ``Stacktrace``.
8+
9+
The returned exception carries the reconstructed ``__traceback__`` and a
10+
wired ``__cause__`` / ``__context__`` chain, so it can be fed to a debugger
11+
or the ``traceback`` module.
12+
13+
Caveat: the original exception *classes* are not recoverable from a
14+
snapshot — ``Stacktrace.exception_object`` is a serialized object — so every
15+
node in the reconstructed chain is a plain ``Exception`` carrying the
16+
original message. Read the true class name from the message (e.g.
17+
``KeyError: 'foo'``) or from ``exception_object`` if you need it.
18+
"""
19+
20+
from types import TracebackType
21+
from typing import Any
22+
23+
# Mirror CPython's cap in ``traceback.py`` (and tintype's DAP renderer) so a
24+
# cyclic or pathologically deep ``__cause__`` / ``__context__`` chain cannot
25+
# spin forever or blow the stack.
26+
_MAX_CHAIN_DEPTH = 10
27+
28+
29+
def _synthesize(stacktrace: Any) -> Exception:
30+
"""Build a single synthetic ``Exception`` (message + traceback, no chain)
31+
from one stacktrace."""
32+
orig = stacktrace.exception_object
33+
exc = Exception(str(orig) if orig is not None else "(unknown exception)")
34+
tb: TracebackType | None = stacktrace.get_traceback()
35+
if tb is not None:
36+
exc.__traceback__ = tb
37+
return exc
38+
39+
40+
def reconstruct_exception(stacktrace: Any) -> BaseException | None:
41+
"""Reconstruct a ``BaseException`` with its traceback and chained causes.
42+
43+
Returns ``None`` when the stacktrace has no exception (e.g. a thread
44+
snapshot) or no reconstructable traceback. At most ``_MAX_CHAIN_DEPTH``
45+
(10) chained exceptions are wired; a longer chain is silently truncated at
46+
the tail, matching CPython's own cap in ``traceback.py``. See the module
47+
docstring for the "class is always ``Exception``" caveat.
48+
"""
49+
50+
if stacktrace.exception_object is None or not stacktrace.frames:
51+
return None
52+
53+
head = _synthesize(stacktrace)
54+
55+
current_st = stacktrace
56+
current_exc: BaseException = head
57+
seen: set[int] = {id(stacktrace)}
58+
depth = 0
59+
while depth < _MAX_CHAIN_DEPTH:
60+
# Prefer ``__cause__`` (``raise X from Y``) over ``__context__``
61+
# (implicit during-handling chain), matching CPython's precedence.
62+
nxt = current_st.get_cause()
63+
wire_cause = nxt is not None
64+
if nxt is None:
65+
nxt = current_st.get_context()
66+
if nxt is None or id(nxt) in seen:
67+
break
68+
seen.add(id(nxt))
69+
nxt_exc = _synthesize(nxt)
70+
if wire_cause:
71+
current_exc.__cause__ = nxt_exc
72+
current_exc.__suppress_context__ = True
73+
else:
74+
current_exc.__context__ = nxt_exc
75+
current_st = nxt
76+
current_exc = nxt_exc
77+
depth += 1
78+
79+
return head

_snapshot.cpp

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,23 @@ PYBIND11_MODULE(_snapshot, m) {
812812

813813
return result;
814814
},
815-
"Generate a Python traceback from this stacktrace's frames.");
815+
"Generate a Python traceback from this stacktrace's frames.")
816+
.def(
817+
"reconstruct_exception",
818+
[](const py::object& self) -> py::object {
819+
// Delegate to the pure-Python helper (mirrors get_traceback's
820+
// delegation to tintype._traceback). The helper walks
821+
// get_cause()/get_context()/get_traceback() and wires a real
822+
// BaseException chain.
823+
py::module_ exception_utils =
824+
py::module_::import("tintype._exception");
825+
py::object fn = exception_utils.attr("reconstruct_exception");
826+
return fn(self);
827+
},
828+
"Reconstruct a BaseException (with wired __cause__/__context__ and "
829+
"__traceback__) from this stacktrace, or None if it has no "
830+
"exception. The reconstructed class is always Exception; read the "
831+
"original class from the message or exception_object.");
816832

817833
py::class_<snapshot::Snapshot>(m, "Snapshot", py::dynamic_attr())
818834
.def_readonly("timestamp", &snapshot::Snapshot::timestamp)

_snapshot.pyi

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,16 @@ class Stacktrace:
320320
"""Generate a Python traceback from this stacktrace's frames."""
321321
...
322322

323+
def reconstruct_exception(self) -> BaseException | None:
324+
"""Reconstruct a BaseException from this stacktrace.
325+
326+
The reconstructed exception carries __traceback__ and a wired
327+
__cause__/__context__ chain. Returns None if the stacktrace has no
328+
exception. The reconstructed class is always Exception (the original
329+
class is not recoverable from a snapshot).
330+
"""
331+
...
332+
323333
class Snapshot:
324334
"""Represents a snapshot record."""
325335

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# This source code is licensed under the MIT license found in the
3+
# LICENSE file in the root directory of this source tree.
4+
5+
# pyre-strict
6+
7+
import os
8+
import tempfile
9+
import unittest
10+
11+
import tintype
12+
from tintype._exception import _MAX_CHAIN_DEPTH
13+
14+
15+
class ReconstructExceptionTest(unittest.TestCase):
16+
def _capture(
17+
self, exc: BaseException
18+
) -> tuple[tintype.SnapshotReader, tintype.Snapshot]:
19+
tmpdir = tempfile.mkdtemp()
20+
path = os.path.join(tmpdir, "test.pytb")
21+
tintype.initialize()
22+
tintype.take_snapshot(exc)
23+
tintype.finalize(path)
24+
reader = tintype.SnapshotReader(path)
25+
snap = reader.get_latest_snapshot()
26+
self.assertIsNotNone(snap)
27+
assert snap is not None
28+
return reader, snap
29+
30+
def _find(self, snap: tintype.Snapshot, type_name: str) -> tintype.Stacktrace:
31+
for st in snap.stacktraces.values():
32+
if st.exception_object is not None and type_name in repr(
33+
st.exception_object
34+
):
35+
return st
36+
self.fail(f"No stacktrace found for exception type {type_name}")
37+
38+
def test_reconstructs_message_and_traceback(self) -> None:
39+
try:
40+
raise KeyError("missing")
41+
except KeyError as e:
42+
captured = e
43+
_reader, snap = self._capture(captured)
44+
st = self._find(snap, "KeyError")
45+
46+
exc = st.reconstruct_exception()
47+
self.assertIsNotNone(exc)
48+
assert exc is not None
49+
# Class is always Exception (original class is not recoverable), but
50+
# the message is preserved and the traceback is wired.
51+
self.assertIsInstance(exc, Exception)
52+
self.assertIn("missing", str(exc))
53+
self.assertIsNotNone(exc.__traceback__)
54+
55+
def test_wires_cause_chain(self) -> None:
56+
try:
57+
try:
58+
raise KeyError("inner")
59+
except KeyError as inner:
60+
raise RuntimeError("outer") from inner
61+
except RuntimeError as e:
62+
captured = e
63+
_reader, snap = self._capture(captured)
64+
st = self._find(snap, "RuntimeError")
65+
66+
exc = st.reconstruct_exception()
67+
assert exc is not None
68+
self.assertIn("outer", str(exc))
69+
cause = exc.__cause__
70+
self.assertIsNotNone(cause)
71+
assert cause is not None
72+
self.assertIn("inner", str(cause))
73+
self.assertTrue(exc.__suppress_context__)
74+
75+
def test_wires_context_chain(self) -> None:
76+
try:
77+
try:
78+
raise ValueError("first")
79+
except ValueError:
80+
raise TypeError("second")
81+
except TypeError as e:
82+
captured = e
83+
_reader, snap = self._capture(captured)
84+
st = self._find(snap, "TypeError")
85+
86+
exc = st.reconstruct_exception()
87+
assert exc is not None
88+
self.assertIn("second", str(exc))
89+
context = exc.__context__
90+
self.assertIsNotNone(context)
91+
assert context is not None
92+
self.assertIn("first", str(context))
93+
94+
def test_chain_depth_is_capped(self) -> None:
95+
def raise_chain(level: int) -> None:
96+
if level == 0:
97+
raise RuntimeError("level-0")
98+
try:
99+
raise_chain(level - 1)
100+
except RuntimeError as inner:
101+
raise RuntimeError(f"level-{level}") from inner
102+
103+
# Unlike the tests above, the raise is behind a call, so the type
104+
# checker cannot prove the ``except`` branch runs — seed ``captured``.
105+
captured: RuntimeError | None = None
106+
try:
107+
raise_chain(_MAX_CHAIN_DEPTH + 3)
108+
except RuntimeError as e:
109+
captured = e
110+
assert captured is not None
111+
_reader, snap = self._capture(captured)
112+
st = snap.stacktraces[1]
113+
114+
exc = st.reconstruct_exception()
115+
assert exc is not None
116+
depth = 0
117+
current = exc
118+
while current.__cause__ is not None or current.__context__ is not None:
119+
current = current.__cause__ or current.__context__
120+
assert current is not None
121+
depth += 1
122+
self.assertEqual(depth, _MAX_CHAIN_DEPTH)
123+
124+
def test_none_for_thread_snapshot(self) -> None:
125+
tmpdir = tempfile.mkdtemp()
126+
path = os.path.join(tmpdir, "thread.pytb")
127+
tintype.initialize()
128+
tintype.take_snapshot()
129+
tintype.finalize(path)
130+
snap = tintype.SnapshotReader(path).get_latest_snapshot()
131+
assert snap is not None
132+
# A plain (non-exception) snapshot has no exception_object, so
133+
# reconstruct_exception is None.
134+
for st in snap.stacktraces.values():
135+
if st.exception_object is None:
136+
self.assertIsNone(st.reconstruct_exception())
137+
return
138+
self.skipTest("no non-exception stacktrace present")

0 commit comments

Comments
 (0)