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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .c8rc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"exclude": [
"src/**/*.d.ts",
"src/debugAdapter/types.ts",
"src/sourcemap/SourceMapper.ts"
"src/sourcemap/SourceMapper.ts",
"src/trace/main.ts",
"src/server/main.ts"
],
"extension": [".ts"],
"reporter": ["text", "html", "lcov"],
Expand Down
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Contributing

> **Audience:** `contributor` · `maintainer`
>
> **TL;DR:** How to hack on the extension — set up a dev environment
> (devcontainer or by hand), the everyday build/lint/test commands, the
> test-first convention and how to regenerate fixtures, and a tour of how the
> trace-replay adapter works internally (architecture map included).

Thanks for your interest in improving the Soroban Debugger! This document covers
how to get a development environment running and the conventions we follow.

Expand Down Expand Up @@ -72,6 +79,28 @@ Because the whole recording is in memory, stepping *backward* is just as cheap
as stepping forward. The adapter runs in-process in the extension host
(`DebugAdapterInlineImplementation`).

```mermaid
flowchart TB
subgraph build["Build (LiveBackend)"]
CRATE["contract crate"] -->|"CARGO_PROFILE_RELEASE_DEBUG=true<br/>STRIP=none, OPT_LEVEL=0"| WASM["wasm + DWARF<br/>(pristine linker output)"]
end
WASM --> KOMET["komet-node<br/>executes the whole transaction"]
KOMET --> TRACE["entire trace<br/>one record per wasm instruction"]

subgraph model["In-memory model — vscode-free"]
TRACE --> VAL["validate positions<br/>vs static disassembly"]
WASM -. "DWARF" .-> MAP["map code offset → Rust file:line"]
VAL --> CUR["cursor machine<br/>(forward == backward cost)"]
MAP --> CUR
end

CUR -->|"each DAP request<br/>just moves the cursor"| DAP["SorobanDebugSession<br/>StoppedEvents / frames / disassembly"]
```

A `rawTrace` replay skips the *Build* and *komet-node* stages entirely — the
JSONL trace is loaded straight into the model (and a paired `wasmPath` still
feeds the DWARF/disassembly seams).

- **The build injects debug info without touching your `Cargo.toml`.** It sets
`CARGO_PROFILE_RELEASE_DEBUG=true` / `CARGO_PROFILE_RELEASE_STRIP=none` for
`stellar contract build`, so the wasm carries DWARF. The **pristine linker
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Stellar Debugger

> **Audience:** `soroban developer` · `contract author` · `new user`
>
> **TL;DR:** What the extension is and how to use it — install it, add a
> `soroban` launch config, press **F5**, and step forward/backward through your
> Rust contract. Covers requirements, the launch-config reference, and where to
> go for use outside VS Code.

**Time-travel debugging for Stellar/Soroban smart contracts, right inside your
editor.** Set a breakpoint in your Rust contract, hit debug, and step **forward
and backward** through exactly what your contract did — line by line.
Expand Down Expand Up @@ -87,6 +94,13 @@ step through — forward or backward.
Two settings let you point at executables that aren't on your `PATH`:
`soroban.stellar.path` and `soroban.kometNode.path`.

### Beyond the editor

The debugger is also available outside VS Code — as a one-shot CLI that prints a
Rust-level execution trace as JSONL (for scripts, CI, and AI agents), and as a
standalone DAP server over TCP (for other editors). See
[**docs/standalone-interfaces.md**](docs/standalone-interfaces.md).

## Contributing

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for how to
Expand Down
191 changes: 191 additions & 0 deletions docs/interfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# Debugger interfaces

> **Audience:** `contributor` · `maintainer` · `integrator` (internals)
>
> **TL;DR:** The internal design behind the three ways to consume the adapter
> (VS Code inline, TCP DAP server, one-shot CLI) and the `vscode`-free shared
> core they all sit on. Documents the `SourceStop`/`TraceVar` JSONL schema and
> the ground-truth fixtures. For *user*-facing usage of the standalone tools,
> see [`standalone-interfaces.md`](./standalone-interfaces.md).

The trace-replay debug adapter (`SorobanDebugSession`) can be consumed three ways.
All three share one headless, `vscode`-free core; none of them touch the DAP
stepping engine (S1–S20, see [`stepping.md`](./stepping.md)).

```mermaid
flowchart TB
BF["backendFor(args)"]
BF -->|"rawTrace set"| RTB["RawTraceBackend<br/>offline JSONL replay"]
BF -->|"otherwise"| LB["LiveBackend<br/>build → komet-node → trace"]
RTB --> RT["ResolvedTrace"]
LB --> RT

subgraph core["shared headless core — vscode-free"]
BSM["buildStopModel → StopModel<br/>single source of truth for stop points"]
PCA["pcAtIndex"]
PSS["projectSourceStop<br/>serializable, eager var expansion"]
end

RT --> BSM
PCA --> PSS

BSM --> SDS["SorobanDebugSession<br/>DAP handlers + stepping engine"]
PCA --> SDS
BSM --> RCT["runCliTrace"]
PSS --> RCT

subgraph ifaces["interfaces"]
VSC["VS Code inline"]
DAP["soroban-dap<br/>standalone TCP DAP server"]
CLI["soroban-trace<br/>one-shot JSONL trace"]
end

SDS --> VSC
SDS --> DAP
RCT --> CLI
```

## Shared headless core

All modules below are pure (no `vscode`, no DAP wire I/O) and unit-testable.

### `backendFor(args): SessionBackend` — `src/debugAdapter/backendFor.ts`

Selects the trace-acquisition backend from launch args: `args.rawTrace` present →
`RawTraceBackend` (offline replay of a JSONL trace, symbol-rich when `wasmPath` is
also given), else `LiveBackend` (the full build→komet-node→trace pipeline). Extracted
verbatim from the inline selector previously in `extension.ts`; reused by the extension,
the TCP server, and the CLI. Reads only `args.rawTrace`, so it needs no `vscode`.

### `buildStopModel(resolved): StopModel` — `src/debugAdapter/stopModel.ts`

The single source of truth for a trace's stop points, so the IDE and the CLI can never
disagree about where a "stop" is. Given a `ResolvedTrace` it derives, exactly as
`SorobanDebugSession.launchRequest` did inline:

```ts
interface StopModel {
/** Validated code offset → trace indices (never raw pos; global-init excluded). */
validatedPosToIndices: Map<number, number[]>;
/** Visible (validated-position) record indices, ascending. */
visibleIndices: number[];
/** Call depth per record (parallel to records), via computeDepths. */
depths: number[];
/** Raw line-run starts, pre-S17/S18 (for breakpoint narrowing). */
rawRunStarts: number[];
/** Statement-granularity stop points, post-S17/S18 (the source stops). */
runStarts: number[];
/** runStarts[0] ?? visibleIndices[0] ?? 0. */
firstStopPoint: number;
/** runStarts[last] ?? visibleIndices[last] ?? max(0, records.length-1). */
lastStopPoint: number;
}
```

Composition (unchanged): `computeDepths(records, positions, disassembly.functionRanges)`
→ `computeRunStarts(positions, depths, i => source.lineKeyForIndex(i))` →
`statementStops(rawRunStarts, depths, i => classifyLineRole(source.sourceTextForIndex(i)))`
(all from `stops.ts`).

### `pcAtIndex(positions, index): number | null` — `src/debugAdapter/stopModel.ts`

The current-PC rule the session uses: the validated code offset at `index`, or the
nearest *earlier* record that has one, else `null`. Keeps variable scope aligned
between IDE and CLI. Extracted from `SorobanDebugSession.currentPc`.

### `projectSourceStop(resolved, stopModel, index, opts): SourceStop` — `src/trace/projectStop.ts`

A **serializable** projection of one stop — NOT shared with the DAP handlers, whose
lazy `Handles`/child-thunk machinery is deliberately different. Reuses only the
low-level resolver calls:

- `source.locationForIndex(index)` → `{path, line, column?}` (or unmapped)
- `pcAtIndex(resolved.positions, index)` → the PC
- `variables.functionNameAt(pc)` → function name (**may be `null`** even with DWARF)
- `makeRuntimeState(record, memoryImage, index)` + `variables.variablesInScope(pc)` +
`variables.decodeVariable(v, state, pc)` → decoded variables

Children (`DecodedValue.children`) are expanded **eagerly** into plain arrays, bounded
by a per-stop budget: `maxDepth` (default 3), `maxChildren` (default 64), and a global
per-stop node cap (~1500) that appends a `{name:"…", truncated:true}` marker when hit.
Pointer-cycle safety is already handled inside `ValueDecoder`; the budget only bounds
breadth×depth blow-up. A variable with no DWARF name renders as `<anon>` (matching the
DAP handler). `column` may be absent.

```ts
interface SourceStop {
step: number; // 0-based ordinal among source stops
traceIndex: number; // index into model.records
depth: number; // stopModel.depths[traceIndex]
pc: string | null; // hex, e.g. "0x2d", or null
function: string | null; // functionNameAt(pc) or null
instr: string; // renderInstr(record.instr)
source: { path: string; line: number; column?: number } | null;
variables: TraceVar[];
}
interface TraceVar {
name: string; // "<anon>" when DWARF gives none
type?: string;
value: string;
children?: TraceVar[]; // present only when expandable and within budget
truncated?: boolean; // marker node when the budget was hit
}
```

## Interface 1 — one-shot CLI (`soroban-trace`) — `src/trace/main.ts` + `runTrace.ts`

`runCliTrace(resolved, opts): string[]` (pure, in `src/trace/runTrace.ts`) walks
`stopModel.runStarts` in order — provably the same sequence a user sees stepping in
(statement-granularity `stepIn` visits `runStarts[0..n]` then terminates per S20) — and
emits kind-tagged JSONL:

```jsonl
{"kind":"meta","function":"add","wasm":"…","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":"result","returnValue":"…","terminated":true}
```

If `stopModel.runStarts` is empty (no DWARF / no source), the CLI errors rather than
silently emitting `visibleIndices` as if they were source statements (an explicit
`--allow-no-source` opt-in may relax this to instruction-level stops later).

`src/trace/main.ts` is a thin, coverage-excluded entry: parse argv, build launch args,
`backendFor(args).resolve(...)`, `runCliTrace`, write to stdout or `--out`, then
`backend.dispose()`. Flags: `--raw-trace <jsonl>` `--wasm <wasm>` (offline, the default
path); or live `--contract <dir> --function <name> [--args-json '[…]']`; plus `--out`,
`--depth`, `--max-children`.

## Interface 2 — standalone TCP DAP server (`soroban-dap`) — `src/server/dapServer.ts`

`startDapServer({host?, port}): Promise<{port, close}>` opens a `net.createServer`; for
each connection it creates `new SorobanDebugSession(backendFor)` (the selector overload)
and pipes `session.start(socket, socket)`. On socket close it calls `session.shutdown()`
so `disconnectRequest → backend.dispose()` runs and a `LiveBackend` komet-node process is
not leaked. This is DAP's canonical "server mode": any DAP client connects to the port
(VS Code `"debugServer": <port>`, nvim-dap, IntelliJ, Emacs dap-mode). `src/server/main.ts`
is the thin, coverage-excluded entry that parses `--host`/`--port` and logs the address.

Plain request/response HTTP is intentionally not offered: DAP is a bidirectional,
event-driven protocol (async `stopped`/`output`/`terminated` events) that does not fit
HTTP's request/response shape. WebSocket could be added later for browser-fronted clients.

## Session constructor change

`SorobanDebugSession` now accepts **either** a concrete `SessionBackend` (unchanged;
used by the extension and the stdio test harness) **or** a selector
`(args: SorobanLaunchArgs) => SessionBackend`, resolved on the first line of
`launchRequest` (the backend is unused before then). The TCP server passes the selector
so the backend can depend on the per-connection launch config.

## Ground-truth fixtures (verified)

Used by the golden tests; `runStarts` is the CLI stop sequence.

| fixture | records | runStarts (source stops) | notes |
|---|---|---|---|
| `adder-debug` | 41 | `[29]` | 1 stop; entry line 16 col 9, fn `invoke_raw_extern`, `arg_0:Val=17179869188`, `arg_1:Val=12884901892` |
| `stepper-debug` | 85 | `[21,27,29,39,44,46,56,61,63,73]` | 10 stops, 2 functions; idx 29 → fn `triple`, line 15, **no column**, `x:u32=0` |
| `increment-debug` | 2717 | `[646,999,1454,1904,1956,2495]` | 6 stops; idx 999 → line 21, `current:u32=15`, `env:Env` (expandable); some vars **unnamed** and `functionNameAt`→`null` |

Fixtures live in `test/fixtures/<name>.trace.jsonl` + `<name>.wasm`. The adder's DWARF
resolves to `examples/adder/src/lib.rs`.
Loading