Ways To Improve Rust Debug Experience

The recent Rust debugger survey reminded me of my experiments on #[debugger_visualizer] and other tricks to improve the debug experience. So I made a tool called dbgvis to help with that.

Introduction

My starting point was simple: a type's Debug/Display impl is usually the best textual view we have of a value, but a debugger can't find it. So people drop back to dbg! or tracing just to see their data โ€” and then lose the interactivity of actually sitting at a breakpoint. But actually the Debug trait impl function is sitting there in the binary, it's just that the debugger has no access to that information.

The most direct way to fix that is to not add anything to the program at all and just teach the debugger to find the impl. That is what BugStalker's vard command does, and I ported this trick to a new project rust-debug-fmt but does the same things in python(which could be directly loaded into debugger): locate the monomorphized <T as Debug>::fmt rustc already compiled into the binary, call it inside the debuggee with a hand-built Formatter whose sink is a fresh String, read the bytes back, free them. The debuggers do the hard part for free: an ABI-correct inferior call, DWARF field offsets for Formatter and String (so one code path covers rustc 1.81 through nightly, legacy and v0 mangling), crash unwinding. It runs on stable, on Linux and macOS, installs with one curl | sh, and with set rfmt-auto on / rfmt-set auto on it plugs into print, info locals, frame variable, gdb-dashboard and the CodeLLDB variables panel as a pretty printer / type summary, so there isn't even a new command to learn.

Its limit is built into the idea: it can only render a type whose Debug::fmt the program itself instantiated somewhere. rustc monomorphizes lazily and the linker drops the rest, so a type nobody ever {:?}-ed simply isn't in the binary. The stopgap is a debug-only hook that takes <T as Debug>::fmt as a function pointer through black_box, type-only, no values needed, but now you are back to maintaining a list by hand, and for generic code that list is exactly the set of concrete instantiations you can't see from the source. Deciding which impls have to exist is a compile-time question, and that is where dbgvis starts.

The way dbgvis goes about it is to have a register_type! macro emits a formatter function pointer into a dedicated linker section, and linkme collects those across crates into one table the visualizer runtime can look up. Which impl gets used is decided by specialization โ€” a dedicated Visualize trait if the type has one, otherwise Debug, otherwise Display โ€” so it degrades gracefully instead of failing to compile. From there #[debugger_visualizer(gdb_script_file=โ€ฆ)] can embed a GDB script into the artifact (a convenience for auto-load, not essential โ€” you can also load the script by hand), and when GDB asks for a value the runtime just invokes the registered formatter in-process and hands the rendered text back through a small bounded-buffer protocol. The visualizer ends up being the bridge between the debugger and the running process.

The obvious problem is that hand-writing register_type! doesn't scale โ€” generics blow it up immediately, just as BugStalker is limited. So I wrote a rustc driver (it links rustc_private) that inspects the compiler's own type information โ€” the MIR locals and the concrete instances the monomorphization collector produces, rather than parsing .debug_info โ€” and auto-generates a register_type! for each concrete type a debug session might actually want. A second compile pass folds those generated registrations back into the crate.

There are some constraints: it needs nightly today (that specialization-based dispatch), it targets GDB on Linux x86_64 for now (Apple/MSVC don't emit .debug_gdb_scripts), and because the driver links rustc_private it's pinned to the toolchain it was built against. On LLDB, the same mailbox works and I ship a script to load by hand (LLDB has no .debug_gdb_scripts auto-load), but matching is weaker: LLDB won't hand back a type for the anchor symbol, so I can't compare DWARF shape the way I do on GDB, and its type names drop lifetimes and const generics โ€” so two types that differ only in those are indistinguishable, so both of these types won't be accepted. rust-debug-fmt has the same blind spot in a different form: it matches candidates by the debugger's own spelling of the parameter types, so under LLDB two impls that differ only in lifetimes or const generics look identical and it takes the first one (the v0-demangled symbol name does carry the consts, so that one is fixable).

To make that concrete: both frontends have a dbgvis print --all that renders every registered local in the current frame at once. Stopped in a frame that holds a Borrowed<'_, Vec<u8>, Marker, 17> and a โ€ฆ, 19>, GDB prints both โ€” its DWARF-shape match tells the const generics apart โ€” while LLDB, which sees two identical names, reports them as ambiguous and refuses rather than guess. Same tool, same mailbox; the gap is entirely in what each debugger will tell me about a type.

The open question I keep circling back to is scope. Deciding which types the driver should auto-register, how far into dependencies to reach, and how to treat visibility and un-nameable types, all without exploding the registration table or breaking the build, is the part I'm least sure I've got right, and where your opinions would help most. rust-debug-fmt dodges the question by definition ("whatever the program instantiated"), which is exactly why it is unlikely to show you a type you never printed.

Usage

Using dbgvis is a little hacky, but the mental burden is acceptable IMO. Add dbgvis as a dependency and install the CLI, then let the driver do the registration:

cargo add dbgvis --features derive                # the driver injects register_type!, which the derive feature provides
cargo install cargo-dbgvis                        # the CLI itself builds on stable
cargo dbgvis setup                                # under nightly + rustc-dev: compile the driver once
cargo dbgvis config my-bin >> .cargo/config.toml  # point the project's build at the driver
cargo +nightly build

By default the driver only scans the executable's own functions. To also print a dependency's types โ€” say regex's internals โ€” you need two things. First, tell the driver which crates to scan, in .cargo/config.toml:

[env]
DBGVIS_SCAN_CRATES = "regex.*"    # comma-separated regexes matched against crate names

Second, because RUSTC_WORKSPACE_WRAPPER only wraps workspace crates and never registry dependencies, force those dependencies to keep their MIR in Cargo.toml (otherwise only their #[inline]/generic functions are visible):

cargo-features = ["profile-rustflags"]
[profile.dev.package."*"]
rustflags = ["-Zalways-encode-mir"]

cargo dbgvis config writes a rustc-workspace-wrapper plus a couple of DBGVIS_* variables into .cargo/config.toml, so every later cargo +nightly build goes through the driver and gets the auto-generated registrations. The GDB script is embedded in the artifact by the runtime, so debugging is just rust-gdb with the binary added to the auto-load safe path:

rust-gdb -iex "add-auto-load-safe-path $PWD/target/debug/my-bin" target/debug/my-bin
(gdb) break my_bin::checkpoint
(gdb) run
(gdb) dbgvis print app          # AppState { points: {"app": [Some(Point { x: 5, y: 6 })]}, ... }
(gdb) dbgvis print --all        # every registered local in the frame
(gdb) dbgvis types              # what got registered

For LLDB, cargo dbgvis script --lldb writes the bridge script to target/dbgvis/ and you command script import it by hand; no rebuild needed. If you would rather not run a compiler driver at all, the manual route still works: #[derive(dbgvis::Visualize)] for a recursive logical view, #[dbgvis::register] on a #[derive(Debug)] type to reuse its Debug, or dbgvis::register_type!(T) for a third-party type or one concrete instance of a generic. cargo dbgvis doctor tells you when the driver and the nightly it was built against have drifted apart, which, given the rustc_private pin, is the failure you will actually hit.

Projects

4 Likes