feat(debug): enhance type hinting in gdb and Python debug visualizers

This commit is contained in:
mynameisdeleted
2026-07-09 16:55:16 -04:00
parent 4f693f8cff
commit b93b7f204f
3 changed files with 136 additions and 34 deletions

View File

@@ -158,13 +158,19 @@ def _append_field(fields_out, name, value, is_pointer=False, type_hint=None):
def _process_member(label, member_val, node_id, fields_out, edges, nodes, visited, type_hint=None):
member_type = member_val.type.strip_typedefs()
# Every field gets a type hint, not just ones a wrapper (pretty-printer
# or Rust-enum-variant) has already labeled -- a plain `int`/`char *`
# member is just as worth showing as one that came out of an unwrapped
# std::optional. The caller's type_hint (a wrapper name) still wins when
# present, since that's more informative than the leaf's own raw type.
own_type_hint = type_hint or _type_name(member_type)
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)
_append_field(fields_out, label, _addr_str(ptr_int), is_pointer=True, type_hint=own_type_hint)
if ptr_int:
edges.append((node_id, _addr_str(ptr_int), label))
_visit_pointer(member_val, ptr_int, nodes, edges, visited)
@@ -199,7 +205,7 @@ def _process_member(label, member_val, node_id, fields_out, edges, nodes, visite
_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)
_append_field(fields_out, label, _format_scalar(member_val), type_hint=own_type_hint)
def _describe_struct(val, node_id, nodes, edges, visited):
@@ -217,7 +223,7 @@ def _describe_struct(val, node_id, nodes, edges, visited):
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
nodes[node_id] = {"fields": fields_out, "type": wrapper_name}
return
t = val.type.strip_typedefs()
@@ -230,7 +236,7 @@ def _describe_struct(val, node_id, nodes, edges, visited):
continue
_process_member(f.name, member_val, node_id, fields_out, edges, nodes, visited)
nodes[node_id] = fields_out
nodes[node_id] = {"fields": fields_out, "type": _type_name(t)}
def _visit_pointer(ptr_val, ptr_int, nodes, edges, visited):
@@ -269,14 +275,16 @@ def _visit_pointer(ptr_val, ptr_int, nodes, edges, visited):
text = text[:500] + "…"
except (gdb.error, UnicodeDecodeError):
text = _format_scalar(ptr_val.dereference())
nodes[node_id] = [{"name": "value", "value": text, "isPointer": False}]
leaf_fields = []
_append_field(leaf_fields, "value", text, type_hint=_type_name(pointee_type))
nodes[node_id] = {"fields": leaf_fields, "type": _type_name(ptr_val.type.strip_typedefs())}
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}
]
leaf_fields = []
_append_field(leaf_fields, "value", _format_scalar(pointee), type_hint=_type_name(pointee_type))
nodes[node_id] = {"fields": leaf_fields, "type": _type_name(ptr_val.type.strip_typedefs())}
def _walk_primary_root(root_val, nodes, edges, visited):
@@ -297,9 +305,9 @@ def _walk_primary_root(root_val, nodes, edges, visited):
_describe_struct(root_val, node_id, nodes, edges, visited)
return node_id
else:
nodes["root"] = [
{"name": "value", "value": _format_scalar(root_val), "isPointer": False}
]
leaf_fields = []
_append_field(leaf_fields, "value", _format_scalar(root_val), type_hint=_type_name(t))
nodes["root"] = {"fields": leaf_fields, "type": _type_name(t)}
return None
@@ -424,7 +432,12 @@ def build_graph_json(root_val, watched_roots):
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()]
nodes_json = [
{"id": nid, "fields": node["fields"], "type": node["type"]}
if node.get("type")
else {"id": nid, "fields": node["fields"]}
for nid, node in nodes.items()
]
edges_json = [
{"from": src, "to": dst, "label": label}
for (src, dst, label) in edges

View File

@@ -28,10 +28,28 @@ This mirrors debuggers/gdb_debug_graph.py's auto-discovery: it's what lets
show the whole list, because `lst` (a sibling local) gets auto-walked too.
"""
import builtins
import json
import sys
import types
# On gdb (C++/Rust), typing bare `root` into the Watch box happens to work
# as a throwaway "just show me the locals/globals" expression -- gdb
# resolves it to a real (if coincidental) always-null `void*` symbol from
# libc.so.6, so $debug_graph(root) contributes no primary node of its own
# but still triggers full auto-discovery of the frame's locals/globals.
# debugpy has no such accident to lean on -- evaluating a name that isn't
# actually bound anywhere raises NameError before our code ever runs -- so
# this deliberately recreates the same "always-resolvable, always-None"
# spelling as a builtin. This runs as an import-time side effect, but that's
# safe to rely on even the *first* time someone types `root`: evaluating a
# call expression like `__import__('py_debug_graph').debug_graph(root)`
# resolves the callable (which imports this module and runs this line)
# before it evaluates the `root` argument, so the patch is always in place
# by the time `root` itself gets looked up.
if not hasattr(builtins, "root"):
builtins.root = None
def _addr_str(obj_id):
return hex(obj_id)
@@ -67,9 +85,15 @@ def _append_field(fields_out, name, value, is_pointer=False, type_hint=None):
fields_out.append(field)
def _describe_struct(val, node_id, nodes, edges, visited):
def _fields_from_items(items, node_id, nodes, edges, visited):
"""Builds one field row per (name, value) pair, recursing into
describable values as edges exactly like a struct's own members --
shared between _describe_struct's vars(obj).items() and the
globals()-as-root special case's plain dict.items() below, so a
"container of named values" only has one field-building rule
regardless of whether the container is an object or a dict."""
fields_out = []
for name, member_val in vars(val).items():
for name, member_val in 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))
@@ -78,8 +102,16 @@ def _describe_struct(val, node_id, nodes, edges, 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
# Every field gets a type hint, not just the describable ones --
# a plain int/str/None member is just as worth labeling as a
# pointer-like one.
_append_field(fields_out, name, _format_scalar(member_val), type_hint=_type_name(member_val))
return fields_out
def _describe_struct(val, node_id, nodes, edges, visited):
fields_out = _fields_from_items(vars(val).items(), node_id, nodes, edges, visited)
nodes[node_id] = {"fields": fields_out, "type": _type_name(val)}
def _walk_primary_root(root_val, nodes, edges, visited):
@@ -89,7 +121,13 @@ def _walk_primary_root(root_val, nodes, edges, visited):
_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}]
nodes["root"] = {
"fields": [{
"name": "value", "value": _format_scalar(root_val), "isPointer": False,
"typeHint": _type_name(root_val),
}],
"type": _type_name(root_val),
}
return None
@@ -135,7 +173,26 @@ def build_graph_json(root_val, watched_roots, frame):
skip_ids = set()
used_names = set()
primary_id = _walk_primary_root(root_val, nodes, edges, visited)
if frame is not None and root_val is globals:
# Bare `globals` (no parens, e.g. typing "globals" into the Watch
# box) evaluates to the *builtin function itself*, not its result --
# walked normally that's just an opaque "<function globals at ...>"
# scalar. Calling it here would also be wrong: globals() returns
# whatever frame *calls* it, which would be this module's own
# globals, not the paused frame's. Substituting the frame we
# already have (passed in from debug_graph()'s sys._getframe(1))
# is what actually shows the debuggee's global variables. Filtered
# through the same _skip_global used for auto-discovered globals
# below, so this node shows the same clean set, not every dunder,
# module and class in the namespace.
primary_id = "globals()"
fields_out = _fields_from_items(
[(k, v) for k, v in frame.f_globals.items() if not _skip_global(k, v)],
primary_id, nodes, edges, visited,
)
nodes[primary_id] = {"fields": fields_out, "type": "dict"}
else:
primary_id = _walk_primary_root(root_val, nodes, edges, visited)
if primary_id is not None:
skip_ids.add(primary_id)
@@ -166,7 +223,12 @@ def build_graph_json(root_val, watched_roots, frame):
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()]
nodes_json = [
{"id": nid, "fields": node["fields"], "type": node["type"]}
if node.get("type")
else {"id": nid, "fields": node["fields"]}
for nid, node in nodes.items()
]
edges_json = [
{"from": src, "to": dst, "label": label}
for (src, dst, label) in edges

View File

@@ -34,6 +34,7 @@ module.exports = function (register, lib) {
var sNode = sj.sOpenObject({
id: sj.sString(),
fields: sj.sArrayOf(sField),
type: sj.sOptionalProp(sj.sString(), {}),
});
var sEdge = sj.sOpenObject({
@@ -572,7 +573,18 @@ module.exports = function (register, lib) {
textOverflow: "ellipsis",
minWidth: "0",
});
headerLabel.textContent = n.id;
headerLabel.appendChild(document.createTextNode(n.id));
if (n.type) {
var nodeTypeSpan = el("span", {
color: colors.fieldType,
fontSize: "10px",
fontWeight: "normal",
marginLeft: "6px",
});
nodeTypeSpan.textContent = n.type;
headerLabel.appendChild(nodeTypeSpan);
header.title = n.id + " : " + n.type;
}
header.appendChild(headerLabel);
var kids = childrenOf[n.id];
@@ -752,7 +764,6 @@ module.exports = function (register, lib) {
return { root: r, kind: rootKind(r), badge: box, line: null, matched: false, height: height, x: 0, y: 0, pinned: false };
});
var nextUnmatchedX = 0;
var badgesPerTarget = {};
viewport.appendChild(world);
@@ -1035,7 +1046,26 @@ module.exports = function (register, lib) {
}
});
nextUnmatchedX = 0;
// Unmatched-root cards (plain scalar locals/globals/watches) that
// aren't pinned by a drag auto-flow into a compact roughly-square
// cluster -- sqrt(count) columns -- instead of one ever-widening
// row, so a handful of locals reads as a group near the top
// instead of a line stretching off to the side. The cluster's
// bottom row sits just above the node cloud (same non-overlap
// tracking as before); earlier rows stack upward from there.
var unmatchedFlow = rootBadges.filter(function (rb) {
return !rb.matched && !rb.pinned && rootKindEnabled[rb.kind];
});
var flowCols = Math.max(1, Math.ceil(Math.sqrt(unmatchedFlow.length)));
var flowRows = Math.ceil(unmatchedFlow.length / flowCols);
var flowRowHeight = 0;
unmatchedFlow.forEach(function (rb) {
flowRowHeight = Math.max(flowRowHeight, rb.height);
});
var flowRowGap = 10;
var flowShelfBottom = (visibleTopY === Infinity ? 0 : visibleTopY) - ROOT_GAP;
var flowIndex = 0;
badgesPerTarget = {};
rootBadges.forEach(function (rb) {
if (!rootKindEnabled[rb.kind]) {
@@ -1067,21 +1097,18 @@ module.exports = function (register, lib) {
} else {
rb.badge.style.display = "";
if (!rb.pinned) {
rb.x = nextUnmatchedX;
// Sit just above whatever's currently the top of the
// visible node cloud, rather than a fixed world y --
// so this shelf of scalar-local cards tracks the
// graph instead of drifting into overlapping it once
// "auto" physics (or gravity re-centering) moves
// nodes around.
var shelfTop = (visibleTopY === Infinity ? 0 : visibleTopY) - ROOT_GAP;
rb.y = shelfTop - rb.height;
nextUnmatchedX += NODE_WIDTH + 20;
var row = Math.floor(flowIndex / flowCols); // 0 = top row
var col = flowIndex % flowCols;
var rowFromBottom = flowRows - 1 - row; // 0 = row nearest the graph
var rowBottom = flowShelfBottom - rowFromBottom * (flowRowHeight + flowRowGap);
rb.x = col * (NODE_WIDTH + 20);
rb.y = rowBottom - rb.height;
flowIndex++;
}
// A pinned (user-dragged) card keeps whatever rb.x/rb.y
// the drag left it at, and drops out of the auto-flow
// row entirely -- the remaining unpinned cards close the
// gap instead of leaving a hole where it used to sit.
// cluster entirely -- the remaining unpinned cards
// re-flow to close the gap instead of leaving a hole.
rb.badge.style.left = rb.x + "px";
rb.badge.style.top = rb.y + "px";
}