Audience:
contributor·maintainerTL;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 Stellar Debugger! This document covers how to get a development environment running and the conventions we follow.
The quickest path is the included devcontainer, which installs the full
toolchain — Rust with the wasm targets, the Stellar CLI, and komet-node (via
kup install komet-node, which also pulls the K toolchain and prebuilt
semantics). Open the repo in VSCode and choose Reopen in Container, or use the
GitHub Codespaces button. See .devcontainer/Dockerfile.
To set things up by hand instead, you need:
- Node.js ≥ 22
- For the live pipeline only: a Rust toolchain with the
wasm32v1-none(orwasm32-unknown-unknown) target, the Stellar CLI, andkomet-node.
Then:
npm installnpm run build # bundle to dist/extension.js (esbuild)
npm run watch # rebuild on change
npm run check-types # tsc --noEmit
npm run lint # eslint over src and test
npm test # recompile src+test to out/, then mocha (~4 min)
npm run package # build a .vsix (vsce) — see the release section belownpm test clears out/ before compiling: tsc leaves the output of a deleted
or renamed source behind, and a stale out/test/*.test.js keeps being collected
by mocha long after its source is gone.
Press F5 (Run Extension) to open an Extension Development Host with the
extension loaded and the examples/ workspace open. Pick a
configuration from the Run and Debug view — the Replay … with symbols
configs need no toolchain at all.
Releases are driven by a tag, and .github/workflows/release.yml does the rest:
- Move the CHANGELOG's
[Unreleased]entries under a new version heading with today's date, and update the link definitions at the bottom. - Bump
versioninpackage.json(the workflow refuses to publish a tag that disagrees with it). - Merge that, then
git tag v<version> && git push origin v<version>on a commit that is already green on CI — CI, not the release workflow, is what runs the realkomet-nodeend-to-end suite.
The workflow then packages the .vsix, publishes it to the VS Code Marketplace and to Open VSX (which is where Cursor, Windsurf and VSCodium install from), and attaches it to a GitHub release. It needs two repository secrets, VSCE_PAT and OVSX_PAT.
.vscodeignore is written as an allowlist — ignore everything, then add back dist/, the icon, and the four metadata files. Keep it that way: examples/*/target and test/fixtures/*/target are gitignored but still sit on disk, and vsce packages from the disk, so a denylist eventually ships gigabytes of Rust build output. Check with npx vsce ls after touching it.
The pinned ESLint 8 is end-of-life. It is a dev-only dependency that never reaches the .vsix, so the migration to ESLint 9's flat config is deliberately a post-release chore rather than release-blocking work.
- Write tests first. New behavior should arrive with a failing test that
describes it, then the implementation that makes it pass. The suite is the
contract; keep it green (
npm test) before opening a PR. - Replay logic is deliberately free of the
vscodeAPI so it can be unit-tested in plain Node. Keepvscode-only code inextension.ts. - Tests run automatically in CI on every push and pull request (Node 22): type-check, lint, build, and test.
The DWARF/trace fixtures under test/fixtures/ are real build + trace outputs
and must stay matched: a trace's pos values are byte offsets into that exact
wasm, so a wasm and its trace only mean anything as a pair. One script
rebuilds every pair and re-checks each one:
scripts/make-fixtures.sh # build the debug wasms, capture matching traces, verifyIt needs the full toolchain (Rust + Stellar CLI + komet-node). Under the hood:
scripts/capture-trace.mjs --wasm … --function … --args-json '{"a":1}'runs one contract call through the real pipeline and writes the node's records verbatim, so a fixture keeps itskindtags, VM event payloads, memory and globals.scripts/verify-addresses.mjs --wasm … --trace …re-derives the address convention the whole debugger rests on (komet'spos, the DWARF line addresses and the disassembly all being code-section-payload-relative, with no delta). Worth running on its own after a komet-node or rustc upgrade.
The debug adapter is a trace-replay cursor machine. komet-node
executes a whole transaction and returns the entire execution trace — one
record per WebAssembly instruction — and the adapter loads that into an
in-memory model and services every DAP stepping request by moving a cursor.
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).
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 setsCARGO_PROFILE_RELEASE_DEBUG=true/CARGO_PROFILE_RELEASE_STRIP=noneforstellar contract build, so the wasm carries DWARF. The pristine linker output (target/…/release/deps/*.wasm) is what gets uploaded, because the Stellar CLI's metadata-injection step rewrites the wasm and strips the DWARF line programs. - DWARF → Rust. An in-repo DWARF v4/v5 line-table parser (
src/dwarf/) maps wasm code offsets to Rustfile:line. Breakpoints set in Rust source verify against the executed trace (sliding forward to the nearest executed line). - No-DWARF fallback. A prebuilt wasm without debug info — or a
rawTracereplay withoutwasmPath— degrades gracefully to disassembly-only debugging: frames carry an instruction pointer but no source. - Positions are validated. komet-node's
posis relative to the section being executed (e.g. the code section for function code, the globals section for global initializers), so every record is cross-checked against the static disassembly and only trusted when the mnemonics agree. komet-node's tracer stops at instructions it cannot decode (printing them asunknown, e.g.if), so a trace can be a prefix of the full execution.
extension.ts VSCode glue: config provider + inline adapter factory
debugAdapter/
SorobanDebugSession the DAP conversation and nothing else: each request is
one call into the modules below plus an event
stopModel.ts a trace's stop points: validated positions, visible
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 (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)
LedgerImage Stellar ledger at a cursor: storage/TTLs, balances,
ledger info, host objects, call stack, and the executing
contract; undoes the writes of a trapped sub-call
(docs/state-inspection.md)
artifacts.ts wasm bytes -> { mapper, disassembly, validated positions }
ledgerView.ts the ledger presentation both front ends share: one
snapshot per stop, as a lazy ChildVar tree
wasmView.ts the Locals / Value Stack / Globals scopes, same shape
disassemblyView.ts the Disassembly View rows for one window of addresses
backends/
RawTraceBackend replay a JSONL trace file (+ optional wasmPath for symbols)
LiveBackend turnkey build + spawn + deploy + trace (config + runner
from pipeline/)
komet/
trace.ts JSONL -> TraceRecord[] (K-style mnemonics, section-relative pos)
traceEvents.ts Soroban VM event payloads -> TraceEvent (a malformed or
unknown payload degrades to no event, never fails a session)
mnemonics.ts K-style instr arrays -> wasm mnemonics ('i64.const 255')
KometClient.ts JSON-RPC client (getHealth/sendTransaction/traceTransaction/...)
KometProcess.ts spawns/stops the node as a process group
pipeline/
config.ts launch config -> canonical { steps, trace } (validated)
SequenceRunner.ts runs that sequence against one accumulating ledger, then
resolves the traced tx into a ResolvedTrace
cli/ the argv tokenizer + exit-code shell both CLIs share
soroban/specEncode.ts invoke args -> ScVals, encoded against the contract's own
contractspecv0 spec; `${...}` substitution
soroban/scvalJson.ts trace ScVal JSON -> DecodedValue (display + lazy children)
soroban/strkey.ts raw address bytes -> C…/G… strkey (SDK-free: the SDK costs
~8s of module load inside the adapter, which alone blows
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; for the frame model behind the Callstack view, see docs/callstack.md.
- Branch off
mainand keep PRs focused on a single change. - Make sure
npm run check-types,npm run lint, andnpm testall pass. - Write clear commit messages that explain the why, not just the what.
- Update the CHANGELOG under Unreleased for user-facing changes.
Because the debugger replays a captured trace, a JSONL trace file is often the
most useful thing to attach to a bug report — it reproduces a session with no
toolchain or node required (rawTrace in a launch config). See the issue
templates when you open an issue.