Debugging
d6's only debugging primitive at the language level is the source
debugger()/breakpoint() call (Runtime features and
debugging): it pauses execution, prints
the pause site and every currently-live local, then blocks on one line of
stdin before resuming. dap/ adds a real Debug Adapter Protocol (DAP)
server, d6-dap, that gives an editor's own gutter breakpoints the
identical pause -- without editing source -- for VS Code's "Run & Debug"
view or any other DAP-compatible client (editors/vscode's own
"Debugging" section covers the VS Code side specifically).
For a hands-on example, try the pricing-bug walkthrough, with terminal and editor instructions plus a corrected solution.
What this is
Editor-settable breakpoints, a real call stack, locals at the paused
frame, and next/stepIn/stepOut -- reusing the exact pause/print/
locals machinery debugger()/breakpoint() already had, not a separate
stepping engine. Breakpoints can be added or removed at any time,
including against an already-running debuggee, with no restart needed
(see "Live breakpoints" below). Remaining deliberate limitations:
- Only the innermost, paused frame has locals. An outer call-stack
frame's own locals aren't tracked anywhere the VM can reconstruct after
the fact (no general per-chunk local-debug table exists; only the exact
paused call site's own baked-in operand list does) --
stackTracestill shows every frame's own name/file/line, just no variables for anything but frame 0. - A breakpoint inside a spawned
Threadis invisible. EachThreadruns its own independent VM/heap (docs/threads.md); only the main VM's pauses reach the control channel below. - No interactive stdin forwarding to the debuggee while paused.
Live breakpoints
Every statement is compiled with a DIAMOND_OP_BREAKPOINT_CHECK --
regardless of which lines (if any) were selected as breakpoints when the
debuggee launched -- whenever DIAMOND_DEBUG_FD is set at all. Unlike
the explicit debugger()/breakpoint() call's own DIAMOND_OP_DEBUGGER
(which always pauses, unconditionally, the instant it's reached), this
opcode checks a runtime, freely mutable set of "currently armed" lines
(DiamondVm.debug_active_lines, src/vm.h) before deciding whether to
actually pause. A setBreakpoints request sent at any time -- before
launch, while the debuggee is running, or while it's already stopped at
a different line -- replaces that set wholesale and takes effect with no
recompile or restart: d6-dap resolves its complete, current
breakpoint table (across every source the client has ever set
breakpoints for) and pushes it over the same control channel described
below.
This does mean a debug session's compiled bytecode is meaningfully
larger than an ordinary run's (every statement in the whole program --
prelude included, since d6-dap always compiles fresh with no
cache -- gets its own check, not just the handful of lines actually
selected). Accepted deliberately: a debug session already forgoes the
bytecode cache, the embedded-prelude-template fast path, and JIT
compilation (this and every other debug opcode is unrecognized by the
JIT's own compile-time scan, so any function containing one is
automatically JIT-ineligible, exactly like debugger()/breakpoint()
already were) -- it was never the startup/steady-state-throughput-
sensitive case those optimizations target. An ordinary diamond script.d6 run has none of this instrumentation at all and pays nothing
for the feature existing.
Stepping
next (step over), stepIn, and stepOut all reuse the exact same
per-statement DIAMOND_OP_BREAKPOINT_CHECK live breakpoints already
install everywhere -- no new bytecode, no new compile-time mechanism.
Sending one of these while stopped arms DiamondVm.debug_step_mode
(src/vm.h) with the current pause's own already-tracked run_chunk
recursion depth as a target, then resumes; the next checkpoint hit
satisfying that mode's own depth comparison pauses ("reason":"step" in
the resulting stopped event, distinguishing it from "reason": "breakpoint") and clears the mode -- a real armed breakpoint line
always still wins/pauses first, regardless of any pending step:
stepIn-- pauses at the very next checkpoint hit, at any depth.next(step over) -- pauses at the next checkpoint whose depth is<=the depth stepping started at (i.e. the same frame, or a shallower one if the current statement returns) -- so a call made from the stepped-over statement runs to completion uninterrupted.stepOut-- pauses at the next checkpoint whose depth is<the starting depth -- i.e. back in the caller, right after the current call returns.
One known, accepted edge case: a tail call that runs in place
(docs/callables.md's "Tail-call optimization") deliberately reuses
the same run_chunk recursion depth rather than incrementing it (that
feature's own whole point). Stepping over or out of a statement that
happens to be such a call therefore can't distinguish "still the same
logical call" from "a fresh tail-recursive invocation" by depth alone --
in practice this means step-over lands on the first statement of the
new invocation rather than skipping it entirely. Not fixed: an
accepted, narrow consequence of two features that were never designed
to interact, not a bug in either one on its own.
Choosing which call to step into
A line with several calls, such as total = add(one(), two()), has more than
one place to step into. stepInTargets (supportsStepInTargetsRequest: true)
lists the calls the paused statement makes, in the order they run (here one,
two, add), and stepIn with a targetId enters that one and runs the
others to completion.
The list comes from the pause itself: every paused payload carries a
"targets" array ({"name","line","column"} per call), built by walking the
statement's bytecode up to the next checkpoint
(diamond_collect_call_sites, src/disassemble.c), so stepInTargets answers
from what the adapter already holds and costs no round trip. A target is named
by its callee: a method name for a method call, a function name for a function
call, new for a constructor and call for a call through a closure value.
Entering a target is by name: the VM resumes and pauses at the first
checkpoint exactly one frame deeper whose function is called that, so two
calls to like-named methods on different receivers on one line cannot be told
apart (the first to run wins). If the chosen call is native (a built-in such as
.length()) or never runs, the debuggee pauses at the next statement at that
depth or shallower, as next would, instead of running on. Only the innermost
frame has targets.
How it fits together
editor (DAP client) <--stdio, DAP--> d6-dap <--control socket--> d6 (the debuggee)
d6-dap(dap/main.c) speaks ordinary DAP over its own stdio (Content-Length-framed JSON, vialsp/json.c/lsp/rpc.c-- the identical transportd6-lspalready uses).setBreakpointsrecords(source path, line)pairs, replacing whatever was previously stored for that one path; the real spawn waits forconfigurationDone(DAP's own signal that a client is done sending its initial breakpoints) rather thanlaunchitself, so a breakpoint that arrives afterlaunchbut beforeconfigurationDonestill lands in the debuggee's initial armed set.- At
configurationDone,d6-daploads and require-expands the program exactly the way the CLI's own non-template compile path does (run_source_from_bundle_program,src/run_source.c), resolves every stored breakpoint's original(path, line)against that expanded buffer (diamond_resolve_source_position,src/compiler.h), and spawnsd6 <program> [args...]with two environment variables set (see below) and a dedicatedAF_UNIXSOCK_STREAMsocketpair wired to one end as the "control channel" --DIAMOND_DEBUG_FDnames its fd number in the child. - A
setBreakpointsrequest that arrives after the debuggee is already running takes the exact same resolve step, then instead sends the complete, freshly-recomputed set as a{"command":"setBreakpoints", "offsets":[...]}message over the already-open control channel (send_live_breakpoints,dap/main.c) -- no recompile, no restart.next/stepIn/stepOutwrite a bare{"command":"next"}/"stepIn"/"stepOut"the same way (handle_next/handle_step_in/handle_step_out,dap/main.c) -- no arguments needed, since the VM already knows its own current call depth at the exact moment it processes the command (see "Stepping" above). - The debuggee's stdout/stderr are piped back as DAP
outputevents; its exit becomesexited/terminatedevents. - Every pause (an armed
DIAMOND_OP_BREAKPOINT_CHECK, a step condition being satisfied, or the explicitdebugger()/breakpoint()call's own always-unconditionalDIAMOND_OP_DEBUGGER) writes one hand-formatted JSON payload -- including a"reason":"breakpoint"/"step"fielddap/main.c's ownstoppedevent passes straight through -- framed the sameContent-Lengthway, to the control fd (debugger_structured_helper,src/vm.c) and then blocks reading framed commands back in a loop -- applying anysetBreakpointsin place and only resuming once acontinue/next/stepIn/stepOutarrives -- rather than a single fixed read; the VM itself never links a JSON library for this (parse_debug_commandhand-scans the fixed set of shapes it needs to recognize instead), onlyd6-dap(which needs to speak real DAP to its own client) does. ADIAMOND_OP_BREAKPOINT_CHECKalso does a quick non-blocking check of the same control fd before deciding whether it is armed, on every 32nd statement (DIAMOND_DEBUG_POLL_STATEMENT_MASK,src/vm_internal.h) -- apoll()per statement made a debugged loop about 3x slower. A livesetBreakpointstherefore takes effect up to 32 statements late; arming and stepping are still decided at every statement. See "Live breakpoints" above for why that has to happen there rather than via some other mechanism. d6-daptranslates that payload's(name, line, column)-- expressed in the same expanded-buffer terms the compiler itself used -- back to(original file, original line)viadiamond_resolve_diagnostic_location, the same reverse mapping ordinary compiler diagnostics already use, and turns it into a DAPstoppedevent plus whateverstackTrace/scopes/variablesthe client asks for next.
Breakpoints across multiple required files
The adapter resolves each requested (path, line) to a byte offset in the
expanded source. Each debug checkpoint carries its statement's line-start
offset, so identical line numbers in different files remain distinct.
The stopped frame uses that offset to report the source file and line.
The env-var contract (for a second DAP-compatible client)
DIAMOND_DEBUG_FD=<fd>-- an already-open, connected file descriptor number in the child process (read once, indiamond_vm_init,src/vm.c) that every pause writes its{"event":"paused","stack": [...],"targets":[...],"locals":[...]}payload to and reads framed commands back from, on the same fd -- a plain pipe can't do that; it needs to be a full-duplex descriptor (asocketpairend, asdap/main.cuses). Unset (or a negative/unparseable value) keepsdebugger()/breakpoint()'s ordinary print-to-stdout/getchar()behavior, unaffected by anything below. Also the sole trigger for full-programDIAMOND_OP_BREAKPOINT_CHECKinstrumentation and the live/uncached compile path (src/run_source.c) -- a real DAP session sets this unconditionally, so a debuggee that starts with zero breakpoints selected is still fully instrumented and ready for a livesetBreakpointslater; either breakpoint environment variable alone (noDIAMOND_DEBUG_FD, e.g. direct manual testing with no DAP client at all) also still triggers it.DIAMOND_DEBUG_BREAKPOINT_OFFSETS=<offset>[,<offset>...]-- the initial armed set of byte offsets to line starts in the expanded source. This is whatd6-dapsupplies; livesetBreakpointscommands replace the entire set using{"command":"setBreakpoints","offsets":[...]}. An empty value arms no breakpoints.DIAMOND_DEBUG_BREAKPOINTS=<line>[,<line>...]-- the legacy manual interface, using per-segment line numbers. It remains available for direct debugging, but identical line numbers in different files collide. The offset variable takes precedence if both are set.
Building it
make dap # build/d6-dap
make test-dap # tests/dap_test.sh -- a scripted setBreakpoints+launch+
# configurationDone+continue session against a fixture
make test-all already includes test-dap.