Audience:
contributor·maintainer(replay/stepping engine)TL;DR: The precise contract for how the cursor moves through a recorded trace — statement vs. instruction granularity, forward and backward. Defines the per-record model (visible/mapped/depth/run) and the numbered rules (S1–S20) that the test suite pins, plus the opt-level build prerequisite and the fixtures behind each rule.
The contract between the debugger's replay engine and the user's expectations
when stepping through a recorded trace — at statement (Rust source) and
instruction (Disassembly View) granularity, forward and backward. The
systematic test suite (test/stepping*.test.ts, test/dap.test.ts) pins these
rules; every rule ID below is cited by at least one test.
Every stepping decision is derived from four per-record facts:
- visible(i) — the record has a validated position (
positions[i] !== null): it corresponds to a real instruction in the disassembly. Records that fail validation (global-initializer records whoseposis in another section's address space, synthetic host records withpos: null) are invisible: no address, no source, nothing to show. (In a wasm-lessrawTracereplay, the rawposvalues serve as the positions, sopos: nullrecords are the invisible ones.) - mapped(i) — the record has a source location (
lineKey(i) !== null). Mapped ⇒ visible. - depth(i) — the call depth. Because komet-node emits no record for
implicit returns (a callee falling off its end produces no
return), depth cannot be reconstructed fromcall/returnopcodes alone. When a disassembly with function-body ranges is available, depth MUST be derived from the function membership of consecutive visible records: entering a different function's body right after acall-class record pushes a frame; a transition back to the site right after the matching call pops it — whether or not areturnrecord was emitted. Without function ranges (wasm-less replay), the opcode-based reconstruction remains the fallback. - run(i) — the line run a mapped record belongs to. Scanning the trace
in order over visible records, a mapped record starts a new run iff any
of:
- its line key differs from the current run's key, or
- its depth differs from the current run's depth, or
- it re-executes a code offset the current run has already covered (the
same source line runs again — a loop back-edge landed inside this run).
A same-key record at a new offset merely extends the run: one line often
spans several disjoint address ranges (e.g. a loop's condition and its
back-edge
brare both thewhileline and form one stop per iteration). Unmapped visible records and deeper-frame records in between do not break the current run (they are glue inside it). A run is identified by its first index (its run start). One run = one user-perceived execution of one source line. In the stepper fixture this yields exactly four line-25 runs: the initial loop entry plus one per re-test of thewhilecondition.
Stop points. At instruction granularity the stop points are the visible
records. At statement granularity the stop points are the run starts that
survive declaration/brace filtering (S17, S18): a debugger user expects to
rest on statements and expressions, not on fn/impl headers, the
#[contractimpl] export shim, or block-closing braces. The cursor must only
ever come to rest on a stop point of the active granularity — never on an
invisible record, and (at statement granularity) never on an unmapped or
filtered-out one, except when filtering would leave the trace (or a function
frame) with no stop point at all.
The two granularities are successive filters over the same trace — each stop set is a subset of the one above it:
flowchart TB
ALL["all trace records"] -->|"drop invisible records<br/>(pos fails validation)"| VIS["visible records<br/>= instruction stop points"]
VIS -->|"group into line runs;<br/>keep each run's first index"| RS["run starts<br/>one per line execution (incl. per loop iteration)"]
RS -->|"S17 drop declaration lines<br/>S18 drop non-epilogue braces"| SS["statement stops<br/>= where source stepping rests"]
Instruction stepping visits the middle box; statement stepping visits the bottom box. Filtering never empties a frame (see S17/S18): if it would remove every stop, the unfiltered run starts stand.
- S1 (entry stop): the initial stop lands on the first stop point of the
trace — the first statement stop (the first declaration/brace-filtered run
start, S17/S18) when line info exists, else the first visible record, else
index 0. The user's first view is a real statement inside the invoked
function, never the
#[contractimpl]export shim or a bare signature line, and never a sourceless, addressless frame. - S2 (forward exhaustion): a forward step with no further stop point ahead never strands the cursor on trailing invisible/unmapped records. At instruction granularity — and in wasm-less replay, where statement stepping falls back to the visible records — it moves to the last stop point of the trace (staying put when already there) and still reports a stop. At statement granularity it instead terminates the session (S20): a forward source step past the last source statement finishes the replay rather than re-reporting the same line.
- S3 (backward exhaustion): a backward step with no earlier stop point moves to the first stop point (staying put when already there). It never falls off onto the unmapped records at the head of the trace.
- S4 (step in):
stepInmoves to the next run start in trace order, regardless of depth — entering a callee stops at the callee's first mapped line. - S5 (step over):
nextmoves to the next run start whose depth is ≤ the current depth: a call inside the current line is stepped over in one press — including calls whose return is implicit (noreturnrecord). One press, one new line. Exhausted forward (no such run start ahead), it terminates the session (S20). - S6 (per-iteration loop stops): a source line that executes again (loop
back-edge) is a new run:
next/stepInstop once per iteration, even when consecutive iterations are separated only by unmapped records. - S7 (step out):
stepOutmoves to the next run start whose depth is < the current depth. At the outermost recorded depth there is no shallower stop, so a statementstepOutterminates the session (S20); at instruction granularity it clamps to the last visible record (S2). - S8 (reverse step):
stepBackat statement granularity is the reverse ofnext: it moves to the start of the previous run with depth ≤ the current depth, skipping deeper-frame records. Landing is always on a run start (same index forwardcontinue/breakpoints would use), so forward and backward visits of a line agree on the cursor position. - S9 (reverse at loop): reverse-stepping through a loop revisits the same per-iteration stops as forward stepping, in reverse order.
- S10 (visible-only instruction steps):
stepIn/next/stepBackwithgranularity: 'instruction'move to the next/previous visible record. Every press moves the Disassembly View highlight by exactly one real instruction row (nextadditionally skips deeper frames, i.e. steps over calls;stepIndoes not). No dead presses on invisible records, in either direction. - S11 (instruction pointer honesty): every stop at instruction granularity
has a defined
instructionPointerReferenceequal to the current record's validated position. (At statement granularity stops, the reference is the run start's position.)
- S12 (one stop per line execution): a source breakpoint resolves to the
run starts of its line.
continuehits a line once per execution (once per loop iteration), not once per wasm instruction of the line. - S13 (reverse/forward symmetry):
reverseContinuestops at exactly the same indices as forwardcontinuewould for the same breakpoints, in reverse order — landing on run starts, not run ends. - S14 (continue exhaustion):
continuewith no breakpoint ahead settles on the last stop point (S2);reverseContinuewith none behind settles on the first stop point (S3, reported as a plain stop, not a breakpoint hit). - S15 (instruction breakpoints): an instruction breakpoint at address A stops at every record whose validated position is A — once per execution of that instruction (e.g. once per call for an address inside a callee, once per iteration for an address inside a loop body).
- S16 (frame consistency): whenever the cursor rests on a mapped record, the stack frame carries that record's source and line; the frame is sourceless only when the cursor legitimately rests on an unmapped stop point (instruction granularity, or no line info at all).
- S19 (line-start cursor): whenever the cursor rests on a mapped record, the
stack frame's source column is the 1-based position of the first
non-whitespace character of that source line — never the DWARF column,
which for an opt-0 line table points at an arbitrary interior sub-expression
(the
0oflet mut acc = 0, the3ofmatch x % 3) and jumps unpredictably between visits of the same line. A statement stop already rests on the run start — the line's first executed instruction, before any sub-expression's result is in hand — so with S19 the cursor lands at the start of the statement, predictably, on every visit. Disassembly rows keep their precise per-instruction DWARF columns (disassembleRequestis unaffected); S19 governs only the source stack frame. - S20 (statement forward-exhaustion terminates): a forward statement step
(
next/stepIn/stepOut, granularity ≠instruction, over the source run-start stop set) that finds no qualifying stop point ahead ends the debug session with aTerminatedEventinstead of clamping in place. The replayed contract has returned from its outermost recorded frame; a user pressing step-over at the closing brace expects the program to finish, not the cursor to sit on the same line. Instruction-granularity forward steps still clamp (S2),continue/reverseContinuestill settle on the last/first stop (S14), and reverse steps still clamp to the first stop (S3/S8) — only forward source stepping past the end terminates.
Optimization-level 0 preserves a line-table row for lines a user does not think
of as steppable — the fn/impl header a function is entered "at", the
#[contractimpl] macro shim that wraps every exported entry point, and the
closing brace the compiler attributes a function's return/epilogue to. Left
unfiltered these produce stops on declarations and lone braces, which read as
noise. Statement-granularity stop points are therefore the run starts minus the
following (instruction granularity is unaffected — every visible record remains
an instruction stop):
- S17 (declaration suppression): a run start whose source line is a
declaration — an attribute line (trimmed text begins
#[or#![, including the#[contractimpl]export shim) or an item header (fn, with anypub/const/async/unsafe/externqualifiers;impl;trait;mod) — is not a statement stop. Step-in therefore lands on a function's first real body statement, and the session's entry stop (S1) never rests on the export shim or a signature line. Exception: when a declaration line is the only run start of its function-body frame — a fully collapsed one-line function whose body shares the signature's line (common above opt-0) — it is kept, so step-in still has a target and S4/S16 hold. Attribute lines are never kept by this exception (the export shim is always glue); onlyfn/implheaders are. - S18 (brace suppression): a run start whose source line is a bare closing
brace (trimmed text is
},};, or},) is not a statement stop unless it is the function's final brace — the epilogue the return is attributed to, recognized as a brace run start immediately followed (in run order) by a shallower-depth run start, or by nothing. That single epilogue brace is kept as the one place to inspect the return value before the frame unwinds; every inner block-closing brace is suppressed. When a frame's epilogue is instead attributed to a declaration line (macro-generated export wrappers map their return to#[contractimpl]), S17 governs and no epilogue stop is kept for it.
Filtering never empties the trace: if it would remove every stop, the unfiltered run starts stand (preserving S1/S2/S3). Breakpoints and instruction-granularity stepping are unchanged — S17/S18 shape only where statement stepping comes to rest.
Statement stepping is only as good as the DWARF line table the build emits, and the line table is destroyed by optimization long before any stepping code runs:
opt-level = "z"/"s"/"3"collapse an entire function's statements onto a single source line (the#[contractimpl]/implline), so a function has one statement stop — every step-in / step-over / continue / step-back is a no-op. This is what made the incrementer contract appear to have no working source stepping at all.opt-level = "1"keeps line info for statements that survive as distinct operations (e.g. host-call-heavy code) but still folds pure-arithmetic functions onto one line and flattens loops to a single stop.opt-level = "0"preserves per-statement line info across every construct (sequence, if/else,for,while,match, calls) — this is the only level at which stepping behaves as a debugger user expects.
Therefore the debugger builds contracts with CARGO_PROFILE_RELEASE_OPT_LEVEL=0
injected (alongside CARGO_PROFILE_RELEASE_DEBUG=true /
CARGO_PROFILE_RELEASE_STRIP=none; see ContractBuilder), overriding the
crate's production opt-level for the debug build only — the crate's
Cargo.toml is never edited. Traces are correspondingly larger (opt-0 emits
~10–20× more records than opt-z); that is the cost of statement-level debugging.
-
test/fixtures/adder-debug.{wasm,trace.jsonl}— straight-line code, 6 leading invisible records, wide unmapped gaps between mapped runs (S1–S3, S10–S12 basics). -
test/fixtures/stepper-debug.{wasm,trace.jsonl}—sum_triples(3)onexamples/stepper: a realcallto an#[inline(never)]helper with implicit return (noreturnrecord — S5, S7, S8), and a 3-iterationwhileloop (S6, S9, S12, S13, S15). Built above opt-0, sotriple's body collapses onto its signature line. Statement stops (trace index ↦ line): 21 (:25while), 27 (:26 call line), 29 (:15triple), 39/44 (:25/:26), 46 (:15), 56/61 (:25/:26), 63 (:15), 73 (:25). The#[contractimpl]shim (record 5, :20) and the epilogue that maps back to it (record 84, :20) are dropped by S17;triple's only line is itsfnsignature, kept by the S17 sole-frame-stop exception so step-in still lands (records 29/46/63 are one frame deeper — the trace enters and leavestriplethree times). -
test/fixtures/control-debug.wasm+control-{seq,branch,count,while_call,choose}.trace.jsonl— one opt-0 wasm (examples/control) shared by five traces, each isolating a Rust construct so the suite pins how source stepping crosses it. All five functions live inimpl Control; the#[contractimpl]export shim (:20, at depths 0 then 1) and eachpub fnsignature are dropped by S17, so the body (depth 2, a calledbumpat depth 3) is where stepping rests. Statement-stop ground truth (trace index ↦ line, post-S17/S18):- seq(7) — pure sequence: 236 (:24
let a), 249 (:25let b), 262 (:26let c), 265 (:28}epilogue kept by S18). Body lines strictly increase; one stop each (S4/S5 basics, no re-execution). Dropped: 6/21 (:20 shim), 219 (:23 sig). - branch(3) — if/else,
3 <= 10so the else arm runs: 226 (:33if), 244 (:36 else arm), 245 (:33 merge), 246 (:38r), 248 (:39}). The then arm (:34) never appears — stepping enters only the taken arm. - count(3) —
for i in 0..3: 228 (:43let acc), then theforheader (:44) and the bodyacc += i(:45) alternate per iteration (:44 at 233/414/561/708 — three iterations plus the terminatingnext() -> Nonecheck; :45 at 400/547/694 — the body), 805 (:47acc), 808 (:48}). The body line stops exactly once per iteration (S6/S12) — the defining loop behavior. - while_call(3) —
while+ a realbumpcall: 228/231 (:53/:54 setup), then per iteration 234 (:55while), 243 (:56acc += bump(i)), 266 (:16bumpbody) / 278 (:18bump}epilogue) at depth 3, 291 (:57i += 1); three times (266/278, 337/349, 408/420); then 447 (:55 final test), 456/459 (:59acc/ :60}).stepInat :56 descends to the firstbumpstatement :16 — thefn bumpsignature (records 249/320/391, :15) is dropped by S17;nextat :56 steps over to :57 (S4 vs S5); the callee returns implicitly (noreturnrecord — S5/S7/S8). - choose(7) —
match x % 3,7 % 3 == 1so arm1 => 200runs: 228 (:64match), 240 (:66 arm), 243 (:69r), 245 (:70}). Arms0 =>(:65) and_ =>(:67) never appear.
- seq(7) — pure sequence: 236 (:24
Regenerate all pairs together with scripts/make-fixtures.sh.
The frame stack is reconstructed from function membership of consecutive visible records (spec Model/depth), which is robust to komet-node's missing implicit-return records but has two documented edges the fixtures do not exercise:
- Direct self-recursion (
AcallsA) is invisible to a membership-based stack: the callee has the same function identity as the caller, so no frame is pushed and the recursion appears flat. Mutual recursion (A → B → A) is handled, because the identities differ. No contract in scope recurses directly; revisit if that changes. - Indirect and tail calls (
call_indirect,return_call,return_call_indirect) are treated as frame entries only if komet-node spells them with those exact opcode names. Only plaincallis confirmed against komet output; a contract using trait-object dispatch could emit a spelling we don't recognize (orunknown), in which case the call would be mistaken for straight-line code. Capturing a fixture from such a contract to pin the real spelling is future work.