diff --git a/CHANGELOG.md b/CHANGELOG.md
index fcae92a..93b3a07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8298b27..1411ab9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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)
@@ -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
diff --git a/README.md b/README.md
index 814c25a..3fb2a34 100644
--- a/README.md
+++ b/README.md
@@ -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,
@@ -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
diff --git a/docs/callstack.md b/docs/callstack.md
new file mode 100644
index 0000000..ad7444d
--- /dev/null
+++ b/docs/callstack.md
@@ -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:
function membership of visible records"| ACT["wasm activations
C1 — structure, always trustworthy"]
+ ACT -->|"per activation pc:
DW_TAG_inlined_subroutine chain"| INL["+ inline frames
C2 — the Rust chain optimization erased"]
+ INL -->|"LedgerImage open calls"| CON["+ contract boundaries
C3 — host-level invocations"]
+ CON -->|"DWARF name → name section → func index → address"| OUT["Callstack view / CLI frames
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 `` 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.
diff --git a/docs/stepping.md b/docs/stepping.md
index df91d17..883ed0c 100644
--- a/docs/stepping.md
+++ b/docs/stepping.md
@@ -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
diff --git a/docs/trace-cli-internal.md b/docs/trace-cli-internal.md
index 196dacf..7c3a231 100644
--- a/docs/trace-cli-internal.md
+++ b/docs/trace-cli-internal.md
@@ -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
@@ -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; // 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; // "" when DWARF gives none
type?: string;
diff --git a/docs/trace-cli.md b/docs/trace-cli.md
index c8e02db..e0e7967 100644
--- a/docs/trace-cli.md
+++ b/docs/trace-cli.md
@@ -81,7 +81,7 @@ JSONL to stdout:
```jsonl
{"kind":"meta","function":"add","records":41,"stops":1,"hasDwarf":true}
-{"kind":"stop","step":0,"traceIndex":29,"depth":0,"pc":"0x2d","function":"invoke_raw_extern","instr":"i32.add","source":{"path":".../examples/adder/src/lib.rs","line":16,"column":9},"variables":[{"name":"arg_0","type":"Val","value":"17179869188"},{"name":"arg_1","type":"Val","value":"12884901892"}]}
+{"kind":"stop","step":0,"traceIndex":29,"depth":0,"pc":"0x2d","function":"invoke_raw_extern","frames":[{"level":0,"name":"add","kind":"inline","pc":"0x2d","source":{"path":".../examples/adder/src/lib.rs","line":16}},{"level":1,"name":"invoke_raw","kind":"inline","pc":"0x2d","source":{"path":".../examples/adder/src/lib.rs","line":12}},{"level":2,"name":"adder::__add::invoke_raw_extern","kind":"rust","pc":"0x2d","source":{"path":".../examples/adder/src/lib.rs","line":12}}],"instr":"i32.add","source":{"path":".../examples/adder/src/lib.rs","line":16,"column":9},"variables":[{"name":"arg_0","type":"Val","value":"17179869188"},{"name":"arg_1","type":"Val","value":"12884901892"}]}
{"kind":"result","terminated":true}
```
@@ -91,6 +91,21 @@ Each `stop` carries the source location, the enclosing function, the call
`--max-children`). The full `SourceStop` / `TraceVar` field reference is in
[`trace-cli-internal.md`](./trace-cli-internal.md).
+### The call stack: `frames`
+
+`frames` is the whole call stack at that stop, innermost first — the same frames the editor's Callstack view shows, derived by the same shared code, so a script and a debug session never disagree about who called whom.
+Each frame states its `name`, its `pc`, where it stands (`source`), and which rung of the precision ladder placed it:
+
+| `kind` | meaning |
+| --- | --- |
+| `rust` | a wasm activation located by DWARF |
+| `inline` | a Rust frame the optimizer inlined into the activation below it |
+| `wasm` | an activation with no source-level identity (no DWARF at its pc) |
+| `contract` | a host-level contract invocation — a boundary marker, not a code position |
+
+An outer frame stands at the CALL it is suspended in, not at its own first line, and a frame the user did not write (Rust toolchain, a crates.io dependency, or any sourceless frame in a session that has line info) carries `"subtle": true`, so a consumer can fold the noise away without losing it.
+The example above is a build above opt-level 0, where `add` survives only as an inline frame inside the `#[contractimpl]` wrapper — the rules are specified in [`callstack.md`](./callstack.md).
+
### Machine state: `globals` and `ledger`
When the trace carries them, a `stop` also reports the machine and chain state at that point — the same state the editor's **Globals** and **Ledger** scopes show. `meta` announces both up front (`hasGlobals`, `hasLedger`) so a consumer can branch without probing every stop:
diff --git a/src/debugAdapter/SorobanDebugSession.ts b/src/debugAdapter/SorobanDebugSession.ts
index 89be95b..261e355 100644
--- a/src/debugAdapter/SorobanDebugSession.ts
+++ b/src/debugAdapter/SorobanDebugSession.ts
@@ -29,13 +29,14 @@ import { DebugProtocol } from '@vscode/debugprotocol';
import * as path from 'path';
import { TraceModel } from './TraceModel';
import { firstNonWhitespaceColumn } from './stops';
-import { StopModel, buildStopModel, pcAtIndex } from './stopModel';
+import { StopModel, buildStopModel } from './stopModel';
import { Granularity, ReplayCursor, resolveBreakpoints } from './replayCursor';
import { SourceMapper } from '../sourcemap/SourceMapper';
import { VariableResolver, NullVariableResolver } from '../sourcemap/VariableResolver';
import { Disassembly } from '../wasm/Disassembly';
import { ResolvedTrace, SessionBackend, SorobanLaunchArgs } from './types';
-import { renderInstr } from '../komet/mnemonics';
+import { CallFrame, buildCallStack } from './callStack';
+import { TraceRecord } from '../komet/trace';
import { disassemblyRows, formatAddress, parseAddress } from './disassemblyView';
import { ledgerNodes, ledgerSnapshot } from './ledgerView';
import { globalNodes, localNodes, stackNodes } from './wasmView';
@@ -43,10 +44,14 @@ import { makeRuntimeState } from './runtimeState';
import { DecodedValue, ChildVar } from '../dwarf/ValueDecoder';
const THREAD_ID = 1;
-const FRAME_ID = 1;
-/** Variable-reference handles for the fixed scopes we expose. */
-enum ScopeRef {
+/**
+ * The kinds of scope a frame can offer. A `variablesReference` encodes the kind
+ * TOGETHER with the frame it belongs to (see `scopeRef`), because every scope is
+ * now per-frame: selecting an outer frame must show that frame's state, not the
+ * innermost one's (docs/callstack.md, C7).
+ */
+enum ScopeKind {
Locals = 1,
Stack = 2,
SourceVars = 3,
@@ -56,6 +61,28 @@ enum ScopeRef {
Ledger = 5,
}
+/** How many scope kinds a frame's reference block reserves. */
+const SCOPES_PER_FRAME = 8;
+/** Frame ids are `FRAME_ID_BASE + level`, so frame 0 is a valid (non-zero) id. */
+const FRAME_ID_BASE = 1;
+/** Scope references live above every frame id, child handles above every scope. */
+const SCOPE_REF_BASE = 100_000;
+const CHILD_HANDLE_BASE = 1_000_000;
+
+/** The `variablesReference` naming scope `kind` of the frame at `level`. */
+function scopeRef(level: number, kind: ScopeKind): number {
+ return SCOPE_REF_BASE + level * SCOPES_PER_FRAME + kind;
+}
+
+/** Inverse of `scopeRef`, or null when the reference is not a scope. */
+function decodeScopeRef(reference: number): { level: number; kind: ScopeKind } | null {
+ if (reference < SCOPE_REF_BASE || reference >= CHILD_HANDLE_BASE) {
+ return null;
+ }
+ const offset = reference - SCOPE_REF_BASE;
+ return { level: Math.floor(offset / SCOPES_PER_FRAME), kind: offset % SCOPES_PER_FRAME };
+}
+
export class SorobanDebugSession extends DebugSession {
/**
* Either a concrete backend or a selector resolved on the first line of
@@ -64,13 +91,18 @@ export class SorobanDebugSession extends DebugSession {
* concrete backend.
*/
private backend: SessionBackend | ((args: SorobanLaunchArgs) => SessionBackend);
+ private resolved?: ResolvedTrace;
private model?: TraceModel;
private cursor?: ReplayCursor;
private stops?: StopModel;
private source?: SourceMapper;
private disassembly?: Disassembly;
- /** Per-record validated code offsets, parallel to the trace records. */
- private positions: (number | null)[] = [];
+ /**
+ * The call stack at the current cursor, built once per stop: `stackTrace` and
+ * every following `scopes`/`variables` request must agree on what frame N is.
+ * Cleared whenever the cursor moves (see `reportStop`).
+ */
+ private frames?: CallFrame[];
/** Resolves when the client has finished configuring (e.g. breakpoints). */
private readonly configurationDone: Promise;
@@ -86,11 +118,11 @@ export class SorobanDebugSession extends DebugSession {
/** Source-level variable resolver (Null until a DWARF-bearing wasm loads). */
private variables: VariableResolver = new NullVariableResolver();
/**
- * Handles for lazily-expanded variable children. High start avoids colliding
- * with the fixed ScopeRef range; reset on every stop so refs are fresh per
- * cursor position.
+ * Handles for lazily-expanded variable children. Starts above every frame id
+ * and per-frame scope reference; reset on every stop so refs are fresh per
+ * cursor position (DAP invalidates all references at a stop).
*/
- private readonly childHandles = new Handles<() => ChildVar[]>(1000);
+ private readonly childHandles = new Handles<() => ChildVar[]>(CHILD_HANDLE_BASE);
/**
* Set once the per-connection backend has been disposed, so teardown is
@@ -149,11 +181,11 @@ export class SorobanDebugSession extends DebugSession {
}
try {
const resolved: ResolvedTrace = await this.backend.resolve(args, (msg) => this.log(msg));
+ this.resolved = resolved;
this.model = resolved.model;
this.source = resolved.source;
this.variables = resolved.variables;
this.disassembly = resolved.disassembly;
- this.positions = resolved.positions;
this.stops = buildStopModel(resolved, { justMyCode: args.justMyCode });
this.cursor = new ReplayCursor(this.model, this.stops);
@@ -176,7 +208,7 @@ export class SorobanDebugSession extends DebugSession {
this.sendResponse(response);
this.cursor.toEntry();
- this.sendEvent(new StoppedEvent('entry', THREAD_ID));
+ this.reportStop('entry');
} catch (e) {
// sendErrorResponse surfaces only a one-line, non-copyable modal. Mirror
// the full error (with stack) into the debug console first, so the details
@@ -278,67 +310,85 @@ export class SorobanDebugSession extends DebugSession {
// --- Frames, disassembly, scopes --------------------------------------
+ /**
+ * The single VM thread. Its label carries the cursor's position in the
+ * recording — the one fact a time-travel session has and DAP's frame model
+ * does not, and which a client refreshes on every stop.
+ */
protected threadsRequest(response: DebugProtocol.ThreadsResponse): void {
- response.body = { threads: [new Thread(THREAD_ID, 'soroban-vm')] };
+ const position =
+ this.model && !this.model.isEmpty ? ` [${this.model.cursor}/${this.model.length - 1}]` : '';
+ response.body = { threads: [new Thread(THREAD_ID, `soroban-vm${position}`)] };
this.sendResponse(response);
}
+ /**
+ * The full call stack at the cursor (docs/callstack.md), innermost frame
+ * first, honoring the client's paging window. Every frame is selectable and
+ * carries its own source position and instruction pointer; a contract-boundary
+ * frame is reported as a `label` so clients render it as the marker it is.
+ */
protected stackTraceRequest(
response: DebugProtocol.StackTraceResponse,
- _args: DebugProtocol.StackTraceArguments,
+ args: DebugProtocol.StackTraceArguments,
): void {
- if (!this.model || !this.source) {
- response.body = { stackFrames: [], totalFrames: 0 };
- this.sendResponse(response);
- return;
- }
-
- const index = this.model.cursor;
- const loc = this.source.locationForIndex(index);
- const frameName = `${renderInstr(this.model.current.instr)} [${index}/${this.model.length - 1}]`;
-
- // Unmapped records get no Source at all (and line 0): the client keeps
- // showing the frame name instead of opening a wrong file. S19: a mapped
- // frame reports the line's first non-whitespace column, not the arbitrary
- // DWARF sub-expression column; fall back to the DWARF column when the line
- // text is unavailable or all-whitespace.
- const frame: DebugProtocol.StackFrame = loc
- ? new StackFrame(
- FRAME_ID,
- frameName,
- new Source(path.basename(loc.path), loc.path),
- loc.line,
- firstNonWhitespaceColumn(this.source.sourceTextForIndex(index)) ?? loc.column ?? 0,
- )
- : new StackFrame(FRAME_ID, frameName);
- const reference = this.instructionPointerReference();
- if (reference !== undefined) {
- frame.instructionPointerReference = reference;
- }
- response.body = { stackFrames: [frame], totalFrames: 1 };
+ const frames = this.callFrames();
+ const start = args.startFrame ?? 0;
+ const end = args.levels && args.levels > 0 ? start + args.levels : frames.length;
+ response.body = {
+ stackFrames: frames.slice(start, end).map((frame) => this.toDapFrame(frame)),
+ totalFrames: frames.length,
+ };
this.sendResponse(response);
}
- /**
- * The current PC as a hex address, so the Disassembly View stays anchored on
- * the last real instruction. Absent when no record at or before the cursor has
- * a validated code offset (e.g. a trace opening with global initializers).
- */
- private instructionPointerReference(): string | undefined {
- const pc = this.currentPc();
- return pc === null ? undefined : formatAddress(pc);
+ /** The call stack at the cursor, built once per stop. */
+ private callFrames(): CallFrame[] {
+ if (this.frames === undefined) {
+ this.frames =
+ this.resolved && this.stops && !this.resolved.model.isEmpty
+ ? buildCallStack(
+ { resolved: this.resolved, frames: this.stops.frames, ranges: this.stops.ranges },
+ this.resolved.model.cursor,
+ )
+ : [];
+ }
+ return this.frames;
}
/**
- * The current record's validated code offset, or — when it has none — that of
- * the NEAREST earlier record that does, so in-scope variable lookup stays
- * anchored on the last real instruction. Null when no record qualifies.
+ * One `CallFrame` as DAP. S19: a mapped frame reports its line's first
+ * non-whitespace column, not the arbitrary DWARF sub-expression column; the
+ * DWARF column is the fallback when the line text is unavailable or
+ * all-whitespace. An unmapped frame gets no Source at all (and line 0), so the
+ * client keeps showing the frame name instead of opening a wrong file.
*/
- private currentPc(): number | null {
- if (!this.model) {
- return null;
+ private toDapFrame(frame: CallFrame): DebugProtocol.StackFrame {
+ const id = FRAME_ID_BASE + frame.level;
+ const loc = frame.source;
+ const dap: DebugProtocol.StackFrame = loc
+ ? new StackFrame(
+ id,
+ frame.name,
+ new Source(path.basename(loc.path), loc.path),
+ loc.line,
+ firstNonWhitespaceColumn(this.source?.sourceTextAt(loc.path, loc.line) ?? null) ??
+ loc.column ??
+ 0,
+ )
+ : new StackFrame(id, frame.name);
+ if (frame.kind === 'contract') {
+ dap.presentationHint = 'label';
+ } else if (frame.subtle) {
+ dap.presentationHint = 'subtle';
+ if (dap.source) {
+ dap.source.presentationHint = 'deemphasize';
+ }
}
- return pcAtIndex(this.positions, this.model.cursor);
+ if (frame.pc !== null) {
+ dap.instructionPointerReference = formatAddress(frame.pc);
+ }
+ return dap;
}
protected disassembleRequest(
@@ -349,34 +399,56 @@ export class SorobanDebugSession extends DebugSession {
this.sendResponse(response);
}
+ /**
+ * The scopes of ONE frame (C7). Locals, Value Stack and Variables describe the
+ * selected frame — an outer frame reports the state at its own call
+ * instruction, not the innermost frame's. Globals and the Ledger are VM-wide,
+ * so every code frame offers them; a contract-boundary frame has no state of
+ * its own and offers nothing.
+ */
protected scopesRequest(
response: DebugProtocol.ScopesResponse,
- _args: DebugProtocol.ScopesArguments,
+ args: DebugProtocol.ScopesArguments,
): void {
- // Fresh child-expansion refs per stop: last cursor's handles are stale.
- this.childHandles.reset();
+ const frame = this.frameById(args.frameId);
+ if (!frame || frame.kind === 'contract') {
+ response.body = { scopes: [] };
+ this.sendResponse(response);
+ return;
+ }
+ const level = frame.level;
const scopes: Scope[] = [
- new Scope('Locals', ScopeRef.Locals, false),
- new Scope('Value Stack', ScopeRef.Stack, false),
+ new Scope('Locals', scopeRef(level, ScopeKind.Locals), false),
+ new Scope('Value Stack', scopeRef(level, ScopeKind.Stack), false),
];
// The source-level Variables scope is offered only when the resolver has
// DWARF functions; without it the list is exactly [Locals, Value Stack].
if (this.variables.hasVariables()) {
- scopes.unshift(new Scope('Variables', ScopeRef.SourceVars, false));
+ scopes.unshift(new Scope('Variables', scopeRef(level, ScopeKind.SourceVars), false));
}
// G4: globals appear only for a trace whose records carry them.
- if (this.model?.current.globals !== undefined) {
- scopes.push(new Scope('Globals', ScopeRef.Globals, false));
+ if (this.recordFor(frame)?.globals !== undefined) {
+ scopes.push(new Scope('Globals', scopeRef(level, ScopeKind.Globals), false));
}
// L14: the ledger appears only for a trace carrying ledger information —
// never as an empty tree.
if (this.model?.ledger.hasLedger()) {
- scopes.push(new Scope('Ledger', ScopeRef.Ledger, false));
+ scopes.push(new Scope('Ledger', scopeRef(level, ScopeKind.Ledger), false));
}
response.body = { scopes };
this.sendResponse(response);
}
+ /** The frame a client-supplied frame id refers to, or undefined. */
+ private frameById(frameId: number): CallFrame | undefined {
+ return this.callFrames()[frameId - FRAME_ID_BASE];
+ }
+
+ /** The trace record whose runtime state a frame reports, if it has one. */
+ private recordFor(frame: CallFrame): TraceRecord | undefined {
+ return frame.stateIndex === null ? undefined : this.model?.records[frame.stateIndex];
+ }
+
protected variablesRequest(
response: DebugProtocol.VariablesResponse,
args: DebugProtocol.VariablesArguments,
@@ -389,40 +461,52 @@ export class SorobanDebugSession extends DebugSession {
}
/**
- * The nodes behind a variables reference: one of the fixed scopes, or a
- * container previously handed out behind a child handle. Every scope — wasm
- * locals, the ledger tree, decoded Rust values — arrives as `ChildVar`s, so
- * they all reach DAP through `toDapVariable` and its lazy-children plumbing.
+ * The nodes behind a variables reference: a scope of some frame, or a container
+ * previously handed out behind a child handle. Every scope — wasm locals, the
+ * ledger tree, decoded Rust values — arrives as `ChildVar`s, so they all reach
+ * DAP through `toDapVariable` and its lazy-children plumbing.
*/
private nodesFor(reference: number): ChildVar[] {
- const record = this.model?.current;
- switch (reference) {
- case ScopeRef.Locals:
+ const scope = decodeScopeRef(reference);
+ if (scope === null) {
+ return this.expandChildHandle(reference);
+ }
+ const frame = this.callFrames()[scope.level];
+ if (!frame) {
+ return [];
+ }
+ const record = this.recordFor(frame);
+ switch (scope.kind) {
+ case ScopeKind.Locals:
return record ? localNodes(record) : [];
- case ScopeRef.Stack:
+ case ScopeKind.Stack:
return record ? stackNodes(record) : [];
- case ScopeRef.Globals:
+ case ScopeKind.Globals:
return record ? globalNodes(record) : [];
- case ScopeRef.SourceVars:
- return this.sourceVarNodes();
- case ScopeRef.Ledger:
+ case ScopeKind.SourceVars:
+ return this.sourceVarNodes(frame);
+ case ScopeKind.Ledger:
return this.ledgerScopeNodes();
default:
- return this.expandChildHandle(reference);
+ return [];
}
}
/**
- * The in-scope DWARF variables at the current PC, each decoded against the
- * folded runtime state at the cursor.
+ * A frame's own DWARF variables, decoded against that frame's runtime state:
+ * the register values the trace recorded where the frame stands (its own
+ * instruction, or the call it is suspended in), and linear memory as it is NOW
+ * — a callee may have written through a reference the caller still holds, and
+ * the caller's spilled locals live in that memory.
*/
- private sourceVarNodes(): ChildVar[] {
- const pc = this.currentPc();
- if (!this.model || pc === null) {
+ private sourceVarNodes(frame: CallFrame): ChildVar[] {
+ const record = this.recordFor(frame);
+ const pc = frame.pc;
+ if (!this.model || !record || pc === null) {
return [];
}
- const state = makeRuntimeState(this.model.current, this.model.memory, this.model.cursor);
- return this.variables.variablesInScope(pc).map((v) => ({
+ const state = makeRuntimeState(record, this.model.memory, this.model.cursor);
+ return frame.variables.map((v) => ({
name: v.name ?? '',
value: this.variables.decodeVariable(v, state, pc),
}));
@@ -516,7 +600,7 @@ export class SorobanDebugSession extends DebugSession {
// S8/S10: reverse step over — the previous stop point not in a deeper frame.
if (this.cursor) {
this.cursor.stepBackward(granularityOf(args.granularity), this.cursor.depth);
- this.sendEvent(new StoppedEvent('step', THREAD_ID));
+ this.reportStop('step');
}
}
@@ -536,9 +620,22 @@ export class SorobanDebugSession extends DebugSession {
granularityOf(granularity),
maxDepth(this.cursor.depth),
);
- this.sendEvent(
- outcome === 'terminated' ? new TerminatedEvent() : new StoppedEvent('step', THREAD_ID),
- );
+ if (outcome === 'terminated') {
+ this.sendEvent(new TerminatedEvent());
+ } else {
+ this.reportStop('step');
+ }
+ }
+
+ /**
+ * Report a stop. The cursor has moved, so everything derived from it is stale:
+ * the call stack is dropped (rebuilt on the next `stackTrace`) and the child
+ * handles are reset, which DAP already treats as invalidated at a stop.
+ */
+ private reportStop(reason: 'entry' | 'step' | 'breakpoint'): void {
+ this.frames = undefined;
+ this.childHandles.reset();
+ this.sendEvent(new StoppedEvent(reason, THREAD_ID));
}
/** Run to the next/previous breakpoint, or clamp to the trace's last/first stop. */
@@ -551,9 +648,7 @@ export class SorobanDebugSession extends DebugSession {
direction === 'forward'
? this.cursor.runForward(breakpoints)
: this.cursor.runBackward(breakpoints);
- this.sendEvent(
- new StoppedEvent(outcome === 'breakpoint' ? 'breakpoint' : 'step', THREAD_ID),
- );
+ this.reportStop(outcome === 'breakpoint' ? 'breakpoint' : 'step');
}
// --- Teardown ---------------------------------------------------------
diff --git a/src/debugAdapter/artifacts.ts b/src/debugAdapter/artifacts.ts
index 4df873c..bd8803f 100644
--- a/src/debugAdapter/artifacts.ts
+++ b/src/debugAdapter/artifacts.ts
@@ -118,7 +118,12 @@ export function buildDebugArtifacts(
const table = readLineTable(wasm, report);
const source =
table === null ? new NullSourceMapper() : new DwarfSourceMapper(model, table, positions);
- return { source, variables: resolveVariables(wasm, report), disassembly, positions };
+ return {
+ source,
+ variables: resolveVariables(wasm, table, report),
+ disassembly,
+ positions,
+ };
}
/**
@@ -163,16 +168,22 @@ function readLineTable(wasm: Uint8Array, report: ProgressReporter): DwarfLineTab
}
/**
- * Resolve the source-level variable resolver from the wasm bytes, in its own
- * INDEPENDENT try/catch so a variable-resolution failure never disables the
- * line table (callers have already committed their SourceMapper by this point).
- * Degrades to a NullVariableResolver — the wasm-level variables view.
+ * Resolve the source-level variable/frame resolver from the wasm bytes, in its
+ * own INDEPENDENT try/catch so a resolution failure never disables the line
+ * table (callers have already committed their SourceMapper by this point).
+ * Degrades to a NullVariableResolver — the wasm-level variables and frames view.
*/
-function resolveVariables(wasm: Uint8Array, report: ProgressReporter): VariableResolver {
+function resolveVariables(
+ wasm: Uint8Array,
+ table: DwarfLineTable | null,
+ report: ProgressReporter,
+): VariableResolver {
try {
const dwarf = DwarfDebugInfo.fromWasm(wasm);
if (dwarf && dwarf.scopes.hasFunctions()) {
- return new DwarfVariableResolver(dwarf);
+ // The line table is what turns an inlined call site's file INDEX into a
+ // path; passing it here is why inline frames can report a source location.
+ return new DwarfVariableResolver(dwarf, table ?? undefined);
}
} catch (err) {
if (err instanceof DwarfParseError || err instanceof WasmFormatError) {
diff --git a/src/debugAdapter/callStack.ts b/src/debugAdapter/callStack.ts
new file mode 100644
index 0000000..1af7632
--- /dev/null
+++ b/src/debugAdapter/callStack.ts
@@ -0,0 +1,226 @@
+/**
+ * The call stack at a replay position (docs/callstack.md) — the shared headless
+ * derivation behind the IDE's Callstack view and the CLI's `frames` projection,
+ * so the two can never disagree about who called whom.
+ *
+ * The stack is assembled from three sources, in order of how much they can be
+ * trusted, and each frame states which one it came from:
+ *
+ * 1. **wasm activations** (`WasmFrame`, `stops.ts`) — the physical frame stack
+ * reconstructed from the trace itself. This is ground truth at every
+ * optimization level and is the same structure stepping derives depth from,
+ * so the Callstack view and step-over/step-out always agree.
+ * 2. **DWARF inlined subroutines** — the Rust frames an optimizing compiler
+ * erased from the activation stack. Inserted ABOVE the activation they were
+ * inlined into, they are why an optimized build still shows a Rust call
+ * chain instead of one wrapper function.
+ * 3. **contract-call boundaries** (`LedgerImage`) — the host-level invocations
+ * the trace records. They sit BELOW everything else as non-code labels,
+ * naming the contract and function the wasm frames are running for.
+ *
+ * The naming ladder (C4) is likewise ordered by precision: a DWARF name, else
+ * the module's demangled `name`-section symbol, else the wasm function index,
+ * else the raw code offset. A frame is therefore never nameless, and never named
+ * with something less precise than the build made available.
+ *
+ * Pure module (no `vscode` / DAP imports).
+ */
+
+import { MappedLocation } from '../sourcemap/SourceMapper';
+import { ScopeVar } from '../dwarf/ScopeIndex';
+import { InlineFrame } from '../sourcemap/VariableResolver';
+import { FunctionRange, WasmFrame, isWorkspaceSource } from './stops';
+import { LedgerCallFrame } from './LedgerImage';
+import { ResolvedTrace } from './types';
+import { renderAddress } from '../soroban/scvalJson';
+import { pcAtIndex } from './stopModel';
+
+/** Where a frame's identity came from — the rung of the ladder that named it. */
+export type FrameKind =
+ /** A wasm activation named and located by DWARF. */
+ | 'rust'
+ /** A Rust frame that optimization inlined into the activation below it. */
+ | 'inline'
+ /** A wasm activation with no source-level identity (no DWARF, or none at its pc). */
+ | 'wasm'
+ /** A host-level contract invocation: a boundary marker, not a code position. */
+ | 'contract';
+
+/** One frame of the call stack at a replay position. */
+export interface CallFrame {
+ /** 0 = innermost (where the cursor is), increasing outward. */
+ level: number;
+ /** Display name; never empty (C4). */
+ name: string;
+ kind: FrameKind;
+ /**
+ * The record whose runtime state this frame's variables are read from: the
+ * cursor for the innermost activation, the frame's own call instruction for an
+ * outer one. Null for a contract frame, which has no wasm state of its own.
+ */
+ stateIndex: number | null;
+ /** Code offset this frame is executing at, or null when unknown. */
+ pc: number | null;
+ /** Where to open the editor for this frame, or null when unmapped. */
+ source: MappedLocation | null;
+ /**
+ * True for a frame the user did not write — toolchain or dependency source
+ * (S21's workspace test), and any frame with no source at all in a session
+ * that HAS line info. Presented deemphasized rather than hidden (C5).
+ */
+ subtle: boolean;
+ /** The DWARF variables this frame declares, in scope at its pc (C7). */
+ variables: ScopeVar[];
+}
+
+/** Everything `buildCallStack` reads; a subset of `ResolvedTrace` plus the cursor. */
+export interface CallStackInput {
+ resolved: ResolvedTrace;
+ /** Per-record wasm frame stacks, from `computeFrames`. */
+ frames: readonly (WasmFrame | null)[];
+ /** The function ranges `WasmFrame.fn` indexes (sorted), from `computeFrames`. */
+ ranges: readonly FunctionRange[];
+}
+
+/**
+ * The call stack at trace index `index`, innermost frame first.
+ *
+ * Never empty for a non-empty trace: with no activation, no DWARF and no
+ * contract boundary to go on, the result is the single wasm frame at the
+ * cursor's own address — the honest floor of the ladder.
+ */
+export function buildCallStack(input: CallStackInput, index: number): CallFrame[] {
+ const { resolved, frames, ranges } = input;
+ const hasLineInfo = resolved.source.hasLineInfo();
+ const built: CallFrame[] = [];
+
+ /** Push one frame, numbering it and deriving its deemphasis (C5). */
+ const push = (frame: Omit): void => {
+ const subtle =
+ frame.kind !== 'contract' &&
+ (frame.source === null ? hasLineInfo : !isWorkspaceSource(frame.source.path));
+ built.push({ ...frame, level: built.length, subtle });
+ };
+
+ /** An inlined call site put through the mapper's on-disk policy. */
+ const callSiteLocation = (inline: InlineFrame): MappedLocation | null => {
+ const site = inline.callSite;
+ return site === undefined
+ ? null
+ : resolved.source.locationForFile(site.path, site.line, site.column);
+ };
+
+ /**
+ * Expand one wasm activation into frames: the DWARF frames inlined into it
+ * (innermost first) followed by the activation itself, positioned at record
+ * `at`.
+ */
+ const pushActivation = (frame: WasmFrame | null, at: number): void => {
+ const pc = pcAtIndex(resolved.positions, at);
+ const range = frame && frame.fn >= 0 ? ranges[frame.fn] : undefined;
+ const inlines = pc === null ? [] : resolved.variables.inlineFramesAt(pc);
+
+ // An inlined chain shifts locations by one: the innermost frame stands where
+ // the line table points, and every frame below it stands at the call site of
+ // the frame above (C2). `inlines` is outermost first, so walk it backwards.
+ let below: MappedLocation | null = resolved.source.locationForIndex(at);
+ for (let i = inlines.length - 1; i >= 0; i--) {
+ const inline = inlines[i];
+ push({
+ name: inline.name ?? '',
+ kind: 'inline',
+ stateIndex: at,
+ pc,
+ source: below,
+ variables: inline.variables,
+ });
+ below = callSiteLocation(inline);
+ }
+
+ const qualified = pc === null ? null : resolved.variables.qualifiedFunctionNameAt(pc);
+ push({
+ name: activationName(qualified, range, pc, below),
+ // Source, not the name, decides the kind: rustc leaves some method DIEs
+ // anonymous, and such a frame is still a located Rust frame — it just
+ // borrows its label from the `name` section.
+ kind: below === null ? 'wasm' : 'rust',
+ stateIndex: at,
+ pc,
+ source: below,
+ variables: pc === null ? [] : resolved.variables.variablesInScope(pc),
+ });
+ };
+
+ // The activation stack, innermost first. The position walks outward with it: an
+ // outer activation stands at the call instruction that entered the frame below
+ // it, which is also where its own locals were last observed. Only the
+ // outermost frame has no call site, so the walk cannot end early.
+ let activation = frames[index] ?? null;
+ let at: number | null = index;
+ while (activation !== null && at !== null) {
+ pushActivation(activation, at);
+ at = activation.callSite;
+ activation = activation.caller;
+ }
+ if (built.length === 0) {
+ // No reconstructed activation at all: still report where the cursor is.
+ pushActivation(null, index);
+ }
+
+ // Contract boundaries below the wasm frames, innermost call first.
+ const ledger = resolved.model.ledger;
+ if (ledger.hasLedger()) {
+ for (const call of ledger.callStackAt(index)) {
+ push({
+ name: contractFrameName(call),
+ kind: 'contract',
+ stateIndex: null,
+ pc: null,
+ source: null,
+ variables: [],
+ });
+ }
+ }
+ return built;
+}
+
+/**
+ * A wasm activation's label, down the naming ladder (C4). A frame with no source
+ * location carries its code offset — for a wasm-level session that offset is the
+ * only position the user has, and inside a named function it is stated relative
+ * to the function's start, the way a disassembler does.
+ */
+function activationName(
+ qualified: string | null,
+ range: FunctionRange | undefined,
+ pc: number | null,
+ source: MappedLocation | null,
+): string {
+ const indexed = range?.index === undefined ? null : `func[${range.index}]`;
+ const name = qualified ?? range?.name ?? indexed;
+ if (name === null) {
+ return pc === null ? '' : `wasm@${hex(pc)}`;
+ }
+ if (source !== null || pc === null || range === undefined) {
+ return name;
+ }
+ return pc === range.start ? name : `${name}+${hex(pc - range.start)}`;
+}
+
+/**
+ * A contract invocation as a boundary label: `increment() @ CA5XKA…7QFM`. The
+ * `C…` strkey is elided in the middle — a frame label has to stay readable in a
+ * narrow panel, and the Ledger scope is where the full address is shown.
+ */
+function contractFrameName(call: LedgerCallFrame): string {
+ return `${call.function}() @ ${shortAddress(renderAddress(call.to))}`;
+}
+
+/** Head and tail of an address long enough to need eliding. */
+function shortAddress(address: string): string {
+ return address.length > 16 ? `${address.slice(0, 6)}…${address.slice(-4)}` : address;
+}
+
+function hex(value: number): string {
+ return `0x${value.toString(16)}`;
+}
diff --git a/src/debugAdapter/stopModel.ts b/src/debugAdapter/stopModel.ts
index eb77625..f867f87 100644
--- a/src/debugAdapter/stopModel.ts
+++ b/src/debugAdapter/stopModel.ts
@@ -15,8 +15,10 @@
*/
import {
+ FunctionRange,
+ WasmFrame,
classifyLineRole,
- computeDepths,
+ computeFrames,
computeRunStarts,
myCodeStops,
statementStops,
@@ -28,7 +30,15 @@ export interface StopModel {
validatedPosToIndices: Map;
/** Visible (validated-position) record indices, ascending. */
visibleIndices: number[];
- /** Call depth per record (parallel to records), via computeDepths. */
+ /**
+ * Innermost wasm frame per record, from `computeFrames` — the call stack the
+ * Callstack view is built from (docs/callstack.md, C1). Depth-only consumers
+ * read `depths`, which is this projected.
+ */
+ frames: (WasmFrame | null)[];
+ /** The function ranges `WasmFrame.fn` indexes, sorted by start. */
+ ranges: readonly FunctionRange[];
+ /** Call depth per record (parallel to records). */
depths: number[];
/** Raw line-run starts, pre-S17/S18 (for breakpoint narrowing). */
rawRunStarts: number[];
@@ -69,7 +79,8 @@ export function buildStopModel(
}
});
- const depths = computeDepths(model.records, positions, disassembly.functionRanges);
+ const { frames, ranges } = computeFrames(model.records, positions, disassembly.functionRanges);
+ const depths = frames.map((frame) => frame?.depth ?? 0);
const rawRunStarts = computeRunStarts(positions, depths, (i) => source.lineKeyForIndex(i));
const stmtStops = statementStops(rawRunStarts, depths, (i) =>
classifyLineRole(source.sourceTextForIndex(i)),
@@ -88,6 +99,8 @@ export function buildStopModel(
return {
validatedPosToIndices,
visibleIndices,
+ frames,
+ ranges,
depths,
rawRunStarts,
runStarts,
diff --git a/src/debugAdapter/stops.ts b/src/debugAdapter/stops.ts
index f85e890..eb8cc8f 100644
--- a/src/debugAdapter/stops.ts
+++ b/src/debugAdapter/stops.ts
@@ -17,10 +17,19 @@ import * as path from 'path';
import { TraceRecord, opcode } from '../komet/trace';
-/** A function body in code-offset space: [start, end). */
+/**
+ * A function body in code-offset space: [start, end). Call-depth reconstruction
+ * reads only the bounds; `index` and `name` are what a wasm-level call stack
+ * frame is labelled with (docs/callstack.md, C4) and are absent for a
+ * trace-derived disassembly, which knows no function structure at all.
+ */
export interface FunctionRange {
start: number;
end: number;
+ /** Wasm function index (imports included in the numbering). */
+ index?: number;
+ /** Demangled name from the module's `name` section, when it has one. */
+ name?: string;
}
/** Opcodes that descend into a callee (may increase call depth). */
@@ -29,62 +38,108 @@ const CALL_OPCODES = new Set(['call', 'call_indirect', 'return_call', 'return_ca
const RETURN_OPCODES = new Set(['return']);
/**
- * Fallback call-depth reconstruction from call/return opcodes alone (used when
- * no function-body ranges exist, i.e. wasm-less replay). Depth is recorded at
+ * One activation record of the reconstructed wasm frame stack — the physical
+ * call stack the debugger shows (docs/callstack.md, C1) and the same structure
+ * the stepping depth is read off (`computeDepths`), so the Callstack view and
+ * step-over/step-out can never disagree about what a frame is.
+ *
+ * Frames are IMMUTABLE and SHARED: the walk hands the same object to every
+ * record executing in that activation, and `caller` links it to the frame it
+ * returns into, so the whole trace's stacks cost one object per call.
+ */
+export interface WasmFrame {
+ /** Index into the sorted function ranges; -1 when the pc is in no known body. */
+ fn: number;
+ /** Call depth, 0 = outermost. Equals `computeDepths()[i]` for this frame's records. */
+ depth: number;
+ /**
+ * Record index of the `call` that created this frame — i.e. the CALLER's
+ * position while this frame runs, which is what an outer stack frame reports.
+ * Null for the outermost frame and wherever the walk lost the call site.
+ */
+ callSite: number | null;
+ /** The frame this one returns into, or null at the outermost. */
+ caller: WasmFrame | null;
+}
+
+/** The per-record frame stacks of a trace, plus the ranges the walk indexed. */
+export interface FrameStacks {
+ /**
+ * Innermost frame per record (parallel to `records`); null only for records
+ * ahead of the first frame the walk could establish.
+ */
+ frames: (WasmFrame | null)[];
+ /** The function ranges `WasmFrame.fn` indexes, sorted by start; empty in the opcode fallback. */
+ ranges: readonly FunctionRange[];
+}
+
+/**
+ * Fallback frame reconstruction from call/return opcodes alone (used when no
+ * function-body ranges exist, i.e. wasm-less replay). Depth is recorded at
* instruction entry, so a `return` belongs to the frame it leaves. Implicit
- * returns are invisible to this walk — see computeDepths.
+ * returns are invisible to this walk — see computeFrames. Frames carry no
+ * function identity here (`fn: -1`): without ranges there is nothing to name.
*/
-export function opcodeDepths(records: readonly TraceRecord[]): number[] {
- const depths = new Array(records.length);
- let depth = 0;
+function opcodeFrames(records: readonly TraceRecord[]): (WasmFrame | null)[] {
+ const frames = new Array(records.length);
+ let frame: WasmFrame = { fn: -1, depth: 0, callSite: null, caller: null };
for (let i = 0; i < records.length; i++) {
- depths[i] = depth;
+ frames[i] = frame;
const op = opcode(records[i]);
if (CALL_OPCODES.has(op)) {
- depth++;
- } else if (RETURN_OPCODES.has(op) && depth > 0) {
- depth--;
+ frame = { fn: -1, depth: frame.depth + 1, callSite: i, caller: frame };
+ } else if (RETURN_OPCODES.has(op) && frame.caller !== null) {
+ frame = frame.caller;
}
}
- return depths;
+ return frames;
+}
+
+/**
+ * Fallback call-depth reconstruction from call/return opcodes alone; see
+ * `opcodeFrames`, of which this is the depth projection.
+ */
+export function opcodeDepths(records: readonly TraceRecord[]): number[] {
+ return opcodeFrames(records).map((frame) => frame?.depth ?? 0);
}
/**
- * Call depth per trace record (spec Model/depth).
+ * The wasm frame stack per trace record (spec Model/depth, docs/callstack.md C1).
*
- * With function-body ranges, depth follows a frame stack over the VISIBLE
- * records (validated `positions[i] !== null`): moving into a different
+ * With function-body ranges, the stack follows the function membership of the
+ * VISIBLE records (validated `positions[i] !== null`): moving into a different
* function's body right after a call-class record pushes a frame; any other
* transition pops back to that function's frame (matching implicit returns,
* which produce no record) or, when the function is not on the stack at all,
- * replaces the current frame. Invisible records carry the depth of the
+ * replaces the current frame. Invisible records carry the frame of the
* surrounding visible context. Without ranges (or with an empty list) the
* opcode-based reconstruction is the fallback.
*/
-export function computeDepths(
+export function computeFrames(
records: readonly TraceRecord[],
positions: readonly (number | null)[],
functionRanges?: readonly FunctionRange[],
-): number[] {
+): FrameStacks {
if (!functionRanges || functionRanges.length === 0) {
- return opcodeDepths(records);
+ return { frames: opcodeFrames(records), ranges: [] };
}
const ranges = [...functionRanges].sort((a, b) => a.start - b.start);
- const depths = new Array(records.length);
- /** Frame stack of function identities (range indices; -1 = outside all bodies). */
- const stack: number[] = [];
+ const frames = new Array(records.length);
+ /** Frame stack, outermost first; the last entry is the executing frame. */
+ const stack: WasmFrame[] = [];
let prevVisible = -1;
for (let i = 0; i < records.length; i++) {
const pos = positions[i] ?? null;
if (pos === null) {
- depths[i] = Math.max(0, stack.length - 1);
+ frames[i] = stack[stack.length - 1] ?? null;
continue;
}
const fn = functionIndexAt(ranges, pos);
- if (stack.length === 0) {
- stack.push(fn);
- } else if (fn !== stack[stack.length - 1]) {
+ const top = stack[stack.length - 1];
+ if (top === undefined) {
+ stack.push({ fn, depth: 0, callSite: null, caller: null });
+ } else if (fn !== top.fn) {
// A genuine call ENTRY lands on the callee body's first instruction right
// after a call-class record; a return lands just after the caller's call
// (never on a body's first instruction), so a call record alone does not
@@ -97,20 +152,46 @@ export function computeDepths(
prevVisible >= 0 &&
CALL_OPCODES.has(opcode(records[prevVisible]));
if (isEntry) {
- stack.push(fn);
+ stack.push({ fn, depth: stack.length, callSite: prevVisible, caller: top });
} else {
- const frame = stack.lastIndexOf(fn);
+ const frame = lastIndexOfFn(stack, fn);
if (frame >= 0) {
stack.length = frame + 1;
} else {
- stack[stack.length - 1] = fn;
+ // Execution surfaced in a function that is not on the stack at all:
+ // the identity changes but the activation (and its depth) does not.
+ stack[stack.length - 1] = { ...top, fn };
}
}
}
- depths[i] = stack.length - 1;
+ frames[i] = stack[stack.length - 1];
prevVisible = i;
}
- return depths;
+ return { frames, ranges };
+}
+
+/** Topmost stack position holding function identity `fn`, or -1. */
+function lastIndexOfFn(stack: readonly WasmFrame[], fn: number): number {
+ for (let i = stack.length - 1; i >= 0; i--) {
+ if (stack[i].fn === fn) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Call depth per trace record (spec Model/depth) — the depth projection of
+ * `computeFrames`, which is where the reconstruction itself is documented.
+ */
+export function computeDepths(
+ records: readonly TraceRecord[],
+ positions: readonly (number | null)[],
+ functionRanges?: readonly FunctionRange[],
+): number[] {
+ return computeFrames(records, positions, functionRanges).frames.map(
+ (frame) => frame?.depth ?? 0,
+ );
}
/**
diff --git a/src/dwarf/LineTable.ts b/src/dwarf/LineTable.ts
index 3687cd0..8c661c9 100644
--- a/src/dwarf/LineTable.ts
+++ b/src/dwarf/LineTable.ts
@@ -34,9 +34,26 @@ export interface LineEntry {
export class DwarfLineTable {
/** All entries from all units, sorted by address. */
readonly entries: readonly LineEntry[];
+ /**
+ * Per line program (keyed by its `.debug_line` offset, i.e. a CU's
+ * DW_AT_stmt_list) the unit's resolved file table, indexable by file index.
+ * `.debug_info` states an inlined call site as such an index (docs/callstack.md,
+ * C2), and this is the only place the two tables can be joined.
+ */
+ private readonly filesByProgram: Map;
- private constructor(entries: LineEntry[]) {
+ private constructor(entries: LineEntry[], filesByProgram: Map) {
this.entries = entries;
+ this.filesByProgram = filesByProgram;
+ }
+
+ /**
+ * The path of `fileIndex` in the line program at `stmtListOffset` — the
+ * resolution DWARF's `DW_AT_call_file` needs. Undefined when either index is
+ * unknown to the table.
+ */
+ filePath(stmtListOffset: number, fileIndex: number): string | undefined {
+ return this.filesByProgram.get(stmtListOffset)?.[fileIndex];
}
/**
@@ -59,21 +76,24 @@ export class DwarfLineTable {
const lineStr = parsed.customSection('.debug_line_str');
const entries: LineEntry[] = [];
+ const filesByProgram = new Map();
const cus = scanCompilationUnits({ info, abbrev, str, lineStr });
- const seenOffsets = new Set();
for (const cu of cus) {
- if (cu.stmtListOffset === undefined || seenOffsets.has(cu.stmtListOffset)) {
+ if (cu.stmtListOffset === undefined || filesByProgram.has(cu.stmtListOffset)) {
continue;
}
- seenOffsets.add(cu.stmtListOffset);
const unit = parseLineProgram(debugLine, cu.stmtListOffset, { str, lineStr });
+ filesByProgram.set(
+ cu.stmtListOffset,
+ unit.files.map((_, index) => resolveFilePath(unit, cu, index)),
+ );
collectEntries(unit, cu, entries);
}
// Sort by address; at equal addresses end_sequence rows come first so a
// new sequence starting exactly where another ended wins the lookup.
entries.sort((a, b) => a.address - b.address || Number(b.endSequence) - Number(a.endSequence));
- return new DwarfLineTable(entries);
+ return new DwarfLineTable(entries, filesByProgram);
}
/**
diff --git a/src/dwarf/ScopeIndex.ts b/src/dwarf/ScopeIndex.ts
index 59e5412..b5297bd 100644
--- a/src/dwarf/ScopeIndex.ts
+++ b/src/dwarf/ScopeIndex.ts
@@ -16,24 +16,43 @@
* PC, and inner declarations are appended after outer ones so callers may treat
* later entries as shadowing.
*
+ * `inlineScopesAt` answers the other half of a call stack (docs/callstack.md,
+ * C2): the chain of `DW_TAG_inlined_subroutine` instances covering the PC, which
+ * is how the Rust call chain survives inlining. Optimization inlines whole
+ * functions into one wasm body — `sum_triples` disappears into the
+ * `#[contractimpl]` wrapper's — so without this chain a frame would be labelled
+ * with the *host* function while the cursor sits on the *inlined* function's
+ * source line. Each instance carries its own declarations and the call site it
+ * was expanded at, which is what the frame BELOW it reports as its position.
+ *
* Pure module (no `vscode` and no `src/wasm` imports). The optional
* `nameFallback` lets the wiring layer supply a disassembly-derived name for an
* anonymous subprogram without coupling this module to it.
*/
import { Cursor } from './cursor';
-import { DebugInfo, Die, dieName, dieUint, dieRef } from './die';
+import { DebugInfo, Die, dieName, dieUint, dieRef, dieString } from './die';
import {
DW_TAG_subprogram,
DW_TAG_formal_parameter,
DW_TAG_variable,
DW_TAG_lexical_block,
+ DW_TAG_inlined_subroutine,
+ DW_TAG_namespace,
+ DW_TAG_structure_type,
DW_AT_low_pc,
DW_AT_high_pc,
DW_AT_ranges,
DW_AT_location,
DW_AT_type,
DW_AT_frame_base,
+ DW_AT_stmt_list,
+ DW_AT_call_file,
+ DW_AT_call_line,
+ DW_AT_call_column,
+ DW_AT_abstract_origin,
+ DW_AT_specification,
+ DW_AT_linkage_name,
} from './constants';
/** One in-scope variable or parameter, with the raw material for value decoding. */
@@ -59,10 +78,37 @@ export interface ScopeVar {
export interface FunctionScope {
die: Die;
name?: string;
+ /**
+ * `name` prefixed with the DIE's enclosing namespaces and types, e.g.
+ * `control::__while_call::invoke_raw` — what a stack frame is labelled with.
+ * Absent exactly when `name` is (rustc leaves some method DIEs anonymous).
+ */
+ qualifiedName?: string;
/** From DW_AT_frame_base, when it is an exprloc. */
frameBaseExpr?: Uint8Array;
}
+/**
+ * One `DW_TAG_inlined_subroutine` instance covering a PC: a Rust-level frame
+ * that has no wasm activation record of its own.
+ */
+export interface InlineScope {
+ /** Name of the inlined function, resolved through abstract origin / specification. */
+ name?: string;
+ /**
+ * Where this inlined call was WRITTEN — a file index into the owning CU's
+ * line program, plus line/column. It is the position of the frame directly
+ * BELOW this one (its caller), not of this frame itself.
+ */
+ callFileIndex?: number;
+ callLine?: number;
+ callColumn?: number;
+ /** The owning CU's DW_AT_stmt_list — which line program `callFileIndex` indexes. */
+ stmtListOffset?: number;
+ /** The parameters and variables this instance declares, in scope at the PC. */
+ variables: ScopeVar[];
+}
+
/** A recorded subprogram: its public scope plus the internal range material. */
interface RecordedFn extends FunctionScope {
/** Contiguous `[low, low + high)` range, when the subprogram has one. */
@@ -71,6 +117,21 @@ interface RecordedFn extends FunctionScope {
rangesOffset?: number;
/** The CU's DW_AT_low_pc — the rangelist base default. */
cuLowPc: number;
+ /** The CU's DW_AT_stmt_list, for resolving inlined call-site file indices. */
+ stmtListOffset?: number;
+}
+
+/** LLVM writes this address for code the linker dropped; it is never a real PC. */
+const TOMBSTONE = 0xffffffff;
+/** Reference hops `resolvedName` follows before giving up. */
+const MAX_NAME_HOPS = 4;
+
+/** What the indexing walk carries down one compilation unit's DIE tree. */
+interface UnitContext {
+ cuLowPc: number;
+ stmtListOffset?: number;
+ /** Enclosing namespace and type names, outermost first. */
+ scope: string[];
}
/** The DIE's `at` attribute bytes when it is an exprloc/block, else undefined. */
@@ -112,34 +173,46 @@ export class ScopeIndex {
private readonly ranged: RecordedFn[] = [];
constructor(
- info: DebugInfo,
+ private readonly info: DebugInfo,
private readonly debugRanges?: Uint8Array,
private readonly nameFallback?: (pc: number) => string | undefined,
) {
for (const unit of info.units) {
const cuLowPc = dieUint(unit.die, DW_AT_low_pc) ?? 0;
- this.indexTree(unit.die, cuLowPc);
+ this.indexTree(unit.die, { cuLowPc, stmtListOffset: dieUint(unit.die, DW_AT_stmt_list), scope: [] });
}
this.contiguous.sort((a, b) => a.lowHigh![0] - b.lowHigh![0]);
}
- /** Walks a DIE subtree, recording every subprogram that carries a code range. */
- private indexTree(die: Die, cuLowPc: number): void {
+ /**
+ * Walks a DIE subtree, recording every subprogram that carries a code range.
+ * `unit.scope` accumulates the enclosing namespace and type names so a
+ * recorded function can report a qualified name.
+ */
+ private indexTree(die: Die, unit: UnitContext): void {
if (die.tag === DW_TAG_subprogram) {
- this.record(die, cuLowPc);
+ this.record(die, unit);
}
+ const nests = die.tag === DW_TAG_namespace || die.tag === DW_TAG_structure_type;
+ const inner: UnitContext =
+ nests && dieName(die) !== undefined ? { ...unit, scope: [...unit.scope, dieName(die)!] } : unit;
for (const child of die.children) {
- this.indexTree(child, cuLowPc);
+ this.indexTree(child, inner);
}
}
- private record(die: Die, cuLowPc: number): void {
+ private record(die: Die, unit: UnitContext): void {
+ const name = dieName(die);
const rec: RecordedFn = {
die,
- name: dieName(die),
+ name,
frameBaseExpr: exprBytes(die, DW_AT_frame_base),
- cuLowPc,
+ cuLowPc: unit.cuLowPc,
+ stmtListOffset: unit.stmtListOffset,
};
+ if (name !== undefined) {
+ rec.qualifiedName = [...unit.scope, name].join('::');
+ }
const low = dieUint(die, DW_AT_low_pc);
const high = dieUint(die, DW_AT_high_pc);
if (low !== undefined && high !== undefined) {
@@ -202,6 +275,116 @@ export class ScopeIndex {
return this.nameFallback?.(pc) ?? null;
}
+ /**
+ * The inlined subroutines covering `pc`, OUTERMOST first — the Rust frames
+ * between the enclosing wasm function and the PC (docs/callstack.md, C2).
+ * Empty when the PC is in no recorded function, or when nothing was inlined
+ * there. An instance whose range this parser cannot read (a DWARF v5
+ * `.debug_rnglists` list, or a `.debug_ranges` section that is absent) is
+ * skipped rather than guessed at: a missing frame degrades the view, an
+ * invented one misreports the program.
+ */
+ inlineScopesAt(pc: number): InlineScope[] {
+ const fn = this.recordAt(pc);
+ if (!fn) {
+ return [];
+ }
+ const out: InlineScope[] = [];
+ this.collectInlines(fn.die, pc, fn, out);
+ return out;
+ }
+
+ /**
+ * Collects the inlined-subroutine instances under `scope` that cover `pc`,
+ * outermost first. A nested instance is a DEEPER frame, so it is appended
+ * after its parent; lexical blocks are transparent (they are not frames).
+ */
+ private collectInlines(scope: Die, pc: number, fn: RecordedFn, out: InlineScope[]): void {
+ for (const child of scope.children) {
+ if (child.tag === DW_TAG_inlined_subroutine) {
+ if (!this.rangeCovers(child, pc, fn.cuLowPc)) {
+ continue;
+ }
+ out.push(this.toInlineScope(child, pc, fn));
+ this.collectInlines(child, pc, fn, out);
+ } else if (child.tag === DW_TAG_lexical_block && this.blockCovers(child, pc, fn.cuLowPc)) {
+ this.collectInlines(child, pc, fn, out);
+ }
+ }
+ }
+
+ /** One inlined instance as a frame: its name, its call site, its own declarations. */
+ private toInlineScope(die: Die, pc: number, fn: RecordedFn): InlineScope {
+ const variables: ScopeVar[] = [];
+ this.collect(die, pc, fn.frameBaseExpr, fn.cuLowPc, variables);
+ const scope: InlineScope = { variables };
+ const name = this.resolvedName(die);
+ if (name !== undefined) {
+ scope.name = name;
+ }
+ const callFileIndex = dieUint(die, DW_AT_call_file);
+ if (callFileIndex !== undefined) {
+ scope.callFileIndex = callFileIndex;
+ }
+ const callLine = dieUint(die, DW_AT_call_line);
+ if (callLine !== undefined) {
+ scope.callLine = callLine;
+ }
+ const callColumn = dieUint(die, DW_AT_call_column);
+ if (callColumn !== undefined && callColumn > 0) {
+ scope.callColumn = callColumn;
+ }
+ if (fn.stmtListOffset !== undefined) {
+ scope.stmtListOffset = fn.stmtListOffset;
+ }
+ return scope;
+ }
+
+ /**
+ * A DIE's own name, or the name of what it is an instance/declaration of:
+ * `DW_AT_abstract_origin` (the out-of-line abstract subprogram an inlined
+ * instance copies) and `DW_AT_specification` (the declaration a definition
+ * completes) are followed in turn, since rustc puts the name on either. The
+ * mangled `DW_AT_linkage_name` is the last resort. Bounded so a cyclic or
+ * pathological reference chain cannot spin.
+ */
+ private resolvedName(die: Die, hops = 0): string | undefined {
+ const name = dieName(die);
+ if (name !== undefined) {
+ return name;
+ }
+ if (hops < MAX_NAME_HOPS) {
+ for (const at of [DW_AT_abstract_origin, DW_AT_specification]) {
+ const ref = dieRef(die, at);
+ const target = ref === undefined ? undefined : this.info.dieByOffset.get(ref);
+ const resolved = target && this.resolvedName(target, hops + 1);
+ if (resolved !== undefined) {
+ return resolved;
+ }
+ }
+ }
+ return dieString(die, DW_AT_linkage_name);
+ }
+
+ /**
+ * Whether the DIE's OWN code range covers `pc`. Unlike `blockCovers`, a DIE
+ * with no readable range covers nothing: an inlined instance must be placed
+ * by its range or not at all. A `low_pc` of 0xffffffff is LLVM's tombstone for
+ * code the linker dropped, never a real address.
+ */
+ private rangeCovers(die: Die, pc: number, cuLowPc: number): boolean {
+ const low = dieUint(die, DW_AT_low_pc);
+ const high = dieUint(die, DW_AT_high_pc);
+ if (low !== undefined && high !== undefined) {
+ return low !== TOMBSTONE && pc >= low && pc < low + high;
+ }
+ const rangesOffset = dieUint(die, DW_AT_ranges);
+ if (rangesOffset !== undefined && this.debugRanges) {
+ return rangesCover(this.debugRanges, rangesOffset, pc, cuLowPc);
+ }
+ return false;
+ }
+
/** The parameters and variables in scope at `pc` (empty if no enclosing function). */
variablesInScope(pc: number): ScopeVar[] {
const fn = this.recordAt(pc);
diff --git a/src/dwarf/constants.ts b/src/dwarf/constants.ts
index 253d290..434dafe 100644
--- a/src/dwarf/constants.ts
+++ b/src/dwarf/constants.ts
@@ -102,6 +102,8 @@ export const DW_TAG_variable = 0x34;
export const DW_TAG_volatile_type = 0x35;
export const DW_TAG_subprogram = 0x2e;
export const DW_TAG_variant = 0x59;
+export const DW_TAG_inlined_subroutine = 0x1d;
+export const DW_TAG_namespace = 0x39;
// Attributes (DW_AT_*) — variables, types, scopes, locations.
export const DW_AT_location = 0x02;
@@ -121,6 +123,14 @@ export const DW_AT_frame_base = 0x40;
export const DW_AT_type = 0x49;
export const DW_AT_ranges = 0x55;
export const DW_AT_data_bit_offset = 0x6b;
+// Inlined-subroutine attributes: where the inlined call was written, and which
+// abstract (or declared) subprogram it is an instance of.
+export const DW_AT_call_column = 0x57;
+export const DW_AT_call_file = 0x58;
+export const DW_AT_call_line = 0x59;
+export const DW_AT_abstract_origin = 0x31;
+export const DW_AT_specification = 0x47;
+export const DW_AT_linkage_name = 0x6e;
// Base-type encodings (DW_ATE_*).
export const DW_ATE_address = 0x01;
diff --git a/src/dwarf/die.ts b/src/dwarf/die.ts
index e7715bb..b56be70 100644
--- a/src/dwarf/die.ts
+++ b/src/dwarf/die.ts
@@ -157,3 +157,9 @@ export function dieRef(die: Die, at: number): number | undefined {
const value = die.attrs.get(at);
return value && value.kind === 'ref' ? value.value : undefined;
}
+
+/** The DIE's `at` attribute as a string, when present as one. */
+export function dieString(die: Die, at: number): string | undefined {
+ const value = die.attrs.get(at);
+ return value && value.kind === 'str' ? value.value : undefined;
+}
diff --git a/src/sourcemap/DwarfSourceMapper.ts b/src/sourcemap/DwarfSourceMapper.ts
index 82208ca..c94c66a 100644
--- a/src/sourcemap/DwarfSourceMapper.ts
+++ b/src/sourcemap/DwarfSourceMapper.ts
@@ -91,6 +91,21 @@ export class DwarfSourceMapper implements SourceMapper {
return this.mapEntry(this.lineTable.lookup(codeOffset));
}
+ locationForFile(filePath: string, line: number, column?: number): MappedLocation | null {
+ if (line <= 0) {
+ return null; // DWARF line 0 is compiler-generated code with no source line.
+ }
+ const normalized = path.normalize(filePath);
+ if (!this.cachedExists(normalized)) {
+ return null;
+ }
+ const loc: MappedLocation = { path: normalized, line };
+ if (column !== undefined && column > 0) {
+ loc.column = column;
+ }
+ return loc;
+ }
+
resolveBreakpoint(requestedPath: string, line: number): ResolvedBreakpoint | null {
const file = this.executedByFile.get(path.normalize(requestedPath));
if (!file) {
@@ -118,14 +133,12 @@ export class DwarfSourceMapper implements SourceMapper {
sourceTextForIndex(index: number): string | null {
const loc = this.locations[index] ?? null;
- if (loc === null) {
- return null;
- }
- const lines = this.cachedLines(loc.path);
- if (lines === null) {
- return null;
- }
- return lines[loc.line - 1] ?? null;
+ return loc === null ? null : this.sourceTextAt(loc.path, loc.line);
+ }
+
+ sourceTextAt(filePath: string, line: number): string | null {
+ const lines = this.cachedLines(path.normalize(filePath));
+ return lines === null ? null : lines[line - 1] ?? null;
}
/** Read and split a source file once per normalized path; null on failure. */
diff --git a/src/sourcemap/NullSourceMapper.ts b/src/sourcemap/NullSourceMapper.ts
index 875c8ba..10eb783 100644
--- a/src/sourcemap/NullSourceMapper.ts
+++ b/src/sourcemap/NullSourceMapper.ts
@@ -22,6 +22,10 @@ export class NullSourceMapper implements SourceMapper {
return null;
}
+ locationForFile(_path: string, _line: number, _column?: number): MappedLocation | null {
+ return null;
+ }
+
resolveBreakpoint(_path: string, _line: number): ResolvedBreakpoint | null {
return null;
}
@@ -37,4 +41,8 @@ export class NullSourceMapper implements SourceMapper {
sourceTextForIndex(_index: number): string | null {
return null;
}
+
+ sourceTextAt(_path: string, _line: number): string | null {
+ return null;
+ }
}
diff --git a/src/sourcemap/SourceMapper.ts b/src/sourcemap/SourceMapper.ts
index e20e7cc..82d96e6 100644
--- a/src/sourcemap/SourceMapper.ts
+++ b/src/sourcemap/SourceMapper.ts
@@ -43,6 +43,12 @@ export interface SourceMapper {
locationForIndex(index: number): MappedLocation | null;
/** Rust location for a static code offset (disassembly rows), or null. */
locationForAddress(codeOffset: number): MappedLocation | null;
+ /**
+ * A location stated OUTSIDE the line table — a DWARF inlined call site — put
+ * through the same usability policy as a mapped record: normalized, and null
+ * when the file is not on disk (docs/callstack.md, C2).
+ */
+ locationForFile(path: string, line: number, column?: number): MappedLocation | null;
/** Resolve a breakpoint request to an executed line, or null when none. */
resolveBreakpoint(path: string, line: number): ResolvedBreakpoint | null;
/** Distinct executed lines in `path` within [fromLine, toLine], ascending. */
@@ -51,4 +57,6 @@ export interface SourceMapper {
lineKeyForIndex(index: number): string | null;
/** Raw source text of the line the record at `index` maps to, or null. */
sourceTextForIndex(index: number): string | null;
+ /** Raw source text of an explicit file/line (any frame's position), or null. */
+ sourceTextAt(path: string, line: number): string | null;
}
diff --git a/src/sourcemap/VariableResolver.ts b/src/sourcemap/VariableResolver.ts
index 6155e14..19b0fa0 100644
--- a/src/sourcemap/VariableResolver.ts
+++ b/src/sourcemap/VariableResolver.ts
@@ -1,23 +1,54 @@
/**
- * Capability interface for resolving in-scope variables at a PC and decoding
- * their runtime values, mirroring the `SourceMapper`/`NullSourceMapper` split.
- * `NullVariableResolver` is the degraded no-DWARF path (every query is empty);
- * `DwarfVariableResolver` drives the real pipeline: `ScopeIndex` locates the
- * enclosing function and its variables, `selectLocation`/`evalLocation` resolve
- * where each value lives, and `decodeValue` renders it against the `TypeRegistry`.
+ * Capability interface for the source-level view of a PC: which function it is
+ * in, which Rust frames were inlined into it, which variables are in scope, and
+ * what their runtime values are. It mirrors the `SourceMapper`/`NullSourceMapper`
+ * split — `NullVariableResolver` is the degraded no-DWARF path (every query is
+ * empty), `DwarfVariableResolver` drives the real pipeline: `ScopeIndex` locates
+ * the enclosing function, its inlined instances and their variables,
+ * `selectLocation`/`evalLocation` resolve where each value lives, and
+ * `decodeValue` renders it against the `TypeRegistry`.
+ *
+ * Frames live here rather than in a separate resolver because they are the same
+ * DWARF lookup: an inlined frame IS a scope, carrying its own name, call site and
+ * declarations (docs/callstack.md, C2). What this layer adds over the raw
+ * `ScopeIndex` is resolution of a call site's line-program FILE INDEX into a
+ * path, which needs the line table alongside `.debug_info`.
*
* Pure module (no `vscode` imports, no external deps).
*/
-import { ScopeVar } from '../dwarf/ScopeIndex';
+import { InlineScope, ScopeVar } from '../dwarf/ScopeIndex';
import { RuntimeState, evalLocation } from '../dwarf/locexpr';
import { DecodedValue, decodeValue } from '../dwarf/ValueDecoder';
import { selectLocation } from '../dwarf/debugLoc';
import { DwarfDebugInfo } from '../dwarf/DebugInfo';
+import { DwarfLineTable } from '../dwarf/LineTable';
+
+/** One Rust frame that was inlined into the function containing the PC. */
+export interface InlineFrame {
+ /** The inlined function's name, or undefined when DWARF names it nowhere. */
+ name?: string;
+ /**
+ * Where the inlined call was written — the position of the frame directly
+ * BELOW this one. Absent when DWARF states no call site or names a file this
+ * table cannot resolve.
+ */
+ callSite?: { path: string; line: number; column?: number };
+ /** The parameters and variables this frame declares, in scope at the PC. */
+ variables: ScopeVar[];
+}
export interface VariableResolver {
hasVariables(): boolean;
functionNameAt(pc: number): string | null;
+ /**
+ * The enclosing function's name qualified by its DWARF namespaces and types
+ * (`control::__while_call::invoke_raw`), or null. This is the frame label;
+ * `functionNameAt` is the bare DIE name.
+ */
+ qualifiedFunctionNameAt(pc: number): string | null;
+ /** Rust frames inlined into the function at `pc`, OUTERMOST first. */
+ inlineFramesAt(pc: number): InlineFrame[];
variablesInScope(pc: number): ScopeVar[];
decodeVariable(v: ScopeVar, state: RuntimeState, pc: number): DecodedValue;
}
@@ -30,6 +61,12 @@ export class NullVariableResolver implements VariableResolver {
functionNameAt(): string | null {
return null;
}
+ qualifiedFunctionNameAt(): string | null {
+ return null;
+ }
+ inlineFramesAt(): InlineFrame[] {
+ return [];
+ }
variablesInScope(): ScopeVar[] {
return [];
}
@@ -40,7 +77,14 @@ export class NullVariableResolver implements VariableResolver {
/** Resolves and decodes variables from a wasm module's DWARF debug info. */
export class DwarfVariableResolver implements VariableResolver {
- constructor(private readonly dwarf: DwarfDebugInfo) {}
+ /**
+ * `lineTable` is optional: without it inlined frames still resolve, they just
+ * report no call-site path (their file index cannot be looked up).
+ */
+ constructor(
+ private readonly dwarf: DwarfDebugInfo,
+ private readonly lineTable?: DwarfLineTable,
+ ) {}
hasVariables(): boolean {
return this.dwarf.scopes.hasFunctions();
@@ -50,6 +94,14 @@ export class DwarfVariableResolver implements VariableResolver {
return this.dwarf.scopes.functionNameAt(pc);
}
+ qualifiedFunctionNameAt(pc: number): string | null {
+ return this.dwarf.scopes.functionAt(pc)?.qualifiedName ?? null;
+ }
+
+ inlineFramesAt(pc: number): InlineFrame[] {
+ return this.dwarf.scopes.inlineScopesAt(pc).map((scope) => this.toInlineFrame(scope));
+ }
+
variablesInScope(pc: number): ScopeVar[] {
return this.dwarf.scopes.variablesInScope(pc);
}
@@ -79,4 +131,23 @@ export class DwarfVariableResolver implements VariableResolver {
return { display: '' };
}
}
+
+ /** One inline scope with its call-site file index resolved to a path. */
+ private toInlineFrame(scope: InlineScope): InlineFrame {
+ const frame: InlineFrame = { variables: scope.variables };
+ if (scope.name !== undefined) {
+ frame.name = scope.name;
+ }
+ const path =
+ scope.stmtListOffset !== undefined && scope.callFileIndex !== undefined
+ ? this.lineTable?.filePath(scope.stmtListOffset, scope.callFileIndex)
+ : undefined;
+ if (path !== undefined && scope.callLine !== undefined) {
+ frame.callSite = { path, line: scope.callLine };
+ if (scope.callColumn !== undefined) {
+ frame.callSite.column = scope.callColumn;
+ }
+ }
+ return frame;
+ }
}
diff --git a/src/trace/projectStop.ts b/src/trace/projectStop.ts
index 050b7a9..3ff4f4d 100644
--- a/src/trace/projectStop.ts
+++ b/src/trace/projectStop.ts
@@ -23,6 +23,7 @@ import {
summarizeScVal,
} from '../soroban/scvalJson';
import { Durability } from '../komet/trace';
+import { FrameKind, buildCallStack } from '../debugAdapter/callStack';
/** A serializable single-stop projection. */
export interface SourceStop {
@@ -36,6 +37,12 @@ export interface SourceStop {
pc: string | null;
/** functionNameAt(pc), or null. */
function: string | null;
+ /**
+ * The call stack at this stop, innermost frame first (docs/callstack.md) — the
+ * same derivation the IDE's Callstack view shows, so a CLI trace and a debug
+ * session never disagree about who called whom. Never empty.
+ */
+ frames: StopFrame[];
/** renderInstr(record.instr). */
instr: string;
/** Mapped source location, or null when unmapped. */
@@ -54,6 +61,21 @@ export interface SourceStop {
ledger?: StopLedger;
}
+/** One call-stack frame of a stop (docs/callstack.md). */
+export interface StopFrame {
+ /** 0 = innermost. */
+ level: number;
+ name: string;
+ /** Which rung of the naming ladder placed this frame: rust/inline/wasm/contract. */
+ kind: FrameKind;
+ /** Hex code offset, e.g. "0x2d", or null. */
+ pc: string | null;
+ /** Where the frame stands, or null when unmapped. */
+ source: { path: string; line: number; column?: number } | null;
+ /** Set for a frame the user did not write (toolchain, dependency, or sourceless). */
+ subtle?: true;
+}
+
/** The ledger projection of one stop (docs/state-inspection.md, Presentation). */
export interface StopLedger {
/** Executing contract as a `C…` strkey, or null before any contract call. */
@@ -216,6 +238,7 @@ export function projectSourceStop(
depth: stopModel.depths[index],
pc: pcHex,
function: functionName,
+ frames: projectFrames(resolved, stopModel, index),
instr: renderInstr(record.instr),
source,
variables,
@@ -239,6 +262,32 @@ export function projectSourceStop(
return stop;
}
+/**
+ * The stop's call stack in the CLI's JSON schema. `variables` are deliberately
+ * NOT repeated per frame: a stop's `variables` are the innermost frame's, and a
+ * per-frame expansion would multiply the output size of every stop.
+ */
+function projectFrames(
+ resolved: ResolvedTrace,
+ stopModel: StopModel,
+ index: number,
+): StopFrame[] {
+ const input = { resolved, frames: stopModel.frames, ranges: stopModel.ranges };
+ return buildCallStack(input, index).map((frame) => {
+ const projected: StopFrame = {
+ level: frame.level,
+ name: frame.name,
+ kind: frame.kind,
+ pc: frame.pc === null ? null : '0x' + frame.pc.toString(16),
+ source: frame.source === null ? null : { ...frame.source },
+ };
+ if (frame.subtle) {
+ projected.subtle = true;
+ }
+ return projected;
+ });
+}
+
/**
* Flatten the shared ledger snapshot at `index` into the CLI's JSON schema,
* flagging the storage entries that moved since `previousIndex`.
diff --git a/src/wasm/Disassembly.ts b/src/wasm/Disassembly.ts
index 6002f56..8dfb94b 100644
--- a/src/wasm/Disassembly.ts
+++ b/src/wasm/Disassembly.ts
@@ -15,6 +15,7 @@
import { BinaryReader } from 'wasmparser';
import { WasmDisassembler } from 'wasmparser/dist/cjs/WasmDis';
import { parseWasmSections, WasmFormatError } from './sections';
+import { demangleRust, functionNames, importedFunctionCount } from './names';
import { renderInstr } from '../komet/mnemonics';
import { TraceModel } from '../debugAdapter/TraceModel';
import { FunctionRange } from '../debugAdapter/stops';
@@ -101,12 +102,23 @@ export class Disassembly {
bytes: bytes.subarray(p.fileOffset, end),
};
});
- const functionRanges = functionBodyOffsets.map(
- (b): FunctionRange => ({
+ // Body order is function-index order after the imports, so the i-th body is
+ // function index `imported + i` — the index the `name` section keys on.
+ const names = functionNames(bytes);
+ const imported = importedFunctionCount(bytes);
+ const functionRanges = functionBodyOffsets.map((b, i): FunctionRange => {
+ const index = imported + i;
+ const symbol = names.get(index);
+ const range: FunctionRange = {
start: b.start - codeSection.payloadStart,
end: b.end - codeSection.payloadStart,
- }),
- );
+ index,
+ };
+ if (symbol !== undefined) {
+ range.name = demangleRust(symbol);
+ }
+ return range;
+ });
return new Disassembly(instructions, functionRanges);
}
diff --git a/src/wasm/names.ts b/src/wasm/names.ts
new file mode 100644
index 0000000..6a57b3c
--- /dev/null
+++ b/src/wasm/names.ts
@@ -0,0 +1,195 @@
+/**
+ * Wasm function symbols: the `name` custom section, the import count that maps
+ * body order to function index, and Rust symbol demangling.
+ *
+ * This is the naming ladder's second rung (docs/callstack.md, C4). When a module
+ * carries DWARF, frames are named from the DIE tree; when it does not — a
+ * release build with `debugInfo: false`, or any wasm whose `.debug_*` sections
+ * were stripped — the `name` section is usually still there, and it holds the
+ * Rust symbol of every function. Demangled, `_ZN7control7Control10while_call17h…E`
+ * reads `control::Control::while_call`, which is what a wasm-level call stack
+ * shows instead of a bare function index.
+ *
+ * Both readers are DELIBERATELY lenient: a truncated or unexpected name
+ * subsection yields the names read so far rather than an error, because a
+ * cosmetic section must never fail a debug session. Structural wasm errors
+ * (bad magic, a section running past EOF) still throw from `parseWasmSections`.
+ *
+ * Pure module (no `vscode` imports).
+ */
+
+import { BinaryReader, BinaryReaderState, ExternalKind } from 'wasmparser';
+import { parseWasmSections, readUleb } from './sections';
+
+/** The `name` section's subsection id for the function-name map. */
+const FUNCTION_NAMES_SUBSECTION = 1;
+/** Wasm section id of the import section. */
+const IMPORT_SECTION_ID = 2;
+
+/**
+ * Function names by wasm function index (imports included in the numbering), as
+ * written in the `name` custom section — still mangled. Empty when the module
+ * carries no `name` section or no function-name subsection.
+ */
+export function functionNames(bytes: Uint8Array): Map {
+ const names = new Map();
+ const section = parseWasmSections(bytes).customSection('name');
+ if (!section) {
+ return names;
+ }
+ let offset = 0;
+ while (offset < section.length) {
+ const id = section[offset];
+ let size: number;
+ let payloadStart: number;
+ try {
+ [size, payloadStart] = readUleb(section, offset + 1);
+ } catch {
+ return names; // Truncated subsection header; keep what we have.
+ }
+ const payloadEnd = payloadStart + size;
+ if (payloadEnd > section.length) {
+ return names;
+ }
+ if (id === FUNCTION_NAMES_SUBSECTION) {
+ readNameMap(section.subarray(payloadStart, payloadEnd), names);
+ return names; // Function names appear once; later subsections name locals.
+ }
+ offset = payloadEnd;
+ }
+ return names;
+}
+
+/**
+ * Read a `namemap` — `count` followed by `(index, name)` pairs — into `out`,
+ * stopping at the first entry that does not fit (a truncated section).
+ */
+function readNameMap(payload: Uint8Array, out: Map): void {
+ let offset: number;
+ let count: number;
+ try {
+ [count, offset] = readUleb(payload, 0);
+ } catch {
+ return;
+ }
+ for (let i = 0; i < count; i++) {
+ try {
+ const [index, afterIndex] = readUleb(payload, offset);
+ const [length, afterLength] = readUleb(payload, afterIndex);
+ const end = afterLength + length;
+ if (end > payload.length) {
+ return;
+ }
+ out.set(index, Buffer.from(payload.subarray(afterLength, end)).toString('utf8'));
+ offset = end;
+ } catch {
+ return;
+ }
+ }
+}
+
+/**
+ * How many functions the module IMPORTS. Wasm numbers imported functions first,
+ * so the i-th function BODY (the i-th entry of `Disassembly.functionRanges`) is
+ * function index `importedFunctionCount(bytes) + i` — the index the `name`
+ * section keys on.
+ */
+export function importedFunctionCount(bytes: Uint8Array): number {
+ const data = new ArrayBuffer(bytes.length);
+ new Uint8Array(data).set(bytes);
+ const reader = new BinaryReader();
+ reader.setData(data, 0, bytes.length);
+ let count = 0;
+ while (reader.read()) {
+ if (reader.state === BinaryReaderState.BEGIN_SECTION) {
+ // Stop once the walk is past the import section (id 2) rather than
+ // decoding every instruction of the code section for nothing. Custom
+ // sections (id 0) may appear anywhere, so they never end the walk.
+ const id = (reader.result as { id: number }).id;
+ if (id > IMPORT_SECTION_ID) {
+ break;
+ }
+ }
+ if (
+ reader.state === BinaryReaderState.IMPORT_SECTION_ENTRY &&
+ (reader.result as { kind: number }).kind === ExternalKind.Function
+ ) {
+ count++;
+ }
+ }
+ return count;
+}
+
+/** Legacy-mangling hash segment: `h` followed by 16 hex digits. */
+const HASH_SEGMENT_RE = /^h[0-9a-f]{16}$/;
+
+/** The fixed `$…$` escapes rustc's legacy mangling emits. */
+const ESCAPES: Record = {
+ SP: ' ',
+ BP: '*',
+ RF: '&',
+ LT: '<',
+ GT: '>',
+ LP: '(',
+ RP: ')',
+ C: ',',
+};
+
+/**
+ * Demangle a Rust LEGACY-mangled symbol (`_ZN…E`), the scheme rustc still emits
+ * by default: length-prefixed path segments, a trailing disambiguating hash
+ * segment, and `$…$` escapes for characters illegal in a symbol name. So
+ * `_ZN7control4bump17h2628dce790f861d2E` becomes `control::bump`.
+ *
+ * Anything else — a plain name, a C symbol, or a v0-mangled symbol (`_R…`,
+ * which rustc emits only under `-Csymbol-mangling-version=v0`) — is returned
+ * unchanged: an undemangled symbol still names the frame, so guessing is worse
+ * than passing it through.
+ */
+export function demangleRust(symbol: string): string {
+ if (!symbol.startsWith('_ZN') || !symbol.endsWith('E')) {
+ return symbol;
+ }
+ const segments: string[] = [];
+ let offset = 3;
+ const body = symbol.slice(0, -1);
+ while (offset < body.length) {
+ const digits = /^\d+/.exec(body.slice(offset));
+ if (!digits) {
+ return symbol; // Not length-prefixed after all; not a legacy symbol.
+ }
+ const length = Number(digits[0]);
+ const start = offset + digits[0].length;
+ if (start + length > body.length) {
+ return symbol;
+ }
+ segments.push(body.slice(start, start + length));
+ offset = start + length;
+ }
+ if (segments.length === 0) {
+ return symbol;
+ }
+ if (HASH_SEGMENT_RE.test(segments[segments.length - 1])) {
+ segments.pop();
+ }
+ return segments.map(unescapeSegment).join('::');
+}
+
+/**
+ * Decode one mangled path segment: the fixed `$…$` escapes, the general
+ * `$u$` form, `..` for `::`, and a leading `_` guarding a segment that
+ * would otherwise start with a digit or `$`.
+ */
+function unescapeSegment(segment: string): string {
+ // The guard is removed BEFORE unescaping: after `$LT$` has become `<` there is
+ // no way to tell a guarded segment from one that starts with a real `_`.
+ const unguarded = /^_(\$|\d)/.test(segment) ? segment.slice(1) : segment;
+ const unescaped = unguarded.replace(/\$(u[0-9a-fA-F]{2,6}|[A-Z]{1,2})\$/g, (match, code: string) => {
+ if (code.startsWith('u')) {
+ const point = Number.parseInt(code.slice(1), 16);
+ return Number.isNaN(point) ? match : String.fromCodePoint(point);
+ }
+ return ESCAPES[code] ?? match;
+ });
+ return unescaped.replace(/\.\./g, '::');
+}
diff --git a/src/wasm/sections.ts b/src/wasm/sections.ts
index de137bf..c74ffba 100644
--- a/src/wasm/sections.ts
+++ b/src/wasm/sections.ts
@@ -130,9 +130,9 @@ export function stripDebugSections(bytes: Uint8Array): Uint8Array {
}
/**
- * Reads a ULEB128 at `offset`; returns [value, offset after the ULEB].
- * Exported for cross-implementation agreement tests against the DWARF
- * `Cursor.uleb` decoder; not part of the public wasm API.
+ * Reads a ULEB128 at `offset`; returns [value, offset after the ULEB]. Used by
+ * the `name`-section reader (`names.ts`), and by cross-implementation agreement
+ * tests against the DWARF `Cursor.uleb` decoder.
*/
export function readUleb(bytes: Uint8Array, offset: number): [number, number] {
let value = 0;
diff --git a/test/callStack.test.ts b/test/callStack.test.ts
new file mode 100644
index 0000000..3158670
--- /dev/null
+++ b/test/callStack.test.ts
@@ -0,0 +1,377 @@
+/**
+ * Unit suite for the call stack (docs/callstack.md):
+ *
+ * buildCallStack({ resolved, frames, ranges }, index): CallFrame[]
+ * from src/debugAdapter/callStack.ts
+ *
+ * Values are pinned to the real fixtures, whose stacks are ground truth:
+ * - adder-debug (above opt-0): `add` is fully INLINED into the
+ * `#[contractimpl]` wrapper, so the whole Rust chain is inline frames (C2).
+ * - stepper-debug: `triple` is a REAL call (`#[inline(never)]`) whose caller
+ * `sum_triples` is itself inlined — a mixed physical/inline stack (C1 + C2).
+ * - control-debug (opt-0): `bump` called from `Control::while_call`, both real
+ * wasm functions — the case where activations alone carry the Rust chain.
+ * - the same traces replayed with NO wasm — the degraded ladder (C4).
+ */
+
+import * as assert from 'assert';
+import * as fs from 'fs';
+import * as path from 'path';
+import { buildCallStack, CallFrame } from '../src/debugAdapter/callStack';
+import { buildStopModel } from '../src/debugAdapter/stopModel';
+import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend';
+import { ResolvedTrace } from '../src/debugAdapter/types';
+import { parseTraceJsonl, toTraceRecord, TraceRecord } from '../src/komet/trace';
+import { TraceModel } from '../src/debugAdapter/TraceModel';
+import { Disassembly } from '../src/wasm/Disassembly';
+import { NullSourceMapper } from '../src/sourcemap/NullSourceMapper';
+import { NullVariableResolver } from '../src/sourcemap/VariableResolver';
+import { buildDebugArtifacts } from '../src/debugAdapter/artifacts';
+import { stripDebugSections } from '../src/wasm/sections';
+
+const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures');
+
+/** Resolve a fixture trace, with its wasm (symbol-rich) or without (degraded). */
+async function resolveFixture(trace: string, wasm?: string): Promise {
+ const args: Record = { rawTrace: path.join(FIXTURES, `${trace}.trace.jsonl`) };
+ if (wasm !== undefined) {
+ args.wasmPath = path.join(FIXTURES, `${wasm}.wasm`);
+ }
+ return new RawTraceBackend().resolve(args as never, () => {});
+}
+
+/** The call stack at `index`, via the shared stop model. */
+function stackAt(resolved: ResolvedTrace, index: number): CallFrame[] {
+ const stops = buildStopModel(resolved);
+ return buildCallStack({ resolved, frames: stops.frames, ranges: stops.ranges }, index);
+}
+
+/** `name @ file:line` per frame — the shape a reader of the view sees. */
+function outline(frames: CallFrame[]): string[] {
+ return frames.map(
+ (f) => `${f.name} @ ${f.source === null ? '-' : `${path.basename(f.source.path)}:${f.source.line}`}`,
+ );
+}
+
+describe('buildCallStack (docs/callstack.md)', () => {
+ describe('adder-debug: a fully inlined Rust chain (C2)', () => {
+ let resolved: ResolvedTrace;
+ before(async () => {
+ resolved = await resolveFixture('adder-debug', 'adder-debug');
+ });
+
+ it('reports the inlined callee, its inliner, and the wasm activation', () => {
+ // Index 29 is the sole statement stop, lib.rs:16 (`a + b`) at pc 0x2d. The
+ // ONLY wasm activation there is the export wrapper; `add` and the macro's
+ // `invoke_raw` exist only as DWARF inline instances.
+ assert.deepStrictEqual(outline(stackAt(resolved, 29)), [
+ 'add @ lib.rs:16',
+ 'invoke_raw @ lib.rs:12',
+ 'adder::__add::invoke_raw_extern @ lib.rs:12',
+ ]);
+ });
+
+ it('marks the inline frames as such and the activation as a rust frame (C1/C2)', () => {
+ const frames = stackAt(resolved, 29);
+ assert.deepStrictEqual(
+ frames.map((f) => f.kind),
+ ['inline', 'inline', 'rust'],
+ );
+ assert.deepStrictEqual(
+ frames.map((f) => f.level),
+ [0, 1, 2],
+ );
+ });
+
+ it('positions every frame at the same pc but at its own source line (C2)', () => {
+ // Inlined code has ONE pc; what differs per frame is where the call was
+ // written. The innermost frame stands on the line the line table names, the
+ // outer ones on their callee's call site.
+ const frames = stackAt(resolved, 29);
+ assert.deepStrictEqual(
+ frames.map((f) => f.pc),
+ [0x2d, 0x2d, 0x2d],
+ );
+ assert.deepStrictEqual(
+ frames.map((f) => f.source?.line),
+ [16, 12, 12],
+ );
+ });
+
+ it('reads every frame’s state from the same record when nothing was called', () => {
+ for (const frame of stackAt(resolved, 29)) {
+ assert.strictEqual(frame.stateIndex, 29);
+ }
+ });
+
+ it('treats workspace frames as prominent (C5)', () => {
+ assert.deepStrictEqual(
+ stackAt(resolved, 29).map((f) => f.subtle),
+ [false, false, false],
+ );
+ });
+ });
+
+ describe('stepper-debug: a real call under an inlined caller (C1 + C2)', () => {
+ let resolved: ResolvedTrace;
+ before(async () => {
+ resolved = await resolveFixture('stepper-debug', 'stepper-debug');
+ });
+
+ it('shows the callee, the CALL SITE of its caller, and the wrapper chain', () => {
+ // Index 29 is inside `triple` (depth 1), called from lib.rs:26
+ // `acc.wrapping_add(triple(i))`. The caller frame must stand on line 26 —
+ // the call it is suspended in — not on `sum_triples`' own first line.
+ assert.deepStrictEqual(outline(stackAt(resolved, 29)), [
+ 'stepper::triple @ lib.rs:15',
+ 'sum_triples @ lib.rs:26',
+ 'invoke_raw @ lib.rs:20',
+ 'stepper::__sum_triples::invoke_raw_extern @ lib.rs:20',
+ ]);
+ });
+
+ it('reads an outer frame’s state from its own call instruction (C7)', () => {
+ const frames = stackAt(resolved, 29);
+ // The callee's state is the cursor's record; the caller's is the record of
+ // the `call` that entered it (28), where its locals were last observed.
+ assert.deepStrictEqual(
+ frames.map((f) => f.stateIndex),
+ [29, 28, 28, 28],
+ );
+ });
+
+ it('has no caller frames at all before any call is made', () => {
+ // Index 21 (lib.rs:25, the `while`) runs at depth 0: one activation.
+ const frames = stackAt(resolved, 21);
+ assert.strictEqual(frames.filter((f) => f.kind !== 'inline').length, 1);
+ assert.deepStrictEqual(outline(frames), [
+ 'sum_triples @ lib.rs:25',
+ 'invoke_raw @ lib.rs:20',
+ 'stepper::__sum_triples::invoke_raw_extern @ lib.rs:20',
+ ]);
+ });
+
+ it('agrees with the stepping depth about how deep the stack is', () => {
+ // The Callstack view and step-over/step-out are derived from the SAME frame
+ // reconstruction, so the number of activations is depth + 1 (C1).
+ const stops = buildStopModel(resolved);
+ for (const index of [21, 27, 29, 46, 63, 73]) {
+ const frames = buildCallStack(
+ { resolved, frames: stops.frames, ranges: stops.ranges },
+ index,
+ );
+ const activations = frames.filter((f) => f.kind !== 'inline' && f.kind !== 'contract');
+ assert.strictEqual(
+ activations.length,
+ stops.depths[index] + 1,
+ `index ${index}: ${activations.length} activations at depth ${stops.depths[index]}`,
+ );
+ }
+ });
+ });
+
+ describe('control-debug: opt-0, where activations carry the Rust chain (C1)', () => {
+ let resolved: ResolvedTrace;
+ before(async () => {
+ resolved = await resolveFixture('control-while_call', 'control-debug');
+ });
+
+ it('shows a real callee over its real caller, each on its own line', () => {
+ // Index 266 is `bump`'s body (lib.rs:16) at depth 3, called from
+ // `while_call` at lib.rs:56.
+ assert.deepStrictEqual(outline(stackAt(resolved, 266)), [
+ 'control::bump @ lib.rs:16',
+ 'control::Control::while_call @ lib.rs:56',
+ 'control::__while_call::invoke_raw @ lib.rs:20',
+ 'control::__while_call::invoke_raw_extern @ lib.rs:20',
+ ]);
+ });
+
+ it('names a frame whose DWARF DIE is anonymous from the name section (C4)', () => {
+ // rustc leaves `Control::while_call`'s subprogram DIE unnamed; the demangled
+ // `name`-section symbol is the next rung of the ladder, and the frame is
+ // still located by DWARF, so it stays a rust frame.
+ const frame = stackAt(resolved, 266)[1];
+ assert.strictEqual(frame.name, 'control::Control::while_call');
+ assert.strictEqual(frame.kind, 'rust');
+ });
+
+ it('exposes each frame’s own variables (C7)', () => {
+ const frames = stackAt(resolved, 266);
+ const names = (frame: CallFrame): string[] =>
+ frame.variables.map((v) => v.name ?? '');
+ assert.deepStrictEqual(names(frames[0]), ['x']);
+ // The caller's own locals, not the callee's.
+ for (const expected of ['n', 'acc', 'i']) {
+ assert.ok(
+ names(frames[1]).includes(expected),
+ `expected ${expected} among the caller's variables, got: ${names(frames[1]).join(', ')}`,
+ );
+ }
+ assert.ok(!names(frames[1]).includes('x'), 'the caller must not report the callee’s x');
+ });
+ });
+
+ describe('no wasm at all: the degraded ladder (C4)', () => {
+ let resolved: ResolvedTrace;
+ before(async () => {
+ resolved = await resolveFixture('adder-debug');
+ });
+
+ it('reports one addressed wasm frame, with no source and no name to give', () => {
+ const frames = stackAt(resolved, 29);
+ assert.deepStrictEqual(outline(frames), ['wasm@0x2d @ -']);
+ assert.strictEqual(frames[0].kind, 'wasm');
+ assert.strictEqual(frames[0].pc, 0x2d);
+ });
+
+ it('does not deemphasize a sourceless frame when the session has no line info (C5)', () => {
+ // Everything is sourceless here, so deemphasizing would grey out the whole
+ // view and say nothing.
+ assert.strictEqual(stackAt(resolved, 29)[0].subtle, false);
+ });
+ });
+
+ describe('wasm without DWARF: named from the name section (C4)', () => {
+ it('labels every frame with the demangled symbol and its offset in the function', () => {
+ // The activation structure survives losing DWARF — index 29 is still
+ // `triple` called from the wrapper — and each frame is named from the
+ // `name` section, carrying the offset that is now the only position it has.
+ const frames = stackAt(strippedStepper(), 29);
+ assert.deepStrictEqual(
+ frames.map((f) => f.kind),
+ ['wasm', 'wasm'],
+ );
+ assert.strictEqual(frames[0].name, 'stepper::triple');
+ assert.match(frames[1].name, /^sum_triples\+0x[0-9a-f]+$/);
+ for (const frame of frames) {
+ assert.strictEqual(frame.source, null);
+ assert.strictEqual(frame.subtle, false, 'nothing is deemphasized without line info');
+ }
+ });
+ });
+
+ describe('the bottom rungs of the naming ladder (C4)', () => {
+ it('falls back to the wasm function index when the module names nothing', () => {
+ // composite.wasm carries neither DWARF nor a `name` section, so a frame can
+ // only be identified by which function body it is in.
+ const frames = stackAt(unnamedModule(), 0);
+ assert.strictEqual(frames.length, 1);
+ assert.match(frames[0].name, /^func\[\d+\](\+0x[0-9a-f]+)?$/);
+ assert.strictEqual(frames[0].kind, 'wasm');
+ });
+
+ it('still reports the cursor on a record ahead of the first established frame', async () => {
+ // adder's records 0..5 precede every visible instruction, so the frame walk
+ // has nothing on its stack yet. The view must still show where the cursor
+ // is rather than come back empty.
+ const resolved = await resolveFixture('adder-debug', 'adder-debug');
+ const stops = buildStopModel(resolved);
+ assert.strictEqual(stops.frames[0], null, 'record 0 precedes the first frame');
+ const frames = buildCallStack({ resolved, frames: stops.frames, ranges: stops.ranges }, 0);
+ assert.strictEqual(frames.length, 1);
+ assert.strictEqual(frames[0].level, 0);
+ assert.strictEqual(frames[0].stateIndex, 0);
+ });
+
+ it('reports a synthetic frame when no record has an address at all', async () => {
+ // Every record of this trace is synthetic (pos null): there is no pc, so no
+ // function, no name and no offset — but still a frame, never an empty view.
+ const resolved = await resolveFixture('synthetic-all-null');
+ const frames = stackAt(resolved, 0);
+ assert.strictEqual(frames.length, 1);
+ assert.strictEqual(frames[0].name, '');
+ assert.strictEqual(frames[0].pc, null);
+ });
+ });
+
+ describe('contract boundaries (C3)', () => {
+ it('appends one label frame per open contract call, innermost first', () => {
+ const frames = stackAt(syntheticCrossContract(), 4);
+ const contracts = frames.filter((f) => f.kind === 'contract');
+ assert.deepStrictEqual(
+ contracts.map((f) => f.name),
+ ['inner() @ 0xbbbb', 'outer() @ 0xaaaa'],
+ );
+ // A boundary is not a code position: nothing to open, nothing to inspect.
+ for (const frame of contracts) {
+ assert.strictEqual(frame.pc, null);
+ assert.strictEqual(frame.stateIndex, null);
+ assert.strictEqual(frame.source, null);
+ assert.deepStrictEqual(frame.variables, []);
+ }
+ });
+
+ it('puts the boundary frames below every wasm frame', () => {
+ const frames = stackAt(syntheticCrossContract(), 4);
+ const firstContract = frames.findIndex((f) => f.kind === 'contract');
+ assert.ok(firstContract > 0, 'expected at least one wasm frame above the boundaries');
+ assert.ok(
+ frames.slice(firstContract).every((f) => f.kind === 'contract'),
+ 'a wasm frame must never appear below a contract boundary',
+ );
+ });
+ });
+});
+
+/**
+ * stepper-debug's trace replayed against a DWARF-STRIPPED stepper wasm: real
+ * disassembly and a real `name` section, no line info and no DIEs. This is what a
+ * release build (`debugInfo: false`) gives the debugger.
+ */
+function strippedStepper(): ResolvedTrace {
+ const wasm = stripDebugSections(
+ new Uint8Array(fs.readFileSync(path.join(FIXTURES, 'stepper-debug.wasm'))),
+ );
+ const records = parseTraceJsonl(
+ fs.readFileSync(path.join(FIXTURES, 'stepper-debug.trace.jsonl'), 'utf8'),
+ );
+ const model = new TraceModel(records);
+ return { model, ...buildDebugArtifacts(wasm, model, () => {}) };
+}
+
+/**
+ * A one-record trace inside composite.wasm — a module with no DWARF and no
+ * `name` section. `['unknown']` is the mnemonic komet emits for opcodes its
+ * printer cannot decode, and position validation accepts it on the exact-address
+ * check alone, so the record lands inside a real function body.
+ */
+function unnamedModule(): ResolvedTrace {
+ const wasm = new Uint8Array(fs.readFileSync(path.join(FIXTURES, 'composite.wasm')));
+ const body = Disassembly.fromWasm(wasm).functionRanges[0];
+ const records = [
+ toTraceRecord({ kind: 'instr', pos: body.start, instr: ['unknown'], stack: [], locals: {} }, 1),
+ ];
+ const model = new TraceModel(records);
+ return { model, ...buildDebugArtifacts(wasm, model, () => {}) };
+}
+
+/** A trace with two nested contract calls open at index 4. */
+function syntheticCrossContract(): ResolvedTrace {
+ const A = 'a'.repeat(4);
+ const B = 'b'.repeat(4);
+ const call = (to: string, fn: string, depth: number): TraceRecord =>
+ toTraceRecord(
+ {
+ kind: 'callContract',
+ from: { type: 'address', addrType: 'contract', value: A },
+ to: { type: 'address', addrType: 'contract', value: to },
+ function: fn,
+ depth,
+ args: [],
+ },
+ 1,
+ );
+ const nop = (pos: number | null): TraceRecord =>
+ toTraceRecord({ kind: 'instr', pos, instr: ['nop'], stack: [], locals: {} }, 1);
+
+ const records = [nop(0), call(A, 'outer', 1), nop(1), call(B, 'inner', 2), nop(2)];
+ const model = new TraceModel(records);
+ return {
+ model,
+ source: new NullSourceMapper(),
+ variables: new NullVariableResolver(),
+ disassembly: Disassembly.fromTrace(model),
+ positions: records.map((r) => r.pos),
+ };
+}
diff --git a/test/dap.test.ts b/test/dap.test.ts
index 377aecb..181d0b2 100644
--- a/test/dap.test.ts
+++ b/test/dap.test.ts
@@ -63,6 +63,15 @@ describe('SorobanDebugSession (DAP replay)', () => {
return bpResponse;
}
+ /**
+ * Assert where the replay cursor is. C8: the position in the recording is
+ * reported in the THREAD's name (`soroban-vm [29/40]`), not in a frame label —
+ * a frame name states what the program is doing.
+ */
+ async function assertAt(index: number): Promise {
+ assert.strictEqual(await cursorIndex(), index, 'unexpected replay cursor position');
+ }
+
async function topFrame(): Promise {
const res = await dc.stackTraceRequest(THREAD);
assert.ok(res.body.stackFrames.length >= 1, 'expected at least one stack frame');
@@ -75,6 +84,15 @@ describe('SorobanDebugSession (DAP replay)', () => {
assert.strictEqual((stopped as DebugProtocol.StoppedEvent).body.reason, reason);
}
+ /** The replay cursor's trace index, read off the thread label (C8). */
+ async function cursorIndex(): Promise {
+ const threads = await dc.threadsRequest();
+ const name = threads.body.threads[0].name;
+ const probe = /\[(\d+)\/\d+\]$/.exec(name);
+ assert.ok(probe, `thread name carries no cursor probe: ${name}`);
+ return Number(probe[1]);
+ }
+
/**
* Walk backward at instruction granularity until the cursor clamps at the
* first visible record, returning the top frame there. Statement-stop
@@ -84,14 +102,14 @@ describe('SorobanDebugSession (DAP replay)', () => {
*/
async function rewindToFirstVisible(): Promise {
const INSTR = { ...THREAD, granularity: 'instruction' as const };
- let prev = '';
- let frame = await topFrame();
- while (frame.name !== prev) {
- prev = frame.name;
+ let prev = -1;
+ let index = await cursorIndex();
+ while (index !== prev) {
+ prev = index;
await stopAfter(dc.stepBackRequest(INSTR), 'step');
- frame = await topFrame();
+ index = await cursorIndex();
}
- return frame;
+ return topFrame();
}
it('advertises reverse debugging and stepping granularity', async () => {
@@ -152,7 +170,7 @@ describe('SorobanDebugSession (DAP replay)', () => {
const frame = await topFrame();
assert.ok(frame.source?.path?.endsWith(LIB_RS_SUFFIX), `unexpected source: ${frame.source?.path}`);
assert.strictEqual(frame.line, 16);
- assert.ok(frame.name.includes('[29/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(29);
assert.strictEqual(frame.instructionPointerReference, '0x2d');
});
@@ -182,7 +200,7 @@ describe('SorobanDebugSession (DAP replay)', () => {
let frame = await topFrame();
assert.ok(frame.source?.path?.endsWith(LIB_RS_SUFFIX), `unexpected source: ${frame.source?.path}`);
assert.strictEqual(frame.line, 16);
- assert.ok(frame.name.includes('[29/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(29);
// (A forward statement next here would exhaust the single stop and END the
// session under S20 — see the dedicated S20 test below — so it is omitted
@@ -194,7 +212,7 @@ describe('SorobanDebugSession (DAP replay)', () => {
await stopAfter(dc.reverseContinueRequest(THREAD), 'breakpoint');
frame = await topFrame();
assert.strictEqual(frame.line, 16);
- assert.ok(frame.name.includes('[29/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(29);
});
it('S12/S13: a breakpoint on the S17-dropped #[contractimpl] line still resolves and fires at its run starts', async () => {
@@ -220,12 +238,12 @@ describe('SorobanDebugSession (DAP replay)', () => {
frame.source?.path?.endsWith(LIB_RS_SUFFIX),
`unexpected source: ${frame.source?.path}`,
);
- assert.ok(frame.name.includes('[6/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(6);
await stopAfter(dc.continueRequest(THREAD), 'breakpoint');
frame = await topFrame();
assert.strictEqual(frame.line, 12);
- assert.ok(frame.name.includes('[40/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(40);
});
it('S20: default-granularity next past the single statement stop terminates', async () => {
@@ -249,13 +267,12 @@ describe('SorobanDebugSession (DAP replay)', () => {
await launchAndStop(WITH_WASM);
// Rewind to the first visible record (6) before pinning the head sequence
// — the statement entry now lands on record 29 (S17).
- const entry = await rewindToFirstVisible();
- assert.ok(entry.name.includes('[6/40]'), `unexpected head frame name: ${entry.name}`);
+ await rewindToFirstVisible();
+ await assertAt(6);
await stopAfter(dc.stepInRequest({ ...THREAD, granularity: 'instruction' }), 'step');
const frame = await topFrame();
- assert.notStrictEqual(frame.name, entry.name);
- assert.ok(frame.name.includes('[7/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(7);
// Record 7 is still inside the lib.rs:12 run (S16).
assert.strictEqual(frame.line, 12);
});
@@ -276,12 +293,12 @@ describe('SorobanDebugSession (DAP replay)', () => {
await stopAfter(dc.stepInRequest(THREAD), 'step');
let frame = await topFrame();
- assert.ok(frame.name.includes('[1/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(1);
assert.strictEqual(frame.source, undefined);
await stopAfter(dc.stepBackRequest(THREAD), 'step');
frame = await topFrame();
- assert.ok(frame.name.includes('[0/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(0);
});
it('exposes locals and value stack at the cursor', async () => {
@@ -461,7 +478,7 @@ describe('SorobanDebugSession (DAP replay)', () => {
await stopAfter(dc.stepInRequest({ ...THREAD, granularity: 'instruction' }), 'step');
}
const frame = await topFrame();
- assert.ok(frame.name.includes('[6/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(6);
assert.strictEqual(frame.instructionPointerReference, '0x5');
});
});
@@ -518,8 +535,7 @@ describe('SorobanDebugSession (DAP replay)', () => {
// With only that (unverified) breakpoint set, continue settles on the
// last statement stop (record 29, :16) — never the trailing shim records.
await stopAfter(dc.continueRequest(THREAD), 'step');
- const frame = await topFrame();
- assert.ok(frame.name.includes('[29/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(29);
});
it('triggers on the validated record at an address, not a raw global-init pos', async () => {
@@ -536,7 +552,7 @@ describe('SorobanDebugSession (DAP replay)', () => {
await stopAfter(dc.continueRequest(THREAD), 'breakpoint');
const frame = await topFrame();
assert.strictEqual(frame.instructionPointerReference, '0xb');
- assert.ok(frame.name.includes('[9/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(9);
// Function code maps to Rust; the global-init record has no source.
assert.ok(
frame.source?.path?.endsWith(LIB_RS_SUFFIX),
@@ -576,8 +592,7 @@ describe('SorobanDebugSession (DAP replay)', () => {
// No breakpoints remain, so continue settles on the last statement stop
// (record 29, :16) — never the trailing #[contractimpl] shim records.
await stopAfter(dc.continueRequest(THREAD), 'step');
- const frame = await topFrame();
- assert.ok(frame.name.includes('[29/40]'), `unexpected frame name: ${frame.name}`);
+ await assertAt(29);
});
it('applies the offset field to the instruction reference', async () => {
diff --git a/test/dapControlStepping.test.ts b/test/dapControlStepping.test.ts
index 914b24c..ffcf330 100644
--- a/test/dapControlStepping.test.ts
+++ b/test/dapControlStepping.test.ts
@@ -60,8 +60,10 @@ describe('Control-flow stepping (docs/stepping.md, DAP level)', () => {
const res = await dc.stackTraceRequest(THREAD);
assert.ok(res.body.stackFrames.length >= 1, 'expected at least one stack frame');
const frame = res.body.stackFrames[0];
- const probe = /\[(\d+)\/\d+\]$/.exec(frame.name);
- assert.ok(probe, `frame name carries no trace-index probe: ${frame.name}`);
+ const threads = await dc.threadsRequest();
+ const label = threads.body.threads[0].name;
+ const probe = /\[(\d+)\/\d+\]$/.exec(label);
+ assert.ok(probe, `thread label carries no cursor probe: ${label}`);
return { index: Number(probe[1]), line: frame.line, col: frame.column, path: frame.source?.path };
}
diff --git a/test/dapFrames.test.ts b/test/dapFrames.test.ts
new file mode 100644
index 0000000..b3e1b13
--- /dev/null
+++ b/test/dapFrames.test.ts
@@ -0,0 +1,288 @@
+/**
+ * DAP-level suite for the Callstack view (docs/callstack.md, C1–C8), driven over
+ * the real adapter by @vscode/debugadapter-testsupport.
+ *
+ * test/callStack.test.ts pins the frame derivation at the pure level; this suite
+ * pins what a DAP CLIENT sees: how many frames it gets, how they are labelled and
+ * hinted, what paging returns, and — the part a call stack is actually FOR — that
+ * selecting an outer frame inspects that frame's state and not the innermost
+ * one's.
+ */
+
+import * as assert from 'assert';
+import * as path from 'path';
+import { DebugClient } from '@vscode/debugadapter-testsupport';
+import { DebugProtocol } from '@vscode/debugprotocol';
+
+const ADAPTER = path.join(__dirname, 'support', 'adapterEntry.js');
+const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures');
+
+const STEPPER = {
+ rawTrace: path.join(FIXTURES, 'stepper-debug.trace.jsonl'),
+ wasmPath: path.join(FIXTURES, 'stepper-debug.wasm'),
+};
+const ADDER = {
+ rawTrace: path.join(FIXTURES, 'adder-debug.trace.jsonl'),
+ wasmPath: path.join(FIXTURES, 'adder-debug.wasm'),
+};
+const ADDER_RAW = { rawTrace: ADDER.rawTrace };
+const INCREMENT = {
+ rawTrace: path.join(FIXTURES, 'increment-debug.trace.jsonl'),
+ wasmPath: path.join(FIXTURES, 'increment-debug.wasm'),
+};
+
+const THREAD = { threadId: 1 };
+const STMT = { ...THREAD, granularity: 'statement' as const };
+
+describe('Callstack view (docs/callstack.md, DAP level)', () => {
+ let dc: DebugClient;
+
+ beforeEach(async () => {
+ dc = new DebugClient('node', ADAPTER, 'soroban');
+ await dc.start();
+ });
+
+ afterEach(async () => {
+ await dc.stop();
+ });
+
+ /** Launch and wait for the entry stop. */
+ async function launchAndStop(launchArgs: object): Promise {
+ const [, , stopped] = await Promise.all([
+ dc.configurationSequence(),
+ dc.launch(launchArgs as never),
+ dc.waitForEvent('stopped'),
+ ]);
+ assert.strictEqual((stopped as DebugProtocol.StoppedEvent).body.reason, 'entry');
+ }
+
+ async function stopAfter(request: Promise): Promise {
+ await Promise.all([request, dc.waitForEvent('stopped')]);
+ }
+
+ async function frames(args: object = {}): Promise {
+ return dc.stackTraceRequest({ ...THREAD, ...args });
+ }
+
+ /** `name @ file:line` per frame. */
+ function outline(response: DebugProtocol.StackTraceResponse): string[] {
+ return response.body.stackFrames.map(
+ (f) => `${f.name} @ ${f.source ? `${path.basename(f.source.path ?? '')}:${f.line}` : '-'}`,
+ );
+ }
+
+ /** The scopes of one frame, by frame id. */
+ async function scopesOf(frameId: number): Promise {
+ return (await dc.scopesRequest({ frameId })).body.scopes;
+ }
+
+ /** `name=value` for every variable of a named scope of a frame. */
+ async function scopeContents(frameId: number, scope: string): Promise {
+ const found = (await scopesOf(frameId)).find((s) => s.name === scope);
+ assert.ok(found, `frame ${frameId} offers no ${scope} scope`);
+ const res = await dc.variablesRequest({ variablesReference: found.variablesReference });
+ return res.body.variables.map((v) => `${v.name}=${v.value}`);
+ }
+
+ describe('the stack a client receives (C1, C2, C6)', () => {
+ it('reports the whole Rust chain, innermost first, with totalFrames', async () => {
+ await launchAndStop(STEPPER);
+ // Entry stop is lib.rs:25 in `sum_triples`, which is INLINED into the
+ // export wrapper: one activation, three frames.
+ const res = await frames();
+ assert.deepStrictEqual(outline(res), [
+ 'sum_triples @ lib.rs:25',
+ 'invoke_raw @ lib.rs:20',
+ 'stepper::__sum_triples::invoke_raw_extern @ lib.rs:20',
+ ]);
+ assert.strictEqual(res.body.totalFrames, 3);
+ });
+
+ it('grows by a frame when stepping into a real call, and shows the call site', async () => {
+ await launchAndStop(STEPPER);
+ // :25 -> :26 (the call line) -> into `triple`.
+ await stopAfter(dc.nextRequest(STMT));
+ await stopAfter(dc.stepInRequest(STMT));
+ assert.deepStrictEqual(outline(await frames()), [
+ 'stepper::triple @ lib.rs:15',
+ 'sum_triples @ lib.rs:26',
+ 'invoke_raw @ lib.rs:20',
+ 'stepper::__sum_triples::invoke_raw_extern @ lib.rs:20',
+ ]);
+ });
+
+ it('gives every frame a distinct id and its own instruction pointer (C6)', async () => {
+ await launchAndStop(STEPPER);
+ await stopAfter(dc.nextRequest(STMT));
+ await stopAfter(dc.stepInRequest(STMT));
+ const stack = (await frames()).body.stackFrames;
+ assert.strictEqual(new Set(stack.map((f) => f.id)).size, stack.length);
+ for (const frame of stack) {
+ assert.match(frame.instructionPointerReference ?? '', /^0x[0-9a-f]+$/);
+ }
+ // The callee runs in `triple`'s body; its caller is suspended at the call.
+ assert.notStrictEqual(
+ stack[0].instructionPointerReference,
+ stack[1].instructionPointerReference,
+ );
+ });
+
+ it('honors the client’s paging window (C6)', async () => {
+ await launchAndStop(STEPPER);
+ const full = (await frames()).body.stackFrames;
+ const page = await frames({ startFrame: 1, levels: 1 });
+ assert.strictEqual(page.body.stackFrames.length, 1);
+ assert.strictEqual(page.body.totalFrames, full.length);
+ assert.strictEqual(page.body.stackFrames[0].id, full[1].id);
+ assert.strictEqual(page.body.stackFrames[0].name, full[1].name);
+ });
+
+ it('reports the same frame for the same id across requests (C6)', async () => {
+ await launchAndStop(STEPPER);
+ const first = (await frames()).body.stackFrames;
+ const again = (await frames()).body.stackFrames;
+ assert.deepStrictEqual(
+ again.map((f) => [f.id, f.name, f.line]),
+ first.map((f) => [f.id, f.name, f.line]),
+ );
+ });
+
+ it('reports the line’s first non-whitespace column on every mapped frame (S19)', async () => {
+ await launchAndStop(STEPPER);
+ for (const frame of (await frames()).body.stackFrames) {
+ if (frame.source) {
+ assert.ok(frame.column > 0, `frame ${frame.name} reports column ${frame.column}`);
+ }
+ }
+ });
+ });
+
+ describe('presentation (C3, C5, C8)', () => {
+ it('carries the recording position in the thread label, not a frame name', async () => {
+ await launchAndStop(ADDER);
+ const threads = await dc.threadsRequest();
+ assert.match(threads.body.threads[0].name, /^soroban-vm \[\d+\/40\]$/);
+ for (const frame of (await frames()).body.stackFrames) {
+ assert.ok(
+ !/\[\d+\/\d+\]/.test(frame.name),
+ `a frame name must not carry the cursor: ${frame.name}`,
+ );
+ }
+ });
+
+ it('updates the thread label as the cursor moves', async () => {
+ await launchAndStop(ADDER);
+ const before = (await dc.threadsRequest()).body.threads[0].name;
+ await stopAfter(dc.stepBackRequest({ ...THREAD, granularity: 'instruction' }));
+ assert.notStrictEqual((await dc.threadsRequest()).body.threads[0].name, before);
+ });
+
+ it('labels a contract boundary as such and hangs it below the code frames (C3)', async () => {
+ await launchAndStop(INCREMENT);
+ const stack = (await frames()).body.stackFrames;
+ const boundary = stack.filter((f) => f.presentationHint === 'label');
+ assert.strictEqual(boundary.length, 1, `expected one boundary frame in: ${outline({ body: { stackFrames: stack } } as never).join(' | ')}`);
+ assert.match(boundary[0].name, /^\w+\(\) @ /);
+ assert.strictEqual(stack[stack.length - 1].id, boundary[0].id);
+ assert.strictEqual(boundary[0].source, undefined);
+ });
+
+ it('deemphasizes a frame outside the workspace, without hiding it (C5)', async () => {
+ // Instruction-stepping into the SDK's conversion glue reaches frames whose
+ // source is a crates.io path that does not exist on this machine.
+ await launchAndStop(INCREMENT);
+ const seen: DebugProtocol.StackFrame[] = [];
+ for (let i = 0; i < 40 && seen.length === 0; i++) {
+ const stack = (await frames()).body.stackFrames;
+ seen.push(...stack.filter((f) => f.presentationHint === 'subtle'));
+ await stopAfter(dc.stepInRequest({ ...THREAD, granularity: 'instruction' }));
+ }
+ assert.ok(seen.length > 0, 'expected at least one deemphasized frame while stepping');
+ for (const frame of seen) {
+ assert.notStrictEqual(frame.name, '', 'a deemphasized frame is still named');
+ }
+ });
+
+ it('offers one addressed frame and no source when there is no wasm at all (C4)', async () => {
+ await launchAndStop(ADDER_RAW);
+ const stack = (await frames()).body.stackFrames;
+ assert.strictEqual(stack.length, 1);
+ assert.match(stack[0].name, /^wasm@0x[0-9a-f]+$/);
+ assert.strictEqual(stack[0].source, undefined);
+ assert.strictEqual(stack[0].line, 0);
+ });
+ });
+
+ describe('inspecting a selected frame (C7)', () => {
+ it('reads an outer frame’s wasm locals from that frame’s own record', async () => {
+ await launchAndStop(STEPPER);
+ await stopAfter(dc.nextRequest(STMT));
+ await stopAfter(dc.stepInRequest(STMT));
+ const stack = (await frames()).body.stackFrames;
+
+ const callee = await scopeContents(stack[0].id, 'Locals');
+ const caller = await scopeContents(stack[1].id, 'Locals');
+ assert.notDeepStrictEqual(
+ caller,
+ callee,
+ `the caller must not report the callee's locals: ${callee.join(', ')}`,
+ );
+ // `triple(x)` has one local; `sum_triples` is mid-loop with several.
+ assert.ok(callee.length >= 1 && caller.length > callee.length, `${callee.length} vs ${caller.length}`);
+ });
+
+ it('shows each frame’s own Rust variables', async () => {
+ await launchAndStop(STEPPER);
+ await stopAfter(dc.nextRequest(STMT));
+ await stopAfter(dc.stepInRequest(STMT));
+ const stack = (await frames()).body.stackFrames;
+
+ // `triple`'s parameter is `x`; the frame below it is `sum_triples`, which
+ // has no `x` of its own.
+ const callee = await scopeContents(stack[0].id, 'Variables');
+ assert.ok(
+ callee.some((v) => v.startsWith('x=')),
+ `expected x among triple's variables, got: ${callee.join(', ')}`,
+ );
+ const caller = await scopeContents(stack[1].id, 'Variables');
+ assert.ok(
+ !caller.some((v) => v.startsWith('x=')),
+ `the caller must not report the callee's x, got: ${caller.join(', ')}`,
+ );
+ });
+
+ it('offers the VM-wide scopes on every code frame', async () => {
+ await launchAndStop(INCREMENT);
+ const stack = (await frames()).body.stackFrames;
+ for (const frame of stack.filter((f) => f.presentationHint !== 'label')) {
+ const names = (await scopesOf(frame.id)).map((s) => s.name);
+ assert.ok(names.includes('Ledger'), `frame ${frame.name} offers: ${names.join(', ')}`);
+ assert.ok(names.includes('Locals'), `frame ${frame.name} offers: ${names.join(', ')}`);
+ }
+ });
+
+ it('offers nothing to inspect on a contract boundary (C3)', async () => {
+ await launchAndStop(INCREMENT);
+ const stack = (await frames()).body.stackFrames;
+ const boundary = stack.find((f) => f.presentationHint === 'label');
+ assert.ok(boundary, 'expected a boundary frame');
+ assert.deepStrictEqual(await scopesOf(boundary.id), []);
+ });
+
+ it('keeps a frame’s children expandable after another frame is selected', async () => {
+ // Handles are reset per STOP, not per scopes request: a client that expands
+ // frame 0, selects frame 1, and comes back must not get an empty tree.
+ await launchAndStop(STEPPER);
+ await stopAfter(dc.nextRequest(STMT));
+ await stopAfter(dc.stepInRequest(STMT));
+ const stack = (await frames()).body.stackFrames;
+
+ const scope = (await scopesOf(stack[0].id)).find((s) => s.name === 'Locals');
+ assert.ok(scope);
+ const before = await dc.variablesRequest({ variablesReference: scope.variablesReference });
+ await scopesOf(stack[1].id);
+ const after = await dc.variablesRequest({ variablesReference: scope.variablesReference });
+ assert.deepStrictEqual(after.body.variables, before.body.variables);
+ });
+ });
+});
diff --git a/test/dapStepping.test.ts b/test/dapStepping.test.ts
index 8dcc1f4..58b1adb 100644
--- a/test/dapStepping.test.ts
+++ b/test/dapStepping.test.ts
@@ -51,7 +51,7 @@ const INSTR = { ...THREAD, granularity: 'instruction' as const };
/** What the top stack frame shows at a stop. */
interface Stop {
- /** Trace index, parsed from the frame name's '[/]' probe. */
+ /** Trace index, read off the thread label's '[/]' probe (C8). */
index: number;
line: number;
/** 1-based source column reported on the frame (S19: first non-whitespace). */
@@ -76,8 +76,10 @@ describe('Stepping spec (docs/stepping.md, DAP level)', () => {
const res = await dc.stackTraceRequest(THREAD);
assert.ok(res.body.stackFrames.length >= 1, 'expected at least one stack frame');
const frame = res.body.stackFrames[0];
- const probe = /\[(\d+)\/\d+\]$/.exec(frame.name);
- assert.ok(probe, `frame name carries no trace-index probe: ${frame.name}`);
+ const threads = await dc.threadsRequest();
+ const label = threads.body.threads[0].name;
+ const probe = /\[(\d+)\/\d+\]$/.exec(label);
+ assert.ok(probe, `thread label carries no cursor probe: ${label}`);
return {
index: Number(probe[1]),
line: frame.line,
diff --git a/test/dapVariables.test.ts b/test/dapVariables.test.ts
index f7d5668..9b447bf 100644
--- a/test/dapVariables.test.ts
+++ b/test/dapVariables.test.ts
@@ -43,9 +43,25 @@ describe('SorobanDebugSession source-level Variables view', () => {
return res.body.stackFrames[0];
}
- /** The scopes offered for the current top frame. */
+ /**
+ * The frame whose name contains `part`. The adder fixture is built above
+ * opt-level 0, so `add` survives only as an INLINE frame (docs/callstack.md,
+ * C2) and the parameters of the `#[contractimpl]` wrapper belong to the
+ * wrapper's own frame — which is the one these tests inspect.
+ */
+ async function frameNamed(part: string): Promise {
+ const res = await dc.stackTraceRequest(THREAD);
+ const frame = res.body.stackFrames.find((f) => f.name.includes(part));
+ assert.ok(
+ frame,
+ `no frame named like ${part}; got: ${res.body.stackFrames.map((f) => f.name).join(' | ')}`,
+ );
+ return frame;
+ }
+
+ /** The scopes offered for the frame owning the wrapper's parameters. */
async function topScopes(): Promise {
- const frame = await topFrame();
+ const frame = await frameNamed('invoke_raw_extern');
const res = await dc.scopesRequest({ frameId: frame.id });
return res.body.scopes;
}
@@ -99,7 +115,8 @@ describe('SorobanDebugSession source-level Variables view', () => {
await launchAndStop(NO_WASM);
// NullVariableResolver reports no functions -> the Variables scope is never
// prepended, so a trace without DWARF sees exactly [Locals, Value Stack].
- const names = (await topScopes()).map((s) => s.name);
+ const frame = await topFrame();
+ const names = (await dc.scopesRequest({ frameId: frame.id })).body.scopes.map((s) => s.name);
assert.deepStrictEqual(names, ['Locals', 'Value Stack']);
});
diff --git a/test/dwarfSourceMapper.test.ts b/test/dwarfSourceMapper.test.ts
index 1eb30e5..2e25ba2 100644
--- a/test/dwarfSourceMapper.test.ts
+++ b/test/dwarfSourceMapper.test.ts
@@ -322,6 +322,42 @@ describe('sourcemap/DwarfSourceMapper (adder debug fixture)', () => {
`duplicate existence checks: ${calls.sort().join(', ')}`,
);
});
+
+ // A DWARF inlined call site is stated as a file/line pair rather than an
+ // address, and an outer frame's position comes from it (docs/callstack.md, C2).
+ describe('locationForFile (frame positions stated outside the line table)', () => {
+ it('normalizes an existing file and keeps a positive column', () => {
+ const loc = mapper.locationForFile(`${path.dirname(libRs)}/../src/lib.rs`, 12, 5);
+ assertLibRsLocation(loc, 12);
+ assert.strictEqual(loc!.column, 5);
+ });
+
+ it('omits a column DWARF states as 0 (unknown)', () => {
+ assert.strictEqual(mapper.locationForFile(libRs, 12, 0)?.column, undefined);
+ assert.strictEqual(mapper.locationForFile(libRs, 12)?.column, undefined);
+ });
+
+ it('is null for line 0 and for a file that is not on disk', () => {
+ // Line 0 is DWARF's "compiler-generated, no source line"; a file the user
+ // cannot open must not become a frame position either.
+ assert.strictEqual(mapper.locationForFile(libRs, 0), null);
+ assert.strictEqual(mapper.locationForFile('/nowhere/absent.rs', 3), null);
+ });
+ });
+
+ describe('sourceTextAt', () => {
+ it('reads any line of a file, agreeing with sourceTextForIndex', () => {
+ // Index 29 maps to lib.rs:16; asking for that file/line directly must give
+ // the same text a frame at that record would show (S19's input).
+ assert.strictEqual(mapper.sourceTextAt(libRs, 16), mapper.sourceTextForIndex(29));
+ assert.ok((mapper.sourceTextAt(libRs, 16) ?? '').includes('a + b'));
+ });
+
+ it('is null past the end of the file and for an unreadable one', () => {
+ assert.strictEqual(mapper.sourceTextAt(libRs, 100000), null);
+ assert.strictEqual(mapper.sourceTextAt('/nowhere/absent.rs', 1), null);
+ });
+ });
});
describe('sourcemap/NullSourceMapper', () => {
@@ -331,8 +367,11 @@ describe('sourcemap/NullSourceMapper', () => {
assert.strictEqual(mapper.hasLineInfo(), false);
assert.strictEqual(mapper.locationForIndex(0), null);
assert.strictEqual(mapper.locationForAddress(0), null);
+ assert.strictEqual(mapper.locationForFile('/any/file.rs', 1), null);
assert.strictEqual(mapper.lineKeyForIndex(0), null);
assert.strictEqual(mapper.resolveBreakpoint('/any/file.rs', 1), null);
assert.deepStrictEqual(mapper.executedLines('/any/file.rs', 1, 99), []);
+ assert.strictEqual(mapper.sourceTextForIndex(0), null);
+ assert.strictEqual(mapper.sourceTextAt('/any/file.rs', 1), null);
});
});
diff --git a/test/justMyCode.test.ts b/test/justMyCode.test.ts
index ed53ec0..5c27376 100644
--- a/test/justMyCode.test.ts
+++ b/test/justMyCode.test.ts
@@ -151,6 +151,10 @@ class StubSourceMapper implements SourceMapper {
return null;
}
+ locationForFile(): MappedLocation | null {
+ return null;
+ }
+
resolveBreakpoint(): ResolvedBreakpoint | null {
return null;
}
@@ -171,6 +175,10 @@ class StubSourceMapper implements SourceMapper {
const info = this.infos[index];
return info ? info.text : null;
}
+
+ sourceTextAt(): string | null {
+ return null;
+ }
}
/** A `nop` record: no call/return opcode, so computeDepths yields depth 0. */
diff --git a/test/projectStop.test.ts b/test/projectStop.test.ts
index 04aa8e5..3e2daed 100644
--- a/test/projectStop.test.ts
+++ b/test/projectStop.test.ts
@@ -77,6 +77,27 @@ describe('projectSourceStop (docs/trace-cli-internal.md, serializable stop proje
assert.strictEqual(v.truncated, undefined);
}
});
+
+ it('projects the whole call stack, innermost first (docs/callstack.md)', () => {
+ const sm = buildStopModel(resolved);
+ const stop = projectSourceStop(resolved, sm, 29);
+
+ // The adder is built above opt-0, so `add` survives only as an inline frame
+ // inside the #[contractimpl] wrapper: one activation, three frames (C2).
+ assert.deepStrictEqual(
+ stop.frames.map((f) => [f.level, f.name, f.kind, f.pc, f.source?.line]),
+ [
+ [0, 'add', 'inline', '0x2d', 16],
+ [1, 'invoke_raw', 'inline', '0x2d', 12],
+ [2, 'adder::__add::invoke_raw_extern', 'rust', '0x2d', 12],
+ ],
+ );
+ // `subtle` is a marker: absent, never `false`, for a workspace frame (C5).
+ for (const frame of stop.frames) {
+ assert.strictEqual(frame.subtle, undefined);
+ assert.ok(frame.source!.path.endsWith('examples/adder/src/lib.rs'));
+ }
+ });
});
describe('stepper-debug idx 29 (function `triple`)', () => {
diff --git a/test/replayCursor.test.ts b/test/replayCursor.test.ts
index 9bd09df..e08e971 100644
--- a/test/replayCursor.test.ts
+++ b/test/replayCursor.test.ts
@@ -39,6 +39,9 @@ function stopModel(opts: {
return {
validatedPosToIndices: opts.validatedPosToIndices ?? new Map(),
visibleIndices,
+ // The cursor reads depths, never the frames they are projected from.
+ frames: depths.map((depth) => ({ fn: -1, depth, callSite: null, caller: null })),
+ ranges: [],
depths,
rawRunStarts: opts.rawRunStarts ?? runStarts,
runStarts,
@@ -204,10 +207,12 @@ describe('resolveBreakpoints', () => {
hasLineInfo: () => true,
locationForIndex: (): MappedLocation | null => null,
locationForAddress: (): MappedLocation | null => null,
+ locationForFile: (): MappedLocation | null => null,
resolveBreakpoint: (): ResolvedBreakpoint | null => ({ line: 1, indices }),
executedLines: () => [],
lineKeyForIndex: () => null,
sourceTextForIndex: () => null,
+ sourceTextAt: () => null,
};
}
diff --git a/test/scopeIndex.test.ts b/test/scopeIndex.test.ts
index 5e296dd..b00e1cd 100644
--- a/test/scopeIndex.test.ts
+++ b/test/scopeIndex.test.ts
@@ -11,6 +11,9 @@ import {
DW_TAG_formal_parameter,
DW_TAG_variable,
DW_TAG_lexical_block,
+ DW_TAG_inlined_subroutine,
+ DW_TAG_namespace,
+ DW_TAG_structure_type,
DW_AT_name,
DW_AT_low_pc,
DW_AT_high_pc,
@@ -18,6 +21,13 @@ import {
DW_AT_location,
DW_AT_type,
DW_AT_frame_base,
+ DW_AT_stmt_list,
+ DW_AT_call_file,
+ DW_AT_call_line,
+ DW_AT_call_column,
+ DW_AT_abstract_origin,
+ DW_AT_specification,
+ DW_AT_linkage_name,
} from '../src/dwarf/constants';
const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures');
@@ -340,7 +350,182 @@ describe('dwarf/ScopeIndex', () => {
const hit = scope.functionAt(0x410);
assert.ok(hit, 'the anonymous subprogram is still located by range');
assert.strictEqual(hit.name, undefined, 'it genuinely has no DIE name');
+ assert.strictEqual(hit.qualifiedName, undefined, 'no name, nothing to qualify');
assert.strictEqual(scope.functionNameAt(0x410), 'wasm_func_1040');
});
});
+
+ // --- Frames: qualified names and inlined instances (docs/callstack.md) ----
+
+ describe('qualifiedName (docs/callstack.md C4)', () => {
+ it('prefixes the DIE name with its enclosing namespaces and types', () => {
+ const fn = die(730, DW_TAG_subprogram, [
+ [DW_AT_name, str('bump')],
+ [DW_AT_low_pc, uint(0x10)],
+ [DW_AT_high_pc, uint(0x10)],
+ ]);
+ const impl = die(720, DW_TAG_structure_type, [[DW_AT_name, str('Control')]], [fn]);
+ const ns = die(710, DW_TAG_namespace, [[DW_AT_name, str('control')]], [impl]);
+ const cu = die(700, DW_TAG_compile_unit, [], [ns]);
+ const scope = new ScopeIndex(debugInfoOf(cu));
+
+ assert.strictEqual(scope.functionAt(0x14)?.qualifiedName, 'control::Control::bump');
+ // The bare name is unchanged — it is what `functionNameAt` reports.
+ assert.strictEqual(scope.functionNameAt(0x14), 'bump');
+ });
+
+ it('ignores an unnamed enclosing scope rather than emitting an empty segment', () => {
+ const fn = die(830, DW_TAG_subprogram, [
+ [DW_AT_name, str('f')],
+ [DW_AT_low_pc, uint(0x10)],
+ [DW_AT_high_pc, uint(0x10)],
+ ]);
+ const anonymous = die(820, DW_TAG_namespace, [], [fn]);
+ const cu = die(800, DW_TAG_compile_unit, [], [anonymous]);
+ assert.strictEqual(new ScopeIndex(debugInfoOf(cu)).functionAt(0x10)?.qualifiedName, 'f');
+ });
+ });
+
+ describe('inlineScopesAt (docs/callstack.md C2)', () => {
+ /**
+ * `outer` (0x100..0x1ff) contains an inlined `middle` (0x110..0x11f) which
+ * itself contains an inlined `inner` (0x118..0x11b). `middle` declares `m`.
+ */
+ function nested(): ScopeIndex {
+ const inner = die(940, DW_TAG_inlined_subroutine, [
+ [DW_AT_name, str('inner')],
+ [DW_AT_low_pc, uint(0x118)],
+ [DW_AT_high_pc, uint(0x4)],
+ [DW_AT_call_file, uint(2)],
+ [DW_AT_call_line, uint(77)],
+ [DW_AT_call_column, uint(9)],
+ ]);
+ const m = die(935, DW_TAG_variable, [[DW_AT_name, str('m')], [DW_AT_location, block(0x91, 0x10)]]);
+ const middle = die(
+ 930,
+ DW_TAG_inlined_subroutine,
+ [
+ [DW_AT_name, str('middle')],
+ [DW_AT_low_pc, uint(0x110)],
+ [DW_AT_high_pc, uint(0x10)],
+ [DW_AT_call_file, uint(1)],
+ [DW_AT_call_line, uint(42)],
+ ],
+ [m, inner],
+ );
+ const outer = die(
+ 920,
+ DW_TAG_subprogram,
+ [
+ [DW_AT_name, str('outer')],
+ [DW_AT_low_pc, uint(0x100)],
+ [DW_AT_high_pc, uint(0x100)],
+ [DW_AT_frame_base, block(0xed, 0x00, 0x00)],
+ ],
+ [middle],
+ );
+ const cu = die(900, DW_TAG_compile_unit, [[DW_AT_stmt_list, uint(64)]], [outer]);
+ return new ScopeIndex(debugInfoOf(cu));
+ }
+
+ it('reports the covering instances outermost first, with their call sites', () => {
+ const scopes = nested().inlineScopesAt(0x119);
+ assert.deepStrictEqual(
+ scopes.map((s) => [s.name, s.callFileIndex, s.callLine, s.callColumn]),
+ [
+ ['middle', 1, 42, undefined],
+ ['inner', 2, 77, 9],
+ ],
+ );
+ // Every instance names the line program its call file index belongs to.
+ assert.deepStrictEqual(scopes.map((s) => s.stmtListOffset), [64, 64]);
+ });
+
+ it('reports only the instances whose own range covers the pc', () => {
+ const index = nested();
+ assert.deepStrictEqual(
+ index.inlineScopesAt(0x112).map((s) => s.name),
+ ['middle'],
+ );
+ assert.deepStrictEqual(index.inlineScopesAt(0x150), []);
+ // Outside every function there is nothing to expand.
+ assert.deepStrictEqual(index.inlineScopesAt(0x900), []);
+ });
+
+ it('gives each instance its OWN declarations, with the frame base threaded in', () => {
+ const scopes = nested().inlineScopesAt(0x119);
+ const middle = scopes[0];
+ assert.deepStrictEqual(middle.variables.map((v) => v.name), ['m']);
+ assert.ok(middle.variables[0].frameBaseExpr, 'the enclosing frame base must be threaded in');
+ // `m` belongs to `middle`, not to the deeper instance…
+ assert.deepStrictEqual(scopes[1].variables, []);
+ // …and not to the enclosing function either, which never descends into an
+ // inlined instance.
+ assert.deepStrictEqual(nested().variablesInScope(0x119), []);
+ });
+
+ it('skips an instance with no readable range instead of placing it anywhere', () => {
+ // No low_pc/high_pc and no ranges: the instance cannot be placed, and a
+ // guessed frame would misreport the program (C2).
+ const rangeless = die(1030, DW_TAG_inlined_subroutine, [[DW_AT_name, str('nowhere')]]);
+ // A tombstoned instance is dropped for the same reason.
+ const tombstoned = die(1035, DW_TAG_inlined_subroutine, [
+ [DW_AT_name, str('dropped')],
+ [DW_AT_low_pc, uint(0xffffffff)],
+ [DW_AT_high_pc, uint(0x10)],
+ ]);
+ const fn = die(
+ 1020,
+ DW_TAG_subprogram,
+ [
+ [DW_AT_name, str('host')],
+ [DW_AT_low_pc, uint(0x10)],
+ [DW_AT_high_pc, uint(0x10)],
+ ],
+ [rangeless, tombstoned],
+ );
+ const cu = die(1000, DW_TAG_compile_unit, [], [fn]);
+ assert.deepStrictEqual(new ScopeIndex(debugInfoOf(cu)).inlineScopesAt(0x14), []);
+ });
+
+ it('resolves the name through abstract_origin, specification, and linkage_name', () => {
+ // rustc points an instance at an abstract subprogram that carries only a
+ // DW_AT_specification, whose declaration holds the name. Nothing else does.
+ const declaration = die(1140, DW_TAG_subprogram, [[DW_AT_name, str('wrapping_add')]]);
+ const abstract = die(1130, DW_TAG_subprogram, [[DW_AT_specification, ref(1140)]]);
+ const mangledOnly = die(1150, DW_TAG_subprogram, [
+ [DW_AT_linkage_name, str('_ZN4core3fmt5writeE')],
+ ]);
+ const instance = die(1160, DW_TAG_inlined_subroutine, [
+ [DW_AT_abstract_origin, ref(1130)],
+ [DW_AT_low_pc, uint(0x10)],
+ [DW_AT_high_pc, uint(0x8)],
+ ]);
+ const mangledInstance = die(1170, DW_TAG_inlined_subroutine, [
+ [DW_AT_abstract_origin, ref(1150)],
+ [DW_AT_low_pc, uint(0x18)],
+ [DW_AT_high_pc, uint(0x8)],
+ ]);
+ const nameless = die(1180, DW_TAG_inlined_subroutine, [
+ [DW_AT_low_pc, uint(0x20)],
+ [DW_AT_high_pc, uint(0x8)],
+ ]);
+ const fn = die(
+ 1120,
+ DW_TAG_subprogram,
+ [
+ [DW_AT_name, str('host')],
+ [DW_AT_low_pc, uint(0x10)],
+ [DW_AT_high_pc, uint(0x20)],
+ ],
+ [instance, mangledInstance, nameless],
+ );
+ const cu = die(1100, DW_TAG_compile_unit, [], [fn, declaration, abstract, mangledOnly]);
+ const index = new ScopeIndex(debugInfoOf(cu));
+
+ assert.strictEqual(index.inlineScopesAt(0x12)[0].name, 'wrapping_add');
+ assert.strictEqual(index.inlineScopesAt(0x1a)[0].name, '_ZN4core3fmt5writeE');
+ assert.strictEqual(index.inlineScopesAt(0x22)[0].name, undefined);
+ });
+ });
});
diff --git a/test/wasmNames.test.ts b/test/wasmNames.test.ts
new file mode 100644
index 0000000..b32c391
--- /dev/null
+++ b/test/wasmNames.test.ts
@@ -0,0 +1,153 @@
+/**
+ * The wasm symbol layer behind wasm-level call-stack frames (docs/callstack.md,
+ * C4): the `name` custom section, the import count that maps body order to
+ * function index, and Rust legacy demangling.
+ *
+ * Real fixtures pin the mapping (a name read for a body must be the name of
+ * THAT body), and hand-built sections pin the leniency: a truncated name
+ * section must degrade to the names read so far, never throw.
+ */
+
+import * as assert from 'assert';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+ demangleRust,
+ functionNames,
+ importedFunctionCount,
+} from '../src/wasm/names';
+import { Disassembly } from '../src/wasm/Disassembly';
+
+const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures');
+const read = (name: string): Uint8Array => new Uint8Array(fs.readFileSync(path.join(FIXTURES, name)));
+
+/** A wasm module with just a header and one custom section. */
+function moduleWithCustomSection(name: string, payload: number[]): Uint8Array {
+ const nameBytes = Buffer.from(name, 'utf8');
+ const content = [nameBytes.length, ...nameBytes, ...payload];
+ return new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // header
+ 0x00, content.length, ...content, // custom section
+ ]);
+}
+
+/**
+ * A `name` section carrying only a function-name subsection. `claimed`
+ * overstates the entry count, which is what a truncated section looks like.
+ */
+function nameSection(entries: [number, string][], claimed = entries.length): Uint8Array {
+ const map: number[] = [claimed];
+ for (const [index, name] of entries) {
+ const bytes = Buffer.from(name, 'utf8');
+ map.push(index, bytes.length, ...bytes);
+ }
+ return moduleWithCustomSection('name', [1, map.length, ...map]);
+}
+
+describe('wasm function names', () => {
+ describe('functionNames', () => {
+ it('reads the function-name map of a real contract', () => {
+ const names = functionNames(read('stepper-debug.wasm'));
+ assert.strictEqual(names.get(0), '_ZN7stepper6triple17h35eddc3334b434dbE');
+ assert.strictEqual(names.get(1), 'sum_triples');
+ });
+
+ it('is empty for a module with no name section', () => {
+ assert.strictEqual(functionNames(read('composite.wasm')).size, 0);
+ });
+
+ it('reads what it can from a truncated name section instead of throwing', () => {
+ const entries: [number, string][] = [
+ [0, 'first'],
+ [1, 'second'],
+ ];
+ assert.deepStrictEqual([...functionNames(nameSection(entries))], entries);
+ // A map claiming four entries but holding two: the two survive.
+ assert.deepStrictEqual([...functionNames(nameSection(entries, 4))], entries);
+ });
+
+ it('degrades to empty on a malformed section rather than failing a session', () => {
+ // A subsection claiming more bytes than the section holds…
+ assert.strictEqual(functionNames(moduleWithCustomSection('name', [1, 99, 1])).size, 0);
+ // …a name whose length runs past the payload…
+ assert.strictEqual(functionNames(moduleWithCustomSection('name', [1, 4, 1, 0, 40, 0x66])).size, 0);
+ // …and a subsection header cut off after its id.
+ assert.strictEqual(functionNames(moduleWithCustomSection('name', [1])).size, 0);
+ });
+
+ it('skips subsections that are not the function-name map', () => {
+ // Subsection 0 is the module name; the function map follows it.
+ const moduleName = [0, 3, 2, 0x68, 0x69];
+ const functions = [1, 5, 1, 7, 2, 0x66, 0x6e];
+ const names = functionNames(moduleWithCustomSection('name', [...moduleName, ...functions]));
+ assert.deepStrictEqual([...names], [[7, 'fn']]);
+ });
+ });
+
+ describe('importedFunctionCount', () => {
+ it('counts the imported host functions of a real contract', () => {
+ // increment-debug.wasm imports three host functions; the arithmetic-only
+ // fixtures import none at all (they carry no import section).
+ assert.strictEqual(importedFunctionCount(read('increment-debug.wasm')), 3);
+ assert.strictEqual(importedFunctionCount(read('stepper-debug.wasm')), 0);
+ });
+
+ it('offsets body order into function-index space', () => {
+ // The i-th function body is function index importCount + i, so the name a
+ // range reports must be the name of that body's own function. stepper's
+ // three bodies are `triple`, the `#[contractimpl]` wrapper, and the SDK's
+ // section shim, in that order.
+ const bytes = read('stepper-debug.wasm');
+ const ranges = Disassembly.fromWasm(bytes).functionRanges;
+ assert.strictEqual(ranges[0].index, importedFunctionCount(bytes));
+ assert.strictEqual(ranges[0].name, 'stepper::triple');
+ assert.strictEqual(ranges[1].name, 'sum_triples');
+ assert.strictEqual(ranges[1].index, 1);
+ });
+
+ it('leaves a range unnamed when the module carries no name section', () => {
+ for (const range of Disassembly.fromWasm(read('composite.wasm')).functionRanges) {
+ assert.strictEqual(range.name, undefined);
+ assert.strictEqual(typeof range.index, 'number');
+ }
+ });
+
+ it('names nothing at all for a trace-derived disassembly', () => {
+ // Disassembly.fromTrace knows no function structure, so there are no
+ // ranges to name — the wasm-level frame ladder ends at the address.
+ const model = { records: [{ pos: 4, instr: ['nop'] }] } as never;
+ assert.deepStrictEqual(Disassembly.fromTrace(model).functionRanges, []);
+ });
+ });
+
+ describe('demangleRust', () => {
+ it('demangles a legacy symbol and drops its hash segment', () => {
+ assert.strictEqual(demangleRust('_ZN7control7Control10while_call17h0b04c88804cf85f6E'), 'control::Control::while_call');
+ assert.strictEqual(demangleRust('_ZN7control4bump17h2628dce790f861d2E'), 'control::bump');
+ });
+
+ it('decodes the $…$ escapes and `..` path separators', () => {
+ assert.strictEqual(
+ demangleRust('_ZN60_$LT$soroban_sdk..env..Env$u20$as$u20$core..clone..Clone$GT$5clone17h1357aacfed26b0c7E'),
+ '::clone',
+ );
+ assert.strictEqual(demangleRust('_ZN1a5b$C$c17h0000000000000000E'), 'a::b,c');
+ });
+
+ it('passes through anything that is not legacy-mangled', () => {
+ // In order: a plain symbol, a v0-mangled one (documented as not demangled),
+ // the empty string, a body that is not length-prefixed, a length running
+ // past the end, and a `_ZN…E` with no segments at all.
+ for (const symbol of [
+ 'sum_triples',
+ '_RNvC7control4bump',
+ '',
+ '_ZNnot_a_lengthE',
+ '_ZN99tooshortE',
+ '_ZNE',
+ ]) {
+ assert.strictEqual(demangleRust(symbol), symbol);
+ }
+ });
+ });
+});