feat(debug): add Python debug visualizer and enhance gdb integration
This commit is contained in:
549
debuggers/gdb_debug_graph.py
Normal file
549
debuggers/gdb_debug_graph.py
Normal file
@@ -0,0 +1,549 @@
|
||||
"""Generic "pointer-memory structure" -> Debug Visualizer JSON walker.
|
||||
|
||||
Works on any struct/union reachable from an expression, purely by reading
|
||||
DWARF debug info through gdb's Python API -- no changes to the debuggee's
|
||||
source are required, and it works the same way for any language gdb (or
|
||||
rust-gdb) understands.
|
||||
|
||||
Rule: every member of a struct/union becomes one row in that node's field
|
||||
table. A member whose type is a pointer becomes an edge (and, if non-null,
|
||||
is recursed into); everything else is just stringified in place. A visited-
|
||||
address set makes this safe for cycles, so it handles lists, trees, and
|
||||
arbitrary (possibly cyclic) graphs uniformly.
|
||||
|
||||
Loaded via the "source" gdb command (see .vscode/launch.json), this defines
|
||||
a convenience function usable directly from the Debug Visualizer's Watch
|
||||
box:
|
||||
|
||||
$debug_graph(list)
|
||||
$debug_graph(list, "top", top) # extra name/pointer marker pairs
|
||||
|
||||
Besides EXPR and any explicit name/pointer pairs, every call also walks and
|
||||
labels, as additional roots:
|
||||
- every other local variable (and argument) visible at the current PC,
|
||||
from the innermost lexical block out to function scope -- kind "local"
|
||||
- every file-scope global/static visible from the current source file --
|
||||
kind "global"
|
||||
the explicit name/pointer pairs are labeled kind "watched". This is what
|
||||
lets e.g. $debug_graph(top) -- which VS Code's expressionTemplate produces
|
||||
when you just type "top" in the Watch box -- still show the list: `top` is
|
||||
a bare `int*` with no struct to recurse into on its own, but `list` (a
|
||||
sibling local at that point) gets auto-walked too, so `top`'s address lands
|
||||
on a node already present in the combined graph. Each root's "kind" rides
|
||||
along in the output JSON so the visualizer can offer show/hide toggles per
|
||||
category (see vis-plugins/node-table-visualizer.js).
|
||||
"""
|
||||
|
||||
import gdb
|
||||
import json
|
||||
|
||||
# Marks the start of every payload this module returns (see json.dumps'
|
||||
# default separators below): used to recognize our own values in
|
||||
# _JsonResultPrettyPrinter without touching the user's own char* variables.
|
||||
_RESULT_MARKER = '{"kind": {"nodeTable"'
|
||||
|
||||
|
||||
def _addr_str(addr_int):
|
||||
return hex(addr_int)
|
||||
|
||||
|
||||
def _format_scalar(val):
|
||||
try:
|
||||
return str(val)
|
||||
except gdb.error as e:
|
||||
return "<error: %s>" % e
|
||||
|
||||
|
||||
def _is_variant_like(t):
|
||||
"""True for gdb's DWARF encoding of a Rust enum: a struct where one or
|
||||
more discriminant fields are marked "artificial" alongside the real
|
||||
variant payload fields. Deliberately loose (a C++ class with a vtable
|
||||
pointer can also have an artificial field) -- callers must tolerate
|
||||
_flatten_variant() finding no accessible variant and falling back
|
||||
gracefully."""
|
||||
if t.code != gdb.TYPE_CODE_STRUCT:
|
||||
return False
|
||||
try:
|
||||
fields = t.fields()
|
||||
except TypeError:
|
||||
return False
|
||||
has_artificial = any(getattr(f, "artificial", False) for f in fields)
|
||||
has_payload = any(not getattr(f, "artificial", False) for f in fields)
|
||||
return has_artificial and has_payload
|
||||
|
||||
|
||||
def _flatten_variant(val, t, label):
|
||||
"""For a live enum variant, yields (label, value) leaves to treat as if
|
||||
they were direct fields of the enclosing struct -- unwrapping the
|
||||
tuple-style __0 payload field so `next: Option<Box<Node<T>>>` reads
|
||||
exactly like a plain `next` pointer field. Yields nothing for a unit
|
||||
variant (e.g. None) or when no variant is accessible.
|
||||
|
||||
Which variant is live is determined by trying each non-artificial
|
||||
field and seeing which one gdb accepts -- accessing an inactive Rust
|
||||
enum variant raises gdb.error -- rather than parsing gdb's printed
|
||||
text, since that text's format depends on the debugger's current
|
||||
language setting."""
|
||||
for f in t.fields():
|
||||
if f.name is None or getattr(f, "artificial", False):
|
||||
continue
|
||||
try:
|
||||
variant_val = val[f.name]
|
||||
except gdb.error:
|
||||
continue
|
||||
variant_type = variant_val.type.strip_typedefs()
|
||||
if variant_type.code not in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
|
||||
return # unit variant (e.g. None): no payload to flatten
|
||||
payload_fields = [pf for pf in variant_type.fields() if pf.name is not None]
|
||||
for pf in payload_fields:
|
||||
sub_label = (
|
||||
label if (pf.name == "__0" and len(payload_fields) == 1) else "%s.%s" % (label, pf.name)
|
||||
)
|
||||
yield sub_label, variant_val[pf.name]
|
||||
return
|
||||
|
||||
|
||||
def _pretty_printer_summary(val):
|
||||
"""Returns (leaves, empty_text) if val has a registered gdb
|
||||
pretty-printer (e.g. one of libstdc++'s STL printers, loaded via
|
||||
-enable-pretty-printing), else None.
|
||||
|
||||
`leaves` is a list of (label, gdb.Value) pairs from the printer's
|
||||
children() -- e.g. std::optional<T>'s single "[contained value]" child
|
||||
-- to recurse into exactly like a struct's own fields, so a contained
|
||||
pointer still becomes a proper edge instead of an opaque string.
|
||||
`empty_text` is what to show when there are no children (e.g. an
|
||||
empty/disengaged std::optional, or an empty std::vector): the
|
||||
printer's own to_string().
|
||||
|
||||
Leaning on gdb's pretty-printers rather than hardcoding STL internals
|
||||
(std::optional's actual data lives behind private base-class
|
||||
subobjects our own field walk would otherwise skip) means this works
|
||||
uniformly for std::optional, std::unique_ptr, std::vector, etc. --
|
||||
the same principle as _flatten_variant leaning on gdb for Rust enums
|
||||
instead of reimplementing niche-optimization layout ourselves."""
|
||||
printer = gdb.default_visualizer(val)
|
||||
if printer is None:
|
||||
return None
|
||||
leaves = []
|
||||
if hasattr(printer, "children"):
|
||||
try:
|
||||
leaves = [(str(name), child_val) for name, child_val in printer.children()]
|
||||
except gdb.error:
|
||||
leaves = []
|
||||
if leaves:
|
||||
return leaves, None
|
||||
try:
|
||||
return [], str(printer.to_string())
|
||||
except gdb.error:
|
||||
return [], "<pretty-printer error>"
|
||||
|
||||
|
||||
def _type_name(t):
|
||||
"""Best-effort display name for a type (e.g. "std::optional<int>",
|
||||
"core::option::Option<...>::Some"), used as a typeHint so unwrapped
|
||||
fields/roots don't lose what wrapper they came from."""
|
||||
try:
|
||||
return t.tag or str(t)
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _append_field(fields_out, name, value, is_pointer=False, type_hint=None):
|
||||
field = {"name": name, "value": value, "isPointer": is_pointer}
|
||||
if type_hint:
|
||||
field["typeHint"] = type_hint
|
||||
fields_out.append(field)
|
||||
|
||||
|
||||
def _process_member(label, member_val, node_id, fields_out, edges, nodes, visited, type_hint=None):
|
||||
member_type = member_val.type.strip_typedefs()
|
||||
|
||||
if member_type.code == gdb.TYPE_CODE_PTR:
|
||||
try:
|
||||
ptr_int = int(member_val)
|
||||
except gdb.error:
|
||||
ptr_int = 0
|
||||
_append_field(fields_out, label, _addr_str(ptr_int), is_pointer=True, type_hint=type_hint)
|
||||
if ptr_int:
|
||||
edges.append((node_id, _addr_str(ptr_int), label))
|
||||
_visit_pointer(member_val, ptr_int, nodes, edges, visited)
|
||||
return
|
||||
|
||||
if member_type.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
|
||||
summary = _pretty_printer_summary(member_val)
|
||||
if summary is not None:
|
||||
leaves, empty_text = summary
|
||||
wrapper_name = _type_name(member_type)
|
||||
if not leaves:
|
||||
_append_field(fields_out, label, empty_text, type_hint=wrapper_name)
|
||||
elif len(leaves) == 1:
|
||||
_process_member(label, leaves[0][1], node_id, fields_out, edges, nodes, visited, wrapper_name)
|
||||
else:
|
||||
for leaf_label, leaf_val in leaves:
|
||||
_process_member(
|
||||
"%s.%s" % (label, leaf_label), leaf_val, node_id, fields_out, edges, nodes, visited,
|
||||
wrapper_name,
|
||||
)
|
||||
return
|
||||
|
||||
if _is_variant_like(member_type):
|
||||
leaves = list(_flatten_variant(member_val, member_type, label))
|
||||
wrapper_name = _type_name(member_type)
|
||||
if leaves:
|
||||
for leaf_label, leaf_val in leaves:
|
||||
_process_member(leaf_label, leaf_val, node_id, fields_out, edges, nodes, visited, wrapper_name)
|
||||
else:
|
||||
# Unit variant (e.g. None) or an active variant we couldn't
|
||||
# resolve -- fall back to gdb's own rendering of it.
|
||||
_append_field(fields_out, label, _format_scalar(member_val), type_hint=wrapper_name)
|
||||
return
|
||||
|
||||
_append_field(fields_out, label, _format_scalar(member_val), type_hint=type_hint)
|
||||
|
||||
|
||||
def _describe_struct(val, node_id, nodes, edges, visited):
|
||||
"""val: a gdb.Value of struct/union type (already dereferenced)."""
|
||||
fields_out = []
|
||||
|
||||
summary = _pretty_printer_summary(val)
|
||||
if summary is not None:
|
||||
leaves, empty_text = summary
|
||||
wrapper_name = _type_name(val.type.strip_typedefs())
|
||||
if not leaves:
|
||||
_append_field(fields_out, "value", empty_text, type_hint=wrapper_name)
|
||||
elif len(leaves) == 1:
|
||||
_process_member("value", leaves[0][1], node_id, fields_out, edges, nodes, visited, wrapper_name)
|
||||
else:
|
||||
for leaf_label, leaf_val in leaves:
|
||||
_process_member(leaf_label, leaf_val, node_id, fields_out, edges, nodes, visited, wrapper_name)
|
||||
nodes[node_id] = fields_out
|
||||
return
|
||||
|
||||
t = val.type.strip_typedefs()
|
||||
for f in t.fields():
|
||||
if f.name is None or getattr(f, "is_base_class", False):
|
||||
continue # anonymous member / base-class subobject
|
||||
try:
|
||||
member_val = val[f.name]
|
||||
except gdb.error:
|
||||
continue
|
||||
_process_member(f.name, member_val, node_id, fields_out, edges, nodes, visited)
|
||||
|
||||
nodes[node_id] = fields_out
|
||||
|
||||
|
||||
def _visit_pointer(ptr_val, ptr_int, nodes, edges, visited):
|
||||
"""Cycle-guards only on the struct/union branch (via `visited`), and
|
||||
only fills in a scalar leaf if nothing has described this address yet
|
||||
(via `nodes`). With auto-discovered roots (see build_graph_json), the
|
||||
same address can be reached both as a struct-typed pointer (e.g. a
|
||||
linked-list node) and, separately, as some other variable's plain
|
||||
scalar pointer into one of that struct's fields (e.g. a `T*` pointing
|
||||
at a `Node<T>`'s first member, which shares its address). Whichever
|
||||
order those walks happen in, the richer struct description must win --
|
||||
a scalar walk must never downgrade/overwrite an already-described
|
||||
struct node, and a struct walk must always be able to upgrade a
|
||||
previously-recorded scalar leaf at the same address."""
|
||||
node_id = _addr_str(ptr_int)
|
||||
pointee_type = ptr_val.type.strip_typedefs().target().strip_typedefs()
|
||||
|
||||
if pointee_type.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
|
||||
if node_id in visited:
|
||||
return
|
||||
visited.add(node_id)
|
||||
pointee = ptr_val.dereference()
|
||||
_describe_struct(pointee, node_id, nodes, edges, visited)
|
||||
elif pointee_type.code in (gdb.TYPE_CODE_INT, gdb.TYPE_CODE_CHAR) and pointee_type.sizeof == 1:
|
||||
# char*/const char*: show the whole C string, not just *ptr (one
|
||||
# byte) -- the node's own id/header is already the pointer's
|
||||
# address, so this gives address + full value like everything else.
|
||||
# No explicit `length`: that disables stopping at the NUL
|
||||
# terminator entirely (reads exactly N bytes, running into
|
||||
# whatever garbage memory follows a shorter real string).
|
||||
if node_id in nodes:
|
||||
return
|
||||
try:
|
||||
text = ptr_val.string()
|
||||
if len(text) > 500:
|
||||
text = text[:500] + "…"
|
||||
except (gdb.error, UnicodeDecodeError):
|
||||
text = _format_scalar(ptr_val.dereference())
|
||||
nodes[node_id] = [{"name": "value", "value": text, "isPointer": False}]
|
||||
else:
|
||||
if node_id in nodes:
|
||||
return # already described (richly or otherwise) via another path
|
||||
pointee = ptr_val.dereference()
|
||||
nodes[node_id] = [
|
||||
{"name": "value", "value": _format_scalar(pointee), "isPointer": False}
|
||||
]
|
||||
|
||||
|
||||
def _walk_primary_root(root_val, nodes, edges, visited):
|
||||
"""Walks EXPR itself (the first argument to $debug_graph). Returns the
|
||||
address string of EXPR's own node, if any, so callers can skip re-adding
|
||||
it as a redundant self-pointing root when auto-discovery finds the same
|
||||
variable again as a local."""
|
||||
t = root_val.type.strip_typedefs()
|
||||
if t.code == gdb.TYPE_CODE_PTR:
|
||||
ptr_int = int(root_val)
|
||||
if ptr_int:
|
||||
_visit_pointer(root_val, ptr_int, nodes, edges, visited)
|
||||
return _addr_str(ptr_int)
|
||||
return None
|
||||
elif t.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
|
||||
node_id = _addr_str(int(root_val.address))
|
||||
visited.add(node_id)
|
||||
_describe_struct(root_val, node_id, nodes, edges, visited)
|
||||
return node_id
|
||||
else:
|
||||
nodes["root"] = [
|
||||
{"name": "value", "value": _format_scalar(root_val), "isPointer": False}
|
||||
]
|
||||
return None
|
||||
|
||||
|
||||
def _add_named_root(name, val, kind, nodes, edges, visited, roots_out, skip_addrs):
|
||||
"""Walks `val` (a local/global/watched variable) same as a primary root,
|
||||
then records it as a (name, value, kind, type) root entry -- unless its
|
||||
address is in skip_addrs (already covered by the primary root)."""
|
||||
t = val.type.strip_typedefs()
|
||||
type_name = _type_name(t)
|
||||
if t.code == gdb.TYPE_CODE_PTR:
|
||||
try:
|
||||
ptr_int = int(val)
|
||||
except gdb.error:
|
||||
return
|
||||
if not ptr_int:
|
||||
return
|
||||
addr = _addr_str(ptr_int)
|
||||
if addr in skip_addrs:
|
||||
return
|
||||
_visit_pointer(val, ptr_int, nodes, edges, visited)
|
||||
roots_out.append((name, addr, kind, type_name))
|
||||
elif t.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
|
||||
try:
|
||||
addr = _addr_str(int(val.address))
|
||||
except gdb.error:
|
||||
return
|
||||
if addr in skip_addrs:
|
||||
return
|
||||
if addr not in visited:
|
||||
visited.add(addr)
|
||||
_describe_struct(val, addr, nodes, edges, visited)
|
||||
roots_out.append((name, addr, kind, type_name))
|
||||
else:
|
||||
roots_out.append((name, _format_scalar(val), kind, type_name))
|
||||
|
||||
|
||||
def _frame_local_symbols(frame):
|
||||
"""Yields every local variable/argument gdb.Symbol visible at the
|
||||
current PC, walking from the innermost lexical block out to (but not
|
||||
including) the enclosing file/global scope."""
|
||||
seen = set()
|
||||
block = frame.block()
|
||||
while block is not None and not block.is_global and not block.is_static:
|
||||
for sym in block:
|
||||
if not (sym.is_variable or sym.is_argument):
|
||||
continue
|
||||
if sym.name is None or sym.name in seen:
|
||||
continue
|
||||
seen.add(sym.name)
|
||||
yield sym
|
||||
block = block.superblock
|
||||
|
||||
|
||||
def _file_global_symbols(frame):
|
||||
"""Yields every file-scope global/static gdb.Symbol visible from the
|
||||
current frame's source file."""
|
||||
seen = set()
|
||||
try:
|
||||
symtab = frame.find_sal().symtab
|
||||
except gdb.error:
|
||||
return
|
||||
if symtab is None:
|
||||
return
|
||||
for block in (symtab.global_block(), symtab.static_block()):
|
||||
if block is None:
|
||||
continue
|
||||
for sym in block:
|
||||
if not sym.is_variable:
|
||||
continue
|
||||
if sym.name is None or sym.name in seen:
|
||||
continue
|
||||
seen.add(sym.name)
|
||||
yield sym
|
||||
|
||||
|
||||
def build_graph_json(root_val, watched_roots):
|
||||
nodes = {}
|
||||
edges = []
|
||||
visited = set()
|
||||
roots = []
|
||||
skip_addrs = set()
|
||||
used_names = set()
|
||||
|
||||
primary_addr = _walk_primary_root(root_val, nodes, edges, visited)
|
||||
if primary_addr is not None:
|
||||
skip_addrs.add(primary_addr)
|
||||
|
||||
for name, ptr_val in watched_roots:
|
||||
used_names.add(name)
|
||||
try:
|
||||
ptr_int = int(ptr_val)
|
||||
except gdb.error:
|
||||
ptr_int = 0
|
||||
addr = _addr_str(ptr_int)
|
||||
roots.append((name, addr, "watched", _type_name(ptr_val.type.strip_typedefs())))
|
||||
if ptr_int and addr not in skip_addrs:
|
||||
_visit_pointer(ptr_val, ptr_int, nodes, edges, visited)
|
||||
|
||||
try:
|
||||
frame = gdb.selected_frame()
|
||||
except gdb.error:
|
||||
frame = None
|
||||
|
||||
if frame is not None:
|
||||
for sym in _frame_local_symbols(frame):
|
||||
if sym.name in used_names:
|
||||
continue
|
||||
used_names.add(sym.name)
|
||||
try:
|
||||
val = sym.value(frame)
|
||||
except gdb.error:
|
||||
continue
|
||||
_add_named_root(sym.name, val, "local", nodes, edges, visited, roots, skip_addrs)
|
||||
|
||||
for sym in _file_global_symbols(frame):
|
||||
if sym.name in used_names:
|
||||
continue
|
||||
used_names.add(sym.name)
|
||||
try:
|
||||
val = sym.value(frame)
|
||||
except gdb.error:
|
||||
continue
|
||||
_add_named_root(sym.name, val, "global", nodes, edges, visited, roots, skip_addrs)
|
||||
|
||||
nodes_json = [{"id": nid, "fields": fields} for nid, fields in nodes.items()]
|
||||
edges_json = [
|
||||
{"from": src, "to": dst, "label": label}
|
||||
for (src, dst, label) in edges
|
||||
if dst in nodes
|
||||
]
|
||||
roots_json = [
|
||||
{"name": name, "value": value, "kind": kind, "type": type_name}
|
||||
if type_name
|
||||
else {"name": name, "value": value, "kind": kind}
|
||||
for (name, value, kind, type_name) in roots
|
||||
]
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"kind": {"nodeTable": True},
|
||||
"nodes": nodes_json,
|
||||
"edges": edges_json,
|
||||
"roots": roots_json,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _as_debugger_string(text):
|
||||
"""Wrap `text` as a gdb.Value the Watch box will print as a proper
|
||||
quoted string. Letting Python's str->gdb.Value auto-conversion do this
|
||||
(i.e. just `return text`) produces a plain char array, which gdb's
|
||||
*Rust* value printer renders as a bracketed list of byte codes instead
|
||||
of text -- a display-time quirk based on the currently active
|
||||
language, not something fixable by constructing the value differently.
|
||||
Evaluating a string literal instead makes gdb's own (Rust- or
|
||||
C++-aware, whichever is active) expression parser build a proper
|
||||
string value, which prints correctly either way.
|
||||
|
||||
Under C++, VS Code's debug adapter (MIEngine) additionally gets this
|
||||
value through gdb's MI *variable-object* protocol (`-var-create`), not
|
||||
the plain CLI/`-data-evaluate-expression` path -- and for a raw
|
||||
`char[N]` array, that protocol reports the "value" as just a length
|
||||
placeholder like "[830]" with 830 one-character children, discarding
|
||||
the actual text entirely. `std::string` doesn't have this problem
|
||||
because libstdc++ registers a pretty-printer for it that overrides the
|
||||
default array display with a plain string and no children -- the
|
||||
_JsonResultPrettyPrinter registered below does the same thing for our
|
||||
values specifically, without needing std::string to exist in the
|
||||
debuggee (it wouldn't, since nothing in the target program uses it)."""
|
||||
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return gdb.parse_and_eval('"%s"' % escaped)
|
||||
|
||||
|
||||
class _JsonResultPrettyPrinter:
|
||||
"""Makes gdb's MI variable-object protocol (what VS Code's C++ debug
|
||||
adapter uses for Watch/evaluate) show our returned string in full, with
|
||||
no children -- exactly what libstdc++'s std::string pretty-printer
|
||||
does for MIEngine, which is why that approach worked when toDebugGraph
|
||||
was a real std::string-returning C++ method."""
|
||||
|
||||
def __init__(self, val):
|
||||
self._val = val
|
||||
|
||||
def to_string(self):
|
||||
try:
|
||||
return self._val.string()
|
||||
except (gdb.error, UnicodeDecodeError):
|
||||
return str(self._val)
|
||||
|
||||
def display_hint(self):
|
||||
return "string"
|
||||
|
||||
|
||||
def _pretty_print_lookup(val):
|
||||
t = val.type.strip_typedefs()
|
||||
if t.code == gdb.TYPE_CODE_PTR:
|
||||
elem_type = t.target().strip_typedefs()
|
||||
elif t.code == gdb.TYPE_CODE_ARRAY:
|
||||
elem_type = t.target().strip_typedefs()
|
||||
else:
|
||||
return None
|
||||
if elem_type.code not in (gdb.TYPE_CODE_INT, gdb.TYPE_CODE_CHAR) or elem_type.sizeof != 1:
|
||||
return None # not a char-like element type
|
||||
try:
|
||||
prefix = val.string(length=len(_RESULT_MARKER))
|
||||
except (gdb.error, UnicodeDecodeError):
|
||||
return None
|
||||
if prefix != _RESULT_MARKER:
|
||||
return None
|
||||
return _JsonResultPrettyPrinter(val)
|
||||
|
||||
|
||||
gdb.pretty_printers.append(_pretty_print_lookup)
|
||||
|
||||
|
||||
class DebugGraphFunction(gdb.Function):
|
||||
"""$debug_graph(EXPR[, name, ptr]...) -- render EXPR's pointer-linked
|
||||
structure as Debug Visualizer JSON. Extra name/pointer argument pairs
|
||||
are drawn as external "watched" marker pointers into the graph; every
|
||||
other local (and file-scope global) visible at the current PC is also
|
||||
auto-walked and added as a "local"/"global" root -- see module
|
||||
docstring for why."""
|
||||
|
||||
def __init__(self):
|
||||
super(DebugGraphFunction, self).__init__("debug_graph")
|
||||
|
||||
def invoke(self, *args):
|
||||
if len(args) == 0:
|
||||
raise gdb.GdbError("usage: $debug_graph(expr, [name, ptr]...)")
|
||||
|
||||
root = args[0]
|
||||
extra = args[1:]
|
||||
watched_roots = []
|
||||
i = 0
|
||||
while i + 1 < len(extra):
|
||||
name_val, ptr_val = extra[i], extra[i + 1]
|
||||
try:
|
||||
name = name_val.string()
|
||||
except (gdb.error, TypeError):
|
||||
name = str(name_val)
|
||||
watched_roots.append((name, ptr_val))
|
||||
i += 2
|
||||
|
||||
return _as_debugger_string(build_graph_json(root, watched_roots))
|
||||
|
||||
|
||||
DebugGraphFunction()
|
||||
196
debuggers/py_debug_graph.py
Normal file
196
debuggers/py_debug_graph.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Generic "reference-graph" -> Debug Visualizer JSON walker, the debugpy
|
||||
analog of debuggers/gdb_debug_graph.py.
|
||||
|
||||
Works on any plain Python object reachable from an expression, purely by
|
||||
walking `vars(obj)` -- no changes to the debuggee's source are required.
|
||||
|
||||
Rule: every attribute in `vars(obj)` becomes one row in that node's field
|
||||
table. An attribute whose value is itself a "describable" object (has its
|
||||
own `vars()`) becomes an edge (and is recursed into); everything else
|
||||
(None, numbers, strings, ...) is just stringified in place. A visited-id
|
||||
set makes this safe for cycles, so it handles lists, trees, and arbitrary
|
||||
(possibly cyclic) graphs uniformly.
|
||||
|
||||
Meant to be called via a debugger's "evaluate" request (VS Code's Debug
|
||||
Visualizer expressionTemplate, or a Watch box) while stopped at a
|
||||
breakpoint, e.g.:
|
||||
|
||||
debug_graph(lst)
|
||||
debug_graph(top, lst=lst) # extra name=value watched roots
|
||||
|
||||
Besides the primary root and any explicit keyword roots, every call also
|
||||
walks and labels, as additional roots:
|
||||
- every other local variable visible in the caller's frame -- kind "local"
|
||||
- every module-level global visible from the caller's frame, skipping
|
||||
dunders, modules, classes and functions -- kind "global"
|
||||
This mirrors debuggers/gdb_debug_graph.py's auto-discovery: it's what lets
|
||||
`debug_graph(top)` -- a bare int with no object graph of its own -- still
|
||||
show the whole list, because `lst` (a sibling local) gets auto-walked too.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _addr_str(obj_id):
|
||||
return hex(obj_id)
|
||||
|
||||
|
||||
def _is_describable(val):
|
||||
if val is None or isinstance(val, (bool, int, float, complex, str, bytes, type)):
|
||||
return False
|
||||
if isinstance(val, (types.ModuleType, types.FunctionType, types.BuiltinFunctionType)):
|
||||
return False
|
||||
try:
|
||||
vars(val)
|
||||
except TypeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _format_scalar(val):
|
||||
try:
|
||||
return str(val)
|
||||
except Exception as e: # noqa: BLE001 - mirror gdb's catch-all here
|
||||
return "<error: %s>" % e
|
||||
|
||||
|
||||
def _type_name(val):
|
||||
return type(val).__name__
|
||||
|
||||
|
||||
def _append_field(fields_out, name, value, is_pointer=False, type_hint=None):
|
||||
field = {"name": name, "value": value, "isPointer": is_pointer}
|
||||
if type_hint:
|
||||
field["typeHint"] = type_hint
|
||||
fields_out.append(field)
|
||||
|
||||
|
||||
def _describe_struct(val, node_id, nodes, edges, visited):
|
||||
fields_out = []
|
||||
for name, member_val in vars(val).items():
|
||||
if _is_describable(member_val):
|
||||
member_id = id(member_val)
|
||||
_append_field(fields_out, name, _addr_str(member_id), is_pointer=True, type_hint=_type_name(member_val))
|
||||
edges.append((node_id, _addr_str(member_id), name))
|
||||
if member_id not in visited:
|
||||
visited.add(member_id)
|
||||
_describe_struct(member_val, _addr_str(member_id), nodes, edges, visited)
|
||||
else:
|
||||
_append_field(fields_out, name, _format_scalar(member_val))
|
||||
nodes[node_id] = fields_out
|
||||
|
||||
|
||||
def _walk_primary_root(root_val, nodes, edges, visited):
|
||||
if _is_describable(root_val):
|
||||
node_id = _addr_str(id(root_val))
|
||||
visited.add(id(root_val))
|
||||
_describe_struct(root_val, node_id, nodes, edges, visited)
|
||||
return node_id
|
||||
if root_val is not None:
|
||||
nodes["root"] = [{"name": "value", "value": _format_scalar(root_val), "isPointer": False}]
|
||||
return None
|
||||
|
||||
|
||||
def _add_named_root(name, val, kind, nodes, edges, visited, roots_out, skip_ids):
|
||||
if _is_describable(val):
|
||||
obj_id = id(val)
|
||||
addr = _addr_str(obj_id)
|
||||
if addr in skip_ids:
|
||||
return
|
||||
if obj_id not in visited:
|
||||
visited.add(obj_id)
|
||||
_describe_struct(val, addr, nodes, edges, visited)
|
||||
roots_out.append((name, addr, kind, _type_name(val)))
|
||||
else:
|
||||
roots_out.append((name, _format_scalar(val), kind, _type_name(val) if val is not None else None))
|
||||
|
||||
|
||||
def _is_noise_value(val):
|
||||
if isinstance(val, (types.ModuleType, types.FunctionType, types.BuiltinFunctionType, type)):
|
||||
return True
|
||||
# Anything whose *class* comes from the standard library (typing
|
||||
# constructs, __future__'s _Feature, etc.) is machinery, not user data --
|
||||
# but only applies to describable objects: "builtins" (int, str, ...)
|
||||
# is itself in sys.stdlib_module_names, and plain scalars are never noise.
|
||||
if _is_describable(val) and type(val).__module__ in sys.stdlib_module_names:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _skip_local(name, val):
|
||||
return name.startswith("_") or _is_noise_value(val)
|
||||
|
||||
|
||||
def _skip_global(name, val):
|
||||
return name.startswith("__") or _is_noise_value(val)
|
||||
|
||||
|
||||
def build_graph_json(root_val, watched_roots, frame):
|
||||
nodes = {}
|
||||
edges = []
|
||||
visited = set()
|
||||
roots = []
|
||||
skip_ids = set()
|
||||
used_names = set()
|
||||
|
||||
primary_id = _walk_primary_root(root_val, nodes, edges, visited)
|
||||
if primary_id is not None:
|
||||
skip_ids.add(primary_id)
|
||||
|
||||
for name, val in watched_roots.items():
|
||||
used_names.add(name)
|
||||
_add_named_root(name, val, "watched", nodes, edges, visited, roots, skip_ids)
|
||||
|
||||
if frame is not None:
|
||||
# A debugger's "evaluate" often runs the expression with locals and
|
||||
# globals merged into one dict (so bare names resolve either way),
|
||||
# which collapses frame.f_locals is frame.f_globals as a way to
|
||||
# separate them. Cross-referencing the real module namespace by
|
||||
# identity recovers an accurate local/global split regardless.
|
||||
module = sys.modules.get(frame.f_globals.get("__name__"))
|
||||
module_dict = module.__dict__ if module is not None else {}
|
||||
|
||||
for name, val in frame.f_locals.items():
|
||||
if name in used_names or name in module_dict and module_dict[name] is val:
|
||||
continue # a true module global that leaked into f_locals; handled below
|
||||
if _skip_local(name, val):
|
||||
continue
|
||||
used_names.add(name)
|
||||
_add_named_root(name, val, "local", nodes, edges, visited, roots, skip_ids)
|
||||
|
||||
for name, val in module_dict.items():
|
||||
if name in used_names or _skip_global(name, val):
|
||||
continue
|
||||
used_names.add(name)
|
||||
_add_named_root(name, val, "global", nodes, edges, visited, roots, skip_ids)
|
||||
|
||||
nodes_json = [{"id": nid, "fields": fields} for nid, fields in nodes.items()]
|
||||
edges_json = [
|
||||
{"from": src, "to": dst, "label": label}
|
||||
for (src, dst, label) in edges
|
||||
if dst in nodes
|
||||
]
|
||||
roots_json = [
|
||||
{"name": name, "value": value, "kind": kind, "type": type_name}
|
||||
if type_name
|
||||
else {"name": name, "value": value, "kind": kind}
|
||||
for (name, value, kind, type_name) in roots
|
||||
]
|
||||
|
||||
return {
|
||||
"kind": {"nodeTable": True},
|
||||
"nodes": nodes_json,
|
||||
"edges": edges_json,
|
||||
"roots": roots_json,
|
||||
}
|
||||
|
||||
|
||||
def debug_graph(root, **watched):
|
||||
"""$debug_graph(EXPR[, name=value]...) -- render EXPR's reference graph
|
||||
as Debug Visualizer JSON. Called from a debugger's evaluate/Watch box
|
||||
while stopped at a breakpoint; auto-discovers sibling locals/globals in
|
||||
the paused frame (see module docstring)."""
|
||||
frame = sys._getframe(1)
|
||||
return json.dumps(build_graph_json(root, watched, frame))
|
||||
Reference in New Issue
Block a user