Skip to content

Commit c691315

Browse files
pdepetrometa-codesync[bot]
authored andcommitted
Surface snapshot description on the DAP stopped event
Summary: VS Code renders ``body.description`` from the most recent ``stopped`` event next to the session row in CALL STACK, so we use it to show the snapshot's display label (index / timestamp / exception type). Two wrinkles discovered the hard way: 1. An absent ``description`` *clears* the field rather than preserving the previous value, so we set it on every ``stopped`` emission. 2. On initial launch there's a race: whatever description the adapter provides on the first ``stopped`` event gets overwritten by VS Code's own CALL STACK bootstrap. Sending a throwaway ``"Snapshot"`` event first, immediately followed by the real description event, lets the second one stick — that's what the user actually sees. Plumbing: * New ``_snapshot_display_name(snapshot)`` helper formats ``"Snapshot {N}/{M} — HH:MM:SS.fff UTC (ExceptionType)"``. Timestamps are formatted via ``datetime.fromtimestamp(ts, tz=datetime.timezone.utc)`` so the label is the same for teammates opening the same ``.pytb`` from different wall-clock timezones. Timestamp failures narrow to ``(ValueError, OSError, OverflowError, TypeError)`` and log at ``WARNING`` so bug reports can surface the real cause; the description just loses the timestamp decoration. * ``_send_stopped_event`` gains a ``bootstrap`` kwarg; the only caller that sets it is ``handle_launch``, which now emits a bootstrap event followed by the real one. Restart / jump / continue / step handlers keep emitting a single real ``stopped`` event. Reviewed By: aperez Differential Revision: D103561267 fbshipit-source-id: eef79284274b0ef9cff2db0644c831f3f855b17d
1 parent 55c1893 commit c691315

2 files changed

Lines changed: 244 additions & 15 deletions

File tree

dap/session.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import datetime
1718
import logging
1819
import os
1920
import re
@@ -264,6 +265,13 @@ def handle_launch(self, arguments: dict[str, Any]) -> dict[str, Any] | None:
264265
self._load_snapshot(start_index)
265266

266267
self._emit_thread_events_started()
268+
# Bootstrap-then-real emission pattern: VS Code overwrites the
269+
# CALL STACK description on the very first ``stopped`` event of a
270+
# launch, so we send a throwaway ``"Snapshot"`` first and
271+
# immediately follow it with the real description. The second
272+
# event is what the user actually sees. See
273+
# :meth:`_send_stopped_event` for the full rationale.
274+
self._send_stopped_event(bootstrap=True)
267275
self._send_stopped_event()
268276
return None
269277

@@ -772,7 +780,22 @@ def _reconcile_thread_events(self, old_threads: set[int]) -> None:
772780
"thread", {"reason": "started", "threadId": tid}
773781
)
774782

775-
def _send_stopped_event(self) -> None:
783+
def _send_stopped_event(self, *, bootstrap: bool = False) -> None:
784+
"""Emit a ``stopped`` event for the current snapshot.
785+
786+
VS Code displays ``body.description`` in the CALL STACK panel
787+
(next to the session row) and keeps whatever the most recent
788+
``stopped`` event provided — an absent ``description`` *clears*
789+
the field rather than preserving the prior value. That's why we
790+
set it on every emission.
791+
792+
The ``bootstrap`` flag is an initial-launch quirk: the very first
793+
``stopped`` event of the session is subject to a race where VS
794+
Code overwrites whichever description the adapter provided.
795+
Sending a bootstrap event (with a generic ``"Snapshot"`` label)
796+
immediately followed by the real one lets the "real" description
797+
settle in as the one the user sees.
798+
"""
776799
snapshot = self._current_snapshot
777800
if snapshot is None:
778801
return
@@ -792,12 +815,15 @@ def _send_stopped_event(self) -> None:
792815
has_exception = any(
793816
st.exception_object is not None for st in snapshot.stacktraces.values()
794817
)
795-
body = {
818+
description = "Snapshot" if bootstrap else self._snapshot_display_name(snapshot)
819+
body: dict[str, Any] = {
796820
"reason": "exception" if has_exception else "pause",
797821
"threadId": thread_id,
798822
"preserveFocusHint": False,
799823
"allThreadsStopped": True,
800824
}
825+
if description:
826+
body["description"] = description
801827
self._dispatcher.send_event("stopped", body)
802828

803829
# ---------------------------------------------------------------
@@ -904,6 +930,63 @@ def _require_snapshot(self) -> Snapshot:
904930
raise DispatchError("no current snapshot; did launch succeed?")
905931
return self._current_snapshot
906932

933+
# ---------------------------------------------------------------
934+
# Display-name helpers
935+
# ---------------------------------------------------------------
936+
937+
def _snapshot_display_name(self, snapshot: Snapshot) -> str | None:
938+
"""One-line description of the current snapshot.
939+
940+
Surfaced via the ``stopped`` event's ``description`` field, which
941+
VS Code renders next to the session row in CALL STACK. Keep it
942+
short — the field is a single-line label and VS Code truncates.
943+
944+
Default format::
945+
946+
Snapshot {N}/{M} — {HH:MM:SS.fff} ({ExceptionType})
947+
948+
Returns ``None`` to suppress the description entirely.
949+
"""
950+
parts: list[str] = []
951+
try:
952+
total = self._require_reader().snapshot_count()
953+
except DispatchError:
954+
total = 1
955+
if total > 1:
956+
parts.append(f"Snapshot {self._snapshot_index + 1}/{total}")
957+
else:
958+
parts.append("Snapshot")
959+
960+
try:
961+
# ``snapshot.timestamp`` is microseconds since the Unix epoch.
962+
# Format using UTC so cross-timezone teams opening the same
963+
# ``.pytb`` see identical labels in CALL STACK (the label
964+
# otherwise depends on the viewer's local wall clock, which
965+
# is confusing when correlating snapshots with server-side
966+
# timestamps).
967+
ts_seconds = int(snapshot.timestamp) / 1_000_000.0
968+
dt = datetime.datetime.fromtimestamp(ts_seconds, tz=datetime.timezone.utc)
969+
parts.append("— " + dt.strftime("%H:%M:%S.%f")[:-3] + " UTC")
970+
except (ValueError, OSError, OverflowError, TypeError) as e:
971+
# Concrete failure modes of ``int()`` (TypeError / ValueError),
972+
# ``fromtimestamp()`` (OverflowError / OSError on Windows for
973+
# out-of-range timestamps), and attribute access on a
974+
# malformed Snapshot (AttributeError is intentionally NOT
975+
# caught — it signals a real bug, not a bad timestamp). Log
976+
# at warning so bug reports can surface the real cause; the
977+
# description field just loses the timestamp decoration.
978+
logger.warning(
979+
"failed to format timestamp for snapshot display name: %s", e
980+
)
981+
982+
for st in snapshot.stacktraces.values():
983+
exc = st.exception_object
984+
if exc is not None:
985+
parts.append(f"({type(exc).__name__})")
986+
break
987+
988+
return " ".join(parts) if parts else None
989+
907990

908991
# ---------------------------------------------------------------
909992
# Module-private formatting helpers

dap/tests/test_session.py

Lines changed: 159 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -166,21 +166,29 @@ def test_launch_emits_initial_events(self) -> None:
166166
self._launch_with_snapshots([snap])
167167

168168
messages = _drain_messages(self.stream)
169-
# We expect: launch response, initialized, thread(s), stopped.
170-
# We deliberately emit NO ``process`` event and NO ``description``
171-
# on the stopped event — both surfaces (CALL STACK session row
172-
# and thread row) ended up fighting for the same cell in ways
173-
# VS Code wouldn't render consistently. The Tintype Snapshots
174-
# sidebar group description is the single authoritative cursor
175-
# surface.
169+
# We expect: launch response, initialized, thread(s), two
170+
# stopped events (bootstrap + real). The bootstrap carries a
171+
# generic ``"Snapshot"`` description so VS Code has something to
172+
# overwrite on its initial-launch race; the real event that
173+
# follows sets the actual display name the user sees in the
174+
# CALL STACK panel. We still emit NO ``process`` event — that
175+
# surface is owned by the Tintype Snapshots sidebar.
176176
event_names = [m.get("event") for m in messages if m["type"] == "event"]
177177
self.assertIn("initialized", event_names)
178178
self.assertNotIn("process", event_names)
179179
self.assertIn("thread", event_names)
180-
self.assertIn("stopped", event_names)
180+
self.assertEqual(event_names.count("stopped"), 2)
181181

182-
stopped_event = next(m for m in messages if m.get("event") == "stopped")
183-
self.assertNotIn("description", stopped_event["body"])
182+
stopped_events = [m for m in messages if m.get("event") == "stopped"]
183+
# First is bootstrap — generic "Snapshot" label.
184+
self.assertEqual(stopped_events[0]["body"]["description"], "Snapshot")
185+
# Second is the real one — must include a richer description.
186+
self.assertIn("description", stopped_events[1]["body"])
187+
self.assertIn("Snapshot", stopped_events[1]["body"]["description"])
188+
self.assertNotEqual(
189+
stopped_events[0]["body"]["description"],
190+
stopped_events[1]["body"]["description"],
191+
)
184192

185193
def test_launch_fails_on_empty_snapshot_file(self) -> None:
186194
reader = MagicMock()
@@ -669,6 +677,30 @@ def test_tintype_jump_rejects_non_integer_index(self) -> None:
669677
self.assertFalse(resp["success"])
670678
self.assertIn("int", resp.get("message", ""))
671679

680+
def test_tintype_jump_rejects_bool_index(self) -> None:
681+
"""``bool`` is a subclass of ``int`` in Python, so a bare
682+
``isinstance(raw_index, int)`` check would happily accept
683+
``True`` / ``False`` as indices 1 / 0. The handler explicitly
684+
rejects bools first; cover both values so a refactor that
685+
drops the ``isinstance(raw_index, bool)`` guard doesn't silently
686+
re-introduce the issue."""
687+
self._first_two_snapshot_launch()
688+
for seq_num, bad_index in ((410, True), (411, False)):
689+
with self.subTest(index=bad_index):
690+
self.stream.seek(0)
691+
self.stream.truncate()
692+
_send_request(
693+
self.session,
694+
self.dispatcher,
695+
seq=seq_num,
696+
command="tintypeJumpToSnapshot",
697+
arguments={"index": bad_index},
698+
)
699+
resp = _drain_messages(self.stream)[0]
700+
self.assertFalse(resp["success"])
701+
self.assertIn("int", resp.get("message", ""))
702+
self.assertIn("bool", resp.get("message", ""))
703+
672704
def test_attach_routes_through_launch_handler(self) -> None:
673705
"""An ``attach`` request should run the full launch flow.
674706
@@ -763,15 +795,18 @@ def test_restart_jumps_cursor_to_first_snapshot_and_re_emits_stopped(
763795
# emit one.
764796
self.assertNotIn("process", event_names)
765797

766-
# Stopped event must NOT carry a ``description`` field (would
767-
# leak into the CALL STACK session row and get stuck there).
798+
# Stopped event now carries the snapshot ``description``. Unlike
799+
# launch, restart only emits a single stopped event (no bootstrap)
800+
# — the VS Code race is an initial-launch quirk, so subsequent
801+
# stops take effect on the first try.
768802
stopped_events = [
769803
m
770804
for m in messages
771805
if m.get("type") == "event" and m.get("event") == "stopped"
772806
]
773807
self.assertEqual(len(stopped_events), 1)
774-
self.assertNotIn("description", stopped_events[0]["body"])
808+
self.assertIn("description", stopped_events[0]["body"])
809+
self.assertIn("Snapshot", stopped_events[0]["body"]["description"])
775810

776811
# The cursor must now be back on snapshot #0.
777812
self.assertEqual(self.session._snapshot_index, 0)
@@ -1374,5 +1409,116 @@ def test_set_default_exclude_frame_paths_replaces_wholesale(self) -> None:
13741409
self.assertEqual(hints, ["normal", "normal"])
13751410

13761411

1412+
class StoppedEventDescriptionTest(unittest.TestCase):
1413+
"""``stopped`` events carry a ``description`` field with the snapshot's
1414+
display name. On launch we send a bootstrap ``"Snapshot"`` event first so
1415+
VS Code has something to overwrite on its initial-launch race, followed
1416+
by the real description event the user actually sees."""
1417+
1418+
def setUp(self) -> None:
1419+
self.stream = RecordingStream()
1420+
self.dispatcher = Dispatcher(self.stream)
1421+
self.session = SnapshotDebugSession(self.dispatcher)
1422+
self.session.wire()
1423+
1424+
def _launch_with_snapshots(self, snapshots: list[Any]) -> MagicMock:
1425+
reader = MagicMock()
1426+
reader.snapshot_count.return_value = len(snapshots)
1427+
reader.get_all_source_files.return_value = []
1428+
reader.get_all_snapshots.return_value = snapshots
1429+
reader.get_snapshot_at_index.side_effect = (
1430+
lambda i: snapshots[i] if 0 <= i < len(snapshots) else None
1431+
)
1432+
with (
1433+
patch("tintype.dap.session.SnapshotReader", return_value=reader),
1434+
patch("tintype.dap.session.os.path.isfile", return_value=True),
1435+
):
1436+
_send_request(
1437+
self.session,
1438+
self.dispatcher,
1439+
seq=1,
1440+
command="launch",
1441+
arguments={"pytbPath": "/fake/snap.pytb"},
1442+
)
1443+
return reader
1444+
1445+
def test_launch_description_includes_timestamp_and_index(self) -> None:
1446+
st = _make_stacktrace(
1447+
100, [_make_frame("/a/b.py", "foo", 1, {})], thread_name="MainThread"
1448+
)
1449+
snap = _make_snapshot([st], ts=1_700_000_000_000_000)
1450+
self._launch_with_snapshots([snap])
1451+
1452+
stopped_events = [
1453+
m for m in _drain_messages(self.stream) if m.get("event") == "stopped"
1454+
]
1455+
real = stopped_events[-1]["body"]
1456+
self.assertIn("description", real)
1457+
self.assertIn("Snapshot", real["description"])
1458+
# HH:MM:SS.fff pattern must show up in the real-stop description.
1459+
self.assertRegex(real["description"], r"\d{2}:\d{2}:\d{2}\.\d{3}")
1460+
1461+
def test_description_mentions_exception_type(self) -> None:
1462+
st = _make_stacktrace(
1463+
100,
1464+
[_make_frame("/a/b.py", "foo", 1, {})],
1465+
thread_name="MainThread",
1466+
exception=KeyError("missing"),
1467+
)
1468+
snap = _make_snapshot([st])
1469+
self._launch_with_snapshots([snap])
1470+
1471+
real = [m for m in _drain_messages(self.stream) if m.get("event") == "stopped"][
1472+
-1
1473+
]["body"]
1474+
self.assertIn("KeyError", real["description"])
1475+
1476+
def test_description_updates_across_snapshot_jumps(self) -> None:
1477+
snap1 = _make_snapshot(
1478+
[
1479+
_make_stacktrace(
1480+
100,
1481+
[_make_frame("/a/b.py", "foo", 1, {})],
1482+
thread_name="MainThread",
1483+
)
1484+
],
1485+
ts=1_700_000_000_000_000,
1486+
)
1487+
snap2 = _make_snapshot(
1488+
[
1489+
_make_stacktrace(
1490+
100,
1491+
[_make_frame("/a/b.py", "foo", 2, {})],
1492+
thread_name="MainThread",
1493+
)
1494+
],
1495+
ts=1_700_000_005_000_000,
1496+
)
1497+
self._launch_with_snapshots([snap1, snap2])
1498+
1499+
launch_desc = [
1500+
m for m in _drain_messages(self.stream) if m.get("event") == "stopped"
1501+
][-1]["body"]["description"]
1502+
self.assertIn("1/2", launch_desc)
1503+
1504+
self.stream.seek(0)
1505+
self.stream.truncate()
1506+
_send_request(
1507+
self.session,
1508+
self.dispatcher,
1509+
seq=50,
1510+
command="tintypeJumpToSnapshot",
1511+
arguments={"index": 1},
1512+
)
1513+
jump_events = [
1514+
m for m in _drain_messages(self.stream) if m.get("event") == "stopped"
1515+
]
1516+
# Jump emits exactly one stopped event (no bootstrap).
1517+
self.assertEqual(len(jump_events), 1)
1518+
jump_desc = jump_events[0]["body"]["description"]
1519+
self.assertIn("2/2", jump_desc)
1520+
self.assertNotEqual(launch_desc, jump_desc)
1521+
1522+
13771523
if __name__ == "__main__":
13781524
unittest.main()

0 commit comments

Comments
 (0)