debugger: conditional breakpoints, hit counts and logpoints #37

Closed
opened 2026-10-05 21:07:13 +00:00 by orion · 8 comments
Collaborator

Goal

torb debug accepts the three breakpoint refinements of the Debug Adapter Protocol, so that VS Code (and any DAP client) offers Add Conditional Breakpoint (expression, hit count) and Add Logpoint in the gutter of a .trb file and they work in the VM:

  • Condition (condition): the program stops at the breakpoint only when the condition is true.
  • Hit count (hitCondition): the program stops only on the hit(s) the hit count selects.
  • Logpoint (logMessage): the program does not stop; a message with the values of expressions is written to the debug console.

Design record

  • DEBUGGER.md section 20 "Open": "Conditional breakpoints, hit counts and logpoints: a condition is an expression of section 12".
  • Section 7 (breakpoints, zero cost without a debugger), section 12 (the restricted evaluator), section 9 (requests answered while the program runs), section 19 (what it costs). The ROADMAP names "the debugger's open points" in the issues of Milestone 8; this is one of them.
  • The design record is the specification; this issue only fixes what section 20 leaves open (decisions in the comment below). No collision with CONCEPT "Open Questions".

Seed impact

None. Everything is TorbScript in compiler/src/debugger/ (adapter.trb, debuggee.trb, evaluate.trb) and compiler/src/vm/debug.trb, written in forms the seed already compiles. No syntax change, no new native or kernel operation (runtime/debug.c untouched), no driver flag, no std name looked up by string. So no two-commit seed rule and no seed refresh.

Size

One PR, two Conventional Commits:

  1. feat(debugger): conditions, hit counts and logpoints on breakpoints - code, the three new sessions, updated .expected files, unit tests.
  2. docs(debugger): conditional breakpoints, hit counts and logpoints - DEBUGGER.md, docs/tooling/torb-debug.md, regenerated skills/.

Estimate: about 300 lines of TorbScript plus tests. Independent of the open evaluate-as-snippet and setVariable items of section 20 (the condition grammar below stays inside the restricted evaluator).

Specification

All of it is decided; the reasoning is in the loop's decision comment.

Capabilities (adapter.trb, capabilities()): supportsConditionalBreakpoints: true, plus supportsHitConditionalBreakpoints: true and supportsLogPoints: true. The initialize line of every tests/debug/*.expected changes accordingly.

setBreakpoints reads condition, hitCondition and logMessage of each source breakpoint, keeps them next to the bound statements, and answers each breakpoint with an id. A condition or hitCondition that does not parse is answered verified: false with message saying why; it binds nothing. One source breakpoint has one hit counter, however many chunks its line binds to.

Condition = an expression of section 12 extended to a Bool-valued expression, evaluated read-only over the innermost frame at the breakpoint (no code runs, nothing is allocated in the program):

  • operands: section 12 expressions (name, .field, .0, [3], ["key"]) and literals (42, -1, 2.5, "text", 'a', true, false);
  • ==, !=, <, <=, >, >= between two operands of the same kind (integers with integers, floats with floats, String and Char with their own kind, Bool with ==/!= only), !, &&, ||, parentheses; a bare Bool-valued operand is a condition on its own.
  • Anything else, a kind mismatch, a missing name or a non-Bool result is a condition error (see below).

Hit count (hitCondition): N (stop on the Nth hit, same as == N), == N, >= N, > N, <= N, < N, % N (every Nth hit). A hit is a time the breakpoint's statement is reached with its condition true. The counter starts at 0 at launch, survives a re-sent setBreakpoints of the same file for a breakpoint whose line, condition, hit condition and log message are unchanged, and restarts from 0 when any of them changed.

Logpoint (logMessage): the text with {expression} replaced by the shown value of a section 12 expression (the value as variables shows it); \{ and \} are literal braces. Condition and hit count are applied first; if they select the hit, the message plus a line feed goes out as a DAP output event, category console, with source and line of the breakpoint; the program does not stop. An expression that cannot be evaluated is written <error: why>. A logpoint never produces a stopped event.

Order at a hit (reachStatement, compiler/src/vm/debug.trb): pause / stop-on-entry first (unchanged); then, if the statement has a set bit, the VM asks the host (new callback field on Debugger, read through a binding like stop) whether the breakpoint stops; the host answers stop (reason breakpoint, hitBreakpointIds in the stopped event) or go on. Go on behaves exactly like no breakpoint at that statement, so a step in progress may still end there with reason step. A program without breakpoints, and the statement path of any statement without a set bit, are unchanged; no new work in the loop of a program that is not debugged.

Condition error: the program stops at the breakpoint (reason breakpoint) and an output event, category console, says Breakpoint condition '<condition>' at <file>:<line> could not be evaluated: <why>.

End of input: runToTheEnd also drops the breakpoint metadata, so nothing is evaluated or logged once the adapter said nothing more comes.

Acceptance criteria

  • initialize answers supportsConditionalBreakpoints, supportsHitConditionalBreakpoints and supportsLogPoints as true; the five existing tests/debug/*.expected are updated for exactly that change (by hand: the worker does not bootstrap; CI runs sh tools/debug.sh).
  • New session tests/debug/condition (.dap, .expected, program dir): a breakpoint in a loop with condition i == 3 stops exactly once with i = 3 in the locals; a condition on a field (point.x > 1 && label == "a"); a condition with an unknown name stops and reports the condition error as output; a condition that is not a Bool stops and reports; an unparseable condition is answered verified: false with a message.
  • New session tests/debug/hit-count: hitCondition 3 stops on the third hit only; % 2 stops on every second; >= 4 stops from the fourth on; a condition and a hit count together count only hits where the condition holds; an invalid hit condition is answered verified: false; re-sending the file's breakpoints unchanged keeps the count, a changed one restarts it.
  • New session tests/debug/logpoint: a logpoint in a loop writes one console output line per hit with the interpolated values (and \{), the program runs to its end without a stopped event; a logpoint with a condition logs only where it holds; an unevaluable expression in the message is written as <error: ...>.
  • stopped events of a breakpoint carry hitBreakpointIds with the ids setBreakpoints answered. A step in progress still stops (reason step) at a statement whose condition was false.
  • Unit tests in compiler/tests/debugger.test.trb for the condition parser and evaluator (each operator, precedence, kinds, errors), the hit condition parser and the log message splitter.
  • Cost: tools/debug-cost.sh (tier B) shows no change beyond the runner's noise in the "A breakpoint set" column; the statement path of reachStatement gains no work for a statement without a set bit (section 19 stays true).
  • Docs: DEBUGGER.md sections 7 and 12 describe the three refinements and the condition grammar, section 20 loses the bullet, the status line is updated; docs/tooling/torb-debug.md (requests table, example) is updated; skills/ regenerated (docs skill --check stays green).
  • Gates a and b green in the forge CI of the PR (tools/debug.sh is a gate of tier A; the debugged conformance run of tier B is unaffected).

Dependencies

None. The snippet evaluator and setVariable of section 20 stay separate and may later widen the condition language.

Moved from the planning lists of the docs; the design record stays the specification. Refined by the loop, decisions in the comment below.

## Goal `torb debug` accepts the three breakpoint refinements of the Debug Adapter Protocol, so that VS Code (and any DAP client) offers *Add Conditional Breakpoint* (expression, hit count) and *Add Logpoint* in the gutter of a `.trb` file and they work in the VM: - **Condition** (`condition`): the program stops at the breakpoint only when the condition is true. - **Hit count** (`hitCondition`): the program stops only on the hit(s) the hit count selects. - **Logpoint** (`logMessage`): the program does not stop; a message with the values of expressions is written to the debug console. ## Design record - [DEBUGGER.md section 20 "Open"](https://git.torb.dev/torbscript/language/src/commit/393ef2608e4a60b8ff76d8b6b408951b822d887d/docs/design/DEBUGGER.md#L414): "Conditional breakpoints, hit counts and logpoints: a condition is an expression of section 12". - Section 7 (breakpoints, zero cost without a debugger), section 12 (the restricted evaluator), section 9 (requests answered while the program runs), section 19 (what it costs). The ROADMAP names "the debugger's open points" in the issues of Milestone 8; this is one of them. - The design record is the specification; this issue only fixes what section 20 leaves open (decisions in the comment below). No collision with CONCEPT "Open Questions". ## Seed impact None. Everything is TorbScript in `compiler/src/debugger/` (`adapter.trb`, `debuggee.trb`, `evaluate.trb`) and `compiler/src/vm/debug.trb`, written in forms the seed already compiles. No syntax change, no new native or kernel operation (`runtime/debug.c` untouched), no driver flag, no std name looked up by string. So no two-commit seed rule and no seed refresh. ## Size One PR, two Conventional Commits: 1. `feat(debugger): conditions, hit counts and logpoints on breakpoints` - code, the three new sessions, updated `.expected` files, unit tests. 2. `docs(debugger): conditional breakpoints, hit counts and logpoints` - DEBUGGER.md, `docs/tooling/torb-debug.md`, regenerated `skills/`. Estimate: about 300 lines of TorbScript plus tests. Independent of the open evaluate-as-snippet and `setVariable` items of section 20 (the condition grammar below stays inside the restricted evaluator). ## Specification All of it is decided; the reasoning is in the loop's decision comment. **Capabilities** (`adapter.trb`, `capabilities()`): `supportsConditionalBreakpoints: true`, plus `supportsHitConditionalBreakpoints: true` and `supportsLogPoints: true`. The `initialize` line of **every** `tests/debug/*.expected` changes accordingly. **`setBreakpoints`** reads `condition`, `hitCondition` and `logMessage` of each source breakpoint, keeps them next to the bound statements, and answers each breakpoint with an `id`. A `condition` or `hitCondition` that does not parse is answered `verified: false` with `message` saying why; it binds nothing. One source breakpoint has one hit counter, however many chunks its line binds to. **Condition** = an expression of section 12 extended to a Bool-valued expression, evaluated read-only over the innermost frame at the breakpoint (no code runs, nothing is allocated in the program): - operands: section 12 expressions (`name`, `.field`, `.0`, `[3]`, `["key"]`) and literals (`42`, `-1`, `2.5`, `"text"`, `'a'`, `true`, `false`); - `==`, `!=`, `<`, `<=`, `>`, `>=` between two operands of the same kind (integers with integers, floats with floats, `String` and `Char` with their own kind, `Bool` with `==`/`!=` only), `!`, `&&`, `||`, parentheses; a bare Bool-valued operand is a condition on its own. - Anything else, a kind mismatch, a missing name or a non-Bool result is a *condition error* (see below). **Hit count** (`hitCondition`): `N` (stop on the Nth hit, same as `== N`), `== N`, `>= N`, `> N`, `<= N`, `< N`, `% N` (every Nth hit). A hit is a time the breakpoint's statement is reached **with its condition true**. The counter starts at 0 at `launch`, survives a re-sent `setBreakpoints` of the same file for a breakpoint whose line, condition, hit condition and log message are unchanged, and restarts from 0 when any of them changed. **Logpoint** (`logMessage`): the text with `{expression}` replaced by the shown value of a section 12 expression (the value as `variables` shows it); `\{` and `\}` are literal braces. Condition and hit count are applied first; if they select the hit, the message plus a line feed goes out as a DAP `output` event, category `console`, with `source` and `line` of the breakpoint; the program does **not** stop. An expression that cannot be evaluated is written `<error: why>`. A logpoint never produces a `stopped` event. **Order at a hit** (`reachStatement`, `compiler/src/vm/debug.trb`): pause / stop-on-entry first (unchanged); then, if the statement has a set bit, the VM asks the host (new callback field on `Debugger`, read through a binding like `stop`) whether the breakpoint stops; the host answers *stop* (reason `breakpoint`, `hitBreakpointIds` in the `stopped` event) or *go on*. *Go on* behaves exactly like no breakpoint at that statement, so a step in progress may still end there with reason `step`. A program without breakpoints, and the statement path of any statement without a set bit, are unchanged; no new work in the loop of a program that is not debugged. **Condition error**: the program stops at the breakpoint (reason `breakpoint`) and an `output` event, category `console`, says `Breakpoint condition '<condition>' at <file>:<line> could not be evaluated: <why>`. **End of input**: `runToTheEnd` also drops the breakpoint metadata, so nothing is evaluated or logged once the adapter said nothing more comes. ## Acceptance criteria - [ ] `initialize` answers `supportsConditionalBreakpoints`, `supportsHitConditionalBreakpoints` and `supportsLogPoints` as `true`; the five existing `tests/debug/*.expected` are updated for exactly that change (by hand: the worker does not bootstrap; CI runs `sh tools/debug.sh`). - [ ] New session `tests/debug/condition` (`.dap`, `.expected`, program dir): a breakpoint in a loop with `condition` `i == 3` stops exactly once with `i` = 3 in the locals; a condition on a field (`point.x > 1 && label == "a"`); a condition with an unknown name stops and reports the condition error as `output`; a condition that is not a Bool stops and reports; an unparseable condition is answered `verified: false` with a message. - [ ] New session `tests/debug/hit-count`: `hitCondition` `3` stops on the third hit only; `% 2` stops on every second; `>= 4` stops from the fourth on; a condition and a hit count together count only hits where the condition holds; an invalid hit condition is answered `verified: false`; re-sending the file's breakpoints unchanged keeps the count, a changed one restarts it. - [ ] New session `tests/debug/logpoint`: a logpoint in a loop writes one console `output` line per hit with the interpolated values (and `\{`), the program runs to its end without a `stopped` event; a logpoint with a condition logs only where it holds; an unevaluable expression in the message is written as `<error: ...>`. - [ ] `stopped` events of a breakpoint carry `hitBreakpointIds` with the ids `setBreakpoints` answered. A step in progress still stops (reason `step`) at a statement whose condition was false. - [ ] Unit tests in `compiler/tests/debugger.test.trb` for the condition parser and evaluator (each operator, precedence, kinds, errors), the hit condition parser and the log message splitter. - [ ] Cost: `tools/debug-cost.sh` (tier B) shows no change beyond the runner's noise in the "A breakpoint set" column; the statement path of `reachStatement` gains no work for a statement without a set bit (section 19 stays true). - [ ] Docs: DEBUGGER.md sections 7 and 12 describe the three refinements and the condition grammar, section 20 loses the bullet, the status line is updated; `docs/tooling/torb-debug.md` (requests table, example) is updated; `skills/` regenerated (`docs skill --check` stays green). - [ ] Gates `a` and `b` green in the forge CI of the PR (`tools/debug.sh` is a gate of tier A; the debugged conformance run of tier B is unaffected). ## Dependencies None. The snippet evaluator and `setVariable` of section 20 stay separate and may later widen the condition language. _Moved from the planning lists of the docs; the design record stays the specification. Refined by the loop, decisions in the comment below._
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop). Implements DEBUGGER.md section 20 (conditional breakpoints, hit counts, logpoints over DAP); the design record is the specification, no collision with CONCEPT "Open Questions". Conditions reuse the evaluate path of section 12, so it likely shares work with the evaluate-as-snippet issue. Touches the debugger and its tests/debug/ sessions, seed impact to be confirmed in refine. next: refine (queued)

[loop:torbscript] intake: in scope (author orion, label loop). Implements DEBUGGER.md section 20 (conditional breakpoints, hit counts, logpoints over DAP); the design record is the specification, no collision with CONCEPT "Open Questions". Conditions reuse the evaluate path of section 12, so it likely shares work with the evaluate-as-snippet issue. Touches the debugger and its `tests/debug/` sessions, seed impact to be confirmed in refine. next: refine (queued)
Author
Collaborator

[loop:torbscript] refine: still queued behind the intake order. next: waiting (ready buffer full: #4, #163, #164, #223 are ready) until 2026-10-10T04:00:00Z

[loop:torbscript] refine: still queued behind the intake order. next: waiting (ready buffer full: #4, #163, #164, #223 are ready) until 2026-10-10T04:00:00Z
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261014 until 2026-10-14T00:48Z

[loop:torbscript] claim refine by session:loop-torbscript-20261014 until 2026-10-14T00:48Z
Author
Collaborator

[loop:torbscript] decided by the loop: the open points of DEBUGGER.md section 20 for this item are technical, so no owner question.

  • Condition language: section 12 plus comparisons, !, &&, ||, parentheses and literals. Why: a bare section 12 expression can only name a Bool, but the main use (i == 3, point.x > 1) needs a comparison; it stays read-only (no code runs, nothing allocates), so it needs neither the snippet evaluator nor a seed change. Kinds must match, mixed kinds are an error rather than a silent coercion.
  • Condition error stops (with a console message) instead of continuing: a typo must not silently turn a breakpoint off (gdb and lldb behave the same).
  • Hit count: DAP hitCondition as N, == N, >= N, > N, <= N, < N, % N; plain N means == N. Counted only on hits whose condition holds (the usual debugger meaning); one counter per source breakpoint across chunks; kept across a re-sent setBreakpoints when the breakpoint is unchanged, because VS Code re-sends a whole file on every edit and would otherwise reset the counts.
  • Logpoint: {expression} interpolation with \{ \} escapes (the editors' convention), output event category console with source and line, never stops; an unevaluable expression shows <error: why> so one bad expression does not drop the whole line.
  • Mechanism: the VM keeps its one bit per statement as the only test; when a bit is set it calls the host once (a callback field on Debugger, read through a binding like stop), the host decides stop or go on. Frames are read only on such a hit, so a statement without a set bit and a program without breakpoints do no new work. Measured expectation: none to measure at the statement path; tools/debug-cost.sh "A breakpoint set" (breakpoint on a line that never runs) must stay within the runner's noise (section 19: 1.17 to 1.20 s fibonacci, 1.32 s loops best/median on the forge's runner) - checked in tier B CI, not locally.
  • Go on = no breakpoint: a step in progress may still stop at that statement with reason step.
  • Seed: none. TorbScript-only change in compiler/src/debugger/ and compiler/src/vm/debug.trb; no syntax, native, kernel operation, driver flag or std name looked up by string.
  • Size: one PR, two commits (code and tests, then docs and regenerated skills). Not split: the VM hook, the host decision, the grammar and the three sessions only make sense together, about 300 lines; the grammar is small enough that a separate issue would only add a dependency.
[loop:torbscript] decided by the loop: the open points of DEBUGGER.md section 20 for this item are technical, so no owner question. - **Condition language**: section 12 plus comparisons, `!`, `&&`, `||`, parentheses and literals. Why: a bare section 12 expression can only name a Bool, but the main use (`i == 3`, `point.x > 1`) needs a comparison; it stays read-only (no code runs, nothing allocates), so it needs neither the snippet evaluator nor a seed change. Kinds must match, mixed kinds are an error rather than a silent coercion. - **Condition error stops** (with a console message) instead of continuing: a typo must not silently turn a breakpoint off (gdb and lldb behave the same). - **Hit count**: DAP `hitCondition` as `N`, `== N`, `>= N`, `> N`, `<= N`, `< N`, `% N`; plain `N` means `== N`. Counted only on hits whose condition holds (the usual debugger meaning); one counter per source breakpoint across chunks; kept across a re-sent `setBreakpoints` when the breakpoint is unchanged, because VS Code re-sends a whole file on every edit and would otherwise reset the counts. - **Logpoint**: `{expression}` interpolation with `\{` `\}` escapes (the editors' convention), output event category `console` with source and line, never stops; an unevaluable expression shows `<error: why>` so one bad expression does not drop the whole line. - **Mechanism**: the VM keeps its one bit per statement as the only test; when a bit is set it calls the host once (a callback field on `Debugger`, read through a binding like `stop`), the host decides stop or go on. Frames are read only on such a hit, so a statement without a set bit and a program without breakpoints do no new work. Measured expectation: none to measure at the statement path; `tools/debug-cost.sh` "A breakpoint set" (breakpoint on a line that never runs) must stay within the runner's noise (section 19: 1.17 to 1.20 s fibonacci, 1.32 s loops best/median on the forge's runner) - checked in tier B CI, not locally. - **Go on = no breakpoint**: a step in progress may still stop at that statement with reason `step`. - **Seed**: none. TorbScript-only change in `compiler/src/debugger/` and `compiler/src/vm/debug.trb`; no syntax, native, kernel operation, driver flag or std name looked up by string. - **Size**: one PR, two commits (code and tests, then docs and regenerated skills). Not split: the VM hook, the host decision, the grammar and the three sessions only make sense together, about 300 lines; the grammar is small enough that a separate issue would only add a dependency.
Author
Collaborator

[loop:torbscript] refine: issue rewritten to the definition of ready (goal, specification, 9 testable criteria, DEBUGGER.md sections 7/12/20, seed impact none, one PR in two commits); technical questions decided above, no owner question. Tokens: about 85k (sonnet, refine worker). next: implement

[loop:torbscript] refine: issue rewritten to the definition of ready (goal, specification, 9 testable criteria, DEBUGGER.md sections 7/12/20, seed impact none, one PR in two commits); technical questions decided above, no owner question. Tokens: about 85k (sonnet, refine worker). next: implement
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261015 until 2026-10-15T04:00Z

[loop:torbscript] claim implement by session:loop-torbscript-20261015 until 2026-10-15T04:00Z
Author
Collaborator

[loop:torbscript] implement: conditions, hit conditions and logpoints on breakpoints are in PR #367 (2 feature/docs commits plus 5 test-fix commits that the maintainer may squash into the feat commit before landing; branch loop/37-conditional-breakpoints). Forge CI is green (gates incl. the three new debug sessions condition/hit-count/logpoint and the new unit tests). Unit tests read conditions against a small program without lists: a list constant in the debug-lowered unit-test VM run hits an internal error ("the VM reached code the IR says nothing reaches"), so item access (xs[i]) is evaluated by the code but not covered by a unit test - worth a separate issue. Tokens used: roughly 4M. next: review

[loop:torbscript] implement: conditions, hit conditions and logpoints on breakpoints are in PR https://git.torb.dev/torbscript/language/pulls/367 (2 feature/docs commits plus 5 test-fix commits that the maintainer may squash into the feat commit before landing; branch loop/37-conditional-breakpoints). Forge CI is green (gates incl. the three new debug sessions condition/hit-count/logpoint and the new unit tests). Unit tests read conditions against a small program without lists: a list constant in the debug-lowered unit-test VM run hits an internal error ("the VM reached code the IR says nothing reaches"), so item access (`xs[i]`) is evaluated by the code but not covered by a unit test - worth a separate issue. Tokens used: roughly 4M. next: review
argus closed this issue 2026-10-15 00:46:57 +00:00
Author
Collaborator

[loop:torbscript] close: landed by the merge gate as ce7af1f420 (PR #367); issue closed. Follow-up for the uncovered list item access filed as a new loop issue. next: done

[loop:torbscript] close: landed by the merge gate as https://git.torb.dev/torbscript/language/commit/ce7af1f420c20b4e6022a16069f9061e51898152 (PR https://git.torb.dev/torbscript/language/pulls/367); issue closed. Follow-up for the uncovered list item access filed as a new `loop` issue. next: done
Sign in to join this conversation.
No labels
loop
ready
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#37
No description provided.