From d8f95c9769faef337a17ea1abfed9aaa04b454fd Mon Sep 17 00:00:00 2001 From: Raoul Date: Fri, 17 Jul 2026 09:16:21 +0000 Subject: [PATCH 1/5] Expose debug adapter as a standalone DAP server and a CLI trace tool Add two ways to use the trace-replay debug adapter outside VS Code, both thin wrappers over one shared vscode-free headless core (the DAP stepping engine is untouched): - soroban-dap: the debug adapter served over a TCP socket (net.createServer, one session per connection via a backend-selector constructor overload) so other DAP clients (nvim-dap, IntelliJ, Emacs) can drive it. Binds loopback by default; per-connection teardown disposes the backend on abrupt close. - soroban-trace: a one-shot CLI that emits a Rust-source-level execution trace as kind-tagged JSONL (meta/stop/result) for scripts, CI, and agents. Shared core in src/debugAdapter: backendFor (backend selector), buildStopModel (single source of truth for stop points, extracted from launchRequest), and pcAtIndex. CLI projection lives in src/trace; server in src/server. Both bins are bundled by esbuild; thin main.ts entry points are c8-excluded. Docs: docs/interfaces.md (design + JSONL schema) and docs/standalone-interfaces.md (usage), linked from the README. Full test coverage added (stopModel, backendFor, projectStop, runTrace, dapServer integration). --- .c8rc.json | 4 +- README.md | 7 + docs/interfaces.md | 168 ++++++++++++++++++++++ docs/standalone-interfaces.md | 131 +++++++++++++++++ esbuild.js | 41 ++++-- package.json | 4 + src/debugAdapter/SorobanDebugSession.ts | 93 +++++++----- src/debugAdapter/backendFor.ts | 20 +++ src/debugAdapter/stopModel.ts | 98 +++++++++++++ src/extension.ts | 13 +- src/server/dapServer.ts | 62 ++++++++ src/server/main.ts | 32 +++++ src/trace/main.ts | 75 ++++++++++ src/trace/projectStop.ts | 172 ++++++++++++++++++++++ src/trace/runTrace.ts | 76 ++++++++++ test/backendFor.test.ts | 42 ++++++ test/dapServer.test.ts | 152 ++++++++++++++++++++ test/projectStop.test.ts | 183 ++++++++++++++++++++++++ test/runTrace.test.ts | 133 +++++++++++++++++ test/stopModel.test.ts | 111 ++++++++++++++ 20 files changed, 1559 insertions(+), 58 deletions(-) create mode 100644 docs/interfaces.md create mode 100644 docs/standalone-interfaces.md create mode 100644 src/debugAdapter/backendFor.ts create mode 100644 src/debugAdapter/stopModel.ts create mode 100644 src/server/dapServer.ts create mode 100644 src/server/main.ts create mode 100644 src/trace/main.ts create mode 100644 src/trace/projectStop.ts create mode 100644 src/trace/runTrace.ts create mode 100644 test/backendFor.test.ts create mode 100644 test/dapServer.test.ts create mode 100644 test/projectStop.test.ts create mode 100644 test/runTrace.test.ts create mode 100644 test/stopModel.test.ts diff --git a/.c8rc.json b/.c8rc.json index bf99c36..a665999 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -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"], diff --git a/README.md b/README.md index d210e7f..99e2f52 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,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 diff --git a/docs/interfaces.md b/docs/interfaces.md new file mode 100644 index 0000000..afec291 --- /dev/null +++ b/docs/interfaces.md @@ -0,0 +1,168 @@ +# Debugger interfaces + +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)). + +``` + ┌─────────────────────────────┐ + │ SessionBackend.resolve() │ RawTraceBackend | LiveBackend + │ → ResolvedTrace │ + └──────────────┬──────────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + buildStopModel SorobanDebugSession buildStopModel + │ (DAP handlers) │ + projectSourceStop │ projectSourceStop + │ │ │ + ┌───────┴───────┐ ┌──────┴───────┐ ┌───────┴────────┐ + │ CLI (JSONL) │ │ VS Code inline│ │ TCP DAP server │ + │ soroban-trace │ │ + TCP server │ │ soroban-dap │ + └───────────────┘ └──────────────┘ └────────────────┘ +``` + +## 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; + /** 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 `` (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; // "" 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 ` `--wasm ` (offline, the default +path); or live `--contract --function [--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": `, 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/.trace.jsonl` + `.wasm`. The adder's DWARF +resolves to `examples/adder/src/lib.rs`. diff --git a/docs/standalone-interfaces.md b/docs/standalone-interfaces.md new file mode 100644 index 0000000..4e6fb1c --- /dev/null +++ b/docs/standalone-interfaces.md @@ -0,0 +1,131 @@ +# Standalone interfaces: CLI trace & DAP server + +Besides the VS Code extension, the debugger ships two standalone command-line +tools for using it **outside the editor**: + +- **`soroban-trace`** — a one-shot CLI that prints a Rust-source-level execution + trace as JSONL. Built for scripts, CI, and AI agents that want to *read* an + execution rather than step through it interactively. +- **`soroban-dap`** — the debug adapter served over a TCP socket, so debuggers + *other* than VS Code (nvim-dap, IntelliJ, Emacs `dap-mode`, …) can drive it. + +Both are thin front-ends over the same replay engine the extension uses; see +[`interfaces.md`](./interfaces.md) for the internal design and the full JSONL +schema. + +## Building the tools + +```sh +npm install +npm run build +``` + +This produces `dist/trace.js` and `dist/dap-server.js`. Run them directly with +`node dist/trace.js …` / `node dist/dap-server.js …`, or expose them as the +`soroban-trace` / `soroban-dap` commands by installing the package +(`npm install -g .`, or `npm link` for local development). + +The **offline** examples below need no toolchain. Live mode (building and running +a contract) needs the same tools as the editor — the [Stellar +CLI](https://developers.stellar.org/docs/tools/cli) and +[komet-node](https://github.com/runtimeverification/komet-node) on your `PATH` +(see the [main README](../README.md#requirements)). + +--- + +## `soroban-trace` — Rust-level execution trace as JSONL + +Emits one JSON object per line: a leading `meta` record, one `stop` per +source-level statement (in execution order), and a trailing `result`. + +### Quick start (offline replay of a recorded run) + +```sh +node dist/trace.js \ + --raw-trace test/fixtures/adder-debug.trace.jsonl \ + --wasm test/fixtures/adder-debug.wasm +``` + +```jsonl +{"kind":"meta","wasm":"test/fixtures/adder-debug.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","terminated":true} +``` + +Each `stop` carries the source location, the enclosing function, the call +`depth`, the wasm `pc`/`instr`, and the in-scope variables decoded from DWARF +(aggregates expand into a nested `children` array, bounded by `--depth` / +`--max-children`). The full `SourceStop` / `TraceVar` field reference is in +[`interfaces.md`](./interfaces.md). + +### Live mode (build → deploy → run → trace) + +```sh +node dist/trace.js --contract ./path/to/crate --function add \ + --args-json '[{"value":1,"type":"u32"},{"value":2,"type":"u32"}]' \ + --out trace.jsonl +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--raw-trace ` | Replay a recorded JSONL trace (offline). Pair with `--wasm` for source/variables. | +| `--wasm ` | Contract `.wasm` supplying the DWARF debug info (source lines + variables). | +| `--contract ` | Live mode: crate directory to build, deploy, and run. | +| `--function ` | Live mode: contract function to invoke. | +| `--args-json ` | Function arguments, e.g. `'[{"value":1,"type":"u32"}]'`. | +| `--out ` | Write JSONL to a file instead of stdout. | +| `--depth ` | Max variable-expansion depth (default 3). | +| `--max-children ` | Max children materialized per aggregate (default 64). | +| `--allow-no-source` | Don't error when the trace has no DWARF source stops. | + +If the trace has no Rust source-level stops (no matching `--wasm`, or a +non-debug build) the command exits non-zero with a message, rather than emitting +a misleading trace — pass `--allow-no-source` to override. + +--- + +## `soroban-dap` — standalone DAP server over TCP + +Runs the debug adapter as a TCP server (DAP's canonical "server mode"). Each +client connection gets its own independent session; the launch configuration is +sent by the client over the wire, exactly as in the editor. + +```sh +node dist/dap-server.js --port 4711 +# → Soroban DAP server listening on 127.0.0.1:4711 +``` + +| Flag | Description | +|------|-------------| +| `--port ` | TCP port to listen on (default `4711`). | +| `--host ` | Interface to bind (default `127.0.0.1`, loopback only). | + +The server binds **loopback** by default — it does not expose the debugger to +the network. Only override `--host` on a trusted, isolated network. + +### Connecting a client + +Any DAP client that can attach to a running server works. From VS Code, point a +launch configuration at the port with `debugServer`: + +```jsonc +{ + "type": "soroban", + "request": "launch", + "name": "Attach to soroban-dap", + "debugServer": 4711, + "rawTrace": "test/fixtures/adder-debug.trace.jsonl", + "wasmPath": "test/fixtures/adder-debug.wasm" +} +``` + +Other editors use their own attach mechanism (e.g. nvim-dap's `server`/`port` +adapter config) — the wire protocol is standard DAP, so any conformant client +can drive it. The full set of launch attributes is the same as the editor's +[configuration reference](../README.md#configuration-reference). + +> Plain request/response HTTP is intentionally not offered: DAP is a +> bidirectional, event-driven protocol that doesn't fit HTTP's request/response +> shape. A WebSocket transport could be added if a browser-based client needs one. diff --git a/esbuild.js b/esbuild.js index ced109c..3e4c09a 100644 --- a/esbuild.js +++ b/esbuild.js @@ -3,28 +3,51 @@ const esbuild = require('esbuild'); const watch = process.argv.includes('--watch'); const minify = process.argv.includes('--minify'); -/** @type {import('esbuild').BuildOptions} */ -const options = { - entryPoints: ['src/extension.ts'], +/** Settings shared by every bundle. */ +const common = { bundle: true, - outfile: 'dist/extension.js', platform: 'node', target: 'node22', format: 'cjs', - // `vscode` is provided by the extension host at runtime and must not be bundled. - external: ['vscode'], sourcemap: !minify, minify, logLevel: 'info', }; +/** @type {import('esbuild').BuildOptions} */ +const extension = { + ...common, + entryPoints: ['src/extension.ts'], + outfile: 'dist/extension.js', + // `vscode` is provided by the extension host at runtime and must not be bundled. + external: ['vscode'], +}; + +/** @type {import('esbuild').BuildOptions} */ +const dapServer = { + ...common, + entryPoints: ['src/server/main.ts'], + outfile: 'dist/dap-server.js', + banner: { js: '#!/usr/bin/env node' }, +}; + +/** @type {import('esbuild').BuildOptions} */ +const trace = { + ...common, + entryPoints: ['src/trace/main.ts'], + outfile: 'dist/trace.js', + banner: { js: '#!/usr/bin/env node' }, +}; + +const allOptions = [extension, dapServer, trace]; + async function main() { if (watch) { - const ctx = await esbuild.context(options); - await ctx.watch(); + const contexts = await Promise.all(allOptions.map((o) => esbuild.context(o))); + await Promise.all(contexts.map((c) => c.watch())); console.log('esbuild: watching...'); } else { - await esbuild.build(options); + await Promise.all(allOptions.map((o) => esbuild.build(o))); } } diff --git a/package.json b/package.json index 7f0efb3..f340477 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "Debuggers" ], "main": "./dist/extension.js", + "bin": { + "soroban-dap": "dist/dap-server.js", + "soroban-trace": "dist/trace.js" + }, "activationEvents": [ "onDebug" ], diff --git a/src/debugAdapter/SorobanDebugSession.ts b/src/debugAdapter/SorobanDebugSession.ts index c5a6189..46f81c3 100644 --- a/src/debugAdapter/SorobanDebugSession.ts +++ b/src/debugAdapter/SorobanDebugSession.ts @@ -23,13 +23,8 @@ import { import { DebugProtocol } from '@vscode/debugprotocol'; import * as path from 'path'; import { TraceModel } from './TraceModel'; -import { - classifyLineRole, - computeDepths, - computeRunStarts, - firstNonWhitespaceColumn, - statementStops, -} from './stops'; +import { firstNonWhitespaceColumn } from './stops'; +import { buildStopModel, pcAtIndex } from './stopModel'; import { SourceMapper } from '../sourcemap/SourceMapper'; import { VariableResolver, NullVariableResolver } from '../sourcemap/VariableResolver'; import { Disassembly } from '../wasm/Disassembly'; @@ -52,7 +47,13 @@ enum ScopeRef { } export class SorobanDebugSession extends DebugSession { - private readonly backend: SessionBackend; + /** + * Either a concrete backend or a selector resolved on the first line of + * launchRequest (the TCP server passes the selector so the backend can depend + * on the per-connection launch config). Once launched, this holds the + * concrete backend. + */ + private backend: SessionBackend | ((args: SorobanLaunchArgs) => SessionBackend); private model?: TraceModel; private source?: SourceMapper; private disassembly?: Disassembly; @@ -108,7 +109,15 @@ export class SorobanDebugSession extends DebugSession { */ private readonly sourceVarChildren = new Handles<() => ChildVar[]>(1000); - constructor(backend: SessionBackend) { + /** + * Set once the per-connection backend has been disposed, so teardown is + * idempotent: a clean `disconnect` disposes, and the subsequent socket + * 'close'/'error' from the TCP server must NOT dispose the (already torn + * down) komet-node pipeline a second time. + */ + private disposed = false; + + constructor(backend: SessionBackend | ((args: SorobanLaunchArgs) => SessionBackend)) { super(); this.backend = backend; this.setDebuggerLinesStartAt1(true); @@ -149,6 +158,12 @@ export class SorobanDebugSession extends DebugSession { response: DebugProtocol.LaunchResponse, args: SorobanLaunchArgs, ): Promise { + // Resolve the backend selector (TCP server) to a concrete backend now that + // the per-connection launch config is known; a concrete backend passes + // through unchanged. + if (typeof this.backend === 'function') { + this.backend = this.backend(args); + } try { const resolved: ResolvedTrace = await this.backend.resolve(args, (msg) => this.log(msg)); this.model = resolved.model; @@ -157,25 +172,12 @@ export class SorobanDebugSession extends DebugSession { this.memoryImage = new MemoryImage(this.model.records); this.disassembly = resolved.disassembly; this.positions = resolved.positions; - this.validatedPosToIndices = new Map(); - this.visibleIndices = []; - this.positions.forEach((pos, i) => { - if (pos !== null) { - this.visibleIndices.push(i); - const list = this.validatedPosToIndices.get(pos); - if (list) { - list.push(i); - } else { - this.validatedPosToIndices.set(pos, [i]); - } - } - }); - const source = this.source; - this.depths = computeDepths(this.model.records, this.positions, this.disassembly.functionRanges); - this.rawRunStarts = computeRunStarts(this.positions, this.depths, (i) => source.lineKeyForIndex(i)); - this.runStarts = statementStops(this.rawRunStarts, this.depths, (i) => - classifyLineRole(source.sourceTextForIndex(i)), - ); + const stopModel = buildStopModel(resolved); + this.validatedPosToIndices = stopModel.validatedPosToIndices; + this.visibleIndices = stopModel.visibleIndices; + this.depths = stopModel.depths; + this.rawRunStarts = stopModel.rawRunStarts; + this.runStarts = stopModel.runStarts; if (this.model.isEmpty) { this.sendErrorResponse(response, 2001, 'The trace is empty; nothing to debug.'); @@ -378,13 +380,7 @@ export class SorobanDebugSession extends DebugSession { if (!this.model) { return null; } - for (let i = this.model.cursor; i >= 0; i--) { - const pos = this.positions[i]; - if (pos !== null && pos !== undefined) { - return pos; - } - } - return null; + return pcAtIndex(this.positions, this.model.cursor); } protected disassembleRequest( @@ -583,12 +579,35 @@ export class SorobanDebugSession extends DebugSession { response: DebugProtocol.DisconnectResponse, _args: DebugProtocol.DisconnectArguments, ): Promise { + await this.teardown(); + this.sendResponse(response); + } + + /** + * Dispose the per-connection backend exactly once. This is the single place + * `backend.dispose()` runs, reached both by the client's `disconnect` request + * and by the TCP server's socket 'close'/'error' handlers (an abrupt + * disconnect — editor crash, network drop, SIGKILL — sends no `disconnect` + * over the wire, so without this the LiveBackend's komet-node subprocess would + * leak). Public and idempotent: the `disposed` guard makes a second call after + * a clean disconnect a no-op, so the komet-node pipeline is never + * double-disposed. NOTE: `DebugSession.shutdown()` is a no-op in server mode, + * so it can NOT be relied on for backend teardown — this must be called directly. + */ + async teardown(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; try { - await this.backend.dispose(); + // A launch may never have happened, in which case the backend is still a + // selector function with nothing to dispose. + if (typeof this.backend !== 'function') { + await this.backend.dispose(); + } } catch { // best-effort teardown } - this.sendResponse(response); } protected async terminateRequest( diff --git a/src/debugAdapter/backendFor.ts b/src/debugAdapter/backendFor.ts new file mode 100644 index 0000000..432cee6 --- /dev/null +++ b/src/debugAdapter/backendFor.ts @@ -0,0 +1,20 @@ +/** + * Trace-acquisition backend selector (docs/interfaces.md, "backendFor"). + * + * Picks the vscode-free backend from the launch args: `args.rawTrace` present → + * RawTraceBackend (offline JSONL replay), else LiveBackend (the full + * build → komet-node → trace pipeline). Reads only `args.rawTrace`, so it needs + * no `vscode`; reused by the extension, the TCP server, and the CLI. + */ + +import { SessionBackend, SorobanLaunchArgs } from './types'; +import { RawTraceBackend } from './backends/RawTraceBackend'; +import { LiveBackend } from './backends/LiveBackend'; + +/** Selects a backend per launch configuration. */ +export function backendFor(config: SorobanLaunchArgs): SessionBackend { + if (config.rawTrace) { + return new RawTraceBackend(); + } + return new LiveBackend(); +} diff --git a/src/debugAdapter/stopModel.ts b/src/debugAdapter/stopModel.ts new file mode 100644 index 0000000..038d18a --- /dev/null +++ b/src/debugAdapter/stopModel.ts @@ -0,0 +1,98 @@ +/** + * The shared headless stop model (docs/interfaces.md, "Shared headless core"). + * + * `buildStopModel` is the single source of truth for a trace's stop points, so + * the IDE (SorobanDebugSession) and the CLI can never disagree about where a + * "stop" is. It derives, exactly as SorobanDebugSession.launchRequest did + * inline, the validated-position → indices map, the visible record indices, the + * per-record call depths, the raw line-run starts, the statement-granularity + * run starts (post S17/S18), and the first/last stop points. + * + * `pcAtIndex` is the current-PC rule the session uses: the validated code + * offset at `index`, or the nearest EARLIER record that has one, else null. + * + * Pure module (no `vscode`, no DAP imports). + */ + +import { + classifyLineRole, + computeDepths, + computeRunStarts, + statementStops, +} from './stops'; +import { ResolvedTrace } from './types'; + +export interface StopModel { + /** Validated code offset → trace indices (never raw pos; global-init excluded). */ + validatedPosToIndices: Map; + /** 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; +} + +/** + * Derive the trace's stop model from a resolved trace, reproducing exactly the + * inline computation SorobanDebugSession.launchRequest performed. + */ +export function buildStopModel(resolved: ResolvedTrace): StopModel { + const { model, source, disassembly, positions } = resolved; + + const validatedPosToIndices = new Map(); + const visibleIndices: number[] = []; + positions.forEach((pos, i) => { + if (pos !== null) { + visibleIndices.push(i); + const list = validatedPosToIndices.get(pos); + if (list) { + list.push(i); + } else { + validatedPosToIndices.set(pos, [i]); + } + } + }); + + const depths = computeDepths(model.records, positions, disassembly.functionRanges); + const rawRunStarts = computeRunStarts(positions, depths, (i) => source.lineKeyForIndex(i)); + const runStarts = statementStops(rawRunStarts, depths, (i) => + classifyLineRole(source.sourceTextForIndex(i)), + ); + + const firstStopPoint = runStarts[0] ?? visibleIndices[0] ?? 0; + const lastStopPoint = + runStarts[runStarts.length - 1] ?? + visibleIndices[visibleIndices.length - 1] ?? + Math.max(0, model.records.length - 1); + + return { + validatedPosToIndices, + visibleIndices, + depths, + rawRunStarts, + runStarts, + firstStopPoint, + lastStopPoint, + }; +} + +/** + * The current-PC rule: the validated code offset at `index`, or the nearest + * EARLIER record that has one, else null. + */ +export function pcAtIndex(positions: readonly (number | null)[], index: number): number | null { + for (let i = index; i >= 0; i--) { + const pos = positions[i]; + if (pos !== null && pos !== undefined) { + return pos; + } + } + return null; +} diff --git a/src/extension.ts b/src/extension.ts index 07ed24b..9c4a08e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,9 +11,8 @@ import * as vscode from 'vscode'; import { SorobanDebugSession } from './debugAdapter/SorobanDebugSession'; -import { RawTraceBackend } from './debugAdapter/backends/RawTraceBackend'; -import { LiveBackend } from './debugAdapter/backends/LiveBackend'; -import { SessionBackend, SorobanLaunchArgs } from './debugAdapter/types'; +import { backendFor } from './debugAdapter/backendFor'; +import { SorobanLaunchArgs } from './debugAdapter/types'; export function activate(context: vscode.ExtensionContext): void { const provider = new SorobanConfigurationProvider(); @@ -28,14 +27,6 @@ export function deactivate(): void { // No global resources to release; sessions clean up on disconnect. } -/** Selects a backend per launch configuration. */ -function backendFor(config: SorobanLaunchArgs): SessionBackend { - if (config.rawTrace) { - return new RawTraceBackend(); - } - return new LiveBackend(); -} - class SorobanAdapterFactory implements vscode.DebugAdapterDescriptorFactory { createDebugAdapterDescriptor( session: vscode.DebugSession, diff --git a/src/server/dapServer.ts b/src/server/dapServer.ts new file mode 100644 index 0000000..05d821e --- /dev/null +++ b/src/server/dapServer.ts @@ -0,0 +1,62 @@ +/** + * Standalone TCP DAP server (`soroban-dap`) — docs/interfaces.md, "Interface 2". + * + * Opens a `net.createServer`; for each connection it creates a fresh + * `SorobanDebugSession(backendFor)` (the selector overload, so the backend can + * depend on the per-connection launch config) and pipes `session.start(socket, + * socket)`. On socket close/error it calls `session.teardown()` so + * `backend.dispose()` runs and a `LiveBackend` komet-node process is not leaked + * even on an ABRUPT disconnect that sends no `disconnect` request over the wire + * (editor crash, network drop, SIGKILL). `DebugSession.shutdown()` can NOT be + * used for this: it is a no-op in server mode, so it would never dispose the + * backend — hence the explicit idempotent `teardown()`, which also runs on a + * clean `disconnect` and guards against double-dispose. This is DAP's canonical + * "server mode": any DAP client can connect to the port. No `vscode` import. + */ + +import * as net from 'net'; +import { SorobanDebugSession } from '../debugAdapter/SorobanDebugSession'; +import { backendFor } from '../debugAdapter/backendFor'; +import { SessionBackend, SorobanLaunchArgs } from '../debugAdapter/types'; + +export interface DapServerOptions { + host?: string; + port: number; + /** + * Backend selector; defaults to the real `backendFor`. Injectable so tests + * can observe per-connection teardown with a spy backend. + */ + backendFor?: (args: SorobanLaunchArgs) => SessionBackend; +} + +export async function startDapServer( + opts: DapServerOptions, +): Promise<{ port: number; close: () => Promise }> { + const select = opts.backendFor ?? backendFor; + const server = net.createServer((socket) => { + const session = new SorobanDebugSession(select); + // Server mode: a dropped connection must tear down only this session, never + // the shared server process. Without this, DebugSession.shutdown() calls + // process.exit(0) 100ms after any socket closes (docs/interfaces.md, + // "DAP's canonical server mode"). + session.setRunAsServer(true); + session.start(socket, socket); + // Dispose the per-connection backend on teardown. shutdown() is a no-op in + // server mode, so it can't do this; teardown() is idempotent, so a clean + // `disconnect` (which already disposed) makes this a harmless no-op. + const teardown = () => { + void session.teardown(); + }; + socket.on('close', teardown); + socket.on('error', teardown); + }); + + await new Promise((resolve) => { + server.listen(opts.port, opts.host ?? '127.0.0.1', resolve); + }); + + const port = (server.address() as net.AddressInfo).port; + const close = () => new Promise((resolve) => server.close(() => resolve())); + + return { port, close }; +} diff --git a/src/server/main.ts b/src/server/main.ts new file mode 100644 index 0000000..b88a292 --- /dev/null +++ b/src/server/main.ts @@ -0,0 +1,32 @@ +/** + * Thin CLI entry for the standalone TCP DAP server (`soroban-dap`). + * + * Parses `--host`/`--port` (default 4711) from argv, starts the server, and + * logs the listening address to stderr. Coverage-excluded: the real logic lives + * in `dapServer.ts`, exercised end-to-end by the integration test. + */ + +import { startDapServer } from './dapServer'; + +/** Read the value following a flag, or undefined when absent. */ +function flag(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const host = flag(argv, '--host'); + const portFlag = flag(argv, '--port'); + const port = portFlag !== undefined ? Number(portFlag) : 4711; + + const srv = await startDapServer({ host, port }); + process.stderr.write( + `Soroban DAP server listening on ${host ?? '127.0.0.1'}:${srv.port}\n`, + ); +} + +main().catch((err) => { + process.stderr.write(String(err instanceof Error ? err.stack ?? err.message : err) + '\n'); + process.exit(1); +}); diff --git a/src/trace/main.ts b/src/trace/main.ts new file mode 100644 index 0000000..db50248 --- /dev/null +++ b/src/trace/main.ts @@ -0,0 +1,75 @@ +/** + * Thin CLI entry for the one-shot trace projection (`soroban-trace`). + * + * Parses argv, builds launch args, resolves a trace through the selected + * backend, projects it to JSONL via `runCliTrace`, and writes the result to + * `--out` (or stdout). Coverage-excluded: the real logic lives in + * `runTrace.ts` / `projectStop.ts`, which are exercised directly by unit tests. + */ + +import * as fs from 'fs'; +import { backendFor } from '../debugAdapter/backendFor'; +import { SorobanLaunchArgs } from '../debugAdapter/types'; +import { ScValArg } from '../soroban/scval'; +import { runCliTrace } from './runTrace'; + +/** Read the value following a flag, or undefined when absent. */ +function flag(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +/** Whether a boolean flag is present. */ +function has(argv: string[], name: string): boolean { + return argv.includes(name); +} + +async function main(): Promise { + const argv = process.argv.slice(2); + + const rawTrace = flag(argv, '--raw-trace'); + const wasmPath = flag(argv, '--wasm'); + const contract = flag(argv, '--contract'); + const fn = flag(argv, '--function'); + const argsJson = flag(argv, '--args-json'); + const out = flag(argv, '--out'); + const depth = flag(argv, '--depth'); + const maxChildrenFlag = flag(argv, '--max-children'); + const allowNoSource = has(argv, '--allow-no-source'); + + const args: SorobanLaunchArgs = { + function: fn ?? '', + rawTrace, + wasmPath, + contract, + args: argsJson ? (JSON.parse(argsJson) as ScValArg[]) : undefined, + }; + + const maxDepth = depth !== undefined ? Number(depth) : undefined; + const maxChildren = maxChildrenFlag !== undefined ? Number(maxChildrenFlag) : undefined; + + const backend = backendFor(args); + try { + const resolved = await backend.resolve(args, (msg) => process.stderr.write(msg + '\n')); + const lines = runCliTrace(resolved, { + function: args.function, + wasm: args.wasmPath, + maxDepth, + maxChildren, + allowNoSource, + }); + const output = lines.join('\n') + '\n'; + if (out) { + fs.writeFileSync(out, output); + } else { + process.stdout.write(output); + } + } finally { + await backend.dispose(); + } +} + +main().catch((err) => { + process.stderr.write(String(err instanceof Error ? err.stack ?? err.message : err) + '\n'); + process.exit(1); +}); diff --git a/src/trace/projectStop.ts b/src/trace/projectStop.ts new file mode 100644 index 0000000..4a5de5e --- /dev/null +++ b/src/trace/projectStop.ts @@ -0,0 +1,172 @@ +/** + * A serializable projection of one trace stop (docs/interfaces.md, + * "projectSourceStop"). Unlike the DAP handlers — whose lazy `Handles` / + * child-thunk machinery is deliberately different — this reuses only the + * low-level resolver calls and expands `DecodedValue.children` EAGERLY into + * plain arrays, bounded by a per-stop depth/child/node budget. + * + * Pure module (no `vscode`, no DAP imports). + */ + +import { ResolvedTrace } from '../debugAdapter/types'; +import { StopModel, pcAtIndex } from '../debugAdapter/stopModel'; +import { makeRuntimeState } from '../debugAdapter/runtimeState'; +import { MemoryImage } from '../debugAdapter/MemoryImage'; +import { renderInstr } from '../komet/mnemonics'; +import { DecodedValue } from '../dwarf/ValueDecoder'; + +/** A serializable single-stop projection. */ +export interface SourceStop { + /** 0-based ordinal among source stops. */ + step: number; + /** Index into model.records. */ + traceIndex: number; + /** stopModel.depths[traceIndex]. */ + depth: number; + /** Hex, e.g. "0x2d", or null. */ + pc: string | null; + /** functionNameAt(pc), or null. */ + function: string | null; + /** renderInstr(record.instr). */ + instr: string; + /** Mapped source location, or null when unmapped. */ + source: { path: string; line: number; column?: number } | null; + variables: TraceVar[]; +} + +/** One decoded variable, eagerly expanded within budget. */ +export interface TraceVar { + /** "" when DWARF gives none. */ + name: string; + /** The type's display name; "" when the decoder supplies none. */ + type: string; + value: string; + /** Present only when expandable and within budget. */ + children?: TraceVar[]; + /** Marker set when the budget cut expansion. */ + truncated?: boolean; +} + +/** Per-stop expansion budget plus an optional pre-built memory image. */ +export interface ProjectOpts { + maxDepth?: number; + maxChildren?: number; + maxNodes?: number; + memory?: MemoryImage; +} + +const DEFAULT_MAX_DEPTH = 3; +const DEFAULT_MAX_CHILDREN = 64; +const DEFAULT_MAX_NODES = 1500; + +/** Mutable per-call node counter so a per-stop budget stays isolated. */ +interface NodeCounter { + count: number; +} + +/** + * Expand a decoded value into a serializable TraceVar, materializing children + * eagerly up to the depth/child/node budget. + */ +function expandDecoded( + name: string, + decoded: DecodedValue, + depth: number, + maxDepth: number, + maxChildren: number, + maxNodes: number, + counter: NodeCounter, +): TraceVar { + const node: TraceVar = { name, type: decoded.typeName ?? '', value: decoded.display }; + + if (typeof decoded.children === 'function') { + if (depth >= maxDepth || counter.count >= maxNodes) { + node.truncated = true; + return node; + } + const rawChildren = decoded.children(); + if (rawChildren.length === 0) { + // Genuinely empty — omit the `children` key entirely. + return node; + } + const children: TraceVar[] = []; + const limit = Math.min(rawChildren.length, maxChildren); + for (let i = 0; i < limit; i++) { + counter.count++; + const child = rawChildren[i]; + children.push( + expandDecoded( + child.name, + child.value, + depth + 1, + maxDepth, + maxChildren, + maxNodes, + counter, + ), + ); + } + if (rawChildren.length > maxChildren) { + children.push({ name: '…', type: '', value: '…', truncated: true }); + } + node.children = children; + } + + return node; +} + +/** + * Project the stop at `index` into a serializable `SourceStop`, reusing the + * low-level resolver calls and eagerly expanding decoded variable trees. + */ +export function projectSourceStop( + resolved: ResolvedTrace, + stopModel: StopModel, + index: number, + opts?: ProjectOpts, +): SourceStop { + const maxDepth = opts?.maxDepth ?? DEFAULT_MAX_DEPTH; + const maxChildren = opts?.maxChildren ?? DEFAULT_MAX_CHILDREN; + const maxNodes = opts?.maxNodes ?? DEFAULT_MAX_NODES; + + const record = resolved.model.records[index]; + + const mapped = resolved.source.locationForIndex(index); + let source: SourceStop['source'] = null; + if (mapped) { + source = { path: mapped.path, line: mapped.line }; + if (mapped.column !== undefined && mapped.column !== null) { + source.column = mapped.column; + } + } + + const pc = pcAtIndex(resolved.positions, index); + const pcHex = pc === null ? null : '0x' + pc.toString(16); + const functionName = pc === null ? null : (resolved.variables.functionNameAt(pc) ?? null); + + const variables: TraceVar[] = []; + if (pc !== null && resolved.variables.hasVariables()) { + const memory = opts?.memory ?? new MemoryImage(resolved.model.records); + const state = makeRuntimeState(record, memory, index); + const counter: NodeCounter = { count: 0 }; + for (const v of resolved.variables.variablesInScope(pc)) { + const decoded = resolved.variables.decodeVariable(v, state, pc); + variables.push( + expandDecoded(v.name ?? '', decoded, 0, maxDepth, maxChildren, maxNodes, counter), + ); + } + } + + const step = stopModel.runStarts.indexOf(index); + + return { + step, + traceIndex: index, + depth: stopModel.depths[index], + pc: pcHex, + function: functionName, + instr: renderInstr(record.instr), + source, + variables, + }; +} diff --git a/src/trace/runTrace.ts b/src/trace/runTrace.ts new file mode 100644 index 0000000..5565ce9 --- /dev/null +++ b/src/trace/runTrace.ts @@ -0,0 +1,76 @@ +/** + * The one-shot CLI trace projection (docs/interfaces.md, "Interface 1"). + * + * `runCliTrace` 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: a + * leading `meta` record, one `stop` per runStart, then a trailing `result`. + * + * When `runStarts` is empty (no DWARF / no source) it ERRORS rather than + * silently emitting `visibleIndices` as if they were source statements, unless + * `allowNoSource` opts in. + * + * Pure module (no `vscode`, no DAP imports). + */ + +import { ResolvedTrace } from '../debugAdapter/types'; +import { buildStopModel } from '../debugAdapter/stopModel'; +import { MemoryImage } from '../debugAdapter/MemoryImage'; +import { ProjectOpts, projectSourceStop } from './projectStop'; + +/** Options for the one-shot CLI trace projection. */ +export interface CliTraceOpts extends ProjectOpts { + function?: string; + wasm?: string; + allowNoSource?: boolean; +} + +/** + * Project a resolved trace into kind-tagged JSONL lines. Throws when there are + * no source-level stops unless `opts.allowNoSource` is set. + */ +export function runCliTrace(resolved: ResolvedTrace, opts?: CliTraceOpts): string[] { + const stopModel = buildStopModel(resolved); + + if (stopModel.runStarts.length === 0 && !opts?.allowNoSource) { + throw new Error( + 'No Rust source-level stops in this trace (no DWARF / no source). ' + + 'Pass a matching --wasm, or --allow-no-source.', + ); + } + + const memory = new MemoryImage(resolved.model.records); + const projectOpts: ProjectOpts = { + maxDepth: opts?.maxDepth, + maxChildren: opts?.maxChildren, + maxNodes: opts?.maxNodes, + memory, + }; + + const lines: string[] = [ + JSON.stringify({ + kind: 'meta', + function: opts?.function, + wasm: opts?.wasm, + records: resolved.model.records.length, + stops: stopModel.runStarts.length, + hasDwarf: resolved.variables.hasVariables(), + }), + ]; + + for (const index of stopModel.runStarts) { + lines.push( + JSON.stringify({ kind: 'stop', ...projectSourceStop(resolved, stopModel, index, projectOpts) }), + ); + } + + lines.push( + JSON.stringify({ + kind: 'result', + ...(resolved.returnValue !== undefined ? { returnValue: resolved.returnValue } : {}), + terminated: true, + }), + ); + + return lines; +} diff --git a/test/backendFor.test.ts b/test/backendFor.test.ts new file mode 100644 index 0000000..bafc739 --- /dev/null +++ b/test/backendFor.test.ts @@ -0,0 +1,42 @@ +/** + * Unit suite for the trace-acquisition backend selector (docs/interfaces.md, + * "backendFor"). backendFor(args) picks the vscode-free backend from the launch + * args, reused by the extension, the TCP server, and the CLI: + * + * args.rawTrace present → RawTraceBackend (offline JSONL replay) + * otherwise → LiveBackend (build → komet-node → trace pipeline) + * + * It reads only args.rawTrace, so it needs no vscode. The module does not exist + * yet, so this is the red anchor for it. + */ + +import * as assert from 'assert'; +import { backendFor } from '../src/debugAdapter/backendFor'; +import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend'; +import { LiveBackend } from '../src/debugAdapter/backends/LiveBackend'; + +describe('backendFor (docs/interfaces.md, backend selection)', () => { + it('selects RawTraceBackend when rawTrace is present', () => { + const backend = backendFor({ rawTrace: 'x.jsonl' } as any); + assert.ok( + backend instanceof RawTraceBackend, + 'expected a RawTraceBackend for a rawTrace launch', + ); + assert.ok( + !(backend instanceof LiveBackend), + 'a rawTrace launch must not select the LiveBackend', + ); + }); + + it('selects LiveBackend when there is no rawTrace', () => { + const backend = backendFor({ function: 'add' } as any); + assert.ok( + backend instanceof LiveBackend, + 'expected a LiveBackend for a live (no rawTrace) launch', + ); + assert.ok( + !(backend instanceof RawTraceBackend), + 'a live launch must not select the RawTraceBackend', + ); + }); +}); diff --git a/test/dapServer.test.ts b/test/dapServer.test.ts new file mode 100644 index 0000000..20f11b1 --- /dev/null +++ b/test/dapServer.test.ts @@ -0,0 +1,152 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { DebugClient } from '@vscode/debugadapter-testsupport'; +import { DebugProtocol } from '@vscode/debugprotocol'; +// src/server/dapServer.ts does not exist yet — this import is what makes the +// suite fail to compile/run until M2's TCP server lands (docs/interfaces.md, +// "Interface 2 — standalone TCP DAP server"). +import { startDapServer } from '../src/server/dapServer'; +import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend'; +import { + ProgressReporter, + ResolvedTrace, + SessionBackend, + SorobanLaunchArgs, +} from '../src/debugAdapter/types'; + +const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures'); +const ADDER_TRACE = path.join(FIXTURES, 'adder-debug.trace.jsonl'); +const ADDER_WASM = path.join(FIXTURES, 'adder-debug.wasm'); + +/** The real contract source the fixture's DWARF resolves to. */ +const LIB_RS_SUFFIX = 'examples/adder/src/lib.rs'; + +const WITH_WASM = { rawTrace: ADDER_TRACE, wasmPath: ADDER_WASM }; +const THREAD = { threadId: 1 }; + +describe('startDapServer (standalone TCP DAP server)', () => { + let srv: { port: number; close: () => Promise }; + let clients: DebugClient[]; + + beforeEach(async () => { + clients = []; + srv = await startDapServer({ port: 0 }); + assert.strictEqual(typeof srv.port, 'number', 'expected a numeric ephemeral port'); + assert.ok(srv.port > 0, `expected an assigned ephemeral port, got ${srv.port}`); + }); + + afterEach(async function () { + this.timeout(30000); + for (const dc of clients) { + try { + await dc.stop(); + } catch { + // best-effort teardown; a client may already be disconnected + } + } + await srv.close(); + }); + + /** Connect a fresh DebugClient to the server's TCP port. */ + async function connect(): Promise { + const dc = new DebugClient('node', 'unused', 'soroban'); + clients.push(dc); + await dc.start(srv.port); + return dc; + } + + it('drives a real DAP session over the socket, stopping at the adder entry', async function () { + this.timeout(30000); + const dc = await connect(); + + const [, , stopped] = await Promise.all([ + dc.configurationSequence(), + dc.launch(WITH_WASM as any), + dc.waitForEvent('stopped'), + ]); + assert.strictEqual((stopped as DebugProtocol.StoppedEvent).body.reason, 'entry'); + + const res = await dc.stackTraceRequest(THREAD); + assert.ok(res.body.stackFrames.length >= 1, 'expected at least one stack frame'); + const top = res.body.stackFrames[0]; + assert.strictEqual(top.line, 16); + assert.ok( + top.source?.path?.endsWith(LIB_RS_SUFFIX), + `unexpected source: ${top.source?.path}`, + ); + }); + + it('serves a second independent connection (one session per connection)', async function () { + this.timeout(30000); + // First connection drives a full session to a stop... + const dc1 = await connect(); + await Promise.all([ + dc1.configurationSequence(), + dc1.launch(WITH_WASM as any), + dc1.waitForEvent('stopped'), + ]); + + // ...while a second, independent connection to the same server initializes + // and disconnects cleanly, proving the server is not single-session. + const dc2 = await connect(); + const init = await dc2.initializeRequest(); + assert.ok(init.body, 'expected initialize capabilities on the second connection'); + await dc2.disconnectRequest({}); + }); + + it('disposes the per-connection backend when the socket drops without a disconnect', async function () { + this.timeout(30000); + + // A spy backend delegating to the real replay path, counting disposals. + // This is exactly the LiveBackend leak scenario: a backend resolved by a + // launch (its komet-node pipeline live) must be disposed even when the + // client vanishes without sending a `disconnect` request. + class SpyBackend implements SessionBackend { + disposeCount = 0; + private readonly inner = new RawTraceBackend(); + resolve(args: SorobanLaunchArgs, report: ProgressReporter): Promise { + return this.inner.resolve(args, report); + } + async dispose(): Promise { + this.disposeCount++; + } + } + const spy = new SpyBackend(); + + // A dedicated server whose sole connection uses the spy backend. + const leakSrv = await startDapServer({ port: 0, backendFor: () => spy }); + try { + const dc = new DebugClient('node', 'unused', 'soroban'); + await dc.start(leakSrv.port); + + // Drive a full launch so the backend selector resolves to the concrete + // spy (mirrors a live komet-node pipeline being spun up). + await Promise.all([ + dc.configurationSequence(), + dc.launch(WITH_WASM as any), + dc.waitForEvent('stopped'), + ]); + assert.strictEqual(spy.disposeCount, 0, 'backend should not be disposed while connected'); + + // Abrupt drop: destroy the underlying socket WITHOUT a disconnect request + // (editor crash / network drop). The old code wired socket close to + // session.shutdown(), a no-op in server mode, so dispose never ran. + const socket = (dc as unknown as { _socket?: import('net').Socket })._socket; + assert.ok(socket, 'expected an underlying socket on the DebugClient'); + socket.destroy(); + + // Poll until the teardown handler has disposed the backend. + const deadline = Date.now() + 5000; + while (spy.disposeCount === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 20)); + } + assert.strictEqual( + spy.disposeCount, + 1, + 'abrupt socket drop must dispose the per-connection backend exactly once', + ); + } finally { + await leakSrv.close(); + } + }); +}); diff --git a/test/projectStop.test.ts b/test/projectStop.test.ts new file mode 100644 index 0000000..da1cce3 --- /dev/null +++ b/test/projectStop.test.ts @@ -0,0 +1,183 @@ +/** + * Unit suite for the serializable single-stop projection + * (docs/interfaces.md, "projectSourceStop"). The module under test does not + * exist yet — this is the red anchor for it. + * + * projectSourceStop(resolved, stopModel, index, opts): SourceStop + * from src/trace/projectStop.ts + * + * It reuses only the low-level resolver calls (locationForIndex, pcAtIndex, + * functionNameAt, makeRuntimeState + variablesInScope + decodeVariable) and, + * unlike the DAP handlers, expands DecodedValue.children EAGERLY into plain + * arrays bounded by a per-stop depth/child budget. Values are pinned to the + * verified ground-truth fixtures (docs/interfaces.md ground-truth table). + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import { buildStopModel } from '../src/debugAdapter/stopModel'; +import { projectSourceStop } from '../src/trace/projectStop'; +import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend'; +import { ResolvedTrace } from '../src/debugAdapter/types'; + +const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures'); + +/** Resolve a symbol-rich ResolvedTrace (rawTrace + wasmPath) for a fixture. */ +async function resolveFixture(name: string): Promise { + const args = { + rawTrace: path.join(FIXTURES, `${name}.trace.jsonl`), + wasmPath: path.join(FIXTURES, `${name}.wasm`), + }; + return new RawTraceBackend().resolve(args as any, () => {}); +} + +describe('projectSourceStop (docs/interfaces.md, serializable stop projection)', () => { + describe('adder-debug idx 29 (the sole statement stop)', () => { + let resolved: ResolvedTrace; + + before(async () => { + resolved = await resolveFixture('adder-debug'); + }); + + it('projects the pinned SourceStop shape and values', () => { + const sm = buildStopModel(resolved); + const stop = projectSourceStop(resolved, sm, 29); + + // Ordinal among source stops: runStarts = [29] → step 0. + assert.strictEqual(stop.step, 0); + assert.strictEqual(stop.traceIndex, 29); + assert.strictEqual(stop.depth, 0); + // pc hex = "0x" + pos.toString(16); pos 45 → "0x2d". + assert.strictEqual(stop.pc, '0x2d'); + assert.strictEqual(stop.function, 'invoke_raw_extern'); + assert.ok( + stop.instr.startsWith('i32.add'), + `expected instr to start with i32.add, got: ${stop.instr}`, + ); + + assert.ok(stop.source, 'expected a mapped source location'); + assert.strictEqual(stop.source!.line, 16); + assert.strictEqual(stop.source!.column, 9); + assert.ok( + stop.source!.path.endsWith('examples/adder/src/lib.rs'), + `unexpected source path: ${stop.source!.path}`, + ); + }); + + it('projects the two leaf argument variables with no children', () => { + const sm = buildStopModel(resolved); + const stop = projectSourceStop(resolved, sm, 29); + + assert.deepStrictEqual(stop.variables, [ + { name: 'arg_0', type: 'Val', value: '17179869188' }, + { name: 'arg_1', type: 'Val', value: '12884901892' }, + ]); + // Leaves: neither `children` nor `truncated` keys are emitted. + for (const v of stop.variables) { + assert.strictEqual(v.children, undefined); + assert.strictEqual(v.truncated, undefined); + } + }); + }); + + describe('stepper-debug idx 29 (function `triple`)', () => { + let resolved: ResolvedTrace; + + before(async () => { + resolved = await resolveFixture('stepper-debug'); + }); + + it('omits an absent column and pins the `triple`/x=0 values', () => { + const sm = buildStopModel(resolved); + const stop = projectSourceStop(resolved, sm, 29); + + // runStarts = [21,27,29,...] → idx 29 is the third source stop (step 2). + assert.strictEqual(stop.step, 2); + assert.strictEqual(stop.traceIndex, 29); + assert.strictEqual(stop.function, 'triple'); + + assert.ok(stop.source, 'expected a mapped source location'); + assert.strictEqual(stop.source!.line, 15); + // The null column MUST be omitted, never emitted as `column: null/undefined`. + assert.ok( + !('column' in stop.source!), + `expected column to be omitted, got: ${JSON.stringify(stop.source)}`, + ); + + const x = stop.variables.find((v) => v.name === 'x'); + assert.ok(x, 'expected a variable named x'); + assert.strictEqual(x!.type, 'u32'); + assert.strictEqual(x!.value, '0'); + }); + }); + + describe('increment-debug idx 999 (expandable `env`, unnamed first var)', () => { + let resolved: ResolvedTrace; + + before(async () => { + resolved = await resolveFixture('increment-debug'); + }); + + it('renders an unnamed variable as and tolerates a null function', () => { + const sm = buildStopModel(resolved); + const stop = projectSourceStop(resolved, sm, 999); + + // runStarts = [646,999,...] → idx 999 is the second source stop (step 1). + assert.strictEqual(stop.step, 1); + assert.strictEqual(stop.traceIndex, 999); + // functionNameAt may return null even with DWARF; the field must reflect it. + assert.ok( + stop.function === null || typeof stop.function === 'string', + `function must be a string or null, got: ${String(stop.function)}`, + ); + + assert.ok(stop.source, 'expected a mapped source location'); + assert.strictEqual(stop.source!.line, 21); + + // The first variable has no DWARF name → it renders as "". + assert.strictEqual(stop.variables[0].name, ''); + }); + + it('pins `current:u32=15` and eagerly expands `env` exactly one level', () => { + const sm = buildStopModel(resolved); + const stop = projectSourceStop(resolved, sm, 999); + + const current = stop.variables.find((v) => v.name === 'current'); + assert.ok(current, 'expected a variable named current'); + assert.strictEqual(current!.type, 'u32'); + assert.strictEqual(current!.value, '15'); + + const env = stop.variables.find((v) => v.name === 'env'); + assert.ok(env, 'expected a variable named env'); + assert.strictEqual(env!.type, 'Env'); + assert.strictEqual(env!.value, 'Env'); + + // env IS expandable: exactly one child, env_impl:Guest. + assert.ok(Array.isArray(env!.children), 'expected env to be expanded eagerly'); + assert.strictEqual(env!.children!.length, 1); + const child = env!.children![0]; + assert.strictEqual(child.name, 'env_impl'); + assert.strictEqual(child.type, 'Guest'); + assert.strictEqual(child.value, 'Guest'); + + // Recursive expansion reaches env_impl by name. + assert.strictEqual(env!.children![0].name, 'env_impl'); + + // env_impl has an EMPTY child set → the `children` key MUST be omitted, + // never emitted as `children: []`. Not truncated either — genuinely empty. + assert.strictEqual(child.children, undefined); + assert.strictEqual(child.truncated, undefined); + }); + + it('honors a depth budget: {maxDepth: 0} cuts env children and marks it truncated', () => { + const sm = buildStopModel(resolved); + const stop = projectSourceStop(resolved, sm, 999, { maxDepth: 0 }); + + const env = stop.variables.find((v) => v.name === 'env'); + assert.ok(env, 'expected a variable named env'); + // Expandable but cut by the depth budget: no children, truncated marker set. + assert.strictEqual(env!.children, undefined); + assert.strictEqual(env!.truncated, true); + }); + }); +}); diff --git a/test/runTrace.test.ts b/test/runTrace.test.ts new file mode 100644 index 0000000..d24f97a --- /dev/null +++ b/test/runTrace.test.ts @@ -0,0 +1,133 @@ +/** + * Unit suite for the one-shot CLI trace projection + * (docs/interfaces.md, "Interface 1 — one-shot CLI"). The module under test + * does not exist yet — this is the red anchor for it. + * + * runCliTrace(resolved, opts): string[] from src/trace/runTrace.ts + * + * It walks stopModel.runStarts in order — provably the same sequence a user + * sees stepping in — and emits kind-tagged JSONL lines: a leading `meta` + * record, one `stop` per runStart, then a trailing `result`. If runStarts is + * empty (no DWARF / no source) it ERRORS rather than silently emitting + * visibleIndices, unless opts.allowNoSource is set. Values are pinned to the + * verified ground-truth fixtures. + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import { buildStopModel } from '../src/debugAdapter/stopModel'; +import { runCliTrace } from '../src/trace/runTrace'; +import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend'; +import { ResolvedTrace } from '../src/debugAdapter/types'; + +const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures'); + +/** Resolve a symbol-rich ResolvedTrace (rawTrace + wasmPath) for a fixture. */ +async function resolveFixture(name: string): Promise { + const args = { + rawTrace: path.join(FIXTURES, `${name}.trace.jsonl`), + wasmPath: path.join(FIXTURES, `${name}.wasm`), + }; + return new RawTraceBackend().resolve(args as any, () => {}); +} + +describe('runCliTrace (docs/interfaces.md, one-shot CLI JSONL)', () => { + describe('adder-debug (records=41, runStarts=[29])', () => { + let resolved: ResolvedTrace; + + before(async () => { + resolved = await resolveFixture('adder-debug'); + }); + + it('emits meta, exactly one stop, and a terminated result', () => { + const sm = buildStopModel(resolved); + const lines = runCliTrace(resolved, {}); + assert.ok(Array.isArray(lines), 'expected string[] JSONL lines'); + + // Line 0: meta. + const meta = JSON.parse(lines[0]); + assert.strictEqual(meta.kind, 'meta'); + assert.strictEqual(meta.records, 41); + assert.strictEqual(meta.stops, 1); + assert.strictEqual(meta.hasDwarf, true); + + // Exactly runStarts.length stop lines. + const stops = lines.map((l) => JSON.parse(l)).filter((r) => r.kind === 'stop'); + assert.strictEqual(stops.length, sm.runStarts.length); + assert.strictEqual(stops.length, 1); + + // The last line is the terminated result, with NO returnValue + // (RawTraceBackend sets none). + const result = JSON.parse(lines[lines.length - 1]); + assert.strictEqual(result.kind, 'result'); + assert.strictEqual(result.terminated, true); + assert.ok( + !('returnValue' in result) || result.returnValue === undefined, + `expected no returnValue, got: ${JSON.stringify(result)}`, + ); + }); + + it('pins the step-0 stop values', () => { + const lines = runCliTrace(resolved, {}); + const stop = lines.map((l) => JSON.parse(l)).find((r) => r.kind === 'stop'); + assert.ok(stop, 'expected a stop line'); + + assert.strictEqual(stop.step, 0); + assert.strictEqual(stop.traceIndex, 29); + assert.strictEqual(stop.depth, 0); + assert.strictEqual(stop.pc, '0x2d'); + assert.strictEqual(stop.function, 'invoke_raw_extern'); + assert.ok( + String(stop.instr).startsWith('i32.add'), + `expected instr to start with i32.add, got: ${stop.instr}`, + ); + assert.strictEqual(stop.source.line, 16); + assert.strictEqual(stop.source.column, 9); + assert.ok( + String(stop.source.path).endsWith('examples/adder/src/lib.rs'), + `unexpected source path: ${stop.source.path}`, + ); + assert.deepStrictEqual(stop.variables, [ + { name: 'arg_0', type: 'Val', value: '17179869188' }, + { name: 'arg_1', type: 'Val', value: '12884901892' }, + ]); + }); + }); + + describe('stepper-debug (records=85, 10 source stops)', () => { + it('emits one ascending stop per runStart with matching traceIndex', async () => { + const resolved = await resolveFixture('stepper-debug'); + const sm = buildStopModel(resolved); + const lines = runCliTrace(resolved, {}); + + const stops = lines.map((l) => JSON.parse(l)).filter((r) => r.kind === 'stop'); + assert.strictEqual(stops.length, sm.runStarts.length); + assert.strictEqual(stops.length, 10); + + stops.forEach((stop, step) => { + assert.strictEqual(stop.step, step, `stop ${step} has step ${stop.step}`); + assert.strictEqual( + stop.traceIndex, + sm.runStarts[step], + `stop ${step} traceIndex should equal runStarts[${step}]`, + ); + }); + }); + }); + + describe('no DWARF (rawTrace only, empty runStarts)', () => { + it('throws rather than silently emitting visibleIndices', async () => { + const resolved = await new RawTraceBackend().resolve( + { rawTrace: path.join(FIXTURES, 'adder-debug.trace.jsonl') } as any, + () => {}, + ); + const sm = buildStopModel(resolved); + assert.strictEqual(sm.runStarts.length, 0, 'precondition: no source stops without wasm'); + assert.throws( + () => runCliTrace(resolved, {}), + /source|dwarf|stop/i, + 'expected runCliTrace to throw when there are no source stops', + ); + }); + }); +}); diff --git a/test/stopModel.test.ts b/test/stopModel.test.ts new file mode 100644 index 0000000..1c17561 --- /dev/null +++ b/test/stopModel.test.ts @@ -0,0 +1,111 @@ +/** + * Unit suite for the shared headless stop model (docs/interfaces.md, "Shared + * headless core"). Two pure, vscode-free functions in + * src/debugAdapter/stopModel.ts are the single source of truth the CLI and the + * DAP session both build on, so they can never disagree about where a "stop" is: + * + * buildStopModel(resolved): StopModel — derives, exactly as + * SorobanDebugSession.launchRequest did inline, the validatedPosToIndices + * map, visibleIndices, per-record depths, the raw line-run starts, the + * statement-granularity runStarts (post S17/S18), and the first/last stop + * points. + * pcAtIndex(positions, index) — the current-PC rule: the validated code + * offset at `index`, or the nearest EARLIER record that has one, else null. + * + * Values are pinned to the verified ground-truth fixtures. The module does not + * exist yet, so this is the red anchor for it. + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import { buildStopModel, pcAtIndex } from '../src/debugAdapter/stopModel'; +import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend'; +import { ResolvedTrace } from '../src/debugAdapter/types'; + +const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures'); + +/** Resolve a symbol-rich ResolvedTrace (rawTrace + wasmPath) for a fixture. */ +async function resolveFixture(name: string): Promise { + const args = { + rawTrace: path.join(FIXTURES, `${name}.trace.jsonl`), + wasmPath: path.join(FIXTURES, `${name}.wasm`), + }; + return new RawTraceBackend().resolve(args as any, () => {}); +} + +describe('buildStopModel (docs/interfaces.md, shared headless core)', () => { + describe('adder-debug fixture', () => { + let resolved: ResolvedTrace; + + before(async () => { + resolved = await resolveFixture('adder-debug'); + }); + + it('derives the pinned ground-truth stop model', () => { + const model = buildStopModel(resolved); + assert.deepStrictEqual(model.rawRunStarts, [6, 29, 40]); + assert.deepStrictEqual(model.runStarts, [29]); + assert.strictEqual(model.visibleIndices.length, 35); + assert.strictEqual(model.firstStopPoint, 29); + assert.strictEqual(model.lastStopPoint, 29); + assert.strictEqual(model.depths.length, 41); + }); + + it('maps the validated code offset 45 to trace index 29', () => { + const model = buildStopModel(resolved); + assert.ok(model.validatedPosToIndices instanceof Map, 'validatedPosToIndices should be a Map'); + assert.ok( + model.validatedPosToIndices.get(45)?.includes(29), + 'validatedPosToIndices.get(45) should include index 29', + ); + }); + }); + + describe('stepper-debug fixture', () => { + let resolved: ResolvedTrace; + + before(async () => { + resolved = await resolveFixture('stepper-debug'); + }); + + it('derives the pinned ground-truth stop model', () => { + const model = buildStopModel(resolved); + assert.deepStrictEqual( + model.rawRunStarts, + [5, 21, 27, 29, 39, 44, 46, 56, 61, 63, 73, 84], + ); + assert.deepStrictEqual(model.runStarts, [21, 27, 29, 39, 44, 46, 56, 61, 63, 73]); + assert.strictEqual(model.visibleIndices.length, 80); + assert.strictEqual(model.firstStopPoint, 21); + assert.strictEqual(model.lastStopPoint, 73); + assert.strictEqual(model.depths.length, 85); + }); + }); +}); + +describe('pcAtIndex (docs/interfaces.md, current-PC rule)', () => { + it('returns the validated code offset at the index (adder idx 29 → 45)', async () => { + const resolved = await resolveFixture('adder-debug'); + assert.strictEqual(pcAtIndex(resolved.positions, 29), 45); + }); + + it('returns null before the first validated record (adder head)', async () => { + const resolved = await resolveFixture('adder-debug'); + // The adder's first validated position is at index 6; indices 0..5 are + // unvalidated global-initializer records with no earlier PC. + assert.strictEqual(pcAtIndex(resolved.positions, 0), null); + assert.strictEqual(pcAtIndex(resolved.positions, 5), null); + }); + + it('returns the nearest EARLIER validated offset for an unmapped index', () => { + // Interior unmapped (null) records inherit the last validated PC seen. + const positions = [null, 10, null, null, 20, null]; + assert.strictEqual(pcAtIndex(positions, 1), 10); + assert.strictEqual(pcAtIndex(positions, 2), 10); + assert.strictEqual(pcAtIndex(positions, 3), 10); + assert.strictEqual(pcAtIndex(positions, 4), 20); + assert.strictEqual(pcAtIndex(positions, 5), 20); + // No validated record at or before index 0 → null. + assert.strictEqual(pcAtIndex(positions, 0), null); + }); +}); From 37c6b7dc46ac105a091470ab61a0b01e45deb334 Mon Sep 17 00:00:00 2001 From: Raoul Date: Fri, 17 Jul 2026 09:23:22 +0000 Subject: [PATCH 2/5] Update diagram --- docs/interfaces.md | 47 ++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/docs/interfaces.md b/docs/interfaces.md index afec291..650d9be 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -4,22 +4,37 @@ The trace-replay debug adapter (`SorobanDebugSession`) can be consumed three way All three share one headless, `vscode`-free core; none of them touch the DAP stepping engine (S1–S20, see [`stepping.md`](./stepping.md)). -``` - ┌─────────────────────────────┐ - │ SessionBackend.resolve() │ RawTraceBackend | LiveBackend - │ → ResolvedTrace │ - └──────────────┬──────────────┘ - │ - ┌──────────────────┼──────────────────┐ - │ │ │ - buildStopModel SorobanDebugSession buildStopModel - │ (DAP handlers) │ - projectSourceStop │ projectSourceStop - │ │ │ - ┌───────┴───────┐ ┌──────┴───────┐ ┌───────┴────────┐ - │ CLI (JSONL) │ │ VS Code inline│ │ TCP DAP server │ - │ soroban-trace │ │ + TCP server │ │ soroban-dap │ - └───────────────┘ └──────────────┘ └────────────────┘ +```mermaid +flowchart TB + BF["backendFor(args)"] + BF -->|"rawTrace set"| RTB["RawTraceBackend
offline JSONL replay"] + BF -->|"otherwise"| LB["LiveBackend
build → komet-node → trace"] + RTB --> RT["ResolvedTrace"] + LB --> RT + + subgraph core["shared headless core — vscode-free"] + BSM["buildStopModel → StopModel
single source of truth for stop points"] + PCA["pcAtIndex"] + PSS["projectSourceStop
serializable, eager var expansion"] + end + + RT --> BSM + PCA --> PSS + + BSM --> SDS["SorobanDebugSession
DAP handlers + stepping engine"] + PCA --> SDS + BSM --> RCT["runCliTrace"] + PSS --> RCT + + subgraph ifaces["interfaces"] + VSC["VS Code inline"] + DAP["soroban-dap
standalone TCP DAP server"] + CLI["soroban-trace
one-shot JSONL trace"] + end + + SDS --> VSC + SDS --> DAP + RCT --> CLI ``` ## Shared headless core From f75ec38e64e478dbc08cdc597a882d0db01fdf73 Mon Sep 17 00:00:00 2001 From: Raoul Date: Fri, 17 Jul 2026 09:59:58 +0000 Subject: [PATCH 3/5] Update docs --- CONTRIBUTING.md | 29 +++++++++++++++++++++++++++++ README.md | 7 +++++++ docs/interfaces.md | 8 ++++++++ docs/standalone-interfaces.md | 19 +++++++++++++++++++ docs/stepping.md | 22 ++++++++++++++++++++++ examples/README.md | 7 +++++++ 6 files changed, 92 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a51be5c..15d9a18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. @@ -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
STRIP=none, OPT_LEVEL=0"| WASM["wasm + DWARF
(pristine linker output)"] + end + WASM --> KOMET["komet-node
executes the whole transaction"] + KOMET --> TRACE["entire trace
one record per wasm instruction"] + + subgraph model["In-memory model — vscode-free"] + TRACE --> VAL["validate positions
vs static disassembly"] + WASM -. "DWARF" .-> MAP["map code offset → Rust file:line"] + VAL --> CUR["cursor machine
(forward == backward cost)"] + MAP --> CUR + end + + CUR -->|"each DAP request
just moves the cursor"| DAP["SorobanDebugSession
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 diff --git a/README.md b/README.md index 99e2f52..e3bd373 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/interfaces.md b/docs/interfaces.md index 650d9be..707516c 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -1,5 +1,13 @@ # 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)). diff --git a/docs/standalone-interfaces.md b/docs/standalone-interfaces.md index 4e6fb1c..20c9f06 100644 --- a/docs/standalone-interfaces.md +++ b/docs/standalone-interfaces.md @@ -1,5 +1,13 @@ # Standalone interfaces: CLI trace & DAP server +> **Audience:** `soroban developer` (outside VS Code) · `CI / scripting user` · +> `AI agent integrator` · `other-editor user` +> +> **TL;DR:** How to use the debugger without VS Code. `soroban-trace` prints a +> Rust-source-level execution trace as JSONL (for scripts, CI, and agents); +> `soroban-dap` serves the debug adapter over TCP so nvim-dap/IntelliJ/Emacs can +> drive it. Includes building the tools, offline & live usage, and every flag. + Besides the VS Code extension, the debugger ships two standalone command-line tools for using it **outside the editor**: @@ -13,6 +21,17 @@ Both are thin front-ends over the same replay engine the extension uses; see [`interfaces.md`](./interfaces.md) for the internal design and the full JSONL schema. +```mermaid +flowchart LR + Q{"How do you want
to consume the debugger?"} + Q -->|"read an execution
(scripts / CI / AI agents)"| T["soroban-trace
one-shot JSONL trace"] + Q -->|"step interactively
in another editor"| D["soroban-dap
TCP DAP server"] + Q -->|"step interactively
in VS Code"| E["the VS Code extension
(see the main README)"] + T --> CORE["shared replay engine"] + D --> CORE + E --> CORE +``` + ## Building the tools ```sh diff --git a/docs/stepping.md b/docs/stepping.md index 01f9873..8ccc13d 100644 --- a/docs/stepping.md +++ b/docs/stepping.md @@ -1,5 +1,13 @@ # Stepping semantics +> **Audience:** `contributor` · `maintainer` (replay/stepping engine) +> +> **TL;DR:** The precise contract for how the cursor moves through a recorded +> trace — statement vs. instruction granularity, forward and backward. Defines +> the per-record model (visible/mapped/depth/run) and the numbered rules +> (S1–S20) that the test suite pins, plus the opt-level build prerequisite and +> the fixtures behind each rule. + The contract between the debugger's replay engine and the user's expectations when stepping through a recorded trace — at **statement** (Rust source) and **instruction** (Disassembly View) granularity, forward and backward. The @@ -54,6 +62,20 @@ invisible record, and (at statement granularity) never on an unmapped or filtered-out one, except when filtering would leave the trace (or a function frame) with no stop point at all. +The two granularities are successive filters over the same trace — each stop set +is a subset of the one above it: + +```mermaid +flowchart TB + ALL["all trace records"] -->|"drop invisible records
(pos fails validation)"| VIS["visible records
= instruction stop points"] + VIS -->|"group into line runs;
keep each run's first index"| RS["run starts
one per line execution (incl. per loop iteration)"] + RS -->|"S17 drop declaration lines
S18 drop non-epilogue braces"| SS["statement stops
= where source stepping rests"] +``` + +Instruction stepping visits the middle box; statement stepping visits the +bottom box. Filtering never empties a frame (see S17/S18): if it would remove +every stop, the unfiltered run starts stand. + ## Rules ### Session start and run boundaries diff --git a/examples/README.md b/examples/README.md index 519a7ed..fd7358f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,12 @@ # Soroban Debugger — example workspace +> **Audience:** `new user` · `soroban developer` (getting started) +> +> **TL;DR:** A guided tour of the ready-to-run example contracts and launch +> configs. Start with the zero-dependency **Replay … with symbols** configs to +> see source-level, forward-and-backward debugging in seconds — no toolchain +> needed — then graduate to the full build-and-run pipeline. + This folder is a **self-contained example workspace** for the Soroban Debugger (komet) extension. Pressing **F5** ("Run Extension") in the extension repo opens this folder in an Extension Development Host with the extension already loaded, From 10fe777637e72d3c402208199894d1c1e05f90be Mon Sep 17 00:00:00 2001 From: Raoul Date: Mon, 20 Jul 2026 10:59:53 +0000 Subject: [PATCH 4/5] Add --help/validation to standalone CLIs and reorganize interface docs Improve the devex of the two standalone binaries and split their documentation per interface. CLI devex: - Extract pure argument parsers (src/{server,trace}/cliArgs.ts) that produce a help|error|run result, so both binaries offer `--help` and report invalid/unknown/missing parameters with a usage hint instead of failing opaquely. main.ts entry points become thin shells over them. Docs: - Replace docs/interfaces.md + docs/standalone-interfaces.md with a per-interface user+internal pair each: trace-cli.md / trace-cli-internal.md and dap-cli.md / dap-cli-internal.md. - Add mermaid diagrams, verbatim `--help` usage sections, and lead the trace CLI docs with a live example using the bundled examples/adder. - README links to the two new user-facing docs. Also drop the stale komet-node "returns no value" caveat from the pipeline FAILED error (that limitation is fixed); the error now just identifies the invocation and its tx hash. --- README.md | 10 +- docs/dap-cli-internal.md | 77 +++++++ docs/dap-cli.md | 82 +++++++ docs/standalone-interfaces.md | 150 ------------- docs/{interfaces.md => trace-cli-internal.md} | 87 +++----- docs/trace-cli.md | 123 +++++++++++ examples/README.md | 14 ++ src/debugAdapter/backendFor.ts | 2 +- src/debugAdapter/stopModel.ts | 2 +- src/pipeline/TurnkeyPipeline.ts | 6 +- src/server/cliArgs.ts | 87 ++++++++ src/server/dapServer.ts | 4 +- src/server/main.ts | 33 +-- src/trace/cliArgs.ts | 166 ++++++++++++++ src/trace/main.ts | 67 ++---- src/trace/projectStop.ts | 2 +- src/trace/runTrace.ts | 2 +- test/backendFor.test.ts | 4 +- test/dapServer.test.ts | 2 +- test/pipeline.test.ts | 15 +- test/projectStop.test.ts | 6 +- test/runTrace.test.ts | 4 +- test/serverCli.test.ts | 130 +++++++++++ test/stopModel.test.ts | 6 +- test/traceCli.test.ts | 206 ++++++++++++++++++ 25 files changed, 990 insertions(+), 297 deletions(-) create mode 100644 docs/dap-cli-internal.md create mode 100644 docs/dap-cli.md delete mode 100644 docs/standalone-interfaces.md rename docs/{interfaces.md => trace-cli-internal.md} (63%) create mode 100644 docs/trace-cli.md create mode 100644 src/server/cliArgs.ts create mode 100644 src/trace/cliArgs.ts create mode 100644 test/serverCli.test.ts create mode 100644 test/traceCli.test.ts diff --git a/README.md b/README.md index e3bd373..da58e26 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,12 @@ Two settings let you point at executables that aren't on your `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). +The debugger is also available outside VS Code: + +- [**`soroban-trace`**](docs/trace-cli.md) — a one-shot CLI that prints a + Rust-level execution trace as JSONL, for scripts, CI, and AI agents. +- [**`soroban-dap`**](docs/dap-cli.md) — the debug adapter served over TCP, so other + editors (nvim-dap, IntelliJ, Emacs) can drive it. ## Contributing diff --git a/docs/dap-cli-internal.md b/docs/dap-cli-internal.md new file mode 100644 index 0000000..c7115d7 --- /dev/null +++ b/docs/dap-cli-internal.md @@ -0,0 +1,77 @@ +# `soroban-dap` internals + +> **Audience:** `contributor` · `maintainer` · `integrator` (internals) +> +> **TL;DR:** How the standalone TCP DAP server wraps `SorobanDebugSession` — one +> session per connection over a socket — plus the backend-selector constructor +> and the per-connection teardown that keeps a dropped connection from leaking a +> `komet-node` process. User-facing usage is in [`dap-cli.md`](./dap-cli.md). The +> `vscode`-free shared core (`backendFor` / `buildStopModel` / `pcAtIndex`) is +> documented in +> [`trace-cli-internal.md`](./trace-cli-internal.md#shared-headless-core). + +The server reuses the exact DAP handlers and stepping engine (S1–S20, see +[`stepping.md`](./stepping.md)) the VS Code extension runs — it only changes the +transport from an in-process inline adapter to a TCP socket. + +```mermaid +flowchart TB + C["DAP client
VS Code · nvim-dap · IntelliJ · Emacs"] + C -->|"TCP socket"| SRV["net.createServer"] + SRV -->|"one per connection"| SDS["SorobanDebugSession(backendFor)
DAP handlers + stepping engine"] + SDS --> RESOLVE["backendFor(args) → resolve → ResolvedTrace"] + SDS --> CORE["buildStopModel + pcAtIndex
(shared core)"] + CLOSE["socket close / error"] -->|"session.teardown()"| DISPOSE["backend.dispose()"] +``` + +## `startDapServer(opts): Promise<{port, close}>` — `src/server/dapServer.ts` + +`startDapServer({ host?, port, backendFor? })` opens a `net.createServer`. For each +connection it: + +1. creates `new SorobanDebugSession(select)` where `select` defaults to the shared + `backendFor` selector (injectable via `opts.backendFor` so tests can observe + teardown with a spy backend); +2. calls `session.setRunAsServer(true)` (see below); +3. pipes the DAP wire protocol over the socket with `session.start(socket, socket)`; +4. registers `socket.on('close', …)` and `socket.on('error', …)` → `session.teardown()`. + +It resolves the **actual** listening port (so `port: 0` yields an OS-assigned port) and +returns `{ port, close }`, where `close()` shuts the server down. + +### Per-connection teardown (the two subtleties) + +- **`setRunAsServer(true)` is mandatory.** The base `@vscode/debugadapter` + `DebugSession` calls `process.exit(0)` roughly 100 ms after *any* socket closes + unless it is in server mode. Without this flag a single client disconnect would kill + the whole server process (and every other connection with it). + +- **`teardown()`, not `shutdown()`.** `DebugSession.shutdown()` is a no-op in server + mode, so it can't dispose the backend. `SorobanDebugSession.teardown()` is the single, + idempotent (`disposed`-guarded) place `backend.dispose()` runs. It is reached both by a + clean `disconnect` request *and* by the socket `close`/`error` handlers — so an + **abrupt** disconnect (editor crash, network drop, SIGKILL) that sends no `disconnect` + over the wire still tears down the `LiveBackend`'s `komet-node` subprocess. The + `disposed` guard makes the second call after a clean disconnect a harmless no-op. + +## Session constructor change — `src/debugAdapter/SorobanDebugSession.ts` + +`SorobanDebugSession` 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, which only arrives with +the client's `launch` request. + +## Why TCP, not HTTP + +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. TCP is DAP's canonical "server mode", supported by every +mainstream DAP client. A WebSocket transport could be added later for browser-fronted +clients. + +`src/server/main.ts` is the thin, coverage-excluded entry: it delegates argv parsing to +the pure `parseServerArgs` (`src/server/cliArgs.ts`, unit-tested), starts the server, and +logs the listening address. Help goes to stdout (exit 0); a usage error goes to stderr +(exit 2). diff --git a/docs/dap-cli.md b/docs/dap-cli.md new file mode 100644 index 0000000..be91d2d --- /dev/null +++ b/docs/dap-cli.md @@ -0,0 +1,82 @@ +# `soroban-dap` — standalone DAP server (TCP) + +> **Audience:** `other-editor user` (nvim-dap / IntelliJ / Emacs) · `tooling integrator` +> +> **TL;DR:** `soroban-dap` serves the Soroban debug adapter over a TCP socket +> (DAP's canonical "server mode"), so debuggers *other* than VS Code can drive +> it. For the one-shot JSONL trace CLI, see [`trace-cli.md`](./trace-cli.md); for +> internals, see [`dap-cli-internal.md`](./dap-cli-internal.md). + +`soroban-dap` runs the debug adapter as a TCP server. Each client connection +gets its own independent session; the launch configuration is sent by the client +over the wire, exactly as in the editor. + +## Building + +```sh +npm install +npm run build +``` + +This produces `dist/dap-server.js`. Run it directly with +`node dist/dap-server.js …`, or expose it as the `soroban-dap` command by +installing the package (`npm install -g .`, or `npm link` for local +development). (`npm run build` also builds the trace CLI — see +[`trace-cli.md`](./trace-cli.md).) + +Debugging a real contract needs the same tools as the editor — the [Stellar +CLI](https://developers.stellar.org/docs/tools/cli) and +[komet-node](https://github.com/runtimeverification/komet-node) on your `PATH` +(see the [main README](../README.md#requirements)) — unless the client launches +with a recorded `rawTrace` (offline replay). + +## Usage + +`soroban-dap --help`: + +```text +soroban-dap — serve the Soroban debug adapter over a TCP socket + +Usage: + soroban-dap [--port ] [--host ] + +Options: + --port TCP port to listen on (default 4711). + --host Interface to bind (default 127.0.0.1, loopback only). + -h, --help Show this help. + +Connect any DAP client to the port (e.g. VS Code "debugServer": ). +``` + +```sh +node dist/dap-server.js --port 4711 +# → Soroban DAP server listening on 127.0.0.1:4711 +``` + +The server binds **loopback** by default — it does not expose the debugger to +the network. Only override `--host` on a trusted, isolated network. + +## Connecting a client + +Any DAP client that can attach to a running server works. From VS Code, point a +launch configuration at the port with `debugServer`: + +```jsonc +{ + "type": "soroban", + "request": "launch", + "name": "Attach to soroban-dap", + "debugServer": 4711, + "rawTrace": "test/fixtures/adder-debug.trace.jsonl", + "wasmPath": "test/fixtures/adder-debug.wasm" +} +``` + +Other editors use their own attach mechanism (e.g. nvim-dap's `server`/`port` +adapter config) — the wire protocol is standard DAP, so any conformant client +can drive it. The full set of launch attributes is the same as the editor's +[configuration reference](../README.md#configuration-reference). + +> Plain request/response HTTP is intentionally not offered: DAP is a +> bidirectional, event-driven protocol that doesn't fit HTTP's request/response +> shape. A WebSocket transport could be added if a browser-based client needs one. diff --git a/docs/standalone-interfaces.md b/docs/standalone-interfaces.md deleted file mode 100644 index 20c9f06..0000000 --- a/docs/standalone-interfaces.md +++ /dev/null @@ -1,150 +0,0 @@ -# Standalone interfaces: CLI trace & DAP server - -> **Audience:** `soroban developer` (outside VS Code) · `CI / scripting user` · -> `AI agent integrator` · `other-editor user` -> -> **TL;DR:** How to use the debugger without VS Code. `soroban-trace` prints a -> Rust-source-level execution trace as JSONL (for scripts, CI, and agents); -> `soroban-dap` serves the debug adapter over TCP so nvim-dap/IntelliJ/Emacs can -> drive it. Includes building the tools, offline & live usage, and every flag. - -Besides the VS Code extension, the debugger ships two standalone command-line -tools for using it **outside the editor**: - -- **`soroban-trace`** — a one-shot CLI that prints a Rust-source-level execution - trace as JSONL. Built for scripts, CI, and AI agents that want to *read* an - execution rather than step through it interactively. -- **`soroban-dap`** — the debug adapter served over a TCP socket, so debuggers - *other* than VS Code (nvim-dap, IntelliJ, Emacs `dap-mode`, …) can drive it. - -Both are thin front-ends over the same replay engine the extension uses; see -[`interfaces.md`](./interfaces.md) for the internal design and the full JSONL -schema. - -```mermaid -flowchart LR - Q{"How do you want
to consume the debugger?"} - Q -->|"read an execution
(scripts / CI / AI agents)"| T["soroban-trace
one-shot JSONL trace"] - Q -->|"step interactively
in another editor"| D["soroban-dap
TCP DAP server"] - Q -->|"step interactively
in VS Code"| E["the VS Code extension
(see the main README)"] - T --> CORE["shared replay engine"] - D --> CORE - E --> CORE -``` - -## Building the tools - -```sh -npm install -npm run build -``` - -This produces `dist/trace.js` and `dist/dap-server.js`. Run them directly with -`node dist/trace.js …` / `node dist/dap-server.js …`, or expose them as the -`soroban-trace` / `soroban-dap` commands by installing the package -(`npm install -g .`, or `npm link` for local development). - -The **offline** examples below need no toolchain. Live mode (building and running -a contract) needs the same tools as the editor — the [Stellar -CLI](https://developers.stellar.org/docs/tools/cli) and -[komet-node](https://github.com/runtimeverification/komet-node) on your `PATH` -(see the [main README](../README.md#requirements)). - ---- - -## `soroban-trace` — Rust-level execution trace as JSONL - -Emits one JSON object per line: a leading `meta` record, one `stop` per -source-level statement (in execution order), and a trailing `result`. - -### Quick start (offline replay of a recorded run) - -```sh -node dist/trace.js \ - --raw-trace test/fixtures/adder-debug.trace.jsonl \ - --wasm test/fixtures/adder-debug.wasm -``` - -```jsonl -{"kind":"meta","wasm":"test/fixtures/adder-debug.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","terminated":true} -``` - -Each `stop` carries the source location, the enclosing function, the call -`depth`, the wasm `pc`/`instr`, and the in-scope variables decoded from DWARF -(aggregates expand into a nested `children` array, bounded by `--depth` / -`--max-children`). The full `SourceStop` / `TraceVar` field reference is in -[`interfaces.md`](./interfaces.md). - -### Live mode (build → deploy → run → trace) - -```sh -node dist/trace.js --contract ./path/to/crate --function add \ - --args-json '[{"value":1,"type":"u32"},{"value":2,"type":"u32"}]' \ - --out trace.jsonl -``` - -### Flags - -| Flag | Description | -|------|-------------| -| `--raw-trace ` | Replay a recorded JSONL trace (offline). Pair with `--wasm` for source/variables. | -| `--wasm ` | Contract `.wasm` supplying the DWARF debug info (source lines + variables). | -| `--contract ` | Live mode: crate directory to build, deploy, and run. | -| `--function ` | Live mode: contract function to invoke. | -| `--args-json ` | Function arguments, e.g. `'[{"value":1,"type":"u32"}]'`. | -| `--out ` | Write JSONL to a file instead of stdout. | -| `--depth ` | Max variable-expansion depth (default 3). | -| `--max-children ` | Max children materialized per aggregate (default 64). | -| `--allow-no-source` | Don't error when the trace has no DWARF source stops. | - -If the trace has no Rust source-level stops (no matching `--wasm`, or a -non-debug build) the command exits non-zero with a message, rather than emitting -a misleading trace — pass `--allow-no-source` to override. - ---- - -## `soroban-dap` — standalone DAP server over TCP - -Runs the debug adapter as a TCP server (DAP's canonical "server mode"). Each -client connection gets its own independent session; the launch configuration is -sent by the client over the wire, exactly as in the editor. - -```sh -node dist/dap-server.js --port 4711 -# → Soroban DAP server listening on 127.0.0.1:4711 -``` - -| Flag | Description | -|------|-------------| -| `--port ` | TCP port to listen on (default `4711`). | -| `--host ` | Interface to bind (default `127.0.0.1`, loopback only). | - -The server binds **loopback** by default — it does not expose the debugger to -the network. Only override `--host` on a trusted, isolated network. - -### Connecting a client - -Any DAP client that can attach to a running server works. From VS Code, point a -launch configuration at the port with `debugServer`: - -```jsonc -{ - "type": "soroban", - "request": "launch", - "name": "Attach to soroban-dap", - "debugServer": 4711, - "rawTrace": "test/fixtures/adder-debug.trace.jsonl", - "wasmPath": "test/fixtures/adder-debug.wasm" -} -``` - -Other editors use their own attach mechanism (e.g. nvim-dap's `server`/`port` -adapter config) — the wire protocol is standard DAP, so any conformant client -can drive it. The full set of launch attributes is the same as the editor's -[configuration reference](../README.md#configuration-reference). - -> Plain request/response HTTP is intentionally not offered: DAP is a -> bidirectional, event-driven protocol that doesn't fit HTTP's request/response -> shape. A WebSocket transport could be added if a browser-based client needs one. diff --git a/docs/interfaces.md b/docs/trace-cli-internal.md similarity index 63% rename from docs/interfaces.md rename to docs/trace-cli-internal.md index 707516c..9f2af2b 100644 --- a/docs/interfaces.md +++ b/docs/trace-cli-internal.md @@ -1,16 +1,16 @@ -# Debugger interfaces +# `soroban-trace` internals > **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). +> **TL;DR:** How `soroban-trace` turns a resolved trace into Rust-source-level +> JSONL, and the `vscode`-free shared core it sits on (also used by the DAP +> server — see [`dap-cli-internal.md`](./dap-cli-internal.md)). Documents the +> `SourceStop`/`TraceVar` schema and the ground-truth fixtures. User-facing +> usage is in [`trace-cli.md`](./trace-cli.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)). +The CLI is a pure pipeline over the same replay engine the VS Code extension +uses; it never touches the DAP stepping engine (S1–S20, see +[`stepping.md`](./stepping.md)). It needs no `SorobanDebugSession` at all: ```mermaid flowchart TB @@ -21,33 +21,23 @@ flowchart TB LB --> RT subgraph core["shared headless core — vscode-free"] - BSM["buildStopModel → StopModel
single source of truth for stop points"] + BSM["buildStopModel → StopModel"] PCA["pcAtIndex"] PSS["projectSourceStop
serializable, eager var expansion"] end RT --> BSM PCA --> PSS - - BSM --> SDS["SorobanDebugSession
DAP handlers + stepping engine"] - PCA --> SDS - BSM --> RCT["runCliTrace"] + BSM -->|"runStarts"| RCT["runCliTrace"] PSS --> RCT - - subgraph ifaces["interfaces"] - VSC["VS Code inline"] - DAP["soroban-dap
standalone TCP DAP server"] - CLI["soroban-trace
one-shot JSONL trace"] - end - - SDS --> VSC - SDS --> DAP - RCT --> CLI + RCT --> OUT["kind-tagged JSONL
meta · stop · result"] ``` ## Shared headless core All modules below are pure (no `vscode`, no DAP wire I/O) and unit-testable. +`backendFor`, `buildStopModel`, and `pcAtIndex` are also used by the DAP server +(see [`dap-cli-internal.md`](./dap-cli-internal.md)). ### `backendFor(args): SessionBackend` — `src/debugAdapter/backendFor.ts` @@ -132,12 +122,11 @@ interface TraceVar { } ``` -## Interface 1 — one-shot CLI (`soroban-trace`) — `src/trace/main.ts` + `runTrace.ts` +## `runCliTrace(resolved, opts): string[]` — `src/trace/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: +`runCliTrace` (pure) 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} @@ -145,37 +134,15 @@ emits kind-tagged JSONL: {"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 ` `--wasm ` (offline, the default -path); or live `--contract --function [--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": `, 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. +If `stopModel.runStarts` is empty (no DWARF / no source), it throws rather than +silently emitting `visibleIndices` as if they were source statements (the CLI's +`--allow-no-source` opt-in relaxes this). + +`src/trace/main.ts` is a thin, coverage-excluded entry: it delegates argv parsing to the +pure `parseTraceArgs` (`src/trace/cliArgs.ts`, unit-tested), then +`backendFor(args).resolve(...)`, `runCliTrace`, writes to stdout or `--out`, and +`backend.dispose()`. Help goes to stdout (exit 0); a usage error goes to stderr (exit 2); +a runtime failure exits 1. ## Ground-truth fixtures (verified) diff --git a/docs/trace-cli.md b/docs/trace-cli.md new file mode 100644 index 0000000..4350354 --- /dev/null +++ b/docs/trace-cli.md @@ -0,0 +1,123 @@ +# `soroban-trace` — Rust-level execution trace (CLI) + +> **Audience:** `soroban developer` (outside VS Code) · `CI / scripting user` · +> `AI agent integrator` +> +> **TL;DR:** `soroban-trace` builds and runs a contract once and prints a +> Rust-source-level execution trace as JSONL — one record per source statement, +> with the in-scope variables at that point. Built for scripts, CI, and AI +> agents that want to *read* an execution rather than step through it +> interactively. It can also replay a previously recorded run fully offline. For +> the standalone DAP server, see [`dap-cli.md`](./dap-cli.md); for internals, see +> [`trace-cli-internal.md`](./trace-cli-internal.md). + +`soroban-trace` is a thin front-end over the same replay engine the VS Code +extension uses. It emits one JSON object per line: a leading `meta` record, one +`stop` per source-level statement (in execution order), and a trailing +`result`. + +## Building + +```sh +npm install +npm run build +``` + +This produces `dist/trace.js`. Run it directly with `node dist/trace.js …`, or +expose it as the `soroban-trace` command by installing the package +(`npm install -g .`, or `npm link` for local development). (`npm run build` also +builds the DAP server — see [`dap-cli.md`](./dap-cli.md).) + +Live mode (the primary use below) builds and runs a contract, so it needs the +same tools as the editor — the [Stellar +CLI](https://developers.stellar.org/docs/tools/cli) and +[komet-node](https://github.com/runtimeverification/komet-node) on your `PATH` +(see the [main README](../README.md#requirements)). The offline replay at the end +needs no toolchain. + +## Usage + +`soroban-trace --help`: + +```text +soroban-trace — emit a Rust source-level execution trace as JSONL + +Usage: + soroban-trace --raw-trace [--wasm ] [options] (offline replay) + soroban-trace --contract --function [options] (build & run live) + +Options: + --raw-trace Recorded JSONL trace to replay (offline mode). + --wasm Contract .wasm supplying DWARF debug info (source + variables). + --contract Crate directory to build and run (live mode). + --function Contract function to invoke (required in live mode). + --args-json Function arguments, e.g. '[{"value":1,"type":"u32"}]'. + --out Write JSONL to a file instead of stdout. + --depth Max variable-expansion depth (default 3). + --max-children Max children materialized per aggregate (default 64). + --allow-no-source Don't error when the trace has no source-level stops. + -h, --help Show this help. + +Examples: + soroban-trace --raw-trace run.jsonl --wasm contract.wasm + soroban-trace --contract . --function add --args-json '[{"value":1,"type":"u32"}]' +``` + +## Quick start (build → deploy → run → trace) + +Trace the bundled [`examples/adder`](../examples/adder) contract — a debug build +of `add(a, b) -> u32` — invoking `add(4, 3)`. Run from the repository root: + +```sh +node dist/trace.js \ + --contract examples/adder \ + --function add \ + --args-json '[{"value":4,"type":"u32"},{"value":3,"type":"u32"}]' +``` + +This builds the crate with DWARF debug info at opt-level 0, spawns komet-node, +deploys, invokes `add(4, 3)`, and streams the resulting source-level trace as +JSONL to stdout: + +```jsonl +{"kind":"meta","function":"add","args":[{"value":4,"type":"u32"},{"value":3,"type":"u32"}],"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","terminated":true} +``` + +Each `stop` carries the source location, the enclosing function, the call +`depth`, the wasm `pc`/`instr`, and the in-scope variables decoded from DWARF +(aggregates expand into a nested `children` array, bounded by `--depth` / +`--max-children`). The full `SourceStop` / `TraceVar` field reference is in +[`trace-cli-internal.md`](./trace-cli-internal.md). + +Add `--out trace.jsonl` to write to a file instead of stdout. Other bundled +crates to try: `examples/increment --function increment`, +`examples/stepper --function sum_triples`, `examples/greeter --function store` +(see [`examples/README.md`](../examples/README.md) for each contract's shape). + +> **komet-node in this devcontainer:** the `komet-node` on `$PATH` here is a +> stale build that hangs on value-returning calls (`add`, `increment`). Prefix +> the rebuilt node — `PATH=/home/node/.komet-node/bin:$PATH node dist/trace.js …` +> — until the fix lands on `$PATH`. See +> [`examples/README.md`](../examples/README.md#komet-node-version-note) for the +> full note. + +## Offline replay (a recorded run, no toolchain) + +Already have a recorded `komet-node` trace? Replay it with no build and no +komet-node — pass the matching debug `--wasm` to get source-level stops: + +```sh +node dist/trace.js \ + --raw-trace test/fixtures/adder-debug.trace.jsonl \ + --wasm test/fixtures/adder-debug.wasm +``` + +This produces the same trace as the live command above (it *is* a recorded +`add(4, 3)` run of `examples/adder`), which makes it a handy zero-dependency way +to see the output format. + +If the trace has no Rust source-level stops (no matching `--wasm`, or a +non-debug build) the command exits non-zero with a message, rather than emitting +a misleading trace — pass `--allow-no-source` to override. diff --git a/examples/README.md b/examples/README.md index fd7358f..033c1f5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -88,6 +88,20 @@ Once the nix cache ships the fix, delete that setting and the extension will use `komet-node` from `$PATH`. (The `soroban.stellar.path` setting works the same way for the Stellar CLI.) +The standalone `trace.js` CLI does **not** read the VS Code settings — it always +spawns `komet-node` from `$PATH`. In this devcontainer that is still the stale +build, so a value-returning live call (`add`, `increment`) hangs until the RPC +times out. To use the rebuilt node, put it ahead of the stale one on `$PATH`: + +```sh +PATH=/home/node/.komet-node/bin:$PATH \ +node ../../dist/trace.js --contract . --function add \ + --args-json '[{"value":1,"type":"u32"},{"value":2,"type":"u32"}]' \ + --out trace.jsonl +``` + +Once the fix is on `$PATH`, the `PATH` prefix can be dropped. + ## Adding your own contract Drop a new crate directory here (with a `Cargo.toml` exposing a `#[contract]`), diff --git a/src/debugAdapter/backendFor.ts b/src/debugAdapter/backendFor.ts index 432cee6..417e08f 100644 --- a/src/debugAdapter/backendFor.ts +++ b/src/debugAdapter/backendFor.ts @@ -1,5 +1,5 @@ /** - * Trace-acquisition backend selector (docs/interfaces.md, "backendFor"). + * Trace-acquisition backend selector (docs/trace-cli-internal.md, "backendFor"). * * Picks the vscode-free backend from the launch args: `args.rawTrace` present → * RawTraceBackend (offline JSONL replay), else LiveBackend (the full diff --git a/src/debugAdapter/stopModel.ts b/src/debugAdapter/stopModel.ts index 038d18a..b36eeab 100644 --- a/src/debugAdapter/stopModel.ts +++ b/src/debugAdapter/stopModel.ts @@ -1,5 +1,5 @@ /** - * The shared headless stop model (docs/interfaces.md, "Shared headless core"). + * The shared headless stop model (docs/trace-cli-internal.md, "Shared headless core"). * * `buildStopModel` is the single source of truth for a trace's stop points, so * the IDE (SorobanDebugSession) and the CLI can never disagree about where a diff --git a/src/pipeline/TurnkeyPipeline.ts b/src/pipeline/TurnkeyPipeline.ts index 33c2add..8c5c25c 100644 --- a/src/pipeline/TurnkeyPipeline.ts +++ b/src/pipeline/TurnkeyPipeline.ts @@ -89,11 +89,7 @@ export class TurnkeyPipeline { const result = await client.getTransaction(sent.hash); if (result.status === 'FAILED') { throw new Error( - `Invocation of ${args.function}(...) failed on komet-node (status FAILED, tx ${sent.hash}). ` + - `Note: the current komet-node only completes contract calls that return no value — ` + - `its callTx handling asserts a Void result, so any function returning a value gets ` + - `stuck and the transaction is reported FAILED. Try a function that returns () / no value, ` + - `or update komet-node.`, + `Invocation of ${args.function}(...) failed on komet-node (status FAILED, tx ${sent.hash}).`, ); } diff --git a/src/server/cliArgs.ts b/src/server/cliArgs.ts new file mode 100644 index 0000000..df8a603 --- /dev/null +++ b/src/server/cliArgs.ts @@ -0,0 +1,87 @@ +/** + * Pure argv parsing for the standalone TCP DAP server CLI (`soroban-dap`). + * + * `parseServerArgs` resolves `--help`, validates `--host`/`--port`, and maps a + * raw argv slice onto a discriminated union the (coverage-excluded) shell + * dispatches on. PURE: never reads process.argv, never prints, never exits. + */ + +/** The `soroban-dap` help text. */ +export const SERVER_USAGE = `soroban-dap — serve the Soroban debug adapter over a TCP socket + +Usage: + soroban-dap [--port ] [--host ] + +Options: + --port TCP port to listen on (default 4711). + --host Interface to bind (default 127.0.0.1, loopback only). + -h, --help Show this help. + +Connect any DAP client to the port (e.g. VS Code "debugServer": ). +`; + +/** Outcome of parsing `soroban-dap` argv. */ +export type ServerParse = + | { kind: 'help'; text: string } + | { kind: 'error'; message: string } + | { kind: 'run'; host?: string; port: number }; + +const SERVER_HINT = "Run 'soroban-dap --help' for usage."; + +/** Value-taking flags for `soroban-dap`. */ +const SERVER_VALUE_FLAGS = new Set(['--host', '--port']); + +/** Whether a token is being used as an option (leading dash). */ +function looksLikeFlag(token: string): boolean { + return token.startsWith('-'); +} + +/** + * Devex front door for `soroban-dap`: resolve `--help`, validate tokens and + * `--port`, and map argv onto a `ServerParse`. Pure. + */ +export function parseServerArgs(argv: string[]): ServerParse { + // --help / -h wins anywhere. + if (argv.includes('-h') || argv.includes('--help')) { + return { kind: 'help', text: SERVER_USAGE }; + } + + const err = (message: string): ServerParse => ({ + kind: 'error', + message: `${message} ${SERVER_HINT}`, + }); + + const values: Record = {}; + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (SERVER_VALUE_FLAGS.has(token)) { + const next = argv[i + 1]; + if (next === undefined || looksLikeFlag(next)) { + return err(`Missing value for ${token}`); + } + values[token] = next; + i++; + } else if (looksLikeFlag(token)) { + return err(`Unknown option: ${token}`); + } else { + return err(`Unexpected argument: ${token}`); + } + } + + const host = values['--host']; + const portRaw = values['--port']; + + let port = 4711; + if (portRaw !== undefined) { + if (!/^\d+$/.test(portRaw)) { + return err('--port must be an integer between 0 and 65535.'); + } + port = Number(portRaw); + if (port > 65535) { + return err('--port must be an integer between 0 and 65535.'); + } + } + + return { kind: 'run', host, port }; +} diff --git a/src/server/dapServer.ts b/src/server/dapServer.ts index 05d821e..f0822cf 100644 --- a/src/server/dapServer.ts +++ b/src/server/dapServer.ts @@ -1,5 +1,5 @@ /** - * Standalone TCP DAP server (`soroban-dap`) — docs/interfaces.md, "Interface 2". + * Standalone TCP DAP server (`soroban-dap`) — docs/dap-cli-internal.md, "Interface 2". * * Opens a `net.createServer`; for each connection it creates a fresh * `SorobanDebugSession(backendFor)` (the selector overload, so the backend can @@ -37,7 +37,7 @@ export async function startDapServer( const session = new SorobanDebugSession(select); // Server mode: a dropped connection must tear down only this session, never // the shared server process. Without this, DebugSession.shutdown() calls - // process.exit(0) 100ms after any socket closes (docs/interfaces.md, + // process.exit(0) 100ms after any socket closes (docs/dap-cli-internal.md, // "DAP's canonical server mode"). session.setRunAsServer(true); session.start(socket, socket); diff --git a/src/server/main.ts b/src/server/main.ts index b88a292..c3c3bcf 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -1,28 +1,31 @@ /** * Thin CLI entry for the standalone TCP DAP server (`soroban-dap`). * - * Parses `--host`/`--port` (default 4711) from argv, starts the server, and - * logs the listening address to stderr. Coverage-excluded: the real logic lives - * in `dapServer.ts`, exercised end-to-end by the integration test. + * Parses argv (via `parseServerArgs`), then dispatches: show help (stdout, exit + * 0), report a usage error (stderr, exit 2), or start the server and log the + * listening address to stderr. Coverage-excluded: the real logic lives in + * `cliArgs.ts` / `dapServer.ts`, exercised directly by tests. */ +import { parseServerArgs } from './cliArgs'; import { startDapServer } from './dapServer'; -/** Read the value following a flag, or undefined when absent. */ -function flag(argv: string[], name: string): string | undefined { - const i = argv.indexOf(name); - return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; -} - async function main(): Promise { - const argv = process.argv.slice(2); - const host = flag(argv, '--host'); - const portFlag = flag(argv, '--port'); - const port = portFlag !== undefined ? Number(portFlag) : 4711; + const p = parseServerArgs(process.argv.slice(2)); + + if (p.kind === 'help') { + process.stdout.write(p.text + '\n'); + return; + } + if (p.kind === 'error') { + process.stderr.write(p.message + '\n'); + process.exitCode = 2; + return; + } - const srv = await startDapServer({ host, port }); + const srv = await startDapServer({ host: p.host, port: p.port }); process.stderr.write( - `Soroban DAP server listening on ${host ?? '127.0.0.1'}:${srv.port}\n`, + `Soroban DAP server listening on ${p.host ?? '127.0.0.1'}:${srv.port}\n`, ); } diff --git a/src/trace/cliArgs.ts b/src/trace/cliArgs.ts new file mode 100644 index 0000000..c2de16c --- /dev/null +++ b/src/trace/cliArgs.ts @@ -0,0 +1,166 @@ +/** + * Pure argv parsing for the one-shot trace CLI (`soroban-trace`). + * + * Extracted from the coverage-excluded `main.ts` so the flag semantics can be + * unit-tested directly. `parseTraceArgs` is the devex front door: it resolves + * `--help`, validates tokens and mode selection, and maps argv onto a + * discriminated union the (coverage-excluded) shell dispatches on. PURE: never + * reads process.argv, never prints, never exits. + */ + +import { SorobanLaunchArgs } from '../debugAdapter/types'; +import { ScValArg } from '../soroban/scval'; + +/** The `soroban-trace` help text. */ +export const TRACE_USAGE = `soroban-trace — emit a Rust source-level execution trace as JSONL + +Usage: + soroban-trace --raw-trace [--wasm ] [options] (offline replay) + soroban-trace --contract --function [options] (build & run live) + +Options: + --raw-trace Recorded JSONL trace to replay (offline mode). + --wasm Contract .wasm supplying DWARF debug info (source + variables). + --contract Crate directory to build and run (live mode). + --function Contract function to invoke (required in live mode). + --args-json Function arguments, e.g. '[{"value":1,"type":"u32"}]'. + --out Write JSONL to a file instead of stdout. + --depth Max variable-expansion depth (default 3). + --max-children Max children materialized per aggregate (default 64). + --allow-no-source Don't error when the trace has no source-level stops. + -h, --help Show this help. + +Examples: + soroban-trace --raw-trace run.jsonl --wasm contract.wasm + soroban-trace --contract . --function add --args-json '[{"value":1,"type":"u32"}]' +`; + +/** Outcome of parsing `soroban-trace` argv. */ +export type TraceParse = + | { kind: 'help'; text: string } + | { kind: 'error'; message: string } + | { + kind: 'run'; + launch: SorobanLaunchArgs; + out?: string; + opts: { maxDepth?: number; maxChildren?: number; allowNoSource?: boolean }; + }; + +const TRACE_HINT = "Run 'soroban-trace --help' for usage."; + +/** Value-taking flags for `soroban-trace`. */ +const TRACE_VALUE_FLAGS = new Set([ + '--raw-trace', + '--wasm', + '--contract', + '--function', + '--args-json', + '--out', + '--depth', + '--max-children', +]); + +/** Whether a token is being used as an option (leading dash). */ +function looksLikeFlag(token: string): boolean { + return token.startsWith('-'); +} + +/** Whether a string is a non-negative integer literal. */ +function isNonNegInt(s: string): boolean { + return /^\d+$/.test(s); +} + +/** + * Devex front door for `soroban-trace`: resolve `--help`, validate tokens and + * mode selection, and map argv onto a `TraceParse`. Pure. + */ +export function parseTraceArgs(argv: string[]): TraceParse { + // --help / -h wins anywhere. + if (argv.includes('-h') || argv.includes('--help')) { + return { kind: 'help', text: TRACE_USAGE }; + } + + const err = (message: string): TraceParse => ({ + kind: 'error', + message: `${message} ${TRACE_HINT}`, + }); + + const values: Record = {}; + let allowNoSource = false; + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (TRACE_VALUE_FLAGS.has(token)) { + const next = argv[i + 1]; + if (next === undefined || looksLikeFlag(next)) { + return err(`Missing value for ${token}`); + } + values[token] = next; + i++; + } else if (token === '--allow-no-source') { + allowNoSource = true; + } else if (looksLikeFlag(token)) { + return err(`Unknown option: ${token}`); + } else { + return err(`Unexpected argument: ${token}`); + } + } + + const rawTrace = values['--raw-trace']; + const wasmPath = values['--wasm']; + const contract = values['--contract']; + const fn = values['--function']; + const argsJson = values['--args-json']; + const out = values['--out']; + const depth = values['--depth']; + const maxChildren = values['--max-children']; + + // Mode selection. + if (rawTrace === undefined && contract === undefined && wasmPath === undefined) { + return err( + 'Specify --raw-trace for offline replay, or --contract/--wasm with --function for live mode.', + ); + } + // Live mode (no --raw-trace) requires --function. + if (rawTrace === undefined && fn === undefined) { + return err('--function is required in live mode.'); + } + + // --args-json must JSON.parse to an array. + let args: ScValArg[] | undefined; + if (argsJson !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(argsJson); + } catch (e) { + return err(`Invalid --args-json: ${e instanceof Error ? e.message : String(e)}`); + } + if (!Array.isArray(parsed)) { + return err('Invalid --args-json: expected a JSON array.'); + } + args = parsed as ScValArg[]; + } + + // Numeric options must be non-negative integers. + if (depth !== undefined && !isNonNegInt(depth)) { + return err('--depth must be a non-negative integer.'); + } + if (maxChildren !== undefined && !isNonNegInt(maxChildren)) { + return err('--max-children must be a non-negative integer.'); + } + + const launch: SorobanLaunchArgs = { + function: fn ?? '', + rawTrace, + wasmPath, + contract, + ...(args !== undefined ? { args } : {}), + }; + + const opts: { maxDepth?: number; maxChildren?: number; allowNoSource?: boolean } = {}; + if (depth !== undefined) opts.maxDepth = Number(depth); + if (maxChildren !== undefined) opts.maxChildren = Number(maxChildren); + if (allowNoSource) opts.allowNoSource = true; + + return { kind: 'run', launch, out, opts }; +} diff --git a/src/trace/main.ts b/src/trace/main.ts index db50248..95d4e4f 100644 --- a/src/trace/main.ts +++ b/src/trace/main.ts @@ -1,62 +1,39 @@ /** * Thin CLI entry for the one-shot trace projection (`soroban-trace`). * - * Parses argv, builds launch args, resolves a trace through the selected - * backend, projects it to JSONL via `runCliTrace`, and writes the result to - * `--out` (or stdout). Coverage-excluded: the real logic lives in - * `runTrace.ts` / `projectStop.ts`, which are exercised directly by unit tests. + * Parses argv (via `parseTraceArgs`), then dispatches: show help (stdout, exit + * 0), report a usage error (stderr, exit 2), or resolve a trace through the + * selected backend, project it to JSONL via `runCliTrace`, and write the result + * to `--out` (or stdout). Coverage-excluded: the real logic lives in + * `cliArgs.ts` / `runTrace.ts` / `projectStop.ts`, exercised directly by tests. */ import * as fs from 'fs'; import { backendFor } from '../debugAdapter/backendFor'; -import { SorobanLaunchArgs } from '../debugAdapter/types'; -import { ScValArg } from '../soroban/scval'; +import { parseTraceArgs } from './cliArgs'; import { runCliTrace } from './runTrace'; -/** Read the value following a flag, or undefined when absent. */ -function flag(argv: string[], name: string): string | undefined { - const i = argv.indexOf(name); - return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; -} - -/** Whether a boolean flag is present. */ -function has(argv: string[], name: string): boolean { - return argv.includes(name); -} - async function main(): Promise { - const argv = process.argv.slice(2); + const p = parseTraceArgs(process.argv.slice(2)); - const rawTrace = flag(argv, '--raw-trace'); - const wasmPath = flag(argv, '--wasm'); - const contract = flag(argv, '--contract'); - const fn = flag(argv, '--function'); - const argsJson = flag(argv, '--args-json'); - const out = flag(argv, '--out'); - const depth = flag(argv, '--depth'); - const maxChildrenFlag = flag(argv, '--max-children'); - const allowNoSource = has(argv, '--allow-no-source'); - - const args: SorobanLaunchArgs = { - function: fn ?? '', - rawTrace, - wasmPath, - contract, - args: argsJson ? (JSON.parse(argsJson) as ScValArg[]) : undefined, - }; - - const maxDepth = depth !== undefined ? Number(depth) : undefined; - const maxChildren = maxChildrenFlag !== undefined ? Number(maxChildrenFlag) : undefined; + if (p.kind === 'help') { + process.stdout.write(p.text + '\n'); + return; + } + if (p.kind === 'error') { + process.stderr.write(p.message + '\n'); + process.exitCode = 2; + return; + } - const backend = backendFor(args); + const { launch, out, opts } = p; + const backend = backendFor(launch); try { - const resolved = await backend.resolve(args, (msg) => process.stderr.write(msg + '\n')); + const resolved = await backend.resolve(launch, (msg) => process.stderr.write(msg + '\n')); const lines = runCliTrace(resolved, { - function: args.function, - wasm: args.wasmPath, - maxDepth, - maxChildren, - allowNoSource, + function: launch.function, + wasm: launch.wasmPath, + ...opts, }); const output = lines.join('\n') + '\n'; if (out) { diff --git a/src/trace/projectStop.ts b/src/trace/projectStop.ts index 4a5de5e..fa2bace 100644 --- a/src/trace/projectStop.ts +++ b/src/trace/projectStop.ts @@ -1,5 +1,5 @@ /** - * A serializable projection of one trace stop (docs/interfaces.md, + * A serializable projection of one trace stop (docs/trace-cli-internal.md, * "projectSourceStop"). Unlike the DAP handlers — whose lazy `Handles` / * child-thunk machinery is deliberately different — this reuses only the * low-level resolver calls and expands `DecodedValue.children` EAGERLY into diff --git a/src/trace/runTrace.ts b/src/trace/runTrace.ts index 5565ce9..fbde0ff 100644 --- a/src/trace/runTrace.ts +++ b/src/trace/runTrace.ts @@ -1,5 +1,5 @@ /** - * The one-shot CLI trace projection (docs/interfaces.md, "Interface 1"). + * The one-shot CLI trace projection (docs/trace-cli-internal.md, "Interface 1"). * * `runCliTrace` walks `stopModel.runStarts` in order — provably the same * sequence a user sees stepping in (statement-granularity stepIn visits diff --git a/test/backendFor.test.ts b/test/backendFor.test.ts index bafc739..5c7d2c1 100644 --- a/test/backendFor.test.ts +++ b/test/backendFor.test.ts @@ -1,5 +1,5 @@ /** - * Unit suite for the trace-acquisition backend selector (docs/interfaces.md, + * Unit suite for the trace-acquisition backend selector (docs/trace-cli-internal.md, * "backendFor"). backendFor(args) picks the vscode-free backend from the launch * args, reused by the extension, the TCP server, and the CLI: * @@ -15,7 +15,7 @@ import { backendFor } from '../src/debugAdapter/backendFor'; import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend'; import { LiveBackend } from '../src/debugAdapter/backends/LiveBackend'; -describe('backendFor (docs/interfaces.md, backend selection)', () => { +describe('backendFor (docs/trace-cli-internal.md, backend selection)', () => { it('selects RawTraceBackend when rawTrace is present', () => { const backend = backendFor({ rawTrace: 'x.jsonl' } as any); assert.ok( diff --git a/test/dapServer.test.ts b/test/dapServer.test.ts index 20f11b1..3c33016 100644 --- a/test/dapServer.test.ts +++ b/test/dapServer.test.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { DebugClient } from '@vscode/debugadapter-testsupport'; import { DebugProtocol } from '@vscode/debugprotocol'; // src/server/dapServer.ts does not exist yet — this import is what makes the -// suite fail to compile/run until M2's TCP server lands (docs/interfaces.md, +// suite fail to compile/run until M2's TCP server lands (docs/dap-cli-internal.md, // "Interface 2 — standalone TCP DAP server"). import { startDapServer } from '../src/server/dapServer'; import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend'; diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts index f63748b..fd6b920 100644 --- a/test/pipeline.test.ts +++ b/test/pipeline.test.ts @@ -91,7 +91,20 @@ describe('TurnkeyPipeline (against mock komet-node)', () => { await mock.stop(); mock = new MockKometNode({ trace, traceStatus: 'FAILED' }); port = await mock.start(); - await assert.rejects(() => run(), /FAILED/); + await assert.rejects( + () => run(), + (err: Error) => { + // Still flags the FAILED status and identifies the invocation... + assert.match(err.message, /FAILED/); + assert.match(err.message, /add/); + // ...and pins the invocation to its transaction hash (mock's hashFor + // yields a 64-char hex hash, so this stays non-brittle). + assert.match(err.message, /tx [0-9a-f]{64}/); + // ...but no longer parrots the (now-fixed) value-return limitation. + assert.doesNotMatch(err.message, /no value|Void|update komet-node|stuck/i); + return true; + }, + ); }); }); diff --git a/test/projectStop.test.ts b/test/projectStop.test.ts index da1cce3..28f04d0 100644 --- a/test/projectStop.test.ts +++ b/test/projectStop.test.ts @@ -1,6 +1,6 @@ /** * Unit suite for the serializable single-stop projection - * (docs/interfaces.md, "projectSourceStop"). The module under test does not + * (docs/trace-cli-internal.md, "projectSourceStop"). The module under test does not * exist yet — this is the red anchor for it. * * projectSourceStop(resolved, stopModel, index, opts): SourceStop @@ -10,7 +10,7 @@ * functionNameAt, makeRuntimeState + variablesInScope + decodeVariable) and, * unlike the DAP handlers, expands DecodedValue.children EAGERLY into plain * arrays bounded by a per-stop depth/child budget. Values are pinned to the - * verified ground-truth fixtures (docs/interfaces.md ground-truth table). + * verified ground-truth fixtures (docs/trace-cli-internal.md ground-truth table). */ import * as assert from 'assert'; @@ -31,7 +31,7 @@ async function resolveFixture(name: string): Promise { return new RawTraceBackend().resolve(args as any, () => {}); } -describe('projectSourceStop (docs/interfaces.md, serializable stop projection)', () => { +describe('projectSourceStop (docs/trace-cli-internal.md, serializable stop projection)', () => { describe('adder-debug idx 29 (the sole statement stop)', () => { let resolved: ResolvedTrace; diff --git a/test/runTrace.test.ts b/test/runTrace.test.ts index d24f97a..a76f883 100644 --- a/test/runTrace.test.ts +++ b/test/runTrace.test.ts @@ -1,6 +1,6 @@ /** * Unit suite for the one-shot CLI trace projection - * (docs/interfaces.md, "Interface 1 — one-shot CLI"). The module under test + * (docs/trace-cli-internal.md, "Interface 1 — one-shot CLI"). The module under test * does not exist yet — this is the red anchor for it. * * runCliTrace(resolved, opts): string[] from src/trace/runTrace.ts @@ -31,7 +31,7 @@ async function resolveFixture(name: string): Promise { return new RawTraceBackend().resolve(args as any, () => {}); } -describe('runCliTrace (docs/interfaces.md, one-shot CLI JSONL)', () => { +describe('runCliTrace (docs/trace-cli-internal.md, one-shot CLI JSONL)', () => { describe('adder-debug (records=41, runStarts=[29])', () => { let resolved: ResolvedTrace; diff --git a/test/serverCli.test.ts b/test/serverCli.test.ts new file mode 100644 index 0000000..d6a7bb8 --- /dev/null +++ b/test/serverCli.test.ts @@ -0,0 +1,130 @@ +/** + * Unit suite for the pure argv parser behind the DAP TCP server CLI + * (`soroban-dap`), milestone M3 (CLI devex: --help + argument validation). + * + * parseServerArgs(argv): ServerParse from src/server/cliArgs.ts + * SERVER_USAGE: string the help text + * + * The parser is PURE — it never touches process.argv, never prints, never + * exits. It maps a raw argv slice onto a discriminated union describing what + * the (coverage-excluded) main.ts shell should do: show help, report a usage + * error, or run with a host/port. + * + * This module does not exist yet, so this is the red anchor for it. + */ + +import * as assert from 'assert'; +import { parseServerArgs, SERVER_USAGE, ServerParse } from '../src/server/cliArgs'; + +/** Narrow a ServerParse to the 'run' variant (fails the test otherwise). */ +function asRun(p: ServerParse): Extract { + assert.strictEqual(p.kind, 'run', `expected run, got ${p.kind}: ${JSON.stringify(p)}`); + return p as Extract; +} + +/** Narrow a ServerParse to the 'error' variant and return its message. */ +function asError(p: ServerParse): string { + assert.strictEqual(p.kind, 'error', `expected error, got ${p.kind}: ${JSON.stringify(p)}`); + return (p as Extract).message; +} + +/** Narrow a ServerParse to the 'help' variant and return its text. */ +function asHelp(p: ServerParse): string { + assert.strictEqual(p.kind, 'help', `expected help, got ${p.kind}: ${JSON.stringify(p)}`); + return (p as Extract).text; +} + +describe('parseServerArgs (M3 CLI devex)', () => { + describe('help', () => { + it('--help → kind "help" with the usage text', () => { + const text = asHelp(parseServerArgs(['--help'])); + assert.strictEqual(text, SERVER_USAGE); + }); + + it('-h → kind "help" with the usage text', () => { + const text = asHelp(parseServerArgs(['-h'])); + assert.strictEqual(text, SERVER_USAGE); + }); + + it('--help wins even alongside otherwise-valid flags', () => { + const text = asHelp(parseServerArgs(['--port', '5000', '--help'])); + assert.strictEqual(text, SERVER_USAGE); + }); + + it('SERVER_USAGE carries the documented stable substrings', () => { + for (const needle of ['soroban-dap', 'Usage', '--port', '--host', '-h, --help']) { + assert.ok(SERVER_USAGE.includes(needle), `SERVER_USAGE should contain ${needle}`); + } + }); + }); + + describe('defaults', () => { + it('[] → run with port 4711 and host undefined', () => { + const p = asRun(parseServerArgs([])); + assert.strictEqual(p.port, 4711); + assert.strictEqual(p.host, undefined); + }); + }); + + describe('--port', () => { + it('--port 5000 → run with port 5000', () => { + const p = asRun(parseServerArgs(['--port', '5000'])); + assert.strictEqual(p.port, 5000); + }); + + it('--port 0 → run with port 0 (valid boundary)', () => { + const p = asRun(parseServerArgs(['--port', '0'])); + assert.strictEqual(p.port, 0); + }); + + it('--port 65535 → run with port 65535 (valid upper boundary)', () => { + const p = asRun(parseServerArgs(['--port', '65535'])); + assert.strictEqual(p.port, 65535); + }); + + it('--port abc (non-integer) → error mentioning --port', () => { + const msg = asError(parseServerArgs(['--port', 'abc'])); + assert.ok(msg.includes('--port'), msg); + }); + + it('--port 65536 (off-by-one, out of range) → error "between 0 and 65535"', () => { + const msg = asError(parseServerArgs(['--port', '65536'])); + assert.ok(msg.includes('between 0 and 65535'), msg); + }); + + it('--port 70000 (out of range) → error "between 0 and 65535"', () => { + const msg = asError(parseServerArgs(['--port', '70000'])); + assert.ok(msg.includes('between 0 and 65535'), msg); + }); + + it('--port with no value → "Missing value for --port"', () => { + const msg = asError(parseServerArgs(['--port'])); + assert.ok(msg.includes('Missing value for --port'), msg); + }); + + it('--host --port (value is a flag) → "Missing value for --host"', () => { + const msg = asError(parseServerArgs(['--host', '--port'])); + assert.ok(msg.includes('Missing value for --host'), msg); + }); + }); + + describe('--host', () => { + it('--host 0.0.0.0 --port 9229 → run with both set', () => { + const p = asRun(parseServerArgs(['--host', '0.0.0.0', '--port', '9229'])); + assert.strictEqual(p.host, '0.0.0.0'); + assert.strictEqual(p.port, 9229); + }); + }); + + describe('token validation', () => { + it('unknown flag → "Unknown option: --nope"', () => { + const msg = asError(parseServerArgs(['--nope'])); + assert.ok(msg.includes('Unknown option: --nope'), msg); + }); + + it('stray positional → "Unexpected argument: stray"', () => { + const msg = asError(parseServerArgs(['stray'])); + assert.ok(msg.includes('Unexpected argument: stray'), msg); + }); + }); +}); diff --git a/test/stopModel.test.ts b/test/stopModel.test.ts index 1c17561..5f232c3 100644 --- a/test/stopModel.test.ts +++ b/test/stopModel.test.ts @@ -1,5 +1,5 @@ /** - * Unit suite for the shared headless stop model (docs/interfaces.md, "Shared + * Unit suite for the shared headless stop model (docs/trace-cli-internal.md, "Shared * headless core"). Two pure, vscode-free functions in * src/debugAdapter/stopModel.ts are the single source of truth the CLI and the * DAP session both build on, so they can never disagree about where a "stop" is: @@ -33,7 +33,7 @@ async function resolveFixture(name: string): Promise { return new RawTraceBackend().resolve(args as any, () => {}); } -describe('buildStopModel (docs/interfaces.md, shared headless core)', () => { +describe('buildStopModel (docs/trace-cli-internal.md, shared headless core)', () => { describe('adder-debug fixture', () => { let resolved: ResolvedTrace; @@ -83,7 +83,7 @@ describe('buildStopModel (docs/interfaces.md, shared headless core)', () => { }); }); -describe('pcAtIndex (docs/interfaces.md, current-PC rule)', () => { +describe('pcAtIndex (docs/trace-cli-internal.md, current-PC rule)', () => { it('returns the validated code offset at the index (adder idx 29 → 45)', async () => { const resolved = await resolveFixture('adder-debug'); assert.strictEqual(pcAtIndex(resolved.positions, 29), 45); diff --git a/test/traceCli.test.ts b/test/traceCli.test.ts new file mode 100644 index 0000000..441ddb1 --- /dev/null +++ b/test/traceCli.test.ts @@ -0,0 +1,206 @@ +/** + * Unit suite for the pure argv parser behind the one-shot trace CLI + * (`soroban-trace`), milestone M3 (CLI devex: --help + argument validation). + * + * parseTraceArgs(argv): TraceParse from src/trace/cliArgs.ts + * TRACE_USAGE: string the help text + * + * The parser is PURE — it never touches process.argv, never prints, never + * exits. It maps a raw argv slice onto a discriminated union describing what + * the (coverage-excluded) main.ts shell should do: show help, report a usage + * error, or run with a resolved launch + projection options. + * + * These modules do not export this API yet, so this is the red anchor for it. + */ + +import * as assert from 'assert'; +import { parseTraceArgs, TRACE_USAGE, TraceParse } from '../src/trace/cliArgs'; + +/** Narrow a TraceParse to the 'run' variant (fails the test otherwise). */ +function asRun(p: TraceParse): Extract { + assert.strictEqual(p.kind, 'run', `expected run, got ${p.kind}: ${JSON.stringify(p)}`); + return p as Extract; +} + +/** Narrow a TraceParse to the 'error' variant and return its message. */ +function asError(p: TraceParse): string { + assert.strictEqual(p.kind, 'error', `expected error, got ${p.kind}: ${JSON.stringify(p)}`); + return (p as Extract).message; +} + +/** Narrow a TraceParse to the 'help' variant and return its text. */ +function asHelp(p: TraceParse): string { + assert.strictEqual(p.kind, 'help', `expected help, got ${p.kind}: ${JSON.stringify(p)}`); + return (p as Extract).text; +} + +describe('parseTraceArgs (M3 CLI devex)', () => { + describe('help', () => { + it('--help → kind "help" with the usage text', () => { + const text = asHelp(parseTraceArgs(['--help'])); + assert.strictEqual(text, TRACE_USAGE); + }); + + it('-h → kind "help" with the usage text', () => { + const text = asHelp(parseTraceArgs(['-h'])); + assert.strictEqual(text, TRACE_USAGE); + }); + + it('--help wins even alongside otherwise-valid flags', () => { + const text = asHelp(parseTraceArgs(['--raw-trace', 'run.jsonl', '--help'])); + assert.strictEqual(text, TRACE_USAGE); + }); + + it('TRACE_USAGE carries the documented stable substrings', () => { + for (const needle of [ + 'soroban-trace', + 'Usage', + '--raw-trace', + '--wasm', + '--contract', + '--function', + '--allow-no-source', + '-h, --help', + 'Examples', + ]) { + assert.ok(TRACE_USAGE.includes(needle), `TRACE_USAGE should contain ${needle}`); + } + }); + }); + + describe('mode selection', () => { + it('[] → error, must specify a mode', () => { + const msg = asError(parseTraceArgs([])); + assert.ok(msg.includes('Specify --raw-trace'), msg); + }); + + it('error messages carry the --help hint', () => { + const msg = asError(parseTraceArgs([])); + assert.ok(msg.includes('--help'), `error should hint at --help: ${msg}`); + }); + + it('--contract without --function → error, function required in live mode', () => { + const msg = asError(parseTraceArgs(['--contract', '.'])); + assert.ok(msg.includes('--function is required'), msg); + }); + }); + + describe('offline mode (--raw-trace)', () => { + it('--raw-trace with no value → "Missing value for --raw-trace"', () => { + const msg = asError(parseTraceArgs(['--raw-trace'])); + assert.ok(msg.includes('Missing value for --raw-trace'), msg); + }); + + it('--raw-trace → run with launch.rawTrace set', () => { + const p = asRun(parseTraceArgs(['--raw-trace', 'run.jsonl'])); + assert.strictEqual(p.launch.rawTrace, 'run.jsonl'); + }); + + it('--raw-trace --wasm → run with launch.wasmPath set', () => { + const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--wasm', 'w'])); + assert.strictEqual(p.launch.rawTrace, 'r'); + assert.strictEqual(p.launch.wasmPath, 'w'); + }); + }); + + describe('live mode (--contract/--wasm + --function)', () => { + it('--contract . --function add → run with launch.contract and launch.function', () => { + const p = asRun(parseTraceArgs(['--contract', '.', '--function', 'add'])); + assert.strictEqual(p.launch.contract, '.'); + assert.strictEqual(p.launch.function, 'add'); + }); + }); + + describe('--args-json', () => { + it('malformed JSON → error containing "Invalid --args-json"', () => { + const msg = asError( + parseTraceArgs(['--contract', '.', '--function', 'a', '--args-json', '[bad']), + ); + assert.ok(msg.includes('Invalid --args-json'), msg); + }); + + it('valid JSON array → run with launch.args of the right length', () => { + const p = asRun( + parseTraceArgs([ + '--contract', + '.', + '--function', + 'a', + '--args-json', + '[{"value":1,"type":"u32"}]', + ]), + ); + assert.ok(p.launch.args, 'launch.args should be present'); + assert.strictEqual(p.launch.args!.length, 1); + }); + + it('valid JSON that is not an array (number) → "expected a JSON array"', () => { + const msg = asError( + parseTraceArgs(['--contract', '.', '--function', 'a', '--args-json', '5']), + ); + assert.ok(msg.includes('expected a JSON array'), msg); + }); + + it('valid JSON that is not an array (object) → "expected a JSON array"', () => { + const msg = asError( + parseTraceArgs(['--contract', '.', '--function', 'a', '--args-json', '{}']), + ); + assert.ok(msg.includes('expected a JSON array'), msg); + }); + }); + + describe('numeric options', () => { + it('--depth x (non-integer) → error mentioning --depth', () => { + const msg = asError(parseTraceArgs(['--raw-trace', 'r', '--depth', 'x'])); + assert.ok(msg.includes('--depth'), msg); + }); + + it('--max-children -1 (leading dash) → missing-value branch, "Missing value for --max-children"', () => { + // `-1` looks like a flag, so it trips the missing-value guard before the + // numeric check ever runs. + const msg = asError(parseTraceArgs(['--raw-trace', 'r', '--max-children', '-1'])); + assert.ok(msg.includes('Missing value for --max-children'), msg); + }); + + it('--max-children 2.5 (non-integer value) → "must be a non-negative integer"', () => { + // A non-dash, non-numeric value reaches isNonNegInt and fails there. + const msg = asError(parseTraceArgs(['--raw-trace', 'r', '--max-children', '2.5'])); + assert.ok(msg.includes('--max-children'), msg); + assert.ok(msg.includes('non-negative integer'), msg); + }); + + it('--depth 2 → run with opts.maxDepth === 2', () => { + const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--depth', '2'])); + assert.strictEqual(p.opts.maxDepth, 2); + }); + + it('--depth 0 (boundary) → valid run with opts.maxDepth === 0', () => { + const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--depth', '0'])); + assert.strictEqual(p.opts.maxDepth, 0); + }); + }); + + describe('token validation', () => { + it('unknown flag → "Unknown option: --frobnicate"', () => { + const msg = asError(parseTraceArgs(['--frobnicate'])); + assert.ok(msg.includes('Unknown option: --frobnicate'), msg); + }); + + it('stray positional → "Unexpected argument: stray"', () => { + const msg = asError(parseTraceArgs(['--raw-trace', 'r', 'stray'])); + assert.ok(msg.includes('Unexpected argument: stray'), msg); + }); + }); + + describe('boolean & passthrough options', () => { + it('--allow-no-source → run with opts.allowNoSource === true', () => { + const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--allow-no-source'])); + assert.strictEqual(p.opts.allowNoSource, true); + }); + + it('--out o.jsonl → run with out === "o.jsonl"', () => { + const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--out', 'o.jsonl'])); + assert.strictEqual(p.out, 'o.jsonl'); + }); + }); +}); From 427fae92800bc8458303e082a414cb1607168286 Mon Sep 17 00:00:00 2001 From: Raoul Date: Mon, 20 Jul 2026 11:36:40 +0000 Subject: [PATCH 5/5] Remove TL;DR. --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index da58e26..8c1c000 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,5 @@ # 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.