Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite
The Debugger#
Status: decided and built on 2026-09-28 for the VM, with conditions, hit counts and logpoints on breakpoints since
2026-10-15, setVariable since 2026-10-17 and the tasks as threads since 2026-10-23; native programs are half built: the lines, the layout table and gdb's printers (sections 18 and 20). The goal is the owner's: in VS Code every
test block and every entry file gets a Debug button next to its Run button, and debugging works as in any
mainstream language - breakpoints, stepping, the call stack, locals, watch and evaluate. This record decides how, one
table of options per decision, and says what was chosen and why.
VS Code ──DAP over stdio──► torb debug the adapter (compiler/src/debugger/adapter.trb)
│ answers initialize itself, forwards every other request,
│ turns the program's output into `output` events
│ starts, with the program's own standard output and error as pipes
▼
torb debug --debuggee the debuggee (compiler/src/debugger/debuggee.trb)
│ reads the requests from standard input, answers on standard output
│ (each answer one line behind U+001E, between what the program prints)
▼
the VM with a Debugger compiler/src/vm/debug.trb, the loop of interpret.trb
│ a `statement` instruction per statement - only in a debugged program -
│ asks the Debugger whether to stop; a panic calls it back before it ends
▼
the program's frames, read by address and rendered through the IR's types- 1. The protocol: the Debug Adapter Protocol, and
torb debug - 2. Two processes: the adapter and the debuggee
- 3. The channel between them
- 4. The VM first; native programs later
- 5. What a debugged program carries: statements and the bindings in sight
- 6. A binding lives to the end of its scope
- 7. Breakpoints, and zero cost without a debugger
- 8. Stepping across frames, closures and tasks
- 9. Pause
- 10. Frames and locals
- 11. How values are shown
- 12. Evaluate and watch
- 13. Panics, and
? - 14. Tasks and workers
- 15. The REPL
- 16. Tests and entry files
- 17. VS Code
- 18. Slices
- 19. What it costs
- 20. Open
1. The protocol: the Debug Adapter Protocol, and torb debug#
| Option | For | Against |
|---|---|---|
The Debug Adapter Protocol (DAP) over standard input and output, torb debug as the adapter executable | What VS Code, Neovim (nvim-dap), Emacs (dap-mode), Helix, Zed and the JetBrains IDEs speak; framed like the Language Server Protocol, so torb lsp's framing and JSON are reused | A second protocol besides the language server's |
The debugger inside torb lsp | One process per editor | DAP is its own session with its own life cycle, and an editor starts a debug adapter per session; no client expects a language server to debug |
| A protocol of our own, with an extension-side adapter in JavaScript | Nothing to learn from DAP | Every editor but VS Code would need its own adapter; the logic would live outside the compiler that knows the program |
Decision: DAP over stdio, with torb debug as the adapter, the way torb lsp is the language server: one binary,
one subcommand, nothing to install besides torb. The framing (Content-Length, then the JSON body) is the one
compiler/src/language-server/protocol.trb reads and writes already, and language-server/json.trb builds and reads
the bodies. torb debug adds no native, no flag the build uses and no syntax: it needs no second commit and no seed
that knows it (CLAUDE.md, "Seed and breaking changes").
2. Two processes: the adapter and the debuggee#
A debugged program writes to standard output and standard error, may read standard input, may panic and may call
Process.exit. A DAP adapter on stdio owns standard input and output, and must outlive the program to report its exit.
| Option | For | Against |
|---|---|---|
One process: torb debug speaks DAP and runs the VM | No second process, no second protocol | The program's print lands in the DAP stream; a panic or Process.exit ends the adapter with the program; the VM refuses to run its scheduler inside a task, and a DAP reader that waits for a pause while the program runs has to be one |
| Two processes: the adapter speaks DAP with the editor, a debuggee process runs the program | The program's streams stay the program's, its exit is the debuggee's; the adapter is a relay that survives everything | A channel between the two (section 3) |
| The adapter in the extension, in JavaScript | No second TorbScript process | Only VS Code would have it (section 1) |
Decision: two processes, both torb. torb debug is the adapter: it answers initialize, starts
torb debug --debuggee on launch, forwards every request to it, and turns what the program writes into output
events. The debuggee does everything that needs the program - it compiles it, resolves breakpoints against its line
table, runs it in the VM and answers stackTrace, scopes, variables and evaluate - so the adapter stays a relay
of about two hundred lines, and the logic lives next to the compiler that knows the program. When the debuggee ends,
the adapter sends exited with its exit code and terminated.
The adapter renumbers the seq of every message it sends, so the debuggee's answers need no numbering of their own.
threads is the debuggee's, because the tasks are the program's (section 14).
3. The channel between them#
The debuggee runs the VM at the top level of torb - not in a task (section 2) - so it can only use blocking reads,
and while the program runs it has to notice a pause without waiting for one.
| Option | For | Against |
|---|---|---|
| A loopback socket the adapter listens on | The program keeps all three standard streams | No blocking socket in std/network (every read is a task); raw sockets in the kernel would duplicate the runtime's Winsock loading of runtime/os/iocp.c |
| Files the two poll | Portable, nothing new in C | Latency, and a file two processes write is a race on Windows |
| The debuggee's standard input for requests; its standard output for answers, each one line behind U+001E between what the program prints | Three kernel operations in C: a buffered line reader of standard input, whether a line is waiting without blocking, a flush; the answers and the program's output are one ordered stream, so an answer never overtakes the output before it | A debugged program has no standard input of its own |
Decision: standard input and output, framed by U+001E (the ASCII record separator). The adapter writes each
request as one line of JSON to the debuggee's standard input. The debuggee writes each answer and event as U+001E,
the JSON, and a line feed to its standard output, with the host's own print - through the same buffer as the
program's print - and flushes it (kernel operation DebugFlush). JSON escapes every control character, so neither
U+001E nor a line feed can occur inside a message; the adapter takes everything between two messages as output of the
program, and a record separator the program itself printed that is not followed by a message it can parse is output
too. Standard error stays the program's alone.
The debuggee reads its requests through kernel operations of its own (DebugRead, DebugTake, DebugPending,
runtime/debug.c): a reader with its own buffer, because a FILE * buffer would hide a waiting pause from the
check that looks at the pipe (PeekNamedPipe on Windows, poll elsewhere). An operation of the kernel is a row of a C
table and a constant of the emitter, not a native (docs/design/VM.md section 6), so none of it needs a second commit.
What the program gives up is its standard input: a program that reads it under the debugger reads the adapter's
requests. A program that reads its input is launched with console: "integratedTerminal" or "externalTerminal", and
the channel then is the loopback socket of the first row, below.
The terminal route. The first row's objection was that std/network has no blocking socket; the debuggee does not
need std/network, because runtime/debug.c has the one connection it needs, with the platform's own calls (Winsock
loaded on first use, as runtime/os/iocp.c does, so that no build links -lws2_32). When the client's initialize
says supportsRunInTerminalRequest and a launch has console: "integratedTerminal" or "externalTerminal" and no
test, the adapter listens on 127.0.0.1 at a port the system chooses and sends the reverse request runInTerminal
with torb debug --debuggee --connect <port> --token <token>, the token 16 bytes of Entropy as hexadecimal. The
debuggee connects (DebugConnect), writes {"type":"hello","token":...} as its first line, and the adapter takes the
connection only if the token is the one it made: the port is open to every process of the machine for the moments
until then, and the token is what tells the editor's terminal from any of them. After that the same lines of JSON go
both ways, one per line, read by the same buffer (DebugRead, DebugTake, DebugPending serve the connection while
there is one) and written with DebugWrite, without the U+001E framing, since nothing else is written to the
connection. The two kernel operations are the last two of the table (86 and 87), so no seed is touched.
The adapter does not own the terminal's process, so it cannot read its exit code. The runtime tells it: torb_process_exit
and the panic path run one hook, installed by DebugConnect, that writes the event torbscript/exited with the exit
code down the connection once the streams are flushed. The adapter turns it into exited and terminated; a
connection that ends without it (the terminal was closed, the process killed) is exited with 1. A request that comes
while the process starts waits and is handed on in order behind the launch. A test run stays on pipes, because the
report it reads is the program's standard output.
A program that waits for a line of the terminal is blocked in its read, and the debuggee notices a pause only
between statements (section 9): the pause is answered once the program reads on. The channel is a blocking socket of
runtime/debug.c's own, and it is POSIX and Windows; the playground has no sockets and answers that it cannot connect.
Where the adapter's own input ends, it hands the debuggee one more line, torbscript/endOfInput, after every request
before it: nothing more comes. The debuggee answers what it still holds, lets the program run to its end without
stopping again - the breakpoints gone, the filter off - and the adapter ends once it has. An editor that crashed leaves
no program waiting at a breakpoint forever, and a session written down in advance (tests/debug/) is piped in whole.
TORB_DEBUG_TRACE makes the adapter write a line per message and per step to standard error.
4. The VM first; native programs later#
| Option | For | Against |
|---|---|---|
| The VM first, native programs as a later phase | torb run and torb test are the VM already; the interpreter is TorbScript, so a hook is a line of the loop, and frames, registers and values are data it reads by address | A native binary is not debugged yet |
| Native first: DWARF from the C back end, gdb or lldb behind DAP | The debuggers exist | Their DAP adapters (lldb-dap, cppdbg) speak C: a local is s12_count, a String is a torb_text struct, a list is a pointer, a case is a tag and a union; pretty printers per debugger and per platform before a value reads right |
| Both at once | Twice the work before the first breakpoint |
Decision: the VM first. The native half is assessed and planned here, and built later:
What the C back end gives already.
#linedirectives point every block, every panic and every intrinsic at the TorbScript line (backend/c/emission.trb,lineDirectiveOf), and a slot is a C variable named after its binding (s12_count,slotNameOf). WithdebugInformation = truein a profile ofproject.trb(-g,/Zi), gdb and lldb already set a breakpoint atsrc/main.trb:12and step by TorbScript lines - block by block, because a block and not a statement carries the directive.What the later phase adds. The statement markers of section 5 become a
#lineper statement in the C back end (it writes one already - only a debugged lowering has them);torb build --debugcompiles with-O0 -gand those markers; pretty printers for gdb (Python) and lldb (formatters) showtorb_text,torb_list,torb_mapand the program's layouts from a table the emitter writes (built for gdb, slice 8; lldb is next); andtorb debuglauncheslldb-daporgdb --interpreter=dapfor a native program, translating names (s12_counttocount) invariables.The first step is built (slice 7 of section 18).
torb build --debuglowers withdebugging: trueand compiles with-O0 -g(/Od /Zi) whatever the profile says, intobuild/debug/.tools/debug-lines.sh, a gate of tier B, buildstests/debug-lines/(several statements per block) and checks that the debugged binary prints what the plain build prints and, on Linux, that its DWARF line table (readelf, elseobjdump; skipped where neither exists) has an entry for each statement line.The layout table and gdb's printers are built (slice 8 of section 18).
torb build --debugwritesbuild/debug/<name>.layoutsbeside the binary (backend/c/layouts.trb), and no other build writes it: the C is byte for byte what it was. It is line based and tokens are split on one space; the rest of a line is a type, spelled astorb irspells it (Int64,String,Record(T_main_Point),List(Int64),Map(String, Int64)):torb-layouts 1 record <CName> <declaredName> <repr> field <name> <cMember> <type> case <CName> <declaredName> <repr> field <name> <cMember> <type> a field every variant carries variant <index> <name> field <name> <cMember> <type> <cMember> is the path inside the union: payload.v1.f_radius tuple <CName> <repr> field <name> <cMember> <type> element <descriptorName> <size> <alignment> <type><repr>isinline,boxedorniche/<variant>/<field>, and-stands for a layout that has no declared name. The records, cases and tuples are those the emitter writes a struct for, sorted by their C name; anelementline is thetorb_elementdescriptor of eachList,MapandSetinstance (d_<Type>, astatic constof the C), so that a printer finds a container's item type by the address instorage->element. The runtime's owntorb_element_int64andtorb_element_textare not listed. A type the table does not list is shown raw, and the program still builds and runs.tools/gdb/torb_printers.pyreads the table and registers the printers; it is loaded on request (gdb -x tools/gdb/torb_printers.py build/debug/<name>), never auto-loaded into the binary. Besides the printers it names a local as the source does inbt fulland in the machine interface, andtorb-print <name>andtorb-localsprint a binding in the form of section 11.tools/debug-values.sh, a gate of tier B, buildstests/debug-values/with--debug, checks the table and, where gdb with Python is installed, runsgdb --batchto the fixture's last statement and compares the bindings withtests/debug-values/expected.txt; without gdb it skips with a message. The lldb formatters read the same table and are the next step.
5. What a debugged program carries: statements and the bindings in sight#
A breakpoint names a line, a step goes to the next line, and a frame shows the bindings of its line. The final IR knows
blocks, and a block's location is the construct it came from - an if, a loop, a function's entry - not every
statement. The bytecode's sites hold the locations of blocks and of the instructions that carry one (a call, a
panic), which is what a stopped script's line comes from (docs/design/SCRIPTS.md section 6) and too coarse for a line
of a debugger: const point = Point(1, 2) has no instruction with a location.
| Option | For | Against |
|---|---|---|
| Locations on every instruction | Exact | Every instruction of the IR grows a field; the passes that make instructions have to invent one |
| A side table (block, instruction index) to line | No change to the instructions | The ownership pass and the devirtualization insert and rewrite instructions, and every index moves |
An instruction Statement(at, visible) at the start of every statement, written only when a program is lowered for debugging | Passes carry it through as they carry any instruction without operands; the emitter turns it into the line table and a trap point; the C back end can turn it into a #line | One more case in every exhaustive match over instructions |
Decision: Instruction.Statement(at, visible), written by a lowering that is asked to (lowerWorkspace(debugging: true)). at is the statement's location. visible is every binding of the source that is declared and still in
scope where the statement begins: the lowering knows it exactly, because it keeps the stack of open scopes anyway
(ir/lower/scope.trb), and a list per statement answers "which locals does this line show" without a live range or
a liveness question. Parameters and a closure's captures are in sight for the whole body and are not listed. A marker
is written before every statement of a block, of the top level of an entry file, a test file and a receiver script,
and before the body of every arm of a match; a declaration inside a block writes none.
The bytecode emitter writes a statement instruction for it (Opcode.Statement, its tail the index of the statement
in the chunk's table) and one entry in the chunk's statement table: the code offset, the location, and the register,
width and IR slot of every binding in sight. That table is the line table and the variables table at once. The
names and types of the slots are the IR's, which the debuggee keeps beside the bytecode: it compiled the program
itself (section 2), so there is no debug format to write and to read back.
6. A binding lives to the end of its scope#
The ownership pass releases a value after its last use (docs/BACKEND.md section 2), and a move empties the slot it came from. A debugger that shows a binding for the rest of its block would show a slot whose block was freed - a crash inside the debugger, not only a wrong value.
| Option | For | Against |
|---|---|---|
| Show a binding only while it is live | Nothing changes in the program | Bindings vanish in the middle of their block; a moved slot still holds a pointer, and a borrowed alias of a released value cannot be told apart |
| Zero a slot where it is released or moved from | The slot says whether it holds something | A borrowed alias of another binding is released by nobody and dangles all the same |
The ends of scope of Close values (EndOfScope) for every binding | The machinery exists | It keeps a move a move by design (docs/design/DESTRUCTORS.md 2a), so a binding handed to append is still empty afterwards |
KeepAlive(slot) at the end of every scope, for every binding in it, in a debugged program only | An ordinary borrowed use: the ownership pass puts the release after it and turns an earlier move into a copy, so a binding holds its value to the end of its block, as in the -O0 build of any compiled language | A debugged program holds values longer, which only a leak report's peak can tell |
Decision: Instruction.KeepAlive(slot), written by a debugged lowering where a scope closes, for every binding of
that scope, the last declared first, and where a body ends for its parameters and captures, which every frame shows.
Nothing else about the program changes: the back ends write nothing for it. Where a scope is left by return, break
or ?, nothing is written: the bindings go out of sight there.
A binding whose value may hold a Close object - a parameter the frame owns included - keeps the end of scope it
has (EndOfScope, docs/design/DESTRUCTORS.md 2a) and gets no KeepAlive: keeping it alive would turn the move that
hands it on into a copy, and move its close() to another line. Where such a binding is moved, the bytecode emitter of a debugged chunk zeroes its slot
after the instruction (emptyMovedBindings), so it reads as empty and never as the words of a value somebody else owns
now.
Together with the visible list of section 5 this is the one guarantee the debugger reads values by: a binding in
sight holds a value it owns, borrows from a binding or temporary that outlives it, or is empty.
TORB_VM_DEBUGGED=1 torb run lowers a program for the debugger and runs it without one - the markers do nothing, the
bindings live longer - which has to change nothing the program prints; tools/conformance.sh --vm under it holds the
whole suite to that (section 19).
7. Breakpoints, and zero cost without a debugger#
| Option | For | Against |
|---|---|---|
| A check in front of every instruction | Any instruction can be a breakpoint | A load and a branch per instruction of every program, debugged or not |
Patched opcodes: a breakpoint overwrites an instruction's head with break, which runs the original | Zero cost until a breakpoint is set; the classic design of native debuggers | Stepping still needs a check per line; the image's code is shared by the workers of the pool |
A statement instruction that only a debugged program has, which asks the debugger | A program that is not debugged has no such instruction, so it runs exactly the code it ran before; a debugged one pays one dispatch and one call per statement, which steps, breakpoints and pause all share | A breakpoint is a line, not an instruction; a program is compiled for debugging (as -g compiles for gdb) |
Decision: the statement instruction, only in a debugged program. torb run, torb test and every other host of
the VM lower without markers, and the loop's only change is one more arm of its match and a test of
Registers.debugger where an execute begins (per call back, never per instruction) - section 19 has the measurement.
The arm is one call to atStatement (compiler/src/vm/debug.trb), so that the loop keeps its shape; in a debugged
program that hands the statement to reachStatement, which notes where the loop is,
counts down to the next look at the channel (section 9), and stops for a pause, for a breakpoint - one bit per
statement of every chunk, in a table the host makes anew whenever the breakpoints change, which a run without
breakpoints does not look into - or for the step in progress (section 8).
A breakpoint at a line binds to the first statement of that line in each chunk that has one; a line without a
statement moves to the next line that has one, in the same file, and the answer to setBreakpoints says the line it
bound to (verified and line). A closure written on the line of its caller is a chunk of its own, so a breakpoint
there stops in both.
Refinements of a breakpoint: a condition, a hit count, a log message. setBreakpoints reads condition,
hitCondition and logMessage of each breakpoint (the adapter says so in supportsConditionalBreakpoints,
supportsHitConditionalBreakpoints and supportsLogPoints), and answers every breakpoint with an id. The VM does
not learn any of it: a statement with a set bit calls the host (Debugger.hit, in compiler/src/vm/debug.trb), which
answers stop or go on, and a statement without a bit costs nothing more than before. At a statement that is reached,
the order is: a pause and the stop on entry first, then - if the statement has a set bit - the host, then the step in
progress. Go on behaves as if there were no breakpoint, so a step may still stop there, with the reason step.
- One breakpoint of the editor is one counter, however many chunks its line binds to (a closure on the line of its
caller). The
stoppedevent of a breakpoint names the ones that stopped it inhitBreakpointIds. - The condition is read in the innermost frame and runs no code (grammar in section 12). A condition that is false
does not count as a hit. A condition that cannot be evaluated - a name that is not there, operands of different
kinds, a result that is no
Bool- is a condition error: the program stops (reasonbreakpoint), and anoutputevent of the categoryconsolesaysBreakpoint condition '<condition>' at <file>:<line> could not be evaluated: <why>, so that a broken condition never lets a breakpoint go unnoticed. A condition or hit condition that cannot be read at all is answeredverified: falsewith amessage, and binds nothing. - The hit condition is
N,== N,>= N,> N,<= N,< Nor% N(every Nth). A hit is a time the statement is reached with its condition true, counted from 1; the counter starts at 0 atlaunch. It survives asetBreakpointsthat sends the breakpoint again unchanged (same line, condition, hit condition and log message, and keeps itsid), and restarts when any of the four changed. - A logpoint has a
logMessage:{expression}is replaced by the value of an expression of section 12 asvariablesshows it, and\{and\}are braces of the message. The condition and the hit count apply first. If they select the hit, the message and a line feed go out as anoutputevent of the categoryconsole, withsourceandline, and the program does not stop; a logpoint never producesstopped. An expression that cannot be evaluated is written<error: why>. - When the input ends (section 3) the breakpoints are gone, so are the logpoints:
runToTheEnddrops the metadata with the bits.
8. Stepping across frames, closures and tasks#
A frame's height is how many frames lie below it, counted over every execute in progress - the loop of the
program, and the loops a call back started for a test body, an equals a map asks for, a task the scheduler resumes,
a destructor. The innermost execute notes its chunk, code offset, base and depth in a few words of the debugger at
its every statement; a record of it is written only where a call back begins above it, and taken back where that call
back ends. So the height is known at every statement without walking anything, and the loops below a stop are there
for its frames.
- Step over (
next) stops at the next statement of a lower frame, or of the same frame on another line - or on the same line again where the loop jumped back. - Step in stops at the next statement anywhere but the rest of the current line: a call's first line, a closure's
body however it was called (
map,filter, a witness table, the runtime), a test body, a destructor. - Step out stops at the next statement of a lower frame: the caller's next line.
- The same frame is the same height, base and return address, so a function that returns and a sibling the caller calls next at the same height are told apart.
- Just my code (the launch option
justMyCode, on by default): a step does not stop in a file of the standard library - a step intolist.mapstops in the closure the program gave it. A breakpoint stops everywhere, and a frame of the standard library is showndeemphasized. - Tasks. A task's body is a chunk like any other; where it suspends, its frame goes into the task block and the
loop returns to the scheduler (docs/design/VM.md section 4), and where the scheduler resumes it, a call back
continues it. A step over an
await()stops at the next statement that runs - of the task itself once it resumes, or of whatever else the scheduler runs first.
9. Pause#
| Option | For | Against |
|---|---|---|
A signal (SIGINT, a console control event) the adapter sends, seen by the interrupted() the loop reads every 4096 steps | The check exists (torb repl's Ctrl+C) | No native sends one; on Windows a console control event needs a shared console the adapter does not have |
| A look at the channel every 1000 statements of a debugged program | The requests are on the channel anyway (section 3); DebugPending is one PeekNamedPipe or poll | A program blocked in a native call - readLine, a sleep, a wait for a task - pauses when it runs its next statement |
Decision: the look at the channel. Every thousandth statement the debuggee reads what arrived: pause stops at
the next statement with the reason pause; setBreakpoints, setExceptionBreakpoints and threads are answered at
once; disconnect and terminate end the process. A request that needs a stopped program - stackTrace,
variables, continue - waits, with every request after it, until the program stops, and is answered first then.
A client never sends one while the program runs; a session written down in advance does, and reads as the editor would
have sent it. A pause, disconnect and terminate pass the requests that wait.
10. Frames and locals#
The frames of a stop are read out of the registers: the innermost frame of every execute at its record, and every
caller of it through the return records below the frames (docs/design/VM.md section 4), innermost first. A caller's
line is the last statement before its return address.
A frame is named after its function: its declaration's name, a method's with its type in front (Point.area),
closure for a closure's body, the file's name for the top-level code of an entry or a test file. The body of a test
or a group is a closure too, but one the test runner calls back, as the first frame of an execute of its own; the
frame below it is at the call of test or group, and the name that call was given is read out of that frame's
registers at the stop: test "doubles", group "Math". One body run as many tests - test "case {n}" in a loop - is
named after the test that runs it at each stop. Where the call says no name, the frame keeps closure.
A frame's one scope, Locals, lists its parameters, its captures and the bindings of its statement's visible
list, in declaration order: the value of each read from its registers - through the reference for a var parameter,
whose register holds an address - and shown by its IR type (section 11). A binding the program shadowed shows once,
the innermost. The compiler's own slots - temporaries, the environment of a closure - never show.
11. How values are shown#
A value in the VM is the runtime's own bytes (docs/design/VM.md section 2), so the debuggee reads words and the IR
type says what they are; backend/bytecode/words.trb says where every field lies.
| Type | Shown as | Children |
|---|---|---|
integers, Bool, Char, floats | 42, true, 'a', 2.5 | - |
String | "text", cut after 1000 characters | - |
| a record, a tuple | Point(x: 1, y: 2), fields shown flat to a depth of one | one per field |
| a case | Some(3), .Circle(radius: 2.0), None - the tag word, or the niche's empty first word | the fields of the case |
List, ArrayList, Array | [1, 2, 3] up to ten items, List(1200) beyond | one per item, in pages of 100 |
Map, Set | [a: 1, b: 2], Set(3) | one per entry, the key as the name |
a shared type object | Door(open: true) | one per field |
| a trait-typed value | the value it holds, by the target type of its witness table | the value's |
| a closure, a task, a channel, a file | closure name, Task, Channel, File | - |
A value with children gets a variablesReference, numbered per stop, whose children are read when the editor expands
it (lazily: a list of a million items reads the hundred of the page that is open). An item of a list is found through
the runtime (DebugContainer, torb_list_at, torb_map_next), a narrow item (List<UInt8>) through the kernel's
NarrowLoad, a text through TextOut. The debuggee makes these calls on words of its own, never on the program's
registers, and a null pointer is shown as <none> instead of being followed.
Native column. In a native program built with --debug the same forms come from the pretty printers of
tools/gdb/torb_printers.py (section 4) over the layout table: torb-print writes exactly the forms above, and
gdb's own print shows the printer's Point(x: 1, y: 2) with the fields as children. A String is cut after 1000
characters, a List shows ten items and then List(n) and a Map or Set the same with Set(n); a null storage
pointer is <none>. lldb has no formatters yet.
12. Evaluate and watch#
| Option | For | Against |
|---|---|---|
| A restricted evaluator over the stopped frame: names, fields, tuple fields, indices | Reads what variables reads, so it cannot change the program or run its code; answers hover and watch at once | Not every expression |
A snippet compiled as a continuation and run by the VM, as an entry of torb repl is (docs/design/REPL.md section 5) | Any expression, method calls included | Running code in a stopped program can change it, allocate and panic; a frame's bindings would have to become the snippet's parameters |
Decision: the restricted evaluator first (compiler/src/debugger/evaluate.trb): name, name.field.field,
pair.0, list[3], map["key"], for hover (context: hover), watch and the debug console alike. A name is looked
up in the frame's Locals. A text it refuses is, in the debug console only, a snippet (below).
The condition of a breakpoint (section 7) is read by the same evaluator, extended by comparisons
(compiler/src/debugger/refinements.trb) - still read-only, in the innermost frame, no code runs:
condition = or
or = and { "||" and }
and = not { "&&" not }
not = "!" not | comparison
comparison = operand [ ("==" | "!=" | "<" | "<=" | ">" | ">=") operand ] | "(" or ")"
operand = expression of this section | literal
literal = 42 | -1 | 2.5 | "text" | 'a' | true | falseThe operands of a comparison are of one kind - Int, Float, String, Char or Bool, and a Bool only with
== and !=; a bare operand is a condition if it is a Bool. ! applies to the whole comparison after it (!a == b
is !(a == b)), && binds tighter than ||, and both read their right side only where the left one does not decide.
Anything else - another operator, a call, a kind mismatch, a missing name, a result that is no Bool - is a condition
error.
setVariable writes a scalar back through the place variables read it from, and runs nothing. A Value carries
the address of its first word (place) and the counted block it lies in; a field, a tuple field or an item of a fixed
array derives its place from its parent's, a boxed record or a shared type object from the block at pointer + 16, and
a captured var from its box.
- What is written: an integer of any width, a float, a
Boolor aChar- one word, stored withMachine.store. Anything else (aString, a list, a map, a case, a whole record) answerssuccess: falsewithOnly numbers, Bool and Char can be set: writing one would allocate and update counts inside a stopped program, which this section rules out for evaluate and which the snippet evaluator of section 20 is for. - Where: a local (its register, or the address a
varparameter holds), a field of a record, tuple orshared typeobject, an item of a fixed array, a capturedvar. Items of aList,MapandSetand fields of a case's payload are refused: the read side finds them through the runtime's iterators, not through a stable address. - The text is one literal of the grammar above (
42,-1,2.5,'a',true,false) that fits the target: a whole number inside the range of its integer type, a number for a float (aFloat32is rounded),trueorfalsefor aBool, a character for aChar. Nothing is evaluated, and nothing is written before every check has passed. - Sharing: a boxed record may be held by several bindings (count above one), and a write through one shows through the others, as in any native debugger. A block of constant data (an immortal count) is never written.
- After a write every reference of the stop reads its words again from their places, so a
variablesrequest for the parent or a sibling view shows the new value, and the answer carriesvalue,typeandvariablesReference. The request waits for a stop likevariablesdoes.
A snippet (compiler/src/debugger/snippet.trb) is the fallback of the debug console: an evaluate with context: repl whose text the restricted evaluator refuses - a call of a function or of a method, names.length(),
point.distance(origin), double(count). Hover, watch, clipboard, logpoints and conditions never take it, so they keep
their read-only guarantee. It is compiled like an entry of torb repl (REPL.md sections 4 and 5) and run in the
program that stands:
- One expression, in the innermost frame. Statements, declarations and the outer frames are not evaluated; a text that is one says so. A stop at a panic and a frame of the standard library answer that they run no snippet either.
- The module is the frame's own file, its statements blanked as the REPL blanks an entry's declarations, so every
function and type the file sees is in sight, plus one function whose body is the expression. The frame's Locals that
the expression names are its parameters, written with the type text
variablesshows (Point) - except for the storage of a list, a map or a set, which is written as the implementation a literal builds (ArrayList<String>,TrieMap,TrieSet):List<String>in a signature is the trait, a boxed payload with a table, and the frame holds the words of the storage. A Local whose type the file cannot write - the module that holds the type is not imported there - answers<name> is not available in the snippet. The value goes out through avarparameter, as the REPL's outputs do, and the function is a hosted one (borrowed parameters, a signature no pass moves). - It grows the program in place. The continuation is encoded after the chunks that run (
emitBytecodewithafter), so every shape, layout and closure of the program means the same in the snippet, and the new chunks are loaded into the registers the run uses; the loop of the stopped program keeps the image it has, which stays valid. The first snippet of a run opens the script pool at the top of the registers, asloadScriptdoes. A program lowered for the debugger keeps every slot of its witness tables (the pass that empties the slots nothing calls,ir/called-members.trb, does not run for it): a snippet may call through a slot of a table that the program itself never called. - It is called above every frame of the stop (
callSandboxedwithabove), in a sandbox that grants what an entry oftorb replis granted - everything, the sandbox is only the recovery point - with a budget of 100 million steps and ten seconds. A panic,Process.exitand a limit end the snippet and answersuccess: falsewith the message; the program stays stopped and intact, and a later real panic still stops it. - Nothing in it stops the program. Breakpoints, stepping and a pause request are not looked at while a snippet
runs, and the requests of the client wait. What it prints is an
outputevent of categoryconsole: the debuggee brackets the run withtorbscript/snippetBeginandtorbscript/snippetEnd, which the adapter acts on and does not pass on. - The answer is a value like the ones
variablesshows:result,typeand avariablesReferencewhere it has children, whichvariablesandsetVariableread through the inspector of the snippet's own part of the program. The words lie above the frames until the program goes on, where they are released (destructors run in a sandbox). An expression ofVoidanswers an emptyresult. A compile error is the message of the response relative to the snippet (<snippet>:1:8), andsuccess: false. - What it does not see: the module constants of the frame's file are the snippet's own copies, not the ones the program holds, and a snippet is compiled anew each time.
13. Panics, and ?#
| Option | For | Against |
|---|---|---|
Stop before every panic, as the exception filter Panics (on by default) | A panic ends the program, and the moment before it is the one to look at; a failing assert is a panic, so a failing test stops at its assertion | A panic a test expects stops too, until the filter is turned off |
| Stop only where no recovery point catches it | Tests that expect a panic run through | A failing test would not stop |
Stop at a ? that returns a failure | Where an error starts its way up | Failures are values: a ? that returns one is ordinary control flow, and stopping at every one would stop in every tryFrom of a parser |
Decision: the exception filter panic ("Panics"), on by default. runtime/panic.c calls a debugger's hook
(torb_set_debug_panic_hook) first thing in torb_end_with_panic, before a recovery point catches the panic and
before it prints; the kernel's hook (DebugPanics) calls the interpreter back (TORB_REQUEST_PANIC), and the
debuggee stops with the reason exception and the message, which exceptionInfo answers too. When the person goes
on, the panic goes on - it fails the test, or prints and ends the program with 101. A ? does not stop; a breakpoint
on its line does.
14. Tasks and workers#
| Option | For | Against |
|---|---|---|
| One thread; the program runs on one worker while it is debugged | Deterministic: one statement at a time, a stop stops everything | A task that waits is invisible: only the one that runs shows, above the frames of the loop that resumed it |
| Every task a thread; the program on one worker | The editor lists what runs concurrently, each waiting task at its await() and with its locals; deterministic as before | A step or a continue cannot be asked of one thread alone: all of them stand together |
| Every task a thread, stepped one at a time | As a debugger of threads does | The workers of the pool would have to be held by the debugger: its own record, section 20 |
Decision: every task a DAP thread; a debugged program runs its tasks on one worker - workersFor gives no other
thread an interpreter, as for a program that runs scripts (docs/design/VM.md section 4). What a task does is
observable exactly as on one worker of a native binary.
- The threads.
threadsanswersmain, id 1, the program's own stack, with the scheduler run itsawait()is in; and a threadtask <n>, idn + 1, for each task of the program that is not complete and runs, sits in a queue, waits on something, or has not started. The runtime gives a task its number the first time the debugger lists it (torb_task.serial: 1, 2, 3 in the order the tasks were made, never twice), so a task keeps its id for as long as it lives and the numbers of tasks that finished unseen are never spent. The tasks the runtime makes itself - asleep, awithin- run no code of the program and are not listed. The list is the kernel operationDebugTasks(torb_task_walkinruntime/task.c): read-only, under the lock of the task tree, six words per task - serial, block, state, what it waits on, frame, and the bits queued and started. The program's own top-level code is such a task too: while it waits in anawait()it is a thread of its own with its one frame there, andmainhas no frame, because the stack of the scheduler run it waits in is empty. - Frames. The task whose body runs at the stop is the stopped thread: the VM notes which block the innermost
resume belongs to and the height below it (
Debugger.runningTask,Debugger.taskBelow), and the frames above that height are the task's, the ones below itmain's. A task that waits has one frame: its body at the code offset its state resumes at (Chunk.resumes), which is in the statement of theawait()it waits at, and its locals are read from the task block where the VM saved the frame (StackFrame.held). A task that has not started is at the first statement of its body and shows no locals. The ids of the frames of one stop are one range over all threads, soscopes,variables,evaluateandsetVariableneed no thread. - The stop names its thread in
threadId(allThreadsStoppedstays true).next,stepIn,stepOutandcontinueact on the position where the program stopped, whicheverthreadIdthe client names, because every thread stands there; the scheduler's order decides which task runs next, as on one worker of a native binary.
15. The REPL#
torb repl and the debugger share the VM and the machinery of an entry. The REPL's continuations - a program that grows
by an entry that is checked against everything the session holds - are what the snippet evaluator of section 12 reuses:
the bindings in sight of a frame as the parameters of an entry function, run in the REPL's sandbox so a panic ends the
snippet and not the program. The debugger shares importsOf and addImports of the REPL's session to write the type
of a value, and blanked of its synthesis. A :debug command of the REPL is not planned.
16. Tests and entry files#
The launch arguments (DAP launch) name what runs, as the command line of torb run and torb test does:
| Argument | Meaning |
|---|---|
program | A file or a directory, as torb run <path> takes it |
args | The program's arguments (Process.arguments()) |
test | A test file or a directory of tests, as torb test <path> takes it |
filter | One name or a list of full test names (Group > test), as torb test --filter takes them |
cwd | The working directory of the program; the debuggee changes to it before it compiles (DebugDirectory) |
env | Environment variables for the program (DebugVariable) |
console | "internalConsole" (default): the program's pipes, shown as output. "integratedTerminal" or "externalTerminal": the program runs in the editor's terminal with its standard input (section 3) |
stopOnEntry | Stop at the first statement |
justMyCode | Steps skip the standard library (default true) |
noDebug | Run without markers and without stopping - VS Code's Run Without Debugging |
report | "json": the report of torb test --report json, each event as a torbscript/testReport event instead of output - what the Test Explorer's Debug profile reads |
runtime/test.c reads --filter and --report off the process's own command line (torb_process_arguments), so the
adapter puts them on the debuggee's: torb debug --debuggee --filter "Group > test". tests/debug/ pins whole
sessions of both kinds (section 18).
17. VS Code#
contributes.debuggers: the typetorbscript, the launch attributes of section 16 with their schema, initial configurations and snippets (a file, the current file, the tests of the current file);breakpointsfor the languagetrb.debugging.js: aDebugAdapterDescriptorFactorythat startstorb debugwith thetorbthattoolchain.jsfound, and aDebugConfigurationProviderthat debugs the current file where there is nolaunch.json.- The Test Explorer (
testing.js) gets a third profile, Debug (TestRunProfileKind.Debug): it starts a session withtestandfilterfor what was chosen andreport: "json", and aDebugAdapterTrackermaps thetorbscript/testReportevents onto the test run as the Run profile maps the report's lines. The Test Explorer and the gutter of everytestandgroupthen show Debug beside Run. - Entry files: "Debug File" next to "Run File" in the editor's run menu, and a CodeLens "Run | Debug" above the
first line of an entry file (
src/main.trb, a file with top-level code that is no test file).
18. Slices#
| # | Scope | State |
|---|---|---|
| 1 | Instruction.Statement and Instruction.KeepAlive in the IR, a debugged lowering (debugging: true), the chunk's statement table, Opcode.Statement | Done |
| 2 | The VM's hooks: Registers.debugger, the loop records, reachStatement, stepping, breakpoints, pause; the panic hook; one worker; measured | Done |
| 3 | torb debug: the adapter and the debuggee, the kernel operations of runtime/debug.c, frames, locals, values, evaluate | Done |
| 4 | VS Code: the debugger contribution, launch snippets, the Debug test profile, Debug File and the CodeLens; tools/vscode-test.sh drives them in a real VS Code | Done |
| 5 | Tests: tests/debug/ sessions (tools/debug.sh, a gate of tier A), compiler/tests/debugger.test.trb | Done |
| 6 | Docs: docs/tooling/torb-debug.md, docs/how-to/set-up-your-editor.md | Done |
| 7 | Native programs, step 1 (section 4): the #line per statement of the C back end, torb build --debug (-O0 -g), the line-table gate tools/debug-lines.sh over tests/debug-lines/ | Done |
| 8 | Native programs, step 2 (sections 4 and 11): the layout table torb build --debug writes, the gdb pretty printers tools/gdb/torb_printers.py, the gate tools/debug-values.sh over tests/debug-values/ | Done |
19. What it costs#
Measured on 2026-09-28 on the owner's Windows machine while other builds ran on it, so every number is the CPU time
of the whole process (TotalProcessorTime: reading, checking and lowering the file included), the best and the
median of 11 runs, each program's runs of all four kinds interleaved. The baseline is the torb of main before
the debugger; the other three are the torb with it. The programs are a recursive fibonacci(32) (calls), a list
of 200 000 records summed ten times (loops, fields) and print "hello" (what a run costs besides its program).
| Program | Baseline | With the debugger, not debugged | Lowered for the debugger, none attached | torb debug, attached, no breakpoint |
|---|---|---|---|---|
| fibonacci | 1078 / 1203 ms | 1156 / 1250 ms | 1453 / 1484 ms | 3812 / 3938 ms |
| loops | 1516 / 1594 ms | 1469 / 1641 ms | 1703 / 1781 ms | 3156 / 3297 ms |
| hello | 188 / 234 ms | 203 / 219 ms | 188 / 219 ms | 188 / 219 ms |
Not debugged: the bytecode of both programs is the same instruction for instruction (
torb ir --bytecode), and no instruction does more work: the arm forstatementis one call (atStatement) that no program that is not debugged reaches, andRegisters.debuggeris tested once perexecute, not per instruction. What differs is the machine code ofexecuteLoop, which gcc lays out anew (with link-time optimization,atStatementis inlined into it). Across four series of 11 to 21 runs the loops program's best moved between 3% faster and 1% slower, its median between the same and 8% slower on the busy machine, hello not at all; fibonacci's best was 4 to 7% slower in all four, and 3% faster in a series before the arm became one call.Lowered for the debugger (
TORB_VM_DEBUGGED=1): astatementper statement, which reachesatStatementand finds no debugger, and the bindings that live to the end of their blocks: 12% on the loops, 23 to 35% on the calls, whose every body is two statements.Attached: every statement notes where its loop is, counts down to the next look at the channel and, where a breakpoint is set, tests its bit. The location of a statement is looked up only while a step runs. On the owner's machine, when every statement still wrote a record of its loop and looked into a set of code offsets, that was two to three and a half times the baseline; the forge's numbers of today's design are below.
A cheaper attached run (#43): tier B prints
tools/debug-cost.shon the forge'slinux-x64runner - thetorbof the commit,torb run --vm, the run lowered for the debugger,torb debugattached without and with a breakpoint (on a line that never runs), the best and the median of 7 runs, interleaved. Before is the commit that added the measurement alone (run 732), after is the one that writes a loop's record only where a call back begins and tests breakpoints by a bit per statement (run 754):Program run --vmLowered Attached A breakpoint set fibonacci, before 860 / 870 ms 1070 / 1088 ms 2017 / 2049 ms 2024 / 2044 ms fibonacci, after 825 / 868 ms 1045 / 1061 ms 1179 / 1224 ms 1189 / 1196 ms loops, before 1106 / 1131 ms 1214 / 1242 ms 1788 / 1812 ms 1762 / 1798 ms loops, after 1085 / 1240 ms 1249 / 1341 ms 1320 / 1362 ms 1316 / 1370 ms hello, before 238 / 278 ms 259 / 273 ms 256 / 287 ms 261 / 278 ms hello, after 267 / 288 ms 257 / 285 ms 274 / 285 ms 259 / 287 ms The attached fibonacci run's best is 42% faster (2.34 to 1.43 times
run --vm), the loops' 26% (1.62 to 1.22 times). The issue kept the change only at 10% or more, so it stays.run --vmand the lowered run have no change in their code and moved within the runner's noise.The conformance suite lowered for the debugger (
TORB_VM_DEBUGGED=1 sh tools/conformance.sh --vm) passes every program of the suite and ofvm-only/with the leak gate: the longer lives of section 6 change nothing a program prints. It is a gate of tier B, in laneb2after the runtime's tests and the embedded programs: 287 programs in 86 s in the forge CI (88 s as the gate prints it, side by side with the native suite of laneb1), against 80 s for the plain VM run (75 s on the last main run), so laneb2ends well before laneb1. The first run of the gate found that an owned parameter holding aCloseobject had itskeepAlivetoo (section 6): handed on, it was copied and not moved, and itsclose()ran at the end of the body (destructor-task-parameter.trb).
20. Open#
- Snippets in the outer frames, and statements in a snippet (section 12): built is one expression in the innermost frame, method calls included.
- A worker pool the debugger can hold, so that one task can be stepped while the others stand or run (section 14).
- Native programs (section 4): the lldb formatters,
torb debugfor a native binary, the docs and the editor's launch. The build with a#lineper statement (slice 7 of section 18), the layout table and the gdb printers (slice 8) are done.