feat(debug): add GDB-based debug visualizer for linked lists

Replace the custom toDebugGraph() method that generated JSON directly in C++ with a generic GDB Python script (debug_graph.py). The script traverses pointers and structures to build a graph representation for the VS Code Debug Visualizer, enabling external pointer display and more extensible visualization.

Update launch configurations to load the script and to disable array/string truncation (needed for the visualizer JSON). Add a debugAdapterConfigurations entry in settings.json to use the expression template $debug_graph(${expr}) for the cppdbg adapter.
This commit is contained in:
mynameisdeleted
2026-07-09 12:14:27 -04:00
parent 273d4dbf2b
commit a8d105005e
6 changed files with 511 additions and 32 deletions

20
.vscode/launch.json vendored
View File

@@ -19,6 +19,21 @@
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
},
{
"description": "Don't truncate long arrays (needed for Debug Visualizer JSON)",
"text": "-gdb-set print elements 0",
"ignoreFailures": true
},
{
"description": "Don't truncate long strings (needed for Debug Visualizer JSON)",
"text": "-gdb-set print characters 0",
"ignoreFailures": true
},
{
"description": "Load the generic $debug_graph(...) Debug Visualizer helper",
"text": "source ${workspaceFolder}/gdb/debug_graph.py",
"ignoreFailures": false
}
]
},
@@ -50,6 +65,11 @@
"description": "Don't truncate long strings (needed for Debug Visualizer JSON)",
"text": "-gdb-set print characters 0",
"ignoreFailures": true
},
{
"description": "Load the generic $debug_graph(...) Debug Visualizer helper",
"text": "source ${workspaceFolder}/gdb/debug_graph.py",
"ignoreFailures": false
}
]
}

View File

@@ -1,5 +1,10 @@
{
"debugVisualizer.customVisualizerScriptPaths": [
"${workspaceFolder}/vis-plugins/node-table-visualizer.js"
]
],
"debugVisualizer.debugAdapterConfigurations": {
"cppdbg": {
"expressionTemplate": "$debug_graph(${expr})"
}
}
}

View File

@@ -1,6 +1,5 @@
#include <iostream>
#include <optional>
#include <sstream>
// A single node containing data and a raw pointer to the next node
template <typename T>
@@ -50,39 +49,10 @@ public:
return head_ ? &head_->data : nullptr;
}
// Build a JSON string describing the list for the VS Code "Debug
// Visualizer" extension's custom "node table" view (see
// vis-plugins/node-table-visualizer.js): watch `list.toDebugGraph()`
// while paused. Each node is rendered as its own address-labeled box
// with a field table -- data as a plain value, next as a hex pointer.
std::string toDebugGraph() const {
std::ostringstream json;
json << "{\"kind\":{\"nodeTable\":true},\"nodes\":[";
for (Node<T>* n = head_; n; n = n->next) {
if (n != head_) json << ",";
json << "{\"id\":\"" << n << "\",\"fields\":["
<< "{\"name\":\"data\",\"value\":\"" << n->data << "\"},"
<< "{\"name\":\"next\",\"value\":\"" << n->next << "\",\"isPointer\":true}"
<< "]}";
}
json << "],\"edges\":[";
for (Node<T>* n = head_; n && n->next; n = n->next) {
if (n != head_) json << ",";
json << "{\"from\":\"" << n << "\",\"to\":\"" << n->next << "\",\"label\":\"next\"}";
}
json << "]}";
return json.str();
}
private:
Node<T>* head_;
};
// Force instantiation of every member, including toDebugGraph(), which
// nothing in this file calls directly -- without this, gdb has no symbol
// to invoke from the debugger's watch expression.
template class LinkedList<int>;
int main() {
LinkedList<int> list;

292
gdb/debug_graph.py Normal file
View File

@@ -0,0 +1,292 @@
"""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
"""
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 _process_member(label, member_val, node_id, fields_out, edges, nodes, visited):
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
fields_out.append({"name": label, "value": _addr_str(ptr_int), "isPointer": True})
if ptr_int:
edges.append((node_id, _addr_str(ptr_int), label))
_visit_pointer(member_val, ptr_int, nodes, edges, visited)
return
if _is_variant_like(member_type):
leaves = list(_flatten_variant(member_val, member_type, label))
if leaves:
for leaf_label, leaf_val in leaves:
_process_member(leaf_label, leaf_val, node_id, fields_out, edges, nodes, visited)
else:
# Unit variant (e.g. None) or an active variant we couldn't
# resolve -- fall back to gdb's own rendering of it.
fields_out.append(
{"name": label, "value": _format_scalar(member_val), "isPointer": False}
)
return
fields_out.append({"name": label, "value": _format_scalar(member_val), "isPointer": False})
def _describe_struct(val, node_id, nodes, edges, visited):
"""val: a gdb.Value of struct/union type (already dereferenced)."""
fields_out = []
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):
node_id = _addr_str(ptr_int)
if node_id in visited:
return
visited.add(node_id)
pointee_type = ptr_val.type.strip_typedefs().target().strip_typedefs()
pointee = ptr_val.dereference()
if pointee_type.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
_describe_struct(pointee, node_id, nodes, edges, visited)
else:
# Pointer to a scalar (e.g. int*): still worth a leaf node.
nodes[node_id] = [
{"name": "value", "value": _format_scalar(pointee), "isPointer": False}
]
def build_graph_json(root_val, roots):
nodes = {}
edges = []
visited = set()
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)
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)
else:
nodes["root"] = [
{"name": "value", "value": _format_scalar(root_val), "isPointer": False}
]
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} for (name, value) 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 marker pointers into the graph."""
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:]
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)
try:
ptr_int = int(ptr_val)
except gdb.error:
ptr_int = 0
roots.append((name, _addr_str(ptr_int)))
i += 2
return _as_debugger_string(build_graph_json(root, roots))
DebugGraphFunction()

114
scripts/dump_debug_graph.py Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Drives both the C++ and Rust debug builds under gdb to their equivalent
breakpoint (right after the 30/20/10 list is built), evaluates
$debug_graph(list) exactly as VS Code's "Debug Visualizer" extension would
(via -data-evaluate-expression), and replays the extension's own
parseEvaluationResultFromGenericDebugAdapter algorithm on the raw result --
so we can tell, without going through the VS Code UI, whether our JSON will
actually be recognized or whether the extension will fall back to its
generic byte-walk visualization.
The parsing port below mirrors:
external/vscode-debug-visualizer/extension/src/VisualizationBackend/
parseEvaluationResultFromGenericDebugAdapter.ts
Keep it in sync if that file changes upstream.
Writes debug_cpp.json / debug_rust.json into the output directory (default
~/lg, matching the style of the extension's own captured dumps): either the
resolved data object on success, or a {"PARSE_ERROR": ..., "raw": ...}
diagnostic on failure.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
def run_gdb(gdb_bin, binary, break_file, break_line):
commands = [
"-gdb-set print elements 0",
"-gdb-set print characters 0",
"source %s" % (REPO / "gdb" / "debug_graph.py"),
"-break-insert %s:%d" % (break_file, break_line),
"-exec-run",
'-data-evaluate-expression "$debug_graph(list)"',
]
proc = subprocess.run(
[gdb_bin, "--interpreter=mi", binary],
input="\n".join(commands).encode(),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=15,
cwd=str(REPO),
)
out = proc.stdout.decode(errors="replace")
matches = re.findall(r'\^done,value="(.*)"\s*$', out, re.M)
errors = re.findall(r'\^error,msg="(.*)"\s*$', out, re.M)
if not matches:
raise RuntimeError("no ^done,value=... in gdb output; errors=%r\n---\n%s" % (errors, out))
return matches[-1]
def is_enclosed_with(s, ch):
return s.startswith(ch) and s.endswith(ch) and len(s) >= 2
def parse_like_extension(mi_escaped_value):
"""Port of parseEvaluationResultFromGenericDebugAdapter, applied to the
raw MI `value="..."` payload (itself one layer of MI's own
string-escaping around whatever gdb's evaluate actually reported)."""
result_text = json.loads('"' + mi_escaped_value + '"') # undo MI's escaping
json_data = result_text.strip()
try:
try:
if is_enclosed_with(json_data, '"') or is_enclosed_with(json_data, "'"):
json_data2 = json_data[1:-1]
else:
json_data2 = json_data
result_obj = json.loads(json_data2)
except Exception:
# "in case of C++": the whole thing is itself a JSON string
# literal; unwrap it once, then parse *that* as JSON.
s = json.loads(json_data)
result_obj = json.loads(s)
return {"ok": True, "data": result_obj}
except Exception as e:
return {"ok": False, "error": str(e), "raw": result_text}
def dump(label, gdb_bin, binary, break_file, break_line, out_dir):
try:
raw_value = run_gdb(gdb_bin, binary, break_file, break_line)
except Exception as e:
result = {"PARSE_ERROR": "gdb invocation failed", "detail": str(e)}
else:
parsed = parse_like_extension(raw_value)
if parsed["ok"]:
result = parsed["data"]
else:
result = {
"PARSE_ERROR": parsed["error"],
"raw": parsed["raw"],
"note": "This is what the Debug Visualizer extension would fail to parse, "
"triggering its generic byte-walk fallback (constructGraphFromVariablesReference).",
}
out_path = out_dir / ("debug_%s.json" % label)
out_path.write_text(json.dumps(result, indent=4))
print("Wrote %s (%s)" % (out_path, "OK" if "PARSE_ERROR" not in result else "PARSE_ERROR"))
def main():
out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / "lg"
out_dir.mkdir(parents=True, exist_ok=True)
dump("cpp", "gdb", str(REPO / "cpp" / "list_example_cpp"), "cpp/main.cpp", 66, out_dir)
dump("rust", "rust-gdb", str(REPO / "target" / "debug" / "list_example"), "src/main.rs", 51, out_dir)
if __name__ == "__main__":
main()

View File

@@ -10,6 +10,11 @@
// Data shape produced by cpp/main.cpp's LinkedList<T>::toDebugGraph():
// nodes: [{ id: "0x...", fields: [{ name, value, isPointer? }, ...] }]
// edges: [{ from: "0x...", to: "0x...", label? }]
// roots: [{ name: "top", value: "0x..." }] -- external pointer variables
// (e.g. a local `top`/`end` in the caller's stack frame); drawn as a
// small pill above the graph with an arrow to whichever node's id
// matches its value, since a node's own address equals its first
// member's address.
module.exports = function (register, lib) {
var sj = lib.semanticJson;
@@ -31,10 +36,16 @@ module.exports = function (register, lib) {
label: sj.sOptionalProp(sj.sString(), {}),
});
var sRoot = sj.sOpenObject({
name: sj.sString(),
value: sj.sString(),
});
var sData = sj.sOpenObject({
kind: sj.sOpenObject({ nodeTable: sj.sLiteral(true) }),
nodes: sj.sArrayOf(sNode),
edges: sj.sArrayOf(sEdge),
roots: sj.sOptionalProp(sj.sArrayOf(sRoot), {}),
});
var HEADER_HEIGHT = 24;
@@ -44,6 +55,8 @@ module.exports = function (register, lib) {
var H_GAP = 70;
var V_GAP = 24;
var PADDING = 20;
var ROOT_ROW_HEIGHT = 50;
var ROOT_BADGE_HEIGHT = 22;
function layout(nodes, edges) {
var ids = nodes.map(function (n) {
@@ -146,6 +159,8 @@ module.exports = function (register, lib) {
};
var positions = layout(data.nodes, data.edges);
var roots = data.roots || [];
var topOffset = roots.length > 0 ? ROOT_ROW_HEIGHT : 0;
var byId = {};
data.nodes.forEach(function (n) {
@@ -164,12 +179,33 @@ module.exports = function (register, lib) {
Object.keys(positions).forEach(function (id) {
var p = positions[id];
var x = p.level * (NODE_WIDTH + H_GAP);
var y = p.col * (Math.max(boxHeights[id], HEADER_HEIGHT + ROW_HEIGHT) + V_GAP);
var y = topOffset + p.col * (Math.max(boxHeights[id], HEADER_HEIGHT + ROW_HEIGHT) + V_GAP);
pixelPos[id] = { x: x, y: y };
maxX = Math.max(maxX, x + NODE_WIDTH);
maxY = Math.max(maxY, y + boxHeights[id]);
});
// Spread root badges above the nodes they point to; unmatched roots
// (e.g. a pointer into the middle of a struct, or a stale/null value)
// queue up along the top-left instead.
var rootPos = {};
var nextUnmatchedX = 0;
var badgesPerTarget = {};
roots.forEach(function (r) {
var target = pixelPos[r.value];
var x;
if (target) {
var stackIndex = badgesPerTarget[r.value] || 0;
badgesPerTarget[r.value] = stackIndex + 1;
x = target.x + stackIndex * 90;
} else {
x = nextUnmatchedX;
nextUnmatchedX += NODE_WIDTH / 2 + 20;
}
rootPos[r.name] = { x: x, y: 0, matched: !!target };
maxX = Math.max(maxX, x + NODE_WIDTH);
});
var canvas = el("div", {
position: "relative",
width: maxX + "px",
@@ -307,6 +343,48 @@ module.exports = function (register, lib) {
canvas.appendChild(box);
});
roots.forEach(function (r) {
var pos = rootPos[r.name];
if (pos.matched) {
var targetPos = pixelPos[r.value];
var x1 = pos.x + NODE_WIDTH / 2;
var y1 = ROOT_BADGE_HEIGHT;
var x2 = targetPos.x + NODE_WIDTH / 2;
var y2 = targetPos.y;
var line = document.createElementNS(svgNs, "path");
line.setAttribute("d", "M" + x1 + "," + y1 + " L " + x2 + "," + y2);
line.setAttribute("fill", "none");
line.setAttribute("stroke", colors.edge);
line.setAttribute("stroke-width", "1.5");
line.setAttribute("stroke-dasharray", "3,3");
line.setAttribute("marker-end", "url(#dv-arrow)");
svg.appendChild(line);
}
var badge = el("div", {
position: "absolute",
left: pos.x + "px",
top: "0px",
width: NODE_WIDTH / 2 + "px",
height: ROOT_BADGE_HEIGHT + "px",
lineHeight: ROOT_BADGE_HEIGHT + "px",
textAlign: "center",
borderRadius: "11px",
border: "1px solid " + colors.border,
background: pos.matched ? colors.headerBg : "transparent",
color: pos.matched ? colors.headerFg : colors.pointerValue,
fontFamily: "var(--vscode-editor-font-family, monospace)",
fontSize: "12px",
fontWeight: "bold",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
});
badge.title = r.name + " = " + r.value;
badge.textContent = r.name + (pos.matched ? "" : " (" + r.value + ")");
canvas.appendChild(badge);
});
target.appendChild(canvas);
}