Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The `soroban-trace` CLI reports the same state per stop as `globals` and
`ledger`, with a `changed` flag marking the storage entries that moved since
the previous stop, and `hasGlobals`/`hasLedger` announced in `meta`.
- New contributor spec: [`docs/state-inspection.md`](docs/state-inspection.md),
whose numbered rules (G1–G4, L1–L15) the test suite pins.
- **A real call stack.** The Callstack view now shows every frame that led to the current line — not one frame named after a wasm instruction. Frame *structure* comes from the trace's own wasm activations, so it is right at any optimization level, and DWARF adds the Rust frames inlining erased: an optimized build still shows `add` → `invoke_raw` → the export wrapper rather than one collapsed function. Outer frames stand on the call they are suspended in, every frame is selectable and shows *its own* locals, wasm stack and Rust variables, and the Disassembly view follows the selected frame. Names come off a precision ladder — DWARF, then the demangled `name` section, then the function index, then the code offset — so a release build with no debug info still gets `control::Control::while_call+0x1a` instead of a bare address, and the trace's contract-call boundaries close the stack as labels at the bottom. Frames the user did not write (Rust `std`/`core`, dependencies) are deemphasized rather than hidden. `soroban-trace` reports the same stack per stop as `frames`.
- New contributor specs: [`docs/state-inspection.md`](docs/state-inspection.md) (rules G1–G4, L1–L15) and [`docs/callstack.md`](docs/callstack.md) (rules C1–C8), both pinned by the test suite.

### Changed

- The replay cursor's position in the recording moved out of the stack frame's name and into the thread's label (`soroban-vm [29/40]`): a frame name now says what the program is doing, and where the cursor sits is a property of the recorded thread.
- **The single-invoke launch config is gone.** `contract`, `function`, `args`,
`buildCommand` and `debugInfo` no longer sit at the top level: wrap them in a
`transactions` array (see [`docs/debug-config.md`](docs/debug-config.md)). A
Expand Down
13 changes: 10 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,11 @@ debugAdapter/
records, call depths, statement stops (shared with the CLI)
replayCursor.ts the stepping engine — every forward/reverse move and the
breakpoint resolution, as cursor moves over a StopModel
stops.ts the pure derivations stopModel is built from (depths,
line runs, S17/S18/S21 stop filtering)
stops.ts the pure derivations stopModel is built from (wasm frame
stacks + depths, line runs, S17/S18/S21 stop filtering)
callStack.ts the frames both front ends show: wasm activations, the
Rust frames inlining erased, contract boundaries
(docs/callstack.md)
TraceModel records + replay cursor; owns the two state images below,
built lazily and shared by every consumer
MemoryImage linear memory at a cursor (snapshot-on-change index)
Expand Down Expand Up @@ -181,17 +184,21 @@ soroban/strkey.ts raw address bytes -> C…/G… strkey (SDK-free: the SDK
the DAP handshake)
wasm/
sections.ts wasm section walker (offsets, custom-section lookup)
names.ts the `name` section + Rust demangling: how a frame is
labelled when the build carries no DWARF
Disassembly.ts static disassembly (wasmparser), code-offset addressed
dwarf/ DWARF v4/v5 .debug_line/.debug_info parser -> LineTable
sourcemap/
SourceMapper the mapping seam the adapter talks to
DwarfSourceMapper trace index / code offset -> Rust file:line (+ breakpoints)
NullSourceMapper no-DWARF fallback (disassembly-only)
VariableResolver the source-level view of a pc: enclosing function, inlined
frames, in-scope variables, decoded values
```

All replay logic is free of the `vscode` API, so it can be unit-tested in plain
Node; the `vscode`-only glue lives in `extension.ts`. For a deep dive on the
stepping model, see [`docs/stepping.md`](docs/stepping.md).
stepping model, see [`docs/stepping.md`](docs/stepping.md); for the frame model behind the Callstack view, see [`docs/callstack.md`](docs/callstack.md).

## Pull requests

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ directions.
your actual `.rs` files — not opaque bytecode.
- ⏪ **Step backward.** Step back and reverse-continue as easily as going
forward. Overshot the bug? Just step back. Backward stepping is instant.
- 🧭 **Follow the call stack.** Every Rust frame that led to the current line, including the ones the optimizer inlined away — select any frame to inspect *its* variables and jump to *its* line.
- 🔎 **Inspect state at every step.** See the values in play at the current
point of execution — your Rust variables, the wasm locals, stack and globals.
- 🏦 **See the ledger, not just the code.** Contract storage (instance,
Expand Down Expand Up @@ -136,7 +137,6 @@ internally.

## Roadmap

- Multi-frame call stacks with per-frame locals
- A source-level Variables view with inline values
- Column-level breakpoints

Expand Down
77 changes: 77 additions & 0 deletions docs/callstack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Call stack semantics

> **Audience:** `contributor` · `maintainer` (frames, Callstack view)
>
> **TL;DR:** What the Callstack view shows and why it can be trusted at any optimization level. Frame STRUCTURE always comes from the trace's own wasm activations (C1); DWARF adds the Rust frames inlining erased (C2); the trace's contract boundaries close the stack at the bottom (C3). Names come off a precision ladder — DWARF, then the `name` section, then the function index, then the address (C4) — so a frame is never nameless and never labelled with something less precise than the build made available. The numbered rules C1–C8 are pinned by `test/callStack.test.ts` and `test/dapFrames.test.ts`.

Where the rules live in the code: the activation reconstruction is `computeFrames` in `src/debugAdapter/stops.ts` (assembled into the `StopModel`, so stepping and frames share one derivation); the inline chain is `ScopeIndex.inlineScopesAt` behind `VariableResolver.inlineFramesAt`; the assembly of the three sources into frames is `src/debugAdapter/callStack.ts`, which both `SorobanDebugSession.stackTraceRequest` and the CLI's `projectSourceStop` call. Every one of those is pure and unit-tested without a DAP client.

## Why not "Rust frames OR wasm frames"

A recorded trace and a DWARF section disagree about what a frame is, and both are right about different things:

- The **trace** knows exactly which wasm function bodies are active. It cannot know that four Rust functions were inlined into one of them.
- **DWARF** knows the Rust call chain the programmer wrote. Its line table and inline records are only as complete as the optimizer left them.

So the view does not pick one. It takes the **structure** from the trace, which can never be wrong about the number of live activations, and takes **identity, position and inline depth** from the most precise source available at each frame. That is what makes the same view usable across build settings:

| build | what the stack shows |
| --- | --- |
| opt-0 + DWARF (what the debugger builds by default) | Rust frames one-to-one with activations, plus the occasional `#[inline(always)]` frame; every frame located in the user's source |
| optimized + DWARF | fewer activations, with the erased Rust chain restored as inline frames (C2) — the whole chain is still named and located |
| no DWARF (release, `debugInfo: false`, stripped) | one frame per activation, named from the demangled `name` section, positioned by code offset (C4) |
| no wasm at all (`rawTrace` replay) | one frame per activation from the opcode walk, addressed but unnamed |

```mermaid
flowchart TB
T["trace records"] -->|"computeFrames:<br/>function membership of visible records"| ACT["wasm activations<br/>C1 — structure, always trustworthy"]
ACT -->|"per activation pc:<br/>DW_TAG_inlined_subroutine chain"| INL["+ inline frames<br/>C2 — the Rust chain optimization erased"]
INL -->|"LedgerImage open calls"| CON["+ contract boundaries<br/>C3 — host-level invocations"]
CON -->|"DWARF name → name section → func index → address"| OUT["Callstack view / CLI frames<br/>C4 — named, C5 — deemphasized, C7 — inspectable"]
```

## Rules

- **C1** (activations are the structure): the frames of a stack are, innermost first, the reconstructed wasm activation stack at the cursor — `computeFrames`, the same walk `depths` is projected from.
The number of activation frames is therefore always `depth + 1`, so the Callstack view and `next`/`stepOut` can never disagree about what frame the cursor is in.
An activation is positioned at the record it is executing: the cursor's record for the innermost frame, and for an outer frame the `call` instruction that entered the frame below it — which is what a caller frame reports in every debugger.
Without function-body ranges (wasm-less replay) the opcode walk supplies the same structure, minus function identity.
- **C2** (inline frames): when DWARF is present, each activation's pc is expanded through the `DW_TAG_inlined_subroutine` instances covering it, and each becomes a frame ABOVE the activation.
Positions shift by one along the chain: the innermost frame stands where the line table points, and every frame below it stands at its callee's `DW_AT_call_file`/`DW_AT_call_line` — the line the inlined call was written on.
Without this, a frame would carry the name of the wrapper function while the cursor sat on the inlined function's source line, which is the single most confusing thing a call stack can do.
An instance whose range this parser cannot read (a DWARF v5 `.debug_rnglists` list, or an absent `.debug_ranges`) is skipped, never guessed at: a missing frame degrades the view, an invented one misreports the program.
- **C3** (contract boundaries): the trace's own `callContract` boundaries (`LedgerImage`) are appended BELOW every wasm frame, innermost call first, as `increment() @ CA5XKA…7QFM`.
They are reported to DAP with `presentationHint: 'label'` — they mark a host-level invocation, not a code position, so they have no source, no pc and no scopes.
A trace carrying no call boundaries contributes none.
- **C4** (naming ladder): a frame's label is the first of these that exists — the DWARF subprogram name qualified by its enclosing namespaces and types (`control::__while_call::invoke_raw`); the module's `name`-section symbol, demangled (`control::Control::while_call` — rustc leaves some method DIEs anonymous, so this rung matters even in a DWARF build); the wasm function index (`func[7]`); the raw code offset (`wasm@0x2d`).
A frame with no source location also carries its offset inside the function (`soroban_sdk::…::get+0x99`), because for a wasm-level frame that offset is the only position the user has.
A frame is never nameless, and an inlined frame DWARF names nowhere is `<inlined>` rather than blank.
- **C5** (deemphasis, never hiding): a frame whose source is non-workspace (the S21 test — `/.rustup/`, `/.cargo/`, `/rustc/`) or which has no source at all in a session that HAS line info is reported `presentationHint: 'subtle'` with a `deemphasize`d source.
It is still there: an optimized build can put eight SDK conversion frames between the user's code and the pc, and a stack that quietly dropped them would be a lie about how the program got here.
In a session with no line info at all nothing is deemphasized — greying out every frame says nothing.
- **C6** (the whole stack, paged): `stackTrace` reports every frame with `totalFrames` set, honoring the client's `startFrame`/`levels` window.
Frame ids are the frame's own level, so a client that pages twice gets the same frame for the same id, and each frame carries its own `instructionPointerReference` — the Disassembly view follows the SELECTED frame, not just the innermost one.
- **C7** (frames are inspectable): `scopes`/`variables` answer for the SELECTED frame.
Locals, Value Stack and the source-level Variables of an outer frame are read from that frame's own record (the call it is suspended in), so they are the caller's values, not the innermost frame's; an inline frame reports the variables its own inlined instance declares, which is why stepping into optimized code still shows the callee's parameters and not the host function's.
Linear memory is read at the CURRENT cursor for every frame — a callee may have written through a reference the caller still holds, and at opt-0 the caller's own locals live in that memory.
Globals and the Ledger are VM-wide and are offered on every code frame; a contract-boundary frame offers no scopes.
- **C8** (the recording position): the cursor's place in the recording (`[29/40]`) is reported as part of the THREAD's name, not smuggled into a frame label.
A frame name states what the program is doing; where the replay cursor sits is a property of the recorded thread, and a client refreshes thread names on every stop.

## Fixtures pinning these rules

Each fixture is a different point in the build-settings space, which is exactly what these rules have to survive:

- `adder-debug.{wasm,trace.jsonl}` — built above opt-0, so `add` is inlined into the `#[contractimpl]` wrapper *entirely*. At the statement stop (index 29, pc `0x2d`) the stack is `add` (lib.rs:16) → `invoke_raw` (lib.rs:12) → `adder::__add::invoke_raw_extern` (lib.rs:12): ONE activation, three frames (C2). The same trace replayed with no wasm gives the single frame `wasm@0x2d` (C4).
- `stepper-debug.{wasm,trace.jsonl}` — a real `call` (`triple` is `#[inline(never)]`) under an inlined caller. Inside `triple` (index 29) the stack is `stepper::triple` (lib.rs:15) → `sum_triples` (lib.rs:**26**, the call site) → `invoke_raw` → `invoke_raw_extern`, and the caller's variables are read from record 28 — the `call` — not from the cursor (C1, C2, C7).
- `control-debug.wasm` + `control-while_call.trace.jsonl` — opt-0, where the Rust chain IS the activation chain: inside `bump` (index 266) the stack is `control::bump` (lib.rs:16) → `control::Control::while_call` (lib.rs:56) → `invoke_raw` → `invoke_raw_extern`, with `while_call` named from the `name` section because its DIE is anonymous (C4) and each frame reporting its own variables (C7).
- `stepper-debug.wasm` with its `.debug_*` sections stripped in-test — the release build's stack: `stepper::triple` and `sum_triples+0x…`, named from the `name` section and positioned by offset (C4).
- `composite.wasm` — neither DWARF nor a `name` section, so a frame can only say which function body it is in: `func[N]` (C4).
- `increment-debug.{wasm,trace.jsonl}` — carries ledger events, so the stack ends in the `increment() @ …` boundary frame (C3).

## Known limitations

- The activation reconstruction's own edges apply unchanged (see [`stepping.md`](./stepping.md#known-limitations-of-depth-reconstruction)): direct self-recursion is invisible to a membership-based frame stack, and only the exact opcode spellings `call` / `call_indirect` / `return_call` / `return_call_indirect` are recognized as calls.
- Inline frames need `.debug_ranges` (DWARF v4). A v5 `.debug_rnglists` inline instance is skipped (C2), which costs frames rather than correctness — this parser reads v4 and v5 line programs but only v4 range lists.
- A frame's variables are decoded from the record the frame is positioned at. Wasm locals cannot be modified by a callee, so a caller's locals are exact; values reached THROUGH memory are read at the current cursor and are therefore as current as the trace's last memory snapshot.
- Only legacy Rust symbol mangling (`_ZN…E`) is demangled. A `-Csymbol-mangling-version=v0` build shows its `_R…` symbols verbatim — undemangled, but still the function's identity.
5 changes: 4 additions & 1 deletion docs/stepping.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,11 @@ every stop, the unfiltered run starts stand.

### Frames

The rules below govern where the INNERMOST frame stands.
What the rest of the stack is — the wasm activations under it, the Rust frames inlining erased, the contract boundaries below them — is specified separately in [`callstack.md`](./callstack.md) (C1–C8), which builds on the same frame reconstruction `depth` is projected from.

- **S16** (frame consistency): whenever the cursor rests on a mapped record,
the stack frame carries that record's source and line; the frame is
the innermost 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
Expand Down
12 changes: 12 additions & 0 deletions docs/trace-cli-internal.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ low-level resolver calls:
- `variables.functionNameAt(pc)` → function name (**may be `null`** even with DWARF)
- `makeRuntimeState(record, model.memory, index)` + `variables.variablesInScope(pc)` +
`variables.decodeVariable(v, state, pc)` → decoded variables
`buildCallStack({resolved, frames, ranges}, index)` → the stop's `frames`

`frames` is the SAME derivation the DAP session's `stackTrace` returns (`src/debugAdapter/callStack.ts`), projected to JSON — the CLI adds only hex `pc` formatting and drops the per-frame `variables` (a stop's `variables` are the innermost frame's; repeating every frame's would multiply the output size).

Children (`DecodedValue.children`) are expanded **eagerly** into plain arrays, bounded
by a per-stop budget: `maxDepth` (default 3), `maxChildren` (default 64), and a global
Expand All @@ -108,12 +111,21 @@ interface SourceStop {
depth: number; // stopModel.depths[traceIndex]
pc: string | null; // hex, e.g. "0x2d", or null
function: string | null; // functionNameAt(pc) or null
frames: StopFrame[]; // the call stack, innermost first (docs/callstack.md); never empty
instr: string; // renderInstr(record.instr)
source: { path: string; line: number; column?: number } | null;
variables: TraceVar[];
globals?: Record<string, { type: string; value: string }>; // module-relative index (G1)
ledger?: StopLedger; // omitted when the trace carries no ledger info (L14)
}
interface StopFrame { // see docs/callstack.md for the rules
level: number; // 0 = innermost
name: string; // never empty (C4)
kind: 'rust' | 'inline' | 'wasm' | 'contract';
pc: string | null; // hex code offset, or null for a contract boundary
source: { path: string; line: number; column?: number } | null;
subtle?: true; // non-workspace or sourceless: deemphasize (C5)
}
interface TraceVar {
name: string; // "<anon>" when DWARF gives none
type?: string;
Expand Down
Loading