Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Breakpoints

Everything else in this chapter starts with a program that has already gone wrong. A breakpoint starts with one that has not: you mark a statement, and when the program reaches it, it stops and shows you the frame.

The mark is :bp, written after a statement — or on a line of its own, and with a condition when only some of the times it is reached are interesting:

var doubled = seed * 2 :bp
var doubled = seed * 2 :bp doubled > 100

It is syntax, not a function. There is nothing to import, nothing to call, and no argument to pass — which also means it cannot be shadowed, passed around, or accidentally left inside a data structure. It is a mark on a line, and it costs one call at that line and nothing anywhere else in the program.

Where it stops

After the statement it marks, not before. That is the useful order: the statement’s effect has happened, so the binding it created is in locals with the value it created.

// `:bp` marks a statement. The program runs it, stops, and shows you the frame.
fn grow(seed: Int) -> Int {
    var doubled = seed * 2 :bp
    doubled + 1
}

out(grow(20))
$ praxis run bp-trace.px
stop: breakpoint
grow:
  <debug>:3:28
  3 |     var doubled = seed * 2 :bp
    |                            ^^^

Backtrace:
#0   grow
#1   <entry>

  locals:
    seed: Int = 20
    doubled: Int = 40
  temps:
    <tmp#2: Int> @ "2" = 2
    <tmp#3: Int> @ "seed * 2" = 40
    <tmp#5: Int> @ "1" = <uninit>
    <tmp#6: Int> @ "doubled + 1" = <uninit>
41

doubled: Int = 40 is there because the stop is after the var.

The rule holds for every statement form, including a block’s trailing expression: m + 1 :bp stops once m + 1 has been computed, and the block still yields it. What a marker never does is change what the program computes.

A marker on a line of its own

A :bp with no statement in front of it is a marker on that line, and the program stops there — before the line under it has run. That is how you see the state a trailing marker cannot show, and it is what lets you put a breakpoint on a blank line, at the top of a block, or under a fn you just wrote.

// A marker on a line of its own stops *at* that line — before the statement
// under it has run, which is the state a trailing marker cannot show.
fn grow(seed: Int) -> Int {
    :bp
    var doubled = seed * 2
    doubled + 1
}

out(grow(20))
$ praxis run bp-line.px
stop: breakpoint
grow:
  <debug>:4:5
  4 |     :bp
    |     ^^^

Backtrace:
#0   grow
#1   <entry>

  locals:
    seed: Int = 20
    doubled: Int = <uninit>
  temps:
    <tmp#2: Int> @ "2" = <uninit>
    <tmp#3: Int> @ "seed * 2" = <uninit>
    <tmp#5: Int> @ "1" = <uninit>
    <tmp#6: Int> @ "doubled + 1" = <uninit>
41

doubled: Int = <uninit> is the difference: the parameter is bound, the line below has not run, and that is exactly the frame the marker’s position asks for. Mark the statement above instead and you get the other one.

The one place it reads differently is under a block’s trailing expression, where the stop is after the tail — source order says so, and the block still yields what the tail produced. A marker never changes a block’s value, in any position it can be written in.

Stopping only when it matters

A marker in a loop that runs a thousand times stops a thousand times, and the pass you care about is one of them. Write the condition after the marker and it stops only on the passes that satisfy it:

// `:bp COND` stops only on the pass its condition admits — the one you asked
// about, and not the ones you did not.
fn tally(xs: Vec[Int]) -> Int {
    var total = 0
    for n in xs {
        total = total + n :bp n < 0
    }
    total
}

out(tally([4, -7, 9]))
$ praxis run bp-guard.px
stop: breakpoint
tally:
  <debug>:6:27
  6 |         total = total + n :bp n < 0
    |                           ^^^^^^^^^

Backtrace:
#0   tally
#1   <entry>

  locals:
    xs: Vec[Int] = [4, -7, 9]
    total: Int = -3
    n: Int = -7
  temps:
    <tmp#2: Int> @ "0" = 0
    <tmp#4: Int> = 1
    <tmp#5: Int> = 3
    <tmp#6: Int> = -7
    <tmp#8: Int> @ "total + n" = -3
    <tmp#9: Int> @ "0" = 0
    <tmp#10: Bool> @ "n < 0" = true
    <tmp#11: Unit> = Unit
    <tmp#12: Unit> @ "for n in xs { total = total + n :bp n < 0 }" = <uninit>
  …(1 more)
6

Three passes, one stop. The condition is an ordinary Praxis expression, in the scope the marker stands in and evaluated at the moment the stop would happen — so it sees exactly what the frame would have shown you. n < 0 above reads the loop variable; a condition on a trailing marker reads what the statement it marks just bound, because that statement has already run:

var doubled = seed * 2 :bp doubled > 100

The condition must be Bool. There is no truthiness in Praxis, so :bp n is expected Bool, found Int and not a stop on every nonzero pass — the same rule an if follows, and the same message.

A guard goes on either kind of marker, including one on a line of its own:

for row in grid {
    :bp row.len() == 0
    process(row)
}

There is one thing to know about writing them: the condition is your program’s code, compiled where the marker stands. It runs on every pass, whether or not it admits a stop. That is what makes it able to see the frame at all — a condition the debugger evaluated would have to run code over frames the program is still in the middle of, which is the same thing that rules p EXPR out at a stop. The practical consequence is the one you would expect: keep a guard cheap and keep it free of side effects, because a guard that pushes to a list is a program that behaves differently with the marker in it.

Where you can write it

Anywhere a statement ends — after a var, after an assignment, after a bare expression, after a block — and anywhere a statement can begin. The : and the bp must be adjacent: : bp with a space is not a marker, the same rule min= lives under.

var xs = [1, 2, 3] :bp        // after a binding
xs.push(4) :bp                // after a call statement
total = total + n :bp         // after an assignment
counts[key] += 1 :bp          // after a store through a place
:bp                           // on a line of its own

A marker is not an expression, so it does not go inside one: f(x :bp) does not parse. Mark the statement that contains the call instead.

Three ways a stop is served

Which surface you get follows --debug, exactly as a fault does — there is no second rule to learn.

--debugterminal?at a :bp
never—nothing at all; the marker is inert
auto (default)noprint the frame to stderr, keep running
auto / alwaysyesthe full-screen debugger
alwaysnothe Praxis stop> prompt on stdin

The second row is the one worth naming: at the default, in a pipe or a script or CI, a marker is a trace point. It prints where the program is and what it holds, and the program carries on. That is the output shown above, and it is why :bp is useful in a program you are not sitting in front of.

--debug never makes every marker inert without touching the source, which is what you want when the program is going somewhere else and you have not deleted the marks yet.

Continuing

At a prompt, continue (or c, or cont) lets the program go. It stops again at the next marker it reaches — including the same one, on the next pass of a loop, which the banner numbers for you.

// A marker inside a loop stops on every pass, and the stop is numbered.
var total = 0
for n in [4, 7] {
    total = total + n :bp
}
out(total)
$ printf 'bt\nlocals\ncontinue\nquit\n' | praxis run bp-loop.px --debug always
Stopped at a breakpoint. 1 frame(s). `continue` resumes; `help` lists commands.
Praxis stop> #0   <entry>
  (frame 0 selected)
Praxis stop>   locals:
    total: Int = 4
    n: Int = 4
  temps:
    <tmp#1: Int> @ "0" = 0
    <tmp#3: Vec[Int]> @ "[4, 7]" = [4, 7]
    <tmp#4: Int> @ "4" = 4
    <tmp#5: Unit> = Unit
    <tmp#6: Int> @ "7" = 7
    <tmp#7: Unit> = Unit
    <tmp#8: Int> = 0
    <tmp#9: Int> = 2
    <tmp#10: Int> = 4
    <tmp#12: Int> @ "total + n" = 4
    <tmp#14: Unit> @ "for n in [4, 7] { total = total + n :bp }" = <uninit>
    <tmp#15: Unit> @ "out(total)" = <uninit>
    <tmp#17: Int> = 0
    <tmp#18: Int> @ "var total = 0" = <uninit>
Praxis stop> continuing.
Stopped at a breakpoint (stop #2). 1 frame(s). `continue` resumes; `help` lists commands.
Praxis stop> leaving the debugger; the program runs on and will not stop again.
11

quit at a stop does not end the program — it ends the debugging. The program runs to completion and no later marker takes the terminal again. There is no command that kills a running program from a stop: a Praxis frame unwinds by faulting, and reporting a fault the program did not have would be a lie about what happened. Ctrl-C is what ends a run.

In the full-screen debugger the key is c, and the status bar leads with it.

What a stop cannot do

A stop is the middle of a program, not the end of one, and that costs it three commands:

  • p EXPR and heap EXPR. Evaluating an expression means compiling a function and running it, and the program’s own frames are still on the stack underneath you. type EXPR still works — it type-checks against the frame’s locals and runs nothing.
  • restart and reload. Both re-run the program from the beginning, and there is a program in progress. Continue to the end, and if it faults you have the crash debugger with both.

Everything else is the same command against the same kind of snapshot: bt, frame N, up/down, locals, source, help, quit.

What a marker costs

One call, at the marked line, and nothing else. The wrapper it calls allocates nothing and cannot fault, so the compiler emits no root spill before it and no fault check after — a marked statement is the unmarked statement plus a call. A program with no marker in it emits nothing at all.

:bp COND is that same call with a branch in front of it, so a pass the condition declines costs the test and nothing else — plus whatever the condition itself is, which is your code and priced like it. n < 0 is a comparison; xs.contains(target) is a scan of xs on every pass.

That is worth knowing because it means you can leave a bare marker in a loop that runs a million times and, under --debug never, pay only the call. A guard is the exception: --debug never disarms the stop, not the condition in front of it, so the expression still runs a million times. What you should not do in either case is leave one in and commit it: a marker in a program somebody else runs stops their program.