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#

OptionForAgainst
The Debug Adapter Protocol (DAP) over standard input and output, torb debug as the adapter executableWhat 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 reusedA second protocol besides the language server's
The debugger inside torb lspOne process per editorDAP 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 JavaScriptNothing to learn from DAPEvery 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.

OptionForAgainst
One process: torb debug speaks DAP and runs the VMNo second process, no second protocolThe 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 programThe program's streams stay the program's, its exit is the debuggee's; the adapter is a relay that survives everythingA channel between the two (section 3)
The adapter in the extension, in JavaScriptNo second TorbScript processOnly 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.

OptionForAgainst
A loopback socket the adapter listens onThe program keeps all three standard streamsNo 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 pollPortable, nothing new in CLatency, 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 printsThree 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 itA 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#

OptionForAgainst
The VM first, native programs as a later phasetorb 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 addressA native binary is not debugged yet
Native first: DWARF from the C back end, gdb or lldb behind DAPThe debuggers existTheir 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 onceTwice 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. #line directives 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). With debugInformation = true in a profile of project.trb (-g, /Zi), gdb and lldb already set a breakpoint at src/main.trb:12 and 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 #line per statement in the C back end (it writes one already - only a debugged lowering has them); torb build --debug compiles with -O0 -g and those markers; pretty printers for gdb (Python) and lldb (formatters) show torb_text, torb_list, torb_map and the program's layouts from a table the emitter writes (built for gdb, slice 8; lldb is next); and torb debug launches lldb-dap or gdb --interpreter=dap for a native program, translating names (s12_count to count) in variables.

  • The first step is built (slice 7 of section 18). torb build --debug lowers with debugging: true and compiles with -O0 -g (/Od /Zi) whatever the profile says, into build/debug/. tools/debug-lines.sh, a gate of tier B, builds tests/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, else objdump; 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 --debug writes build/debug/<name>.layouts beside 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 as torb ir spells 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> is inline, boxed or niche/<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; an element line is the torb_element descriptor of each List, Map and Set instance (d_<Type>, a static const of the C), so that a printer finds a container's item type by the address in storage->element. The runtime's own torb_element_int64 and torb_element_text are not listed. A type the table does not list is shown raw, and the program still builds and runs. tools/gdb/torb_printers.py reads 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 in bt full and in the machine interface, and torb-print <name> and torb-locals print a binding in the form of section 11. tools/debug-values.sh, a gate of tier B, builds tests/debug-values/ with --debug, checks the table and, where gdb with Python is installed, runs gdb --batch to the fixture's last statement and compares the bindings with tests/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.

OptionForAgainst
Locations on every instructionExactEvery instruction of the IR grows a field; the passes that make instructions have to invent one
A side table (block, instruction index) to lineNo change to the instructionsThe 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 debuggingPasses 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 #lineOne 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.

OptionForAgainst
Show a binding only while it is liveNothing changes in the programBindings 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 fromThe slot says whether it holds somethingA borrowed alias of another binding is released by nobody and dangles all the same
The ends of scope of Close values (EndOfScope) for every bindingThe machinery existsIt 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 onlyAn 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 languageA 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#

OptionForAgainst
A check in front of every instructionAny instruction can be a breakpointA 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 originalZero cost until a breakpoint is set; the classic design of native debuggersStepping 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 debuggerA 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 shareA 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 stopped event of a breakpoint names the ones that stopped it in hitBreakpointIds.
  • 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 (reason breakpoint), and an output event of the category console says Breakpoint 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 answered verified: false with a message, and binds nothing.
  • The hit condition is N, == N, >= N, > N, <= N, < N or % N (every Nth). A hit is a time the statement is reached with its condition true, counted from 1; the counter starts at 0 at launch. It survives a setBreakpoints that sends the breakpoint again unchanged (same line, condition, hit condition and log message, and keeps its id), 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 as variables shows 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 an output event of the category console, with source and line, and the program does not stop; a logpoint never produces stopped. An expression that cannot be evaluated is written <error: why>.
  • When the input ends (section 3) the breakpoints are gone, so are the logpoints: runToTheEnd drops 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 into list.map stops in the closure the program gave it. A breakpoint stops everywhere, and a frame of the standard library is shown deemphasized.
  • 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#

OptionForAgainst
A signal (SIGINT, a console control event) the adapter sends, seen by the interrupted() the loop reads every 4096 stepsThe 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 programThe requests are on the channel anyway (section 3); DebugPending is one PeekNamedPipe or pollA 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.

TypeShown asChildren
integers, Bool, Char, floats42, true, 'a', 2.5-
String"text", cut after 1000 characters-
a record, a tuplePoint(x: 1, y: 2), fields shown flat to a depth of oneone per field
a caseSome(3), .Circle(radius: 2.0), None - the tag word, or the niche's empty first wordthe fields of the case
List, ArrayList, Array[1, 2, 3] up to ten items, List(1200) beyondone 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 objectDoor(open: true)one per field
a trait-typed valuethe value it holds, by the target type of its witness tablethe value's
a closure, a task, a channel, a fileclosure 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#

OptionForAgainst
A restricted evaluator over the stopped frame: names, fields, tuple fields, indicesReads what variables reads, so it cannot change the program or run its code; answers hover and watch at onceNot 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 includedRunning 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 | false

The 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 Bool or a Char - one word, stored with Machine.store. Anything else (a String, a list, a map, a case, a whole record) answers success: false with Only 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 var parameter holds), a field of a record, tuple or shared type object, an item of a fixed array, a captured var. Items of a List, Map and Set and 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 (a Float32 is rounded), true or false for a Bool, a character for a Char. 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 variables request for the parent or a sibling view shows the new value, and the answer carries value, type and variablesReference. The request waits for a stop like variables does.

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 variables shows (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 a var parameter, 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 (emitBytecode with after), 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, as loadScript does. 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 (callSandboxed with above), in a sandbox that grants what an entry of torb repl is granted - everything, the sandbox is only the recovery point - with a budget of 100 million steps and ten seconds. A panic, Process.exit and a limit end the snippet and answer success: false with 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 output event of category console: the debuggee brackets the run with torbscript/snippetBegin and torbscript/snippetEnd, which the adapter acts on and does not pass on.
  • The answer is a value like the ones variables shows: result, type and a variablesReference where it has children, which variables and setVariable read 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 of Void answers an empty result. A compile error is the message of the response relative to the snippet (<snippet>:1:8), and success: 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 ?#

OptionForAgainst
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 assertionA panic a test expects stops too, until the filter is turned off
Stop only where no recovery point catches itTests that expect a panic run throughA failing test would not stop
Stop at a ? that returns a failureWhere an error starts its way upFailures 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#

OptionForAgainst
One thread; the program runs on one worker while it is debuggedDeterministic: one statement at a time, a stop stops everythingA 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 workerThe editor lists what runs concurrently, each waiting task at its await() and with its locals; deterministic as beforeA step or a continue cannot be asked of one thread alone: all of them stand together
Every task a thread, stepped one at a timeAs a debugger of threads doesThe 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. threads answers main, id 1, the program's own stack, with the scheduler run its await() is in; and a thread task <n>, id n + 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 - a sleep, a within - run no code of the program and are not listed. The list is the kernel operation DebugTasks (torb_task_walk in runtime/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 an await() it is a thread of its own with its one frame there, and main has 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 it main'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 the await() 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, so scopes, variables, evaluate and setVariable need no thread.
  • The stop names its thread in threadId (allThreadsStopped stays true). next, stepIn, stepOut and continue act on the position where the program stopped, whichever threadId the 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:

ArgumentMeaning
programA file or a directory, as torb run <path> takes it
argsThe program's arguments (Process.arguments())
testA test file or a directory of tests, as torb test <path> takes it
filterOne name or a list of full test names (Group > test), as torb test --filter takes them
cwdThe working directory of the program; the debuggee changes to it before it compiles (DebugDirectory)
envEnvironment 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)
stopOnEntryStop at the first statement
justMyCodeSteps skip the standard library (default true)
noDebugRun 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 type torbscript, the launch attributes of section 16 with their schema, initial configurations and snippets (a file, the current file, the tests of the current file); breakpoints for the language trb.
  • debugging.js: a DebugAdapterDescriptorFactory that starts torb debug with the torb that toolchain.js found, and a DebugConfigurationProvider that debugs the current file where there is no launch.json.
  • The Test Explorer (testing.js) gets a third profile, Debug (TestRunProfileKind.Debug): it starts a session with test and filter for what was chosen and report: "json", and a DebugAdapterTracker maps the torbscript/testReport events onto the test run as the Run profile maps the report's lines. The Test Explorer and the gutter of every test and group then 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#

#ScopeState
1Instruction.Statement and Instruction.KeepAlive in the IR, a debugged lowering (debugging: true), the chunk's statement table, Opcode.StatementDone
2The VM's hooks: Registers.debugger, the loop records, reachStatement, stepping, breakpoints, pause; the panic hook; one worker; measuredDone
3torb debug: the adapter and the debuggee, the kernel operations of runtime/debug.c, frames, locals, values, evaluateDone
4VS 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 CodeDone
5Tests: tests/debug/ sessions (tools/debug.sh, a gate of tier A), compiler/tests/debugger.test.trbDone
6Docs: docs/tooling/torb-debug.md, docs/how-to/set-up-your-editor.mdDone
7Native 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
8Native 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).

ProgramBaselineWith the debugger, not debuggedLowered for the debugger, none attachedtorb debug, attached, no breakpoint
fibonacci1078 / 1203 ms1156 / 1250 ms1453 / 1484 ms3812 / 3938 ms
loops1516 / 1594 ms1469 / 1641 ms1703 / 1781 ms3156 / 3297 ms
hello188 / 234 ms203 / 219 ms188 / 219 ms188 / 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 for statement is one call (atStatement) that no program that is not debugged reaches, and Registers.debugger is tested once per execute, not per instruction. What differs is the machine code of executeLoop, which gcc lays out anew (with link-time optimization, atStatement is 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): a statement per statement, which reaches atStatement and 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.sh on the forge's linux-x64 runner - the torb of the commit, torb run --vm, the run lowered for the debugger, torb debug attached 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):

    Programrun --vmLoweredAttachedA breakpoint set
    fibonacci, before860 / 870 ms1070 / 1088 ms2017 / 2049 ms2024 / 2044 ms
    fibonacci, after825 / 868 ms1045 / 1061 ms1179 / 1224 ms1189 / 1196 ms
    loops, before1106 / 1131 ms1214 / 1242 ms1788 / 1812 ms1762 / 1798 ms
    loops, after1085 / 1240 ms1249 / 1341 ms1320 / 1362 ms1316 / 1370 ms
    hello, before238 / 278 ms259 / 273 ms256 / 287 ms261 / 278 ms
    hello, after267 / 288 ms257 / 285 ms274 / 285 ms259 / 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 --vm and 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 of vm-only/ with the leak gate: the longer lives of section 6 change nothing a program prints. It is a gate of tier B, in lane b2 after 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 lane b1), against 80 s for the plain VM run (75 s on the last main run), so lane b2 ends well before lane b1. The first run of the gate found that an owned parameter holding a Close object had its keepAlive too (section 6): handed on, it was copied and not moved, and its close() 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 debug for a native binary, the docs and the editor's launch. The build with a #line per statement (slice 7 of section 18), the layout table and the gdb printers (slice 8) are done.
Esc