From a8d105005e9f4fab78f68b0d809bb3171eb83342 Mon Sep 17 00:00:00 2001 From: mynameisdeleted Date: Thu, 9 Jul 2026 12:14:27 -0400 Subject: [PATCH] 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. --- .vscode/launch.json | 20 ++ .vscode/settings.json | 7 +- cpp/main.cpp | 30 --- gdb/debug_graph.py | 292 +++++++++++++++++++++++++++ scripts/dump_debug_graph.py | 114 +++++++++++ vis-plugins/node-table-visualizer.js | 80 +++++++- 6 files changed, 511 insertions(+), 32 deletions(-) create mode 100644 gdb/debug_graph.py create mode 100755 scripts/dump_debug_graph.py diff --git a/.vscode/launch.json b/.vscode/launch.json index f714eed..d1fc60f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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 } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index dc48258..f24a9ed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,10 @@ { "debugVisualizer.customVisualizerScriptPaths": [ "${workspaceFolder}/vis-plugins/node-table-visualizer.js" - ] + ], + "debugVisualizer.debugAdapterConfigurations": { + "cppdbg": { + "expressionTemplate": "$debug_graph(${expr})" + } + } } diff --git a/cpp/main.cpp b/cpp/main.cpp index 396e052..ee6451b 100644 --- a/cpp/main.cpp +++ b/cpp/main.cpp @@ -1,6 +1,5 @@ #include #include -#include // A single node containing data and a raw pointer to the next node template @@ -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* 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* 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* 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 main() { LinkedList list; diff --git a/gdb/debug_graph.py b/gdb/debug_graph.py new file mode 100644 index 0000000..03c36a6 --- /dev/null +++ b/gdb/debug_graph.py @@ -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 "" % 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>>` 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() diff --git a/scripts/dump_debug_graph.py b/scripts/dump_debug_graph.py new file mode 100755 index 0000000..505e71f --- /dev/null +++ b/scripts/dump_debug_graph.py @@ -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() diff --git a/vis-plugins/node-table-visualizer.js b/vis-plugins/node-table-visualizer.js index 8e1e7a5..f823a3b 100644 --- a/vis-plugins/node-table-visualizer.js +++ b/vis-plugins/node-table-visualizer.js @@ -10,6 +10,11 @@ // Data shape produced by cpp/main.cpp's LinkedList::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); }