Skip to content

Commit 15d9be6

Browse files
Add DWARF-driven Rust source-level Variables view
Debug Soroban contracts at the Rust level: a new "Variables" scope resolves named locals/params with their types and values, expandable for structs, enums, slices and strings — driven by the contract wasm's DWARF, replacing the raw wasm locals/stack view. New pure DWARF subsystem under src/dwarf/: - forms.ts / die.ts: value-returning form reader + full DIE-tree parse with a global offset index (CU-relative and cross-CU ref resolution). - TypeRegistry.ts: lazy, cycle-safe decoded type model (base/pointer/struct/ union/array/enum/Rust variant_part enums/typedef/qualifier). - locexpr.ts / debugLoc.ts: DWARF location/expression evaluator (DW_OP_WASM_location incl. the kind-3 fixed-u32 case, DW_OP_fbreg via the frame base, addr/plus_uconst/stack_value) + v4 .debug_loc selection. - ScopeIndex.ts: PC -> function -> in-scope variables (high_pc-as-size, lexical-block ranges, .debug_ranges). - ValueDecoder.ts: type-aware value + lazy children, with &str/slice/Option recognition, budgets and cycle guards; never throws. - DebugInfo.ts: fromWasm facade, mirroring DwarfLineTable. Runtime + wiring: - trace.ts consumes komet-node's new per-step linear-memory snapshots (hex sparse runs; null = unchanged) and globals; MemoryImage reconstructs memory at any replay cursor; runtimeState bridges a record to the DWARF evaluator. - VariableResolver (Dwarf/Null) mirrors the SourceMapper split; threaded through buildDebugArtifacts and both backends; SorobanDebugSession exposes the source Variables scope with lazily-expandable children. Performance: the wasm uploaded to komet-node is now debug-stripped (stripDebugSections) — carrying DWARF into the executed module bloated the KORE config ~3x and stalled the RPC path; the code section is byte-identical so trace positions still align with the full DWARF used for resolution. Tested per module (unit) plus end-to-end variable inspection over a real komet trace: dapMemoryVariables and the TurnkeyPipeline mock-node test both resolve a shadow-stack parameter (by: u32 == 5) from linear memory; a gated live test (KOMET_NODE_E2E) covers the full stack against a real komet-node.
1 parent 50c258e commit 15d9be6

38 files changed

Lines changed: 9185 additions & 20 deletions

src/debugAdapter/MemoryImage.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Reconstructs WASM linear memory at a replay cursor from the FULL sparse
3+
* snapshots carried by a trace (see src/komet/trace.ts). Each record's `mem`
4+
* (when present) is the whole module memory at that step; a record with no
5+
* snapshot (`undefined`) leaves the memory unchanged since the previous one.
6+
* So the memory at a cursor is simply the latest snapshot at an index <=
7+
* cursor — no folding across records. Any byte not covered by a run reads as 0
8+
* (wasm memory is zero-initialized), including before the first snapshot.
9+
*
10+
* The index of that latest snapshot is precomputed once per record, so a read
11+
* (forward or backward) is a direct lookup plus a copy of the overlapping runs.
12+
*
13+
* Pure module (no `vscode` / DAP imports).
14+
*/
15+
16+
import { TraceRecord } from '../komet/trace';
17+
18+
export class MemoryImage {
19+
private readonly records: ReadonlyArray<TraceRecord>;
20+
/**
21+
* For each record index i, the greatest index j <= i whose record carries a
22+
* `mem` snapshot, or -1 when no snapshot exists at or before i.
23+
*/
24+
private readonly lastSnapshotAt: number[];
25+
26+
constructor(records: ReadonlyArray<TraceRecord>) {
27+
this.records = records;
28+
this.lastSnapshotAt = new Array(records.length);
29+
let last = -1;
30+
for (let i = 0; i < records.length; i++) {
31+
if (records[i].mem !== undefined) {
32+
last = i;
33+
}
34+
this.lastSnapshotAt[i] = last;
35+
}
36+
}
37+
38+
/**
39+
* The `size` bytes at `[addr, addr+size)` as of the latest snapshot at or
40+
* before `cursor`. Never-covered bytes (and everything before the first
41+
* snapshot) read as 0. Returns a fresh `Uint8Array` of length `size`, or
42+
* `undefined` for `size <= 0` / `addr < 0`.
43+
*/
44+
readMemory(cursor: number, addr: number, size: number): Uint8Array | undefined {
45+
if (size <= 0 || addr < 0) {
46+
return undefined;
47+
}
48+
const out = new Uint8Array(size);
49+
50+
if (this.records.length === 0) {
51+
return out;
52+
}
53+
const clamped = Math.max(0, Math.min(cursor, this.records.length - 1));
54+
const j = this.lastSnapshotAt[clamped];
55+
if (j < 0) {
56+
// No snapshot at or before the cursor: memory is all zeros.
57+
return out;
58+
}
59+
60+
const runs = this.records[j].mem!;
61+
const end = addr + size;
62+
for (const run of runs) {
63+
const runEnd = run.addr + run.bytes.length;
64+
const from = Math.max(addr, run.addr);
65+
const to = Math.min(end, runEnd);
66+
for (let a = from; a < to; a++) {
67+
out[a - addr] = run.bytes[a - run.addr];
68+
}
69+
}
70+
return out;
71+
}
72+
}

src/debugAdapter/SorobanDebugSession.ts

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,15 @@ import {
3131
statementStops,
3232
} from './stops';
3333
import { SourceMapper } from '../sourcemap/SourceMapper';
34+
import { VariableResolver, NullVariableResolver } from '../sourcemap/VariableResolver';
3435
import { Disassembly } from '../wasm/Disassembly';
3536
import { ResolvedTrace, SessionBackend, SorobanLaunchArgs } from './types';
3637
import { TypedValue } from '../komet/trace';
3738
import { renderInstr } from '../komet/mnemonics';
39+
import { MemoryImage } from './MemoryImage';
40+
import { makeRuntimeState } from './runtimeState';
41+
import { DecodedValue, ChildVar } from '../dwarf/ValueDecoder';
42+
import { ScopeVar } from '../dwarf/ScopeIndex';
3843

3944
const THREAD_ID = 1;
4045
const FRAME_ID = 1;
@@ -43,6 +48,7 @@ const FRAME_ID = 1;
4348
enum ScopeRef {
4449
Locals = 1,
4550
Stack = 2,
51+
SourceVars = 3,
4652
}
4753

4854
export class SorobanDebugSession extends DebugSession {
@@ -91,6 +97,16 @@ export class SorobanDebugSession extends DebugSession {
9197
private instructionBreakpointAddrs: number[] = [];
9298
/** Container handles for structured variable expansion. */
9399
private readonly variableHandles = new Handles<TypedValue[]>();
100+
/** Source-level variable resolver (Null until a DWARF-bearing wasm loads). */
101+
private variables: VariableResolver = new NullVariableResolver();
102+
/** Folded linear-memory view for decoding memory-backed source variables. */
103+
private memoryImage?: MemoryImage;
104+
/**
105+
* Handles for lazily-expanded source-variable children. High start avoids
106+
* colliding with the fixed ScopeRef range; reset on every stop so refs are
107+
* fresh per cursor position.
108+
*/
109+
private readonly sourceVarChildren = new Handles<() => ChildVar[]>(1000);
94110

95111
constructor(backend: SessionBackend) {
96112
super();
@@ -137,6 +153,8 @@ export class SorobanDebugSession extends DebugSession {
137153
const resolved: ResolvedTrace = await this.backend.resolve(args, (msg) => this.log(msg));
138154
this.model = resolved.model;
139155
this.source = resolved.source;
156+
this.variables = resolved.variables;
157+
this.memoryImage = new MemoryImage(this.model.records);
140158
this.disassembly = resolved.disassembly;
141159
this.positions = resolved.positions;
142160
this.validatedPosToIndices = new Map();
@@ -351,6 +369,24 @@ export class SorobanDebugSession extends DebugSession {
351369
return undefined;
352370
}
353371

372+
/**
373+
* The current record's validated code offset, or — when it has none — the
374+
* NEAREST earlier record that does, so in-scope variable lookup stays
375+
* anchored on the last real instruction. Null when no record qualifies.
376+
*/
377+
private currentPc(): number | null {
378+
if (!this.model) {
379+
return null;
380+
}
381+
for (let i = this.model.cursor; i >= 0; i--) {
382+
const pos = this.positions[i];
383+
if (pos !== null && pos !== undefined) {
384+
return pos;
385+
}
386+
}
387+
return null;
388+
}
389+
354390
protected disassembleRequest(
355391
response: DebugProtocol.DisassembleResponse,
356392
args: DebugProtocol.DisassembleArguments,
@@ -403,12 +439,18 @@ export class SorobanDebugSession extends DebugSession {
403439
response: DebugProtocol.ScopesResponse,
404440
_args: DebugProtocol.ScopesArguments,
405441
): void {
406-
response.body = {
407-
scopes: [
408-
new Scope('Locals', ScopeRef.Locals, false),
409-
new Scope('Value Stack', ScopeRef.Stack, false),
410-
],
411-
};
442+
// Fresh child-expansion refs per stop: last cursor's handles are stale.
443+
this.sourceVarChildren.reset();
444+
const scopes: Scope[] = [
445+
new Scope('Locals', ScopeRef.Locals, false),
446+
new Scope('Value Stack', ScopeRef.Stack, false),
447+
];
448+
// The source-level Variables scope is offered only when the resolver has
449+
// DWARF functions; without it the list is exactly [Locals, Value Stack].
450+
if (this.variables.hasVariables()) {
451+
scopes.unshift(new Scope('Variables', ScopeRef.SourceVars, false));
452+
}
453+
response.body = { scopes };
412454
this.sendResponse(response);
413455
}
414456

@@ -419,6 +461,39 @@ export class SorobanDebugSession extends DebugSession {
419461
const variables: DebugProtocol.Variable[] = [];
420462
const rec = this.model?.current;
421463

464+
if (args.variablesReference === ScopeRef.SourceVars) {
465+
// Source-level variables: resolve the in-scope DWARF variables at the
466+
// current PC and decode each against the folded runtime state.
467+
if (this.model && this.memoryImage) {
468+
const pc = this.currentPc();
469+
if (pc !== null) {
470+
const state = makeRuntimeState(this.model.current, this.memoryImage, this.model.cursor);
471+
for (const v of this.variables.variablesInScope(pc) as ScopeVar[]) {
472+
const decoded = this.variables.decodeVariable(v, state, pc);
473+
variables.push(this.toDapVariable(v.name ?? '<anon>', decoded));
474+
}
475+
}
476+
}
477+
response.body = { variables };
478+
this.sendResponse(response);
479+
return;
480+
}
481+
482+
const childThunk = this.sourceVarChildren.get(args.variablesReference);
483+
if (childThunk) {
484+
// A lazily-expanded source-variable container handed out via toDapVariable.
485+
try {
486+
for (const child of childThunk()) {
487+
variables.push(this.toDapVariable(child.name, child.value));
488+
}
489+
} catch {
490+
variables.push({ name: '<error>', value: '<unreadable>', variablesReference: 0 });
491+
}
492+
response.body = { variables };
493+
this.sendResponse(response);
494+
return;
495+
}
496+
422497
if (rec) {
423498
if (args.variablesReference === ScopeRef.Locals) {
424499
for (const [index, tv] of Object.entries(rec.locals)) {
@@ -666,6 +741,16 @@ export class SorobanDebugSession extends DebugSession {
666741
};
667742
}
668743

744+
/**
745+
* Render a decoded source-level value as a DAP variable. Expandable values
746+
* register their lazy children behind a fresh `sourceVarChildren` handle;
747+
* leaves report a zero reference (not expandable).
748+
*/
749+
private toDapVariable(name: string, decoded: DecodedValue): DebugProtocol.Variable {
750+
const variablesReference = decoded.children ? this.sourceVarChildren.create(decoded.children) : 0;
751+
return { name, value: decoded.display, type: decoded.typeName, variablesReference };
752+
}
753+
669754
private log(message: string): void {
670755
this.sendEvent(new OutputEvent(`${message}\n`, 'console'));
671756
}

src/debugAdapter/artifacts.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { normalizeMnemonic } from '../komet/mnemonics';
2626
import { SourceMapper } from '../sourcemap/SourceMapper';
2727
import { DwarfSourceMapper } from '../sourcemap/DwarfSourceMapper';
2828
import { NullSourceMapper } from '../sourcemap/NullSourceMapper';
29+
import { VariableResolver, NullVariableResolver, DwarfVariableResolver } from '../sourcemap/VariableResolver';
30+
import { DwarfDebugInfo } from '../dwarf/DebugInfo';
2931

3032
/**
3133
* Validate each record's `pos` against the static disassembly: the position is
@@ -67,7 +69,7 @@ export function buildDebugArtifacts(
6769
wasm: Uint8Array,
6870
model: TraceModel,
6971
report: ProgressReporter,
70-
): { source: SourceMapper; disassembly: Disassembly; positions: (number | null)[] } {
72+
): { source: SourceMapper; variables: VariableResolver; disassembly: Disassembly; positions: (number | null)[] } {
7173
let disassembly: Disassembly;
7274
try {
7375
disassembly = Disassembly.fromWasm(wasm);
@@ -81,6 +83,7 @@ export function buildDebugArtifacts(
8183
// (there is no independent ground truth to validate against).
8284
return {
8385
source: new NullSourceMapper(),
86+
variables: new NullVariableResolver(),
8487
disassembly: Disassembly.fromTrace(model),
8588
positions: model.records.map((rec) => rec.pos),
8689
};
@@ -96,17 +99,41 @@ export function buildDebugArtifacts(
9699
`Warning: could not parse the wasm's DWARF debug info (${errorMessage(err)}); ` +
97100
'debugging continues at the wasm level.',
98101
);
99-
return { source: new NullSourceMapper(), disassembly, positions };
102+
return { source: new NullSourceMapper(), variables: resolveVariables(wasm, report), disassembly, positions };
100103
}
101104
throw err;
102105
}
103106
if (table === null || table.entries.length === 0) {
104107
report('Note: the contract wasm carries no DWARF line info; debugging continues at the wasm level.');
105-
return { source: new NullSourceMapper(), disassembly, positions };
108+
return { source: new NullSourceMapper(), variables: resolveVariables(wasm, report), disassembly, positions };
106109
}
107110

108111
const source = new DwarfSourceMapper(model, table, positions);
109-
return { source, disassembly, positions };
112+
return { source, variables: resolveVariables(wasm, report), disassembly, positions };
113+
}
114+
115+
/**
116+
* Resolve the source-level variable resolver from the wasm bytes, in its own
117+
* INDEPENDENT try/catch so a variable-resolution failure never disables the
118+
* line table (callers have already committed their SourceMapper by this point).
119+
* Degrades to a NullVariableResolver — the wasm-level variables view.
120+
*/
121+
function resolveVariables(wasm: Uint8Array, report: ProgressReporter): VariableResolver {
122+
try {
123+
const dwarf = DwarfDebugInfo.fromWasm(wasm);
124+
if (dwarf && dwarf.scopes.hasFunctions()) {
125+
return new DwarfVariableResolver(dwarf);
126+
}
127+
} catch (err) {
128+
if (err instanceof DwarfParseError || err instanceof WasmFormatError) {
129+
report(
130+
`Warning: could not parse DWARF variable info (${errorMessage(err)}); variables view stays wasm-level.`,
131+
);
132+
} else {
133+
throw err;
134+
}
135+
}
136+
return new NullVariableResolver();
110137
}
111138

112139
function errorMessage(err: unknown): string {

src/debugAdapter/backends/RawTraceBackend.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { parseTraceJsonl } from '../../komet/trace';
1414
import { TraceModel } from '../TraceModel';
1515
import { Disassembly } from '../../wasm/Disassembly';
1616
import { NullSourceMapper } from '../../sourcemap/NullSourceMapper';
17+
import { NullVariableResolver } from '../../sourcemap/VariableResolver';
1718
import { buildDebugArtifacts } from '../artifacts';
1819
import { ProgressReporter, ResolvedTrace, SessionBackend, SorobanLaunchArgs } from '../types';
1920

@@ -30,15 +31,16 @@ export class RawTraceBackend implements SessionBackend {
3031
if (args.wasmPath) {
3132
report(`Reading contract wasm from ${args.wasmPath}`);
3233
const wasm = await fs.readFile(args.wasmPath);
33-
const { source, disassembly, positions } = buildDebugArtifacts(wasm, model, report);
34-
return { model, source, disassembly, positions };
34+
const { source, variables, disassembly, positions } = buildDebugArtifacts(wasm, model, report);
35+
return { model, source, variables, disassembly, positions };
3536
}
3637
// Without wasm there is nothing to validate positions against; the raw
3738
// `pos` values are used as-is, which is self-consistent because the
3839
// trace-derived disassembly is built from those same values.
3940
return {
4041
model,
4142
source: new NullSourceMapper(),
43+
variables: new NullVariableResolver(),
4244
disassembly: Disassembly.fromTrace(model),
4345
positions: records.map((rec) => rec.pos),
4446
};

src/debugAdapter/runtimeState.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Bridges a single `TraceRecord` (plus the snapshot-backed `MemoryImage`) at a
3+
* replay cursor to the `RuntimeState` the DWARF location evaluator consumes. The
4+
* trace stores locals/globals/stack as `[wasmType, value]` pairs; the evaluator
5+
* wants bare numeric slot values, so `typedValueToNumber` normalizes them.
6+
*
7+
* Pure module (no `vscode` / DAP imports).
8+
*/
9+
10+
import { TraceRecord, TypedValue } from '../komet/trace';
11+
import { RuntimeState } from '../dwarf/locexpr';
12+
import { MemoryImage } from './MemoryImage';
13+
14+
/**
15+
* Coerce a trace `[wasmType, value]` to a numeric slot value. i32/f32/f64
16+
* arrive as JS numbers; i64 (and other large values) may arrive as decimal
17+
* strings, which become `bigint`; booleans map to 0/1. Anything else — a
18+
* missing value or an unexpected shape — is `undefined`.
19+
*/
20+
export function typedValueToNumber(tv: TypedValue | undefined): number | bigint | undefined {
21+
if (tv === undefined) {
22+
return undefined;
23+
}
24+
const value = tv[1];
25+
switch (typeof value) {
26+
case 'number':
27+
return value;
28+
case 'string':
29+
try {
30+
return BigInt(value);
31+
} catch {
32+
return undefined;
33+
}
34+
case 'boolean':
35+
return value ? 1 : 0;
36+
default:
37+
return undefined;
38+
}
39+
}
40+
41+
/**
42+
* Build a `RuntimeState` view over `record` at `cursor`. Register slots read
43+
* from the record's typed-value maps/array; memory reads delegate to `memory`
44+
* at `cursor` (the latest snapshot at or before it).
45+
*/
46+
export function makeRuntimeState(
47+
record: TraceRecord,
48+
memory: MemoryImage,
49+
cursor: number,
50+
): RuntimeState {
51+
return {
52+
localValue: (index: number) => typedValueToNumber(record.locals[String(index)]),
53+
globalValue: (index: number) => typedValueToNumber(record.globals?.[String(index)]),
54+
stackValue: (index: number) => typedValueToNumber(record.stack[index]),
55+
readMemory: (address: number, size: number) => memory.readMemory(cursor, address, size),
56+
};
57+
}

0 commit comments

Comments
 (0)