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); + } + }); + }); +});