Debugging: DAP for the machine layer, nREPL for the language layer

Do not extend nREPL with registers -- the protocol is trivial, the
implementation is a whole debugger. Emit DWARF from the LLVM backend
and use lldb-dap with dape; no adapter is written. C layout with no
object headers means DWARF describes Flan structs exactly, so lldb
needs no plugin or formatters.

Reload mostly works with DAP for free: lldb watches the loader
rendezvous and re-resolves breakpoint specs against new modules. The
gotcha is our own never-unload rule -- N redefinitions leave N copies
all claiming the same source line, so one breakpoint resolves to N
locations. Needs per-generation DWARF source identity plus a reload
agent that disables superseded locations.

Cuts the instrumentation-based step debugger, the most expensive piece
of milestone 8. Knock-on: &env stops being urgent, and the shadow stack
shrinks to serving nREPL backtraces and restart enumeration.
This commit is contained in:
Joseph Ferano 2026-09-10 16:04:56 +07:00
parent c64e91bdfa
commit 5a8c2327c6

View File

@ -266,8 +266,9 @@ Compile-time overload resolution on argument types. No precedence lists, no meth
combination, no MOP, no runtime dispatch table — see Types.
** Macros
- Needs a ~&env~ equivalent: macros must see lexical environment (names of
locals in scope). Required by the step debugger. Decide now, painful to retrofit.
- ~&env~ equivalent: macros seeing the lexical environment (names of locals in
scope). No longer urgent — its "decide now" status came from the
instrumentation-based step debugger, which is cut (see Tooling). Milestone 5.
- Hygiene model: open decision.
* Host language
@ -542,10 +543,66 @@ Clojure-coupled) — reference it. ~clojure-mode~-derived major mode, overlay
rendering, hydra for stepping bindings (~transient~ is the maintained
alternative).
** Step debugger
Instrumentation-based, like CIDER's — macroexpansion wraps subforms with a
breakpoint that messages the editor and blocks. No native debug info needed.
Limit: only instrumented code.
** Debugging: two layers, two protocols
Do not extend nREPL with registers or memory. The protocol is the easy part —
adding an op is trivial; /implementing/ it means becoming a debugger (ptrace,
trap handling, frame unwinding), duplicating an enormous existing project.
| Capability | DAP (lldb-dap + dape) | nREPL |
|------------------------------------------+-----------------------+-------|
| Registers, memory, native stack | yes | never |
| Breakpoints, watchpoints, stepping | yes | — |
| Eval Flan expressions with real semantics | no | yes |
| Redefine a function and continue | no | yes |
| Invoke a restart | no | yes |
| Pause /before/ unwinding | no | yes |
Neither covers the other's column, so this is not a choice between them.
*DAP is nearly free.* No debug adapter is written: emit DWARF from the LLVM
backend and point ~lldb-dap~ at the binary; dape speaks to that. lldb and gdb
both ship DAP interfaces already.
This is where /no object headers/ pays off a second time. Flan structs *are* C
structs — real machine types, no tag words, no boxing — so DWARF describes them
with no impedance mismatch and lldb shows a ~Cursor~ correctly with no plugin
and no formatters.
*** Reload and DAP
Mostly automatic. lldb watches the loader rendezvous structure, so a ~dlopen~'d
generation is noticed and its DWARF read; and lldb keeps breakpoints as
unresolved /specs/, re-resolving them against newly loaded modules, so a
breakpoint on ~sand.flan:85~ binds to the new function by itself.
*The gotcha is our own never-unload rule.* After N redefinitions there are N
copies of a function, each with DWARF claiming ~sand.flan:85~, so one breakpoint
resolves to N locations and stops in stale generations. Needs glue, and it is
small: give each generation a distinct source identity in its DWARF
(~sand.flan#7~), and have the reload agent disable breakpoint locations in
superseded modules on each reload. This is the one piece that cannot just be
wired up and left alone.
Not available at any price: if execution is paused /inside/ the old function,
redefinition does not move it — register allocation belongs to the old
compilation (see "What redefinition cannot do"). The current frame finishes in
the old code; the next call enters the new one. Also, dape reads source from
disk, so editing further after a generation was compiled drifts the highlighted
line from what is executing.
*** Cut: the instrumentation-based step debugger
Previously planned as CIDER's model — macroexpansion wrapping subforms with
breakpoints. Dropped. It is the most expensive piece of milestone 8
(instrumentation, an overlay protocol, ~&env~, a bespoke Emacs client) and
DAP+DWARF covers most of its use. What DAP cannot cover — pausing before the
stack unwinds with restarts available — comes from ~handler-bind~ over nREPL.
Consequences: *~&env~ loses its urgency* (its "decide now" status came from the
step debugger requiring it — now an ordinary milestone-5 decision), and *the
shadow stack shrinks* to serving only nREPL backtraces and restart enumeration.
Forcing the live-programming workflow into DAP would repeat the CIDER mistake in
the other direction: DAP's model is stop/step/inspect, with no vocabulary for
"invoke ~use-placeholder~ and resume". That stays on nREPL.
** Pause on exception
Better than CIDER's, because ~handler-bind~ has not unwound the stack. Dev-mode