Building a debugger is easy when you own the AST walk
A notebook debugger with breakpoints, stepping, and variable inspection — and why it took a few hundred lines instead of DWARF, source maps, and deoptimization.
The single most consequential decision in Equana was to run the language as a plain AST-walking interpreter — no JIT, no bytecode, just a tree-walker that evaluates one node at a time. That decision costs raw speed on scalar code, and I've written before about why that's an acceptable price when the heavy numerical work runs in WebAssembly kernels anyway. This post is about the other side of that ledger: what the decision bought. And the biggest thing it bought is a real debugger — breakpoints, single-stepping, live variable inspection — for a few hundred lines of code, because when you own the tree walk, the hard parts of debugging simply don't exist.
The three primitives, and what they map to
Every source-level debugger, no matter how sophisticated, is ultimately offering you three things:
- Breakpoints — stop execution at a chosen source location.
- Stepping — advance execution by one unit and stop again.
- Inspection — read the values of variables at the point where you stopped.
In a compiled or JIT-compiled language, none of these map cleanly onto what's actually running, because what's actually running is machine code that no longer resembles your source. In an AST-walking interpreter, all three map onto things the interpreter is already doing.
- A breakpoint is: pause before evaluating the AST node at this line.
- A step is: run exactly one evaluation step, then pause again.
- Inspection is: read the environment map that the interpreter already holds.
That's not an analogy. It's close to a literal description of the implementation.
Why this is genuinely hard for compiled languages
To see why owning the AST walk is such an advantage, look at what a debugger for a compiled language has to reconstruct.
When you compile source to machine code, the correspondence between "line 12 of my program" and "these bytes of x86" is destroyed by the compiler. To get it back, compilers emit a separate debug-info format — on most Unix toolchains that's DWARF, a genuinely large specification describing how source lines, variable names, types, and scopes map onto addresses and registers. A breakpoint becomes: consult DWARF to translate line 12 into a code address, then patch that address (classically with an INT 3 trap instruction) so the CPU faults into the debugger. Reading a local variable becomes: consult DWARF to learn that x currently lives in register rbx — or is split across two registers — or was optimized away entirely.
That last case is the deep one. Optimizing compilers reorder, inline, and eliminate code, so "the current line" and "the value of x" may not exist in any single well-defined place. Serious debuggers and JITs handle this with deoptimization: the ability to unwind an optimized frame back into an equivalent unoptimized one so that the program state becomes inspectable again. That machinery — building it, testing it, keeping it correct as the optimizer evolves — is a large fraction of the engineering in a production JIT.
The browser has its own version of this problem. The JavaScript you ship is minified and bundled, so line 12 of your bundle is not line 12 of your source; source maps exist precisely to translate positions in generated code back to positions in original code so the debugger can lie convincingly about which line you're on.
Every one of these mechanisms — DWARF, trap patching, deoptimization, source maps — is machinery for recovering a source-level view that compilation threw away. An AST-walking interpreter never throws it away. The tree is the program, and it's still sitting in memory while the program runs.
How it actually works in Equana
The interpreter walks statements through an evalStmt dispatch and expressions through evalExpr — a switch over node kinds, each case recursing into its children. Every AST node carries a source span. So there's a natural hook: before evaluating each statement, ask a controller whether we should pause here.
The controller is small. It holds a set of breakpoint lines per cell, and a promise-based pause mechanism:
async checkBreakpoint(cellId, line, variables): Promise<void> {
if (!this.active || this.stopped) return
const shouldPause = this.stepping || this.hasBreakpoint(cellId, line)
if (!shouldPause) return
this.stepping = false
this.onPause?.({ cellId, line, variables })
// Block here until the UI calls resume(...)
const action = await new Promise<DebugAction>((resolve) => {
this.pauseResolve = resolve
})
this.pauseResolve = null
this.onResume?.()
if (action === 'step') this.stepping = true
else if (action === 'stop') { this.stopped = true; throw new StopSignal() }
}
Consider what each of the three primitives is here.
A breakpoint is hasBreakpoint(cellId, line) — a set lookup. If the line before the current statement has a breakpoint, we pause before running that statement. No trap instruction, no address translation. The interpreter already knows which line it's about to execute because the node it's about to walk carries that span.
Pausing is one of my favorite tricks in the whole system. The interpreter's evaluation loop is async, so pausing is just await-ing a promise that the UI resolves later. Because the interpreter runs in a Web Worker, that await blocks the interpreter without freezing the UI thread and without spinning a busy loop. When you click Continue or Step in the notebook, the UI calls resume(action), which resolves the promise, and the tree walk picks up exactly where it left off. The interpreter's own call stack is the debugger's paused stack — no separate stack to reconstruct.
Stepping is a one-line state machine. When you step, the controller sets a stepping flag; the very next checkBreakpoint sees it and pauses again, at the next statement, regardless of breakpoints. One step of the debugger is one statement of the tree walk. There's no notion of stepping "into optimized code" or "over inlined frames," because there's no optimizer and nothing is inlined.
Inspection is the part that would be a research problem in a JIT and is a non-problem here. The interpreter keeps variable bindings in an Environment — plainly, a map from names to values. When we pause, we hand that map straight to the UI as variables. There is nothing to decode. The value of x is environment.get("x"), the same call the interpreter itself makes when it evaluates an identifier. The notebook can list every global binding with its type and value the same way, by walking the environment's entries.
Stopping is a thrown StopSignal. Because evaluation is an ordinary recursive function, aborting it is an ordinary exception unwinding the stack — the same reason break and continue in the language are implemented as thrown signals. No special teardown path.
What it doesn't need
Line up the Equana debugger against the compiled-language checklist and the absences are the whole point:
- No debug-info format. No DWARF, no
.debug_linetables. The AST node's span is the debug info, and it's live. - No source maps. The interpreter runs your source's AST directly. "Line 12" means line 12.
- No deoptimization. Nothing is optimized, so there's no optimized frame to unwind. The paused state is the real state, always.
- No trap patching or signal handling. A breakpoint is a set membership test in a normal
if. - No separate debug build. The debugger is the same interpreter with a hook enabled; there's no distinction between "release" and "debuggable."
The line count reflects it. The breakpoint-and-pause controller is on the order of a hundred lines; the rest is UI — a gutter you click to toggle breakpoints, a highlight on the paused line, a panel that renders the variables map.
The cost, stated plainly
The interpreter pays for this in scalar throughput: an AST walk is slower per operation than compiled code, full stop. Equana's answer is architectural — the interpreter orchestrates, and every expensive array operation dispatches out to a WebAssembly kernel operating on shared memory, so the tree-walk overhead lands on control flow and glue code, not on the inner loops of a matrix multiply. Within that architecture, the interpreter is free to be simple, and simplicity is exactly what makes it inspectable. The same property that lets me reference-count values deterministically instead of tracing a heap is the property that lets me pause on a node and read the environment: I own every step, so I can stop on any of them.
A debugger is one of those features that's assumed to be a heroic effort — and it is, if you're bolting it onto a runtime that spent all its complexity budget on going fast and destroying the source-level view in the process. If you keep the source-level view as the thing you execute, the debugger is nearly free. That trade — give up some raw speed, keep the program legible to itself — is the one Equana makes everywhere.
You can set a breakpoint and step through your own code at equana.dev (alpha, free, sign-in for the full notebook). The reference-counting decision that shares the same "own every step" DNA is in refcounting instead of GC, and the language design that sits on top of this interpreter is in designing a language that reads like English.