Project 5: A Source-Level Debugger

Breakpoints, stepping, locals inspection, and a stack view — over the VM you built.

Effort: a long weekend. Value: it is the payoff for every piece of debug information the curriculum made you carry, and it is the feature that makes a runtime usable by people who did not build it.


Why This Is Cheap

Almost everything is already there:

Debugger needsYou built it in
Instruction → source spanthe line table, Lab 9
Local slot → nameProto::local_names, Lab 10
Upvalue index → nameUpvalDesc::name, Lab 14
The call stackVec<CallFrame>, Lab 11
Values → printableDisplay and __tostring, Labs 3 and 18
A place to hookthe fetch position, Lab 11

The whole project is a hook in the dispatch loop plus a protocol. That it is cheap is the lesson: debug information that is carried consistently makes tooling nearly free, and debug information bolted on afterwards makes it impossible.


The Hook

#![allow(unused)]
fn main() {
// In the fetch position, alongside the budget check.
if self.debug.is_some() {
    if let Some(action) = self.debug_check(ip)? {
        match action {
            DebugAction::Continue => {}
            DebugAction::Pause(reason) => self.enter_debugger(reason, ip)?,
        }
    }
}
}

Cheap when off — one Option test — which is the same instrumentation rule as everything else. Measure it: if debug.is_some() costs anything measurable in loop_10m, put it behind a feature.


What You Build

Breakpoints

#![allow(unused)]
fn main() {
pub enum Breakpoint {
    Line { source: SourceId, line: u32 },
    Function { name: String },
    /// Break when a CONDITION is true — evaluated by the engine itself,
    /// re-entrantly, with its own tiny budget so a bad condition cannot hang
    /// the debugger.
    Conditional { at: Box<Breakpoint>, cond: Proto, budget: u64 },
}
}

A line breakpoint resolves to a set of instruction offsets — the line table maps instructions to spans, so you invert it. Note that a line may have several instructions and some lines have none; break on the first instruction of the line, and report when a requested line has no code.

Stepping

   step over   run until the line changes, at the SAME frame depth or shallower
   step into   run until the line changes, at ANY depth
   step out    run until the frame depth is less than it is now
   continue    run until a breakpoint

All four are "run until a predicate on (frame_depth, current_line) holds" — one function, four predicates. That uniformity falls out of frames being data.

Inspection

#![allow(unused)]
fn main() {
pub struct FrameView {
    pub function: String,
    pub source: String,
    pub line: u32,
    pub locals: Vec<(String, String)>,     // name → rendered value
    pub upvalues: Vec<(String, String)>,
}

fn inspect(&self, frame_index: usize) -> FrameView {
    // Locals: proto.local_names gives (slot, name, live-range). Only report
    // locals whose live range CONTAINS the current ip — otherwise you show a
    // slot that has been reused by a sibling block and print nonsense.
}
}

The live-range detail is the one that bites. Slots are reused across sibling blocks (Lab 10), so a naive slot → name map shows the wrong name after a scope ends. local_names must carry (slot, name, start_ip, end_ip), and if yours does not, that is the first change.

The Interface

Two, and build the first:

.break FILE:LINE      .break FUNCTION      .cond N EXPR
.step   .next   .out   .continue
.frame N   .locals   .upvalues   .stack   .backtrace
.print EXPR           ← evaluated in the CURRENT frame's scope

.print EXPR is the one that makes it a debugger rather than a tracer, and it is also the one with a hazard: evaluating an expression re-enters the VM (the re-entrancy rules apply), it can allocate, and it can have side effects. Give it its own small budget and say in the docs that .print f() can change your program.


Where It Gets Hard

  1. Locals with reused slots. See above. This is the difference between a debugger people trust and one they stop using.
  2. .print re-entrancy. The debugger is host code calling back into a paused VM. All of Section 5's rules apply, and the VM must be in a consistent state at the hook — which it is, because the hook is in the fetch position.
  3. Optimizations erase information. After Lab 29's specialization, an instruction may have been rewritten. Debug info must map back to the original — which is a small preview of what a JIT's deopt map does for real.
  4. Stepping through a metamethod. t.x may run arbitrary script code. Should .next step over it? (Lua's debug hooks say yes: it is a different function.) Decide and document.

The DAP Extension

Once the model above works, speaking the Debug Adapter Protocol makes it work in VS Code and every other DAP client. It is JSON-RPC over stdio and the mapping is nearly one to one:

DAP requestYour model
setBreakpointsBreakpoint::Line
stackTracethe frame stack
scopes / variablesFrameView::locals/upvalues
next / stepIn / stepOut / continuethe four predicates
evaluate.print

That the mapping is one to one is not a coincidence — DAP was designed against the same model, because every debugger has it.


Deliverables

  • A debug hook in the fetch position, Option-gated, with a measured cost.
  • Line, function, and conditional breakpoints; conditional ones budgeted.
  • All four stepping modes, as predicates over (depth, line).
  • Locals and upvalues by name, respecting live ranges.
  • .print evaluating in the current frame's scope, with its own budget and a documented hazard.
  • A backtrace matching the one runtime errors produce.
  • A test that steps through a known program and asserts the exact line sequence.
  • local_names extended with live ranges, if it was not already.
  • Optional: a DAP adapter and a VS Code session screenshot.

Where to Read

rg -n 'lua_sethook|lua_getinfo|lua_getlocal|lua_getupvalue' ldebug.c lapi.c
  • Lua's ldebug.c — lua_sethook, lua_getinfo, lua_getlocal. Lua's whole debug interface is ~700 lines, and note that it is a library (debug) that Ember deliberately excludes from SAFE — a debugger is a capability, and this project should register it explicitly rather than adding it to the default set.
  • The Debug Adapter Protocol specification (microsoft.github.io/debug-adapter-protocol).
  • CPython's sys.settrace and bdb, for a different hook design (per-line callbacks rather than per-instruction).

Validation / Self-check

  1. Which six pieces of debug information did you already have, and which lab produced each?
  2. Why is the debug hook in the fetch position?
  3. What are the four stepping modes as predicates? Why is that uniformity possible?
  4. Why do locals need live ranges? What does a naive slot→name map show?
  5. What are the three hazards of .print, and which section's rules apply?
  6. Should .next step over a metamethod? Defend your answer.
  7. Why is the DAP mapping one to one, and what does that tell you?
  8. Why is a debugger a capability rather than a default?

Next: Project 6 — A WASM Build.