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 needs | You built it in |
|---|---|
| Instruction → source span | the line table, Lab 9 |
| Local slot → name | Proto::local_names, Lab 10 |
| Upvalue index → name | UpvalDesc::name, Lab 14 |
| The call stack | Vec<CallFrame>, Lab 11 |
| Values → printable | Display and __tostring, Labs 3 and 18 |
| A place to hook | the 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
- Locals with reused slots. See above. This is the difference between a debugger people trust and one they stop using.
.printre-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.- 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.
- Stepping through a metamethod.
t.xmay run arbitrary script code. Should.nextstep 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 request | Your model |
|---|---|
setBreakpoints | Breakpoint::Line |
stackTrace | the frame stack |
scopes / variables | FrameView::locals/upvalues |
next / stepIn / stepOut / continue | the 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.
-
.printevaluating 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_namesextended 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 fromSAFE— 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.settraceandbdb, for a different hook design (per-line callbacks rather than per-instruction).
Validation / Self-check
- Which six pieces of debug information did you already have, and which lab produced each?
- Why is the debug hook in the fetch position?
- What are the four stepping modes as predicates? Why is that uniformity possible?
- Why do locals need live ranges? What does a naive slot→name map show?
- What are the three hazards of
.print, and which section's rules apply? - Should
.nextstep over a metamethod? Defend your answer. - Why is the DAP mapping one to one, and what does that tell you?
- Why is a debugger a capability rather than a default?
Next: Project 6 — A WASM Build.