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
namesection, 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 bytest/callStack.test.tsandtest/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.
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 |
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"]
- C1 (activations are the structure): the frames of a stack are, innermost first, the reconstructed wasm activation stack at the cursor —
computeFrames, the same walkdepthsis projected from. The number of activation frames is therefore alwaysdepth + 1, so the Callstack view andnext/stepOutcan 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 thecallinstruction 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_subroutineinstances 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'sDW_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_rnglistslist, 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
callContractboundaries (LedgerImage) are appended BELOW every wasm frame, innermost call first, asincrement() @ CA5XKA…7QFM. They are reported to DAP withpresentationHint: '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'sname-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 reportedpresentationHint: 'subtle'with adeemphasized 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):
stackTracereports every frame withtotalFramesset, honoring the client'sstartFrame/levelswindow. 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 owninstructionPointerReference— the Disassembly view follows the SELECTED frame, not just the innermost one. - C7 (frames are inspectable):
scopes/variablesanswer 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.
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, soaddis inlined into the#[contractimpl]wrapper entirely. At the statement stop (index 29, pc0x2d) the stack isadd(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 framewasm@0x2d(C4).stepper-debug.{wasm,trace.jsonl}— a realcall(tripleis#[inline(never)]) under an inlined caller. Insidetriple(index 29) the stack isstepper::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 — thecall— 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: insidebump(index 266) the stack iscontrol::bump(lib.rs:16) →control::Control::while_call(lib.rs:56) →invoke_raw→invoke_raw_extern, withwhile_callnamed from thenamesection because its DIE is anonymous (C4) and each frame reporting its own variables (C7).stepper-debug.wasmwith its.debug_*sections stripped in-test — the release build's stack:stepper::tripleandsum_triples+0x…, named from thenamesection and positioned by offset (C4).composite.wasm— neither DWARF nor anamesection, 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 theincrement() @ …boundary frame (C3).
- The activation reconstruction's own edges apply unchanged (see
stepping.md): direct self-recursion is invisible to a membership-based frame stack, and only the exact opcode spellingscall/call_indirect/return_call/return_call_indirectare recognized as calls. - Inline frames need
.debug_ranges(DWARF v4). A v5.debug_rnglistsinline 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=v0build shows its_R…symbols verbatim — undemangled, but still the function's identity.