Skip to content

Commit e0a9c76

Browse files
pdepetrometa-codesync[bot]
authored andcommitted
Render exception snapshots with flattened chain + DAP overlay
Summary: Special-case exception snapshots so VS Code's CALL STACK panel stops labelling them as "Thread N" and instead presents the full cause/context chain as a single inline narrative — the same shape users already read from ``traceback.print_exception``. Four DAP-level improvements, all pydevd-parity: 1. **Flattened exception-chain "thread".** When a snapshot carries any exception stacktraces, ``handle_threads`` now replaces them with a single synthetic ``Exception: <ExcType>(<msg>)`` row (id ``-1``). Requests for that id in ``handle_stack_trace`` walk the ``__cause__`` / ``__context__`` chain and return a merged frame list that matches CPython's ``traceback.print_exception`` order — innermost cause first, outermost (most-recently-raised) last, with separator label rows between exception groups: ▸ origin() ← innermost cause frame (top) ▸ foo() ▸ ⬆ CAUSED BY ⬆ ← separator (non-navigable) ▸ raiser() ← outer exception frames ▸ main() ← original entry (bottom) Reading the panel top-down flows: "original cause happened → ⬆ caused by ⬆ → outer exception was then raised below". The up-arrows on the separator correctly point at the cause that was just rendered above them. Within each exception group, frames are innermost-first per DAP convention (``frame[0]`` = active frame). Separator frames use the DAP-standard ``presentationHint: "label"`` so VS Code renders them as dim non-clickable headers. Implicit ``__context__`` chains use ``⬆ DURING HANDLING OF ⬆``. Chain walk is cycle-guarded and bounded at ``_MAX_EXCEPTION_CHAIN_DEPTH = 10``, matching the cap in :mod:`tintype.dap.exceptions`. Real (non-exception) threads remain visible alongside the synthetic row. 2. **Innermost-cause-anchored exception surfaces.** Under the flattened chain the top stack frame is the innermost cause's innermost frame (where the original exception was raised). Two separate DAP surfaces carry exception-description text; both should describe the innermost exception so what the user sees matches the frame they're looking at. We source both from the innermost for the virtual chain thread: * ``ExceptionInfoResponse`` (``exceptionId`` / ``description`` / ``details.message``) drives the red "Exception has occurred" decoration VS Code paints on the top frame's source line, plus the hover tooltip and the Exception Info panel. ``handle_exception_info(threadId=-1)`` routes to the innermost; ``innerException`` is naturally empty there (innermost has no further ``__cause__`` / ``__context__``). Sourcing from the outer effect here would paint the cause's raise site with the wrong exception's message — actively misleading. The outer exception's info still reaches the user via the flattened CALL STACK rows, which carry both exceptions' frames. * ``stopped.text`` is the secondary annotation VS Code shows beside the stopped thread's row in the CALL STACK panel (e.g. ``Paused on exception: TypeError: …``). Sourcing from the innermost keeps the annotation consistent with the red overlay — a user who sees "TypeError" on the offending line shouldn't see "ValueError" on the CALL STACK row that selected it. Output shape is ``<ExcType>: <message>`` (matching CPython's ``traceback`` format), single-line-collapsed and capped at 512 chars so the annotation stays readable. Non-chain threads continue to source from the thread's own exception. Non-exception snapshots leave both surfaces unset. See ``_pick_exception_chain_innermost`` and ``_format_stopped_exception_text`` in ``tintype/dap/session.py``. 3. **``ExceptionDetails.innerException`` chain** — for non-chain threads (and any future caller that passes a chain root stacktrace), ``build_exception_info`` now emits a proper nested ``innerException`` array so clients that render the tree (VS Code included) can expand each chained exception independently. ``stackTrace`` (the pre-rendered text) remains for clients that ignore ``innerException``. See ``_build_details`` in ``tintype/dap/exceptions.py``. 4. **``StackFrame.presentationHint: "subtle"``** — per the DAP spec, frame hints are ``normal | label | subtle``; pydevd uses ``"subtle"`` for framework-noise frames. ``"deemphasize"`` is only a valid value for ``Source.presentationHint``. Aligning with the spec and pydevd. Also updates ``_pick_stop_thread`` to prefer the virtual chain thread for exception snapshots (instead of a specific exception stacktrace id) so the initial stop focuses the synthetic row. Reviewed By: aperez Differential Revision: D103567305 fbshipit-source-id: 2c3cb0b929f0d318e4a49821fe015560a3ca89bc
1 parent c691315 commit e0a9c76

3 files changed

Lines changed: 961 additions & 36 deletions

File tree

dap/exceptions.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,33 @@ def build_exception_info(
4646
is captured on a potentially different machine — a viewer with a
4747
same-named file at that path would otherwise surface its current
4848
contents, which is misleading at best and an info-leak at worst.
49+
50+
The response's ``details`` object carries two representations of the
51+
cause/context chain:
52+
53+
* ``details.stackTrace`` — a pre-rendered CPython-style multi-line
54+
string for clients that show the raw traceback as-is.
55+
* ``details.innerException`` — a DAP-standard nested array of
56+
:class:`ExceptionDetails` objects, one per chained exception,
57+
produced via :func:`_build_details`. This lets DAP clients (e.g.
58+
VS Code's Exception panel) render the chain as an expandable tree
59+
rather than a single blob of text. The outermost (first raised)
60+
exception is the top-level ``details``; each ``innerException``
61+
entry is what caused (or was being handled when) the level above
62+
it was raised. Chain traversal honours ``__cause__`` first, then
63+
``__context__``, mirroring CPython's ``traceback.print_exception``.
4964
"""
5065
if stacktrace.exception_object is None:
5166
return None
5267

53-
type_name, message = _describe(stacktrace.exception_object)
54-
details = {
55-
"message": message,
56-
"typeName": type_name,
57-
"fullTypeName": type_name,
58-
"stackTrace": _format_chain(stacktrace, sources),
59-
}
68+
details = _build_details(stacktrace, depth=0, seen={id(stacktrace)})
69+
# ``stackTrace`` (the pre-rendered string) sits at the top level for
70+
# clients that ignore ``innerException``. Source-line decoration
71+
# lives there — ``_build_details`` itself is type/message-only.
72+
details["stackTrace"] = _format_chain(stacktrace, sources)
6073

74+
type_name = details.get("typeName", "")
75+
message = details.get("message", "")
6176
return {
6277
"exceptionId": type_name,
6378
"description": message,
@@ -66,6 +81,55 @@ def build_exception_info(
6681
}
6782

6883

84+
def _build_details(
85+
stacktrace: Stacktrace,
86+
*,
87+
depth: int,
88+
seen: set[int],
89+
) -> dict[str, Any]:
90+
"""Build a DAP ``ExceptionDetails`` node for ``stacktrace``, with
91+
any cause/context chain nested under ``innerException``.
92+
93+
The chain walk mirrors :func:`_format_chain`'s guards:
94+
95+
* Cycles are detected via the ``seen`` set of Python ``id()``s
96+
passed through the recursion.
97+
* Depth is capped at :data:`_MAX_CHAIN_DEPTH`.
98+
99+
Inner nodes do **not** duplicate the pre-rendered ``stackTrace``
100+
string — that's only set on the top-level details in
101+
:func:`build_exception_info` to avoid exponential blowup of
102+
redundant text on deeply chained exceptions. Because source-line
103+
lookup only matters for the pre-rendered ``stackTrace``, this
104+
function does not need a :class:`SourceRegistry`.
105+
"""
106+
exc = stacktrace.exception_object
107+
type_name, message = _describe(exc) if exc is not None else ("", "")
108+
node: dict[str, Any] = {
109+
"message": message,
110+
"typeName": type_name,
111+
"fullTypeName": type_name,
112+
}
113+
if depth >= _MAX_CHAIN_DEPTH:
114+
return node
115+
116+
# Prefer ``__cause__`` (``raise X from Y``) over ``__context__``
117+
# (implicit during-handling chain), matching CPython's own
118+
# precedence in ``traceback.print_exception``.
119+
next_stacktrace: Stacktrace | None = stacktrace.get_cause()
120+
if next_stacktrace is None:
121+
next_stacktrace = stacktrace.get_context()
122+
if next_stacktrace is None:
123+
return node
124+
if id(next_stacktrace) in seen:
125+
return node
126+
seen.add(id(next_stacktrace))
127+
node["innerException"] = [
128+
_build_details(next_stacktrace, depth=depth + 1, seen=seen)
129+
]
130+
return node
131+
132+
69133
def _describe(exc: object) -> tuple[str, str]:
70134
"""Return ``(type_name, message)`` for a snapshot exception object.
71135

0 commit comments

Comments
 (0)