Xo gets a debugger
Since 0.26.0 an Xo executable has carried its DWARF debug information
inside it (see where macOS keeps a program’s debug info),
so lldb and gdb can show its source lines, functions and variables. But
neither knows what an Xo task is, and neither is on every machine. The
next release adds xo debug: a debugger that ships in the xo binary,
knows the language and its runtime, and speaks to editors and agents as
well as people. This post covers what it does, how it is built, how it
compares with Delve (the Go debugger, which plays the same role for Go),
and two bugs it found before it was done.
A session
Here is a small program with two tasks and a function to stop in:
fn total(prices: List[Int], tax: Int) -> Int {
var sum = 0
for p in prices {
sum += p
}
sum + sum * tax / 100
}
fn main(os: Os) {
let lock = Mutex[Int].new(0)
scope s {
s.spawn(fn() {
os.clock.sleep(10s)
})
s.spawn(fn() {
lock.with(fn(var n) { n += 1 })
})
os.clock.sleep(100ms)
let t = total([120, 80, 45], 8)
os.stdio.println("total ${t}")
}
}Built with xo build (debug information is on by default) and run under
xo debug with a breakpoint in the loop, on a Mac:
(xo) break main.xo:4
(xo) run
stopped at breakpoint 1: 0x100000b5c total [inlined] at main.xo:4
(xo) bt
* #0 0x100000b5c total [inlined] at main.xo:4
#1 0x100000b5c main at main.xo:19
#2 0x1000567b8 xo_entry+20
...
(xo) locals
prices = 0x100800540
tax = 8
sum = 0
p = 120
(xo) tasks
main (task 1): in scope
scope: running
task 2 (spawned at main.xo:12): blocked in sleep at main.xo:13
(xo) task 2
task 2 (spawned at main.xo:12): blocked in sleep at main.xo:13
* #7 0x100000e9c main.fn1 at main.xo:13
(xo) bt
#0 0x100028660 sched.fiber_park at sched.xo:310
#1 0x100022ff4 pool.park_wait_fiber [inlined] at pool.xo:2959
...
#6 0x100002874 cabi.clock_sleep at cabi.xo:581
* #7 0x100000e9c main.fn1 at main.xo:13
...
total was inlined into main, and the debugger still stops in it by
its own name and line and shows its parameters and locals. tasks shows
the program’s task tree: task 3 (the lock.with) had finished, and task
2 was parked in its sleep. task 2 selects that task’s stack, which
belongs to no running thread: it is read from the task’s parked fiber,
and its frames, locals and print work as they do for the stopped
thread. Stepping works by source line, through inlined calls:
(xo) next
0x100000b64 total [inlined] at main.xo:3
(xo) next
0x100000b5c total [inlined] at main.xo:4
(xo) print sum
sum = 120
The commands are break, run, continue, step, next, finish,
locals, print (with a field path), backtrace, frame, threads,
tasks, task N, attach and quit. A list still prints as its
address, as prices does above; showing a collection’s elements is on
the list of what comes next, with conditional breakpoints and evaluating
expressions.
xo debug -p PID PROG attaches to a program that is already running, a
hung server for example: every thread is stopped, tasks shows what
each task is blocked in, and quit detaches and leaves the program
running.
For agents: –batch and –json
Xo is built to be used by AI agents as much as by people, so the
debugger runs without a terminal. --batch takes its commands from -c
flags (without them, from standard input) and echoes each before its
answer; the program is ended after the last one. --json turns
every answer into one JSON object a line:
$ xo debug --batch --json -c 'break main.xo:4' -c run -c locals shop
{"v":1,"cmd":"break","ok":true,"id":1,"locations":[{"pc":"0x100000b48","file":"main.xo","line":4},{"pc":"0x100000d58","file":"main.xo","line":4}]}
{"v":1,"cmd":"run","ok":true,"stop":"breakpoint","id":1,"thread":3843,"frame":{"index":0,"pc":"0x100000b48","function":"total","inlined":true,"file":"main.xo","path":"./main.xo","line":4,"selected":true}}
{"v":1,"cmd":"locals","ok":true,"frame":0,"vars":[{"name":"prices","type":"List[Int]","param":true,...},{"name":"tax","type":"Int","param":true,"text":"8",...},{"name":"sum","type":"Int","param":false,"text":"0",...},{"name":"p","type":"Int","param":false,"text":"120",...}]}
An agent chasing a wrong value can stop at a line, read the locals and
step, with no screen to scrape. The breakpoint above has two locations
because total was inlined at its call and also kept as a function;
the debugger plants both.
In VS Code
xo debug --dap serves the Debug Adapter Protocol, the protocol VS Code
and other editors use to talk to a debugger. The
Xo Language extension
(also on Open VSX), now in
its own repository, xo-lang/vscode,
adds a debug type xo with launch and attach. F5 on an open .xo file
builds its package and debugs it, with breakpoints in the gutter,
the call stack (each of the program’s threads), the variables view and
stepping. Debugging needs an xo with xo debug --dap, the release after
0.39.0; with an older one the extension says which xo it found instead
of failing quietly.
How it is built
The debugger is written in Xo, like the rest of the toolchain, and uses no lldb, gdb or C library of either:
- Its own DWARF reader.
compiler/debugreads the program’s line table, its variables and their locations (including location lists, where a variable moves between registers and the stack), inlined calls, and the unwind tables. It was tested first against the values lldb and gdb print for the same programs, read without them. - Its own process control on each OS. On macOS it starts the program
suspended and receives its breakpoint traps through Mach exception
ports, reading and writing registers and memory with the Mach calls
(
thread_get_state,mach_vm_read,mach_vm_write). On Linux it uses ptrace (PTRACE_SEIZE, register sets,process_vm_readv), with every ptrace call made from one tracer thread, since Linux takes a traced process’s requests only from the thread that seized it, and an Xo fiber can move between threads. Windows uses the system’s debugging functions (WaitForDebugEvent,ReadProcessMemory, thread contexts) and walks the stack with the executable’s own unwind tables (.pdata). Each OS’s calls are listed in the design decision, and the owner approved each list before it was used. - The task walk from the runtime’s own records.
tasksdoes not guess from the stack: it reads the runtime’s task tree, scopes and parking records out of the program’s memory, the same records thatxo dumpprints from a crashed program. A copy of the runtime’s layout in the debugger is checked against the runtime by a test, so a change to the runtime that would make the debugger misread it fails the build instead. - Stepping by line through Xo’s code.
step,nextandfinishfollow source lines through inlined calls, recursion and tail calls, using the line table and the call instructions of each architecture (arm64 and x86_64).
It runs on macOS arm64, Linux arm64 and x86_64, and Windows x86_64, with attach on all three. (Updated 2026-11-05: attaching on Linux and the Windows port landed the day this post went out.)
Compared with Delve
Delve is the Go debugger, and it
plays the same role for Go that xo debug does for Xo: a debugger that
knows the language rather than only C’s view of it, understands the
runtime’s lightweight threads (Go’s goroutines, Xo’s tasks), and serves
DAP to editors (dlv dap). Delve is the far more mature of the two:
conditional breakpoints, expression evaluation, core files, and years of
use. xo debug has none of the first two yet.
The differences that are by design:
- It ships inside
xo. There is nothing to install: the toolchain that built the program can debug it, on every machine that has the toolchain. Delve is a separate install (go installor a package manager). - It is written in Xo, as the compiler is, so it is built and tested by the same toolchain it serves.
- Process control on macOS. Delve’s default backend on macOS is
lldb, which drives LLDB’sdebugserver(orlldb-server); its native macOS backend exists but its install guide says you do not need it and that it has known problems.xo debugmakes the Mach calls itself, so it needs no Xcode command line tools. As for any debugger on macOS, the Mac needs Developer Mode, and the program being debugged needs theget-task-allowentitlement, whichxo buildgives darwin debug builds by default (not--releaseor-sbuilds); the releasedxois signed with the debugger entitlement.
Two bugs it found
Writing a debugger exercises the debug information harder than reading a crash report does, and it found two bugs before it was done:
- A variable’s location ran past the call that overwrote it. After
finishout offact(n), the caller’snread 6 (the call’s result) instead of 3. The variable lived in a register the call does not preserve, and its DWARF range ended after the instructions that move the call’s result, so at the return address it still claimed the register that by then held the result. lldb showed the same wrong value. The compiler’s variable ranges now end at the call’s return address (gotchas/dwarf-range-past-call-result.md; testTestDebugVarsAfterCall, which runs lldb’sfinish). - A breakpoint planted again was not seen. A breakpoint in a loop
stopped once, and then the program usually ran to its end, although
reading the address back showed the trap instruction. On arm64 the
write reaches memory but not the instruction cache, so the CPU kept
running the old instruction until the cache line was evicted. A
second symptom on the same path: while the page was writable it could
not also be executable (macOS refuses both at once), and the program’s
other threads faulted when they ran code on that page. The debugger
now flushes the instruction cache after each write, as LLDB’s
debugserver does, and stops every thread at a stop
(
gotchas/debugger-trap-write-needs-flush-and-stop.md).
The design is decision 0253 in the repository
(knowledgebase/decisions/0253-xo-debugger.md), with the commands in
the spec, section 11.5.