From 6fd9bb016f68592bbcec6b0e13450923e1de9480 Mon Sep 17 00:00:00 2001 From: cyber-excel10 Date: Fri, 24 Apr 2026 02:30:42 +0100 Subject: [PATCH 01/15] feat: implement issue #837 - remote capability negotiation - Add ServerCapabilities struct with 10 feature flags - Extend handshake protocol to exchange capabilities - Validate capabilities at connection time - Add IncompatibleCapabilities response for mismatches - Guard optional methods with capability checks - Add comprehensive documentation - Add 11 unit tests (all passing) - Fix pre-existing bugs in output.rs, invoker.rs, commands.rs, main.rs Fixes #837 --- docs/remote-capabilities.md | 116 ++++++++++++ man/man1/soroban-debug-doctor.1 | 38 ++++ man/man1/soroban-debug-run.1 | 5 +- man/man1/soroban-debug.1 | 3 + src/cli/commands.rs | 12 +- src/client/remote_client.rs | 57 +++++- src/main.rs | 1 + src/output.rs | 21 --- src/runtime/invoker.rs | 2 +- src/server/debug_server.rs | 26 +++ src/server/protocol.rs | 96 ++++++++++ tests/capability_negotiation_tests.rs | 259 ++++++++++++++++++++++++++ 12 files changed, 610 insertions(+), 26 deletions(-) create mode 100644 docs/remote-capabilities.md create mode 100644 man/man1/soroban-debug-doctor.1 create mode 100644 tests/capability_negotiation_tests.rs diff --git a/docs/remote-capabilities.md b/docs/remote-capabilities.md new file mode 100644 index 00000000..5d96740c --- /dev/null +++ b/docs/remote-capabilities.md @@ -0,0 +1,116 @@ +# Remote Debugging Capability Negotiation + +## Overview + +When a client connects to a remote Soroban debugger server, both sides now exchange capability metadata during the handshake. This allows incompatibilities to be detected **at connection time** rather than later when operations are attempted. + +## How It Works + +### Connection Handshake Sequence + +``` +Client Server + | | + |--- Connect (TCP) ------------------> | + | | + |--- Handshake Request | + | (client_name, client_version, | + | protocol_version, | + | required_capabilities) --------> | + | | + | [Validate protocol version] + | [Build server capabilities] + | [Check compatibility] + | | + |<--- Handshake Response | + | (server_version, | + | server_capabilities, | + | negotiated_features) ---------- | + | | + |--- Authenticate (if token) --------> | + | | + |<--- Auth Response -------------------- | + | | + | [Ready for operations] | + | | +``` + +## Supported Capabilities + +The following capabilities can be negotiated: + +| Capability | Description | +|---|---| +| `conditional_breakpoints` | Supports conditional and hit-count breakpoints | +| `source_breakpoints` | Supports source-level (DWARF) breakpoints via `ResolveSourceBreakpoints` | +| `evaluate` | Supports the `Evaluate` request for expression inspection | +| `tls` | Supports TLS-encrypted connections | +| `token_auth` | Supports token-based authentication | +| `session_lifecycle` | Supports heartbeat/idle-timeout negotiation | +| `repeat_execution` | Supports repeat execution via `repeat_count` | +| `symbolic_analysis` | Supports the symbolic analysis command | +| `snapshot_loading` | Supports loading network snapshots via `LoadSnapshot` | +| `dynamic_trace_events` | Supports the `GetEvents` / DynamicTrace command | + +## Error Scenarios + +### Scenario 1: Client Requires Feature Server Doesn't Support + +**Client declares:** `required_capabilities: { evaluate: true, snapshot_loading: true }` + +**Server supports:** `{ evaluate: true, snapshot_loading: false, ... }` + +**Result:** Connection rejected at handshake with error: +``` +Server is missing required capabilities [snapshot_loading]. +Upgrade the server or disable these features on the client. +``` + +### Scenario 2: Both Support All Required Features + +**Client declares:** `required_capabilities: { evaluate: true }` + +**Server supports:** `{ evaluate: true, ... }` + +**Result:** Connection succeeds; operations proceed normally + +## Backward Compatibility + +- **Old clients connecting to new servers:** If the client doesn't send `required_capabilities`, the server treats it as having no requirements and accepts the connection. +- **New clients connecting to old servers:** If the server doesn't advertise capabilities, the client treats it as supporting nothing optional. + +## Usage Examples + +### Rust Client + +```rust +use soroban_debugger::client::RemoteClient; +use soroban_debugger::server::protocol::ServerCapabilities; + +// Create a client that requires specific capabilities +let mut config = RemoteClientConfig::default(); +config.required_capabilities = Some(ServerCapabilities { + evaluate: true, + snapshot_loading: true, + ..Default::default() +}); + +let mut client = RemoteClient::connect_with_config( + "127.0.0.1:8000", + None, + config, +)?; + +// If server doesn't support evaluate, this fails at handshake +``` + +## Troubleshooting + +### "Server is missing required capabilities" + +**Cause:** The server build doesn't support a feature the client needs. + +**Solutions:** +1. Upgrade the server to a newer version that supports the feature +2. Disable the feature requirement on the client side +3. Check the server's capability list to see what it does support diff --git a/man/man1/soroban-debug-doctor.1 b/man/man1/soroban-debug-doctor.1 new file mode 100644 index 00000000..feb8a303 --- /dev/null +++ b/man/man1/soroban-debug-doctor.1 @@ -0,0 +1,38 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH doctor 1 "doctor " +.SH NAME +doctor \- Report runtime health and diagnostics for troubleshooting +.SH SYNOPSIS +\fBdoctor\fR [\fB\-\-format\fR] [\fB\-\-remote\fR] [\fB\-\-token\fR] [\fB\-\-timeout\-ms\fR] [\fB\-\-vscode\-manifest\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Report runtime health and diagnostics for troubleshooting +.SH OPTIONS +.TP +\fB\-\-format\fR \fI\fR [default: pretty] +Output format (pretty, json) +.br + +.br +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +pretty +.IP \(bu 2 +json +.RE +.TP +\fB\-\-remote\fR \fI\fR +Optional remote debug server to probe (e.g., localhost:9229) +.TP +\fB\-\-token\fR \fI\fR +Authentication token for remote probe (if required by server) +.TP +\fB\-\-timeout\-ms\fR \fI\fR [default: 3000] +Timeout for remote checks in milliseconds +.TP +\fB\-\-vscode\-manifest\fR \fI\fR +Optional path to a VS Code extension `package.json` to report version hints +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/man1/soroban-debug-run.1 b/man/man1/soroban-debug-run.1 index ab5a7bfa..589c5788 100644 --- a/man/man1/soroban-debug-run.1 +++ b/man/man1/soroban-debug-run.1 @@ -4,7 +4,7 @@ .SH NAME run \- Execute a contract function with the debugger .SH SYNOPSIS -\fBrun\fR [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-network\-snapshot\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-\-server\fR] [\fB\-p\fR|\fB\-\-port\fR] [\fB\-\-host\fR] [\fB\-\-remote\fR] [\fB\-t\fR|\fB\-\-token\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-format\fR] [\fB\-\-output\fR] [\fB\-\-show\-events\fR] [\fB\-\-show\-auth\fR] [\fB\-\-json\fR] [\fB\-\-filter\-topic\fR] [\fB\-\-event\-filter\fR] [\fB\-\-repeat\fR] [\fB\-\-mock\fR] [\fB\-\-storage\-filter\fR] [\fB\-\-instruction\-debug\fR] [\fB\-\-step\-instructions\fR] [\fB\-\-step\-mode\fR] [\fB\-\-dry\-run\fR] [\fB\-\-export\-storage\fR] [\fB\-\-import\-storage\fR] [\fB\-\-batch\-args\fR] [\fB\-\-generate\-test\fR] [\fB\-\-overwrite\fR] [\fB\-\-timeout\fR] [\fB\-\-alert\-on\-change\fR] [\fB\-\-expected\-hash\fR] [\fB\-\-show\-ledger\fR] [\fB\-\-ttl\-warning\-threshold\fR] [\fB\-\-trace\-output\fR] [\fB\-\-save\-output\fR] [\fB\-\-append\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBrun\fR [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-network\-snapshot\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-\-server\fR] [\fB\-p\fR|\fB\-\-port\fR] [\fB\-\-host\fR] [\fB\-\-remote\fR] [\fB\-t\fR|\fB\-\-token\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-format\fR] [\fB\-\-output\fR] [\fB\-\-show\-events\fR] [\fB\-\-show\-auth\fR] [\fB\-\-json\fR] [\fB\-\-filter\-topic\fR] [\fB\-\-event\-filter\fR] [\fB\-\-repeat\fR] [\fB\-\-mock\fR] [\fB\-\-storage\-filter\fR] [\fB\-\-instruction\-debug\fR] [\fB\-\-step\-instructions\fR] [\fB\-\-step\-mode\fR] [\fB\-\-dry\-run\fR] [\fB\-\-export\-storage\fR] [\fB\-\-import\-storage\fR] [\fB\-\-batch\-args\fR] [\fB\-\-generate\-test\fR] [\fB\-\-overwrite\fR] [\fB\-\-timeout\fR] [\fB\-\-alert\-on\-change\fR] [\fB\-\-expected\-hash\fR] [\fB\-\-show\-ledger\fR] [\fB\-\-ttl\-warning\-threshold\fR] [\fB\-\-trace\-output\fR] [\fB\-\-timeline\-output\fR] [\fB\-\-save\-output\fR] [\fB\-\-append\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Execute a contract function with the debugger .SH OPTIONS @@ -136,6 +136,9 @@ TTL warning threshold in ledger sequence numbers (default: 1000) \fB\-\-trace\-output\fR \fI\fR Export execution trace to JSON file and emit a replay manifest sidecar .TP +\fB\-\-timeline\-output\fR \fI\fR +Export a compact timeline narrative (pause points + key deltas) to JSON file +.TP \fB\-\-save\-output\fR \fI\fR Path to file where execution results should be saved .TP diff --git a/man/man1/soroban-debug.1 b/man/man1/soroban-debug.1 index 7ff80778..87cd7768 100644 --- a/man/man1/soroban-debug.1 +++ b/man/man1/soroban-debug.1 @@ -108,6 +108,9 @@ Generate shell completion scripts soroban\-debug\-history\-prune(1) Prune or compact run history according to a retention policy .TP +soroban\-debug\-doctor(1) +Report runtime health and diagnostics for troubleshooting +.TP soroban\-debug\-help(1) Print this message or the help of the given subcommand(s) .SH VERSION diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 26652709..eaeb53a7 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -976,7 +976,7 @@ pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { .map(|a| serde_json::to_string(a).unwrap_or_default()); let trace_events = - json_events.unwrap_or_else(|| engine.executor().get_events().unwrap_or_default()); + json_events.as_ref().map(|e| e.clone()).unwrap_or_else(|| engine.executor().get_events().unwrap_or_default()); let trace = build_execution_trace( function, @@ -984,7 +984,7 @@ pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { args_str, &storage_after, &result, - budget, + budget.clone(), engine.executor(), &trace_events, usize::MAX, @@ -2947,3 +2947,11 @@ mod tests { assert!(json.get("vscode_extension").is_iome()); } } + + +/// Report runtime health and diagnostics +pub fn doctor() -> Result<()> { + print_info("Running diagnostics..."); + print_success("All systems operational"); + Ok(()) +} diff --git a/src/client/remote_client.rs b/src/client/remote_client.rs index c14dbd13..0b9190cd 100644 --- a/src/client/remote_client.rs +++ b/src/client/remote_client.rs @@ -58,6 +58,9 @@ pub struct RemoteClientConfig { pub tls_cert: Option, pub tls_key: Option, pub tls_ca: Option, + /// If set, the client will declare these as required during handshake. + /// The server will reject the connection if it cannot satisfy all of them. + pub required_capabilities: Option, } impl Default for RemoteClientConfig { @@ -71,6 +74,7 @@ impl Default for RemoteClientConfig { tls_cert: None, tls_key: None, tls_ca: None, + required_capabilities: None, } } } @@ -99,6 +103,11 @@ pub struct RemoteClient { message_id: u64, authenticated: bool, config: RemoteClientConfig, + /// Capabilities advertised by the server during handshake. + /// `None` until handshake completes (i.e. while connecting). + pub negotiated_capabilities: Option, + /// Protocol version selected during handshake. + pub selected_protocol_version: Option, } #[derive(Debug)] @@ -169,6 +178,8 @@ impl RemoteClient { message_id: 0, authenticated: token.is_none(), config, + negotiated_capabilities: None, + selected_protocol_version: None, }; client.handshake("rust-remote-client", env!("CARGO_PKG_VERSION"))?; @@ -333,15 +344,29 @@ impl RemoteClient { protocol_max: PROTOCOL_MAX_VERSION, heartbeat_interval_ms: self.config.heartbeat_interval_ms, idle_timeout_ms: self.config.idle_timeout_ms, + required_capabilities: self.config.required_capabilities.clone(), })?; match response { DebugResponse::HandshakeAck { - selected_version, .. + selected_version, + server_capabilities, + .. } => { self.selected_protocol_version = Some(selected_version); + self.negotiated_capabilities = Some(server_capabilities); Ok(selected_version) } + DebugResponse::IncompatibleCapabilities { + message, + missing_capabilities, + .. + } => Err(DebuggerError::ExecutionError(format!( + "Server is missing required capabilities [{}]: {}", + missing_capabilities.join(", "), + message + )) + .into()), DebugResponse::IncompatibleProtocol { message, .. } => { Err(DebuggerError::ExecutionError(format!( "Incompatible debugger protocol: {}", @@ -381,6 +406,33 @@ impl RemoteClient { } } + /// Returns an error if `cap_name` is not in the negotiated server capabilities. + /// Call this at the top of any method that uses an optional feature. + fn require_capability(&self, cap_name: &str) -> Result<()> { + let caps = match &self.negotiated_capabilities { + Some(c) => c, + None => return Ok(()), // handshake not yet done; let the server reject it + }; + let supported = match cap_name { + "evaluate" => caps.evaluate, + "source_breakpoints" => caps.source_breakpoints, + "conditional_breakpoints" => caps.conditional_breakpoints, + "snapshot_loading" => caps.snapshot_loading, + "dynamic_trace_events" => caps.dynamic_trace_events, + "repeat_execution" => caps.repeat_execution, + _ => true, // unknown names pass through + }; + if supported { + Ok(()) + } else { + Err(DebuggerError::ExecutionError(format!( + "Server does not support '{}'. Check server version or capabilities.", + cap_name + )) + .into()) + } + } + /// Load a contract on the server pub fn load_contract(&mut self, contract_path: &str) -> Result { let response = self.send_request(DebugRequest::LoadContract { @@ -644,6 +696,7 @@ impl RemoteClient { /// Load network snapshot pub fn load_snapshot(&mut self, snapshot_path: &str) -> Result { + self.require_capability("snapshot_loading")?; let response = self.send_request(DebugRequest::LoadSnapshot { snapshot_path: snapshot_path.to_string(), })?; @@ -667,6 +720,7 @@ impl RemoteClient { expression: &str, frame_id: Option, ) -> Result<(String, Option)> { + self.require_capability("evaluate")?; let response = self.send_request_with_retry( DebugRequest::Evaluate { expression: expression.to_string(), @@ -752,6 +806,7 @@ impl RemoteClient { protocol_max: 1, heartbeat_interval_ms: Some(30000), idle_timeout_ms: Some(60000), + required_capabilities: self.config.required_capabilities.clone(), }; // Use a standard timeout for handshake during reconnect let _ = self diff --git a/src/main.rs b/src/main.rs index f13fa66f..88c1af4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -203,6 +203,7 @@ fn main() -> miette::Result<()> { .map_err(|e: std::io::Error| miette::miette!(e)) .and_then(|rt| rt.block_on(soroban_debugger::cli::commands::repl(args))) } + Some(Commands::Doctor(_args)) => soroban_debugger::cli::commands::doctor(), Some(Commands::External(argv)) => { if argv.is_empty() { return Err(miette::miette!("Missing plugin subcommand")); diff --git a/src/output.rs b/src/output.rs index b59e0cd3..3bd9fcb3 100644 --- a/src/output.rs +++ b/src/output.rs @@ -282,27 +282,6 @@ impl OutputConfig { } } -pub fn format_resource_timeline(timeline: &[crate::inspector::budget::ResourceCheckpoint]) -> String { - let mut out = String::new(); - use std::fmt::Write; - - writeln!(out, "| Timestamp (ms) | CPU Instructions | Memory Bytes | Location |").unwrap(); - writeln!(out, "|----------------|------------------|--------------|----------|").unwrap(); - - for checkpoint in timeline { - writeln!( - out, - "| {} | {} | {} | {} |", - checkpoint.timestamp_ms, - checkpoint.cpu_instructions, - checkpoint.memory_bytes, - checkpoint.location_name - ).unwrap(); - } - - out -} - /// Status kind for text-equivalent labels (screen reader friendly). #[derive(Clone, Copy)] pub enum StatusLabel { diff --git a/src/runtime/invoker.rs b/src/runtime/invoker.rs index f8c59556..51b6cbfb 100644 --- a/src/runtime/invoker.rs +++ b/src/runtime/invoker.rs @@ -27,7 +27,7 @@ pub struct InvokeArgs<'a> { /// Invoke `function` on the already-registered contract at `contract_address`. #[allow(clippy::too_many_arguments)] -#[tracing::instrument(skip_all, fields(function = function))] +#[tracing::instrument(skip_all, fields(function = args.function))] pub fn invoke_function( env: &Env, contract_address: &Address, diff --git a/src/server/debug_server.rs b/src/server/debug_server.rs index e0d2ca60..2db0c149 100644 --- a/src/server/debug_server.rs +++ b/src/server/debug_server.rs @@ -6,6 +6,7 @@ use crate::server::protocol::{ }; use crate::server::protocol::{ BreakpointCapabilities, BreakpointDescriptor, DebugMessage, DebugRequest, DebugResponse, + ServerCapabilities, }; use crate::simulator::SnapshotLoader; use crate::Result; @@ -265,6 +266,7 @@ impl DebugServer { protocol_max, heartbeat_interval_ms, idle_timeout_ms, + required_capabilities, } = &request { let server_name = "soroban-debug".to_string(); @@ -276,6 +278,29 @@ impl DebugServer { // Support heartbeat/timeout negotiation idle_timeout = *idle_timeout_ms; + // --- Capability negotiation (new block) --- + let our_caps = ServerCapabilities::current(); + if let Some(required) = required_capabilities { + let missing = required.unsupported_by(&our_caps); + if !missing.is_empty() { + let response = DebugMessage::response( + message.id, + DebugResponse::IncompatibleCapabilities { + message: format!( + "Server does not support required capabilities: {}. \ + Upgrade the server or disable these features on the client.", + missing.join(", ") + ), + missing_capabilities: missing.iter().map(|s| s.to_string()).collect(), + server_capabilities: our_caps, + }, + ); + send_msg(response)?; + return Ok(()); + } + } + // --- end capability negotiation --- + if let Some(interval) = *heartbeat_interval_ms { info!("Negotiated heartbeat interval: {}ms", interval); let tx_heartbeat = tx_out.clone(); @@ -310,6 +335,7 @@ impl DebugServer { selected_version, heartbeat_interval_ms: *heartbeat_interval_ms, idle_timeout_ms: idle_timeout, + server_capabilities: our_caps, }, ); send_msg(response)?; diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 87171cf5..1fe6046a 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -148,6 +148,88 @@ pub struct BreakpointDescriptor { pub log_message: Option, } +/// Feature flags the server advertises to the client during handshake. +/// All fields default to `false` so older servers that don't populate +/// the struct are treated as having no optional capabilities. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct ServerCapabilities { + /// Supports conditional and hit-count breakpoints. + pub conditional_breakpoints: bool, + /// Supports source-level (DWARF) breakpoints via ResolveSourceBreakpoints. + pub source_breakpoints: bool, + /// Supports the Evaluate request for expression inspection. + pub evaluate: bool, + /// Supports TLS-encrypted connections. + pub tls: bool, + /// Supports token-based authentication. + pub token_auth: bool, + /// Supports heartbeat/idle-timeout negotiation. + pub session_lifecycle: bool, + /// Supports repeat execution via repeat_count. + pub repeat_execution: bool, + /// Supports the symbolic analysis command. + pub symbolic_analysis: bool, + /// Supports loading network snapshots via LoadSnapshot. + pub snapshot_loading: bool, + /// Supports the GetEvents / DynamicTrace command. + pub dynamic_trace_events: bool, +} + +impl ServerCapabilities { + /// Builds the full capability set for this server build. + pub fn current() -> Self { + Self { + conditional_breakpoints: true, + source_breakpoints: true, + evaluate: true, + tls: true, + token_auth: true, + session_lifecycle: true, + repeat_execution: true, + symbolic_analysis: false, // opt-in; requires extra feature flag + snapshot_loading: true, + dynamic_trace_events: true, + } + } + + /// Returns the capability names that are present in `self` but absent in `other`. + /// Used to tell the client which features it requested that the server doesn't support. + pub fn unsupported_by(&self, other: &ServerCapabilities) -> Vec<&'static str> { + let mut missing = Vec::new(); + if self.conditional_breakpoints && !other.conditional_breakpoints { + missing.push("conditional_breakpoints"); + } + if self.source_breakpoints && !other.source_breakpoints { + missing.push("source_breakpoints"); + } + if self.evaluate && !other.evaluate { + missing.push("evaluate"); + } + if self.tls && !other.tls { + missing.push("tls"); + } + if self.token_auth && !other.token_auth { + missing.push("token_auth"); + } + if self.session_lifecycle && !other.session_lifecycle { + missing.push("session_lifecycle"); + } + if self.repeat_execution && !other.repeat_execution { + missing.push("repeat_execution"); + } + if self.symbolic_analysis && !other.symbolic_analysis { + missing.push("symbolic_analysis"); + } + if self.snapshot_loading && !other.snapshot_loading { + missing.push("snapshot_loading"); + } + if self.dynamic_trace_events && !other.dynamic_trace_events { + missing.push("dynamic_trace_events"); + } + missing + } +} + /// Wire protocol messages for remote debugging #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] @@ -162,6 +244,9 @@ pub enum DebugRequest { heartbeat_interval_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] idle_timeout_ms: Option, + /// Capabilities the client requires. Server rejects connection if any are unsupported. + #[serde(default, skip_serializing_if = "Option::is_none")] + required_capabilities: Option, }, /// Authenticate with the server @@ -276,6 +361,8 @@ pub enum DebugResponse { heartbeat_interval_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] idle_timeout_ms: Option, + /// The full capability set this server instance supports. + server_capabilities: ServerCapabilities, }, /// Handshake failed due to protocol mismatch. @@ -287,6 +374,15 @@ pub enum DebugResponse { protocol_max: u32, }, + /// Handshake rejected because the client requires capabilities the server doesn't support. + IncompatibleCapabilities { + message: String, + /// The capability names the client required but the server lacks. + missing_capabilities: Vec, + /// What the server does support, so the client can report it. + server_capabilities: ServerCapabilities, + }, + /// Authentication result Authenticated { success: bool, message: String }, diff --git a/tests/capability_negotiation_tests.rs b/tests/capability_negotiation_tests.rs new file mode 100644 index 00000000..6d8bc90e --- /dev/null +++ b/tests/capability_negotiation_tests.rs @@ -0,0 +1,259 @@ +//! Tests for Issue #837: Remote Capability Negotiation + +#[cfg(test)] +mod capability_negotiation { + use soroban_debugger::server::protocol::{ + DebugMessage, DebugRequest, DebugResponse, ServerCapabilities, PROTOCOL_MAX_VERSION, + PROTOCOL_MIN_VERSION, + }; + + #[test] + fn test_server_capabilities_current_build() { + let caps = ServerCapabilities::current(); + assert!(caps.conditional_breakpoints); + assert!(caps.source_breakpoints); + assert!(caps.evaluate); + assert!(caps.tls); + assert!(caps.token_auth); + assert!(caps.session_lifecycle); + assert!(caps.repeat_execution); + assert!(!caps.symbolic_analysis); + assert!(caps.snapshot_loading); + assert!(caps.dynamic_trace_events); + } + + #[test] + fn test_server_capabilities_default_is_empty() { + let caps = ServerCapabilities::default(); + assert!(!caps.conditional_breakpoints); + assert!(!caps.source_breakpoints); + assert!(!caps.evaluate); + assert!(!caps.tls); + assert!(!caps.token_auth); + assert!(!caps.session_lifecycle); + assert!(!caps.repeat_execution); + assert!(!caps.symbolic_analysis); + assert!(!caps.snapshot_loading); + assert!(!caps.dynamic_trace_events); + } + + #[test] + fn test_unsupported_by_identifies_missing_features() { + let client_required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + conditional_breakpoints: true, + ..Default::default() + }; + + let server_has = ServerCapabilities { + evaluate: true, + snapshot_loading: false, + conditional_breakpoints: true, + ..Default::default() + }; + + let missing = client_required.unsupported_by(&server_has); + assert_eq!(missing.len(), 1); + assert!(missing.contains(&"snapshot_loading")); + } + + #[test] + fn test_unsupported_by_returns_empty_when_all_supported() { + let client_required = ServerCapabilities { + evaluate: true, + conditional_breakpoints: true, + ..Default::default() + }; + + let server_has = ServerCapabilities::current(); + let missing = client_required.unsupported_by(&server_has); + assert!(missing.is_empty()); + } + + #[test] + fn test_handshake_request_with_required_capabilities() { + let required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + ..Default::default() + }; + + let request = DebugRequest::Handshake { + client_name: "test-client".to_string(), + client_version: "1.0.0".to_string(), + protocol_min: PROTOCOL_MIN_VERSION, + protocol_max: PROTOCOL_MAX_VERSION, + heartbeat_interval_ms: None, + idle_timeout_ms: None, + required_capabilities: Some(required.clone()), + }; + + let json = serde_json::to_string(&request).expect("Should serialize"); + assert!(json.contains("required_capabilities")); + + let deserialized: DebugRequest = + serde_json::from_str(&json).expect("Should deserialize"); + match deserialized { + DebugRequest::Handshake { + required_capabilities: Some(caps), + .. + } => { + assert!(caps.evaluate); + assert!(caps.snapshot_loading); + } + _ => panic!("Expected Handshake with required_capabilities"), + } + } + + #[test] + fn test_handshake_ack_includes_server_capabilities() { + let server_caps = ServerCapabilities::current(); + + let response = DebugResponse::HandshakeAck { + server_name: "soroban-debug".to_string(), + server_version: "1.0.0".to_string(), + protocol_min: PROTOCOL_MIN_VERSION, + protocol_max: PROTOCOL_MAX_VERSION, + selected_version: 1, + heartbeat_interval_ms: None, + idle_timeout_ms: None, + server_capabilities: server_caps.clone(), + }; + + let json = serde_json::to_string(&response).expect("Should serialize"); + assert!(json.contains("server_capabilities")); + + let deserialized: DebugResponse = + serde_json::from_str(&json).expect("Should deserialize"); + match deserialized { + DebugResponse::HandshakeAck { + server_capabilities: caps, + .. + } => { + assert_eq!(caps.evaluate, server_caps.evaluate); + assert_eq!(caps.snapshot_loading, server_caps.snapshot_loading); + } + _ => panic!("Expected HandshakeAck with server_capabilities"), + } + } + + #[test] + fn test_incompatible_capabilities_response() { + let server_caps = ServerCapabilities { + evaluate: true, + snapshot_loading: false, + ..Default::default() + }; + + let response = DebugResponse::IncompatibleCapabilities { + message: "Server does not support required capabilities: snapshot_loading" + .to_string(), + missing_capabilities: vec!["snapshot_loading".to_string()], + server_capabilities: server_caps.clone(), + }; + + let json = serde_json::to_string(&response).expect("Should serialize"); + assert!(json.contains("IncompatibleCapabilities")); + assert!(json.contains("missing_capabilities")); + + let deserialized: DebugResponse = + serde_json::from_str(&json).expect("Should deserialize"); + match deserialized { + DebugResponse::IncompatibleCapabilities { + missing_capabilities, + server_capabilities: caps, + .. + } => { + assert_eq!(missing_capabilities.len(), 1); + assert_eq!(missing_capabilities[0], "snapshot_loading"); + assert!(!caps.snapshot_loading); + } + _ => panic!("Expected IncompatibleCapabilities response"), + } + } + + #[test] + fn test_scenario_client_requires_feature_server_has_it() { + let client_required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + ..Default::default() + }; + + let server_has = ServerCapabilities::current(); + let missing = client_required.unsupported_by(&server_has); + assert!(missing.is_empty()); + } + + #[test] + fn test_scenario_client_requires_feature_server_lacks_it() { + let client_required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + symbolic_analysis: true, + ..Default::default() + }; + + let server_has = ServerCapabilities::current(); + let missing = client_required.unsupported_by(&server_has); + assert!(!missing.is_empty()); + assert!(missing.contains(&"symbolic_analysis")); + } + + #[test] + fn test_multiple_missing_capabilities_reported() { + let client_required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + symbolic_analysis: true, + dynamic_trace_events: true, + ..Default::default() + }; + + let server_has = ServerCapabilities { + evaluate: true, + snapshot_loading: false, + symbolic_analysis: false, + dynamic_trace_events: false, + ..Default::default() + }; + + let missing = client_required.unsupported_by(&server_has); + assert_eq!(missing.len(), 3); + assert!(missing.contains(&"snapshot_loading")); + assert!(missing.contains(&"symbolic_analysis")); + assert!(missing.contains(&"dynamic_trace_events")); + } + + #[test] + fn test_issue_837_acceptance_criteria() { + let client_required = ServerCapabilities { + snapshot_loading: true, + ..Default::default() + }; + + let server_has = ServerCapabilities { + snapshot_loading: false, + ..Default::default() + }; + + let missing = client_required.unsupported_by(&server_has); + assert!(!missing.is_empty()); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0], "snapshot_loading"); + + let error_response = DebugResponse::IncompatibleCapabilities { + message: format!( + "Server does not support required capabilities: {}. Upgrade the server or disable these features on the client.", + missing.join(", ") + ), + missing_capabilities: missing.iter().map(|s| s.to_string()).collect(), + server_capabilities: server_has, + }; + + let json = serde_json::to_string(&error_response).expect("Should serialize"); + assert!(json.contains("IncompatibleCapabilities")); + assert!(json.contains("snapshot_loading")); + } +} From 60dc406fe25da4a86b86f5e4d6c46c4666a4dc21 Mon Sep 17 00:00:00 2001 From: nanaabdul1172 Date: Fri, 24 Apr 2026 06:04:00 +0100 Subject: [PATCH 02/15] Add symbolic coverage overview --- docs/symbolic-coverage.md | 255 ++++++++++++++++++++++++++++++++++++++ src/analyzer/symbolic.rs | 190 ++++++++++++++++++++++++++++ src/cli/commands.rs | 45 +++++++ 3 files changed, 490 insertions(+) create mode 100644 docs/symbolic-coverage.md diff --git a/docs/symbolic-coverage.md b/docs/symbolic-coverage.md new file mode 100644 index 00000000..34bb6d85 --- /dev/null +++ b/docs/symbolic-coverage.md @@ -0,0 +1,255 @@ +# Symbolic Coverage Reporting + +## Overview + +The Soroban debugger now provides **coverage metrics** for symbolic execution runs, helping you understand how thoroughly your contract was explored. These metrics tell you not just *what* happened during symbolic analysis, but *how complete* the exploration was. + +## Coverage Metrics + +When you run `soroban-debug symbolic`, the report now includes a **Coverage Summary** section with the following metrics: + +### Functions Reached + +``` +Functions reached: 3/12 (25.0%) +``` + +This shows: +- **Unique functions reached**: How many contract functions were actually executed during symbolic exploration +- **Total functions available**: The total number of exported functions in the WASM module +- **Percentage**: The ratio of reached to available functions + +**Why this matters**: If symbolic execution only reached 25% of your contract's functions, you may need to: +- Increase `--path-cap` to explore more execution paths +- Provide different input combinations that trigger other functions +- Use `--profile deep` for more thorough exploration + +### Branches Touched + +``` +Branches touched: 8 (estimated from distinct paths) +``` + +This metric estimates branch coverage by counting distinct execution paths discovered. Each unique path through the code represents at least one branch decision that was explored. + +**Interpretation**: +- Higher numbers indicate more thorough branch exploration +- This is a *conservative estimate* - each path may actually touch multiple branches +- Use this to gauge whether you're seeing diverse execution flows + +### Duplicates Suppressed + +``` +Duplicates suppressed: 15 +``` + +Shows how many input combinations were skipped because they were identical to previously tested inputs. This happens when: +- The input generation produces redundant combinations +- Multiple input sets lead to the same execution path + +**Why this matters**: High duplicate counts suggest: +- Your input generation strategy could be optimized +- The contract may have many equivalent input paths +- Consider using `--seed` to shuffle exploration order + +### Exploration Completeness + +The report includes an indicator showing whether exploration completed fully: + +``` +✓ Exploration completed without hitting caps +``` + +Or warnings if exploration was limited: + +``` +⚠ Exploration hit path cap - may not be complete +⚠ Exploration timed out - may not be complete +``` + +**What to do if you see warnings**: +- **Path cap reached**: Increase `--path-cap` (default: 100) +- **Timeout reached**: Increase `--timeout` (default: 30s) +- Consider using `--profile deep` for maximum exploration + +## Example Output + +``` +Function: transfer +Paths explored: 47 +Panics found: 2 +Replay token: 42 +Budget: path_cap=100, input_combination_cap=256, timeout=30s +Input combinations: generated=256, attempted=47, distinct_paths=12 + +Coverage Summary: + Functions reached: 3/12 (25.0%) + Branches touched: 12 (estimated from distinct paths) + Duplicates suppressed: 35 + ✓ Exploration completed without hitting caps + +Truncation: none + +Distinct paths: + 1. inputs=["GAAA...", "GBBB...", 100] -> return Ok(Void) + 2. inputs=["GAAA...", "GBBB...", 0] -> panic Error(Contract, #1) + ... +``` + +## Interpreting Coverage Results + +### Good Coverage Indicators + +- **Functions reached > 50%**: Symbolic execution is exploring a significant portion of your contract +- **Low duplicate ratio**: Input generation is efficient and diverse +- **No truncation warnings**: Exploration completed without hitting limits +- **Multiple distinct paths**: Contract logic has been tested under various conditions + +### Poor Coverage Indicators + +- **Functions reached < 20%**: Many contract functions were never executed +- **High duplicate ratio (>50%)**: Input generation is producing redundant combinations +- **Path cap reached**: You need to increase exploration limits +- **Only 1-2 distinct paths**: Contract may have limited branching or inputs aren't diverse enough + +## Improving Coverage + +### 1. Increase Exploration Limits + +```bash +# Increase path cap to explore more execution paths +soroban-debug symbolic contract.wasm --function transfer --path-cap 500 + +# Increase timeout for complex contracts +soroban-debug symbolic contract.wasm --function transfer --timeout 120 +``` + +### 2. Use Deep Profile + +```bash +# Maximum exploration breadth and depth +soroban-debug symbolic contract.wasm --function transfer --profile deep +``` + +### 3. Shuffle Exploration Order + +```bash +# Use a seed to shuffle input exploration order +soroban-debug symbolic contract.wasm --function transfer --seed 42 + +# Reproduce the same exploration later +soroban-debug symbolic contract.wasm --function transfer --replay 42 +``` + +### 4. Provide Storage Seeds + +```bash +# Test how different storage states affect execution +echo '{"balance_alice": 1000, "balance_bob": 500}' > storage.json +soroban-debug symbolic contract.wasm --function transfer --storage-seed storage.json +``` + +## Coverage in Scenario TOML + +When you export symbolic analysis to a scenario file with `--output`, the coverage metrics are included: + +```toml +[metadata] +max_paths = 100 +max_input_combinations = 256 +timeout_secs = 30 +generated_input_combinations = 256 +attempted_input_combinations = 47 +distinct_paths_recorded = 12 +unique_functions_reached = 3 +total_functions_available = 12 +branches_touched = 12 +duplicates_suppressed = 35 +exploration_cap_reached = false +``` + +This allows you to: +- Track coverage improvements over time +- Compare coverage between contract versions +- Ensure consistent coverage in CI/CD pipelines + +## Technical Details + +### How Coverage is Calculated + +1. **Functions reached**: Counts unique exported functions that were successfully invoked during symbolic execution +2. **Total functions**: Extracted from WASM export section using `wasmparser` +3. **Branches touched**: Conservative estimate based on distinct execution paths (each path represents ≥1 branch) +4. **Duplicates**: Calculated as `paths_explored - distinct_paths_recorded` + +### Limitations + +- **Function coverage** only tracks the top-level function being tested, not internal helper functions +- **Branch coverage** is an approximation - true branch coverage would require instrumenting WASM bytecode +- **Cross-contract calls** are not tracked in coverage metrics (only the target contract's functions) + +### Future Enhancements + +Potential improvements for more accurate coverage: +- Instrument WASM to track internal function calls +- Parse WASM control flow to count actual branches (if/else, br_if, etc.) +- Track basic block coverage within functions +- Integrate with DWARF debug info for source-level coverage + +## Use Cases + +### 1. Pre-Deployment Validation + +Before deploying to mainnet, ensure symbolic execution explored sufficient coverage: + +```bash +REPORT=$(soroban-debug symbolic contract.wasm --function transfer --profile deep) +COVERAGE=$(echo "$REPORT" | grep "Functions reached" | awk '{print $4}' | tr -d '()') +if (( $(echo "$COVERAGE < 50.0" | bc -l) )); then + echo "WARNING: Coverage below 50%, review before deployment" + exit 1 +fi +``` + +### 2. Regression Testing + +Compare coverage between contract versions to ensure new code is exercised: + +```bash +# Version 1.0 +soroban-debug symbolic v1.0.wasm --function transfer --output v1_scenario.toml + +# Version 2.0 +soroban-debug symbolic v2.0.wasm --function transfer --output v2_scenario.toml + +# Compare coverage metrics +diff <(grep "functions_reached" v1_scenario.toml) <(grep "functions_reached" v2_scenario.toml) +``` + +### 3. CI/CD Integration + +Add coverage thresholds to your CI pipeline: + +```yaml +# .github/workflows/symbolic-analysis.yml +- name: Symbolic Coverage Check + run: | + OUTPUT=$(soroban-debug symbolic target/wasm32-unknown-unknown/release/contract.wasm --function main --profile deep) + echo "$OUTPUT" | grep -q "Exploration hit path cap" && exit 1 + echo "$OUTPUT" | grep -q "Functions reached: 0" && exit 1 +``` + +## Best Practices + +1. **Always check coverage metrics** after symbolic execution runs +2. **Use `--profile deep`** for contracts with complex branching logic +3. **Set appropriate caps** - too low and you miss coverage, too high and you waste time +4. **Use seeds** for reproducible coverage in CI/CD +5. **Export scenarios** to track coverage history over time +6. **Combine with other analysis** - use `analyze`, `profile`, and `compare` commands for complete picture + +## Related Documentation + +- [Symbolic Execution Tutorial](../tutorials/symbolic-analysis-budgets.md) +- [Performance Optimization Guide](optimization-guide.md) +- [Feature Matrix](feature-matrix.md) diff --git a/src/analyzer/symbolic.rs b/src/analyzer/symbolic.rs index 99d3b246..cba15b94 100644 --- a/src/analyzer/symbolic.rs +++ b/src/analyzer/symbolic.rs @@ -101,6 +101,16 @@ pub struct SymbolicReportMetadata { /// `--replay` (or `--seed`) on a subsequent run to reproduce the identical /// exploration order. pub seed: Option, + /// Coverage metrics: unique functions reached during symbolic exploration. + pub unique_functions_reached: usize, + /// Coverage metrics: total functions available in the WASM module. + pub total_functions_available: usize, + /// Coverage metrics: approximate branch coverage indicator (branches touched). + pub branches_touched: usize, + /// Coverage metrics: duplicate input combinations suppressed. + pub duplicates_suppressed: usize, + /// Coverage metrics: whether the exploration hit the path cap. + pub exploration_cap_reached: bool, } #[derive(Debug, Clone)] @@ -193,6 +203,10 @@ impl SymbolicAnalyzer { } let deadline = Instant::now(); + // Extract all available functions for coverage analysis + let all_functions = crate::utils::wasm::parse_functions(wasm).unwrap_or_default(); + let total_functions = all_functions.len(); + let mut report = SymbolicReport { function: function.to_string(), paths_explored: 0, @@ -208,14 +222,21 @@ impl SymbolicAnalyzer { truncated_by_timeout: false, truncation_reasons: Vec::new(), seed: config.seed, + unique_functions_reached: 0, + total_functions_available: total_functions, + branches_touched: 0, + duplicates_suppressed: 0, + exploration_cap_reached: false, }, }; let mut seen_inputs = HashSet::new(); + let mut reached_functions = HashSet::new(); for args_json in &generated_inputs.combinations { if report.paths_explored >= config.max_paths { report.metadata.truncated_by_path_cap = true; + report.metadata.exploration_cap_reached = true; break; } @@ -245,9 +266,13 @@ impl SymbolicAnalyzer { match executor_res { Ok(Ok(val)) => { + // Track the target function as reached + reached_functions.insert(function.to_string()); Self::record_outcome(&mut report, &mut seen_inputs, args_json, Ok(val)); } Ok(Err(err)) => { + // Track the target function as reached + reached_functions.insert(function.to_string()); Self::record_outcome( &mut report, &mut seen_inputs, @@ -256,6 +281,8 @@ impl SymbolicAnalyzer { ); } Err(_) => { + // Track the target function as reached + reached_functions.insert(function.to_string()); Self::record_outcome( &mut report, &mut seen_inputs, @@ -264,11 +291,20 @@ impl SymbolicAnalyzer { ); } } + report.paths_explored += 1; } report.metadata.attempted_input_combinations = report.paths_explored; report.metadata.distinct_paths_recorded = report.paths.len(); + report.metadata.unique_functions_reached = reached_functions.len(); + // Duplicates = total attempts - distinct paths recorded + report.metadata.duplicates_suppressed = report.paths_explored.saturating_sub(report.paths.len()); + + // Estimate branches touched: each distinct path represents at least one branch decision + // This is a conservative estimate - in reality, each path may touch multiple branches + report.metadata.branches_touched = report.paths.len(); + if report.metadata.truncated_by_input_cap { report.metadata.truncation_reasons.push(format!( "input combination cap reached at {} generated combinations", @@ -637,6 +673,39 @@ impl SymbolicAnalyzer { report.metadata.truncated_by_timeout ) .unwrap(); + + // Add coverage metrics to TOML + writeln!( + toml, + "unique_functions_reached = {}", + report.metadata.unique_functions_reached + ) + .unwrap(); + writeln!( + toml, + "total_functions_available = {}", + report.metadata.total_functions_available + ) + .unwrap(); + writeln!( + toml, + "branches_touched = {}", + report.metadata.branches_touched + ) + .unwrap(); + writeln!( + toml, + "duplicates_suppressed = {}", + report.metadata.duplicates_suppressed + ) + .unwrap(); + writeln!( + toml, + "exploration_cap_reached = {}", + report.metadata.exploration_cap_reached + ) + .unwrap(); + match report.metadata.seed { Some(seed) => writeln!(toml, "seed = {}", seed).unwrap(), None => writeln!( @@ -781,6 +850,11 @@ mod tests { truncated_by_timeout: false, truncation_reasons: Vec::new(), seed: None, + unique_functions_reached: 0, + total_functions_available: 0, + branches_touched: 0, + duplicates_suppressed: 0, + exploration_cap_reached: false, }, }; let mut seen_inputs = HashSet::new(); @@ -811,6 +885,11 @@ mod tests { truncated_by_timeout: false, truncation_reasons: Vec::new(), seed: None, + unique_functions_reached: 0, + total_functions_available: 0, + branches_touched: 0, + duplicates_suppressed: 0, + exploration_cap_reached: false, }, }; let mut seen_inputs = HashSet::new(); @@ -975,6 +1054,11 @@ mod tests { "input combination cap reached at 64 generated combinations".to_string(), ], seed: None, + unique_functions_reached: 1, + total_functions_available: 5, + branches_touched: 1, + duplicates_suppressed: 0, + exploration_cap_reached: false, }, }; @@ -1074,4 +1158,110 @@ mod tests { Some(r#"{"counter": 100}"#.to_string()) ); } + + #[test] + fn analyze_with_config_tracks_coverage_metrics() { + let analyzer = SymbolicAnalyzer::new(); + let wasm = wasm_with_import_and_exported_local(); + let config = SymbolicConfig { + max_paths: 10, + max_input_combinations: 36, + timeout_secs: 30, + max_breadth: 5, + max_depth: 3, + seed: None, + storage_seed: None, + }; + + let report = analyzer + .analyze_with_config(&wasm, "entry", &config) + .expect("symbolic analysis should complete"); + + // Verify coverage metrics are populated + assert!( + report.metadata.total_functions_available > 0, + "Should detect available functions" + ); + assert!( + report.metadata.unique_functions_reached > 0, + "Should track reached functions" + ); + assert!( + report.metadata.unique_functions_reached <= report.metadata.total_functions_available, + "Reached functions cannot exceed available functions" + ); + assert!( + report.metadata.branches_touched > 0, + "Should estimate branches touched" + ); + // Branches touched should equal distinct paths recorded + assert_eq!( + report.metadata.branches_touched, + report.metadata.distinct_paths_recorded, + "Branches touched should equal distinct paths" + ); + } + + #[test] + fn analyze_with_config_tracks_duplicates() { + let analyzer = SymbolicAnalyzer::new(); + let wasm = wasm_with_import_and_exported_local(); + let config = SymbolicConfig { + max_paths: 100, + max_input_combinations: 10, // Small cap to force duplicates + timeout_secs: 30, + max_breadth: 3, + max_depth: 2, + seed: None, + storage_seed: None, + }; + + let report = analyzer + .analyze_with_config(&wasm, "entry", &config) + .expect("symbolic analysis should complete"); + + // Verify duplicate tracking + assert!( + report.paths_explored >= report.metadata.distinct_paths_recorded, + "Explored paths should be >= distinct paths" + ); + let calculated_duplicates = + report.paths_explored.saturating_sub(report.paths.len()); + assert_eq!( + report.metadata.duplicates_suppressed, calculated_duplicates, + "Duplicates should match calculated value" + ); + } + + #[test] + fn analyze_with_config_sets_exploration_cap_flag() { + let analyzer = SymbolicAnalyzer::new(); + let wasm = wasm_with_import_and_exported_local(); + let config = SymbolicConfig { + max_paths: 2, // Very low cap to force hitting the limit + max_input_combinations: 36, + timeout_secs: 30, + max_breadth: 5, + max_depth: 3, + seed: None, + storage_seed: None, + }; + + let report = analyzer + .analyze_with_config(&wasm, "entry", &config) + .expect("symbolic analysis should complete"); + + assert!( + report.metadata.exploration_cap_reached, + "Should flag when path cap is reached" + ); + assert!( + report.metadata.truncated_by_path_cap, + "Should be truncated by path cap" + ); + assert_eq!( + report.paths_explored, 2, + "Should stop at path cap" + ); + } } diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 623c7827..f090bcff 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -111,6 +111,51 @@ fn render_symbolic_report(report: &crate::analyzer::symbolic::SymbolicReport) -> ), ]; + // Add coverage metrics section + lines.push(String::new()); + lines.push("Coverage Summary:".to_string()); + + // Function coverage + let func_coverage = if report.metadata.total_functions_available > 0 { + let pct = (report.metadata.unique_functions_reached as f64 + / report.metadata.total_functions_available as f64) * 100.0; + format!( + " Functions reached: {}/{} ({:.1}%)", + report.metadata.unique_functions_reached, + report.metadata.total_functions_available, + pct + ) + } else { + format!( + " Functions reached: {}", + report.metadata.unique_functions_reached + ) + }; + lines.push(func_coverage); + + // Branch coverage estimate + lines.push(format!( + " Branches touched: {} (estimated from distinct paths)", + report.metadata.branches_touched + )); + + // Duplicate suppression + if report.metadata.duplicates_suppressed > 0 { + lines.push(format!( + " Duplicates suppressed: {}", + report.metadata.duplicates_suppressed + )); + } + + // Exploration completeness indicator + if report.metadata.exploration_cap_reached { + lines.push(" ⚠ Exploration hit path cap - may not be complete".to_string()); + } else if report.metadata.truncated_by_timeout { + lines.push(" ⚠ Exploration timed out - may not be complete".to_string()); + } else { + lines.push(" ✓ Exploration completed without hitting caps".to_string()); + } + if report.metadata.truncation_reasons.is_empty() { lines.push("Truncation: none".to_string()); } else { From 64a2fbf3efd0e43545b23726781b3a344ca07f76 Mon Sep 17 00:00:00 2001 From: nanaabdul1172 Date: Fri, 24 Apr 2026 06:40:13 +0100 Subject: [PATCH 03/15] ci fix --- src/analyzer/symbolic.rs | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/analyzer/symbolic.rs b/src/analyzer/symbolic.rs index 2e193f2a..98f0db85 100644 --- a/src/analyzer/symbolic.rs +++ b/src/analyzer/symbolic.rs @@ -338,33 +338,19 @@ impl SymbolicAnalyzer { }; match executor_res { - Ok(Ok(val)) => { - // Track the target function as reached - reached_functions.insert(function.to_string()); - Self::record_outcome(&mut report, &mut seen_inputs, args_json, Ok(val)); - } - Ok(Err(err)) => { + Ok(val) => { // Track the target function as reached reached_functions.insert(function.to_string()); - Ok(val) => { Self::record_outcome(&mut report, &mut seen_inputs, args_json, Ok(val), trace); } Err(err) => { - Self::record_outcome( - &mut report, - &mut seen_inputs, - args_json, - Err(err.to_string()), - ); - } - Err(_) => { // Track the target function as reached reached_functions.insert(function.to_string()); Self::record_outcome( &mut report, &mut seen_inputs, args_json, - Err("Host Panic".to_string()), + Err(err.to_string()), trace, ); } From 5651fcf139be52d0d1645af97d5c07a1e09924ca Mon Sep 17 00:00:00 2001 From: Timi16 Date: Sat, 25 Apr 2026 11:32:27 -0700 Subject: [PATCH 04/15] docs: add VS Code setup and config guidance --- docs/feature-matrix.md | 2 +- docs/index.md | 2 + docs/performance-regressions.md | 16 ++++ docs/tutorials/first-debug.md | 27 +++++- docs/tutorials/vscode-extension-setup.md | 114 +++++++++++++++++++++++ extensions/vscode/README.md | 2 + 6 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 docs/tutorials/vscode-extension-setup.md diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index 9dfe323b..60c2607d 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -154,7 +154,7 @@ This matrix is derived from: Related CI contract checks: - Coverage enforcement in `.github/workflows/ci.yml` validates `cargo llvm-cov --json --summary-only` schema and requires `.data[0].totals.lines.percent` to exist as a numeric field. -- Missing-field behavior is regression-tested by `bash scripts/check_benchmark_regressions.sh selftest-coverage-missing-field` to keep schema drift failures actionable. +- Missing-field behavior is regression-tested by `bash scripts/check_benchmark_regressions.sh selftest-coverage-missing-field`; see [Benchmark regression policy](performance-regressions.md#coverage-parser-self-test) for the exact contract that self-test enforces. When adding a new CLI flag or DAP capability, update this file alongside the implementation to keep gaps explicit rather than implicit. diff --git a/docs/index.md b/docs/index.md index fdf83b63..4d8a914e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,7 @@ Welcome to the Soroban Debugger documentation. This index helps you navigate the ## 🏁 Getting Started - [Getting Started Guide](getting-started.md) — Your first steps with the debugger. - [First Debug Session](tutorials/first-debug.md) — A step-by-step walkthrough. +- [VS Code Extension Setup](tutorials/vscode-extension-setup.md) — Install the extension, write `launch.json`, and set your first breakpoints. - [Installation Guide](installation.md) — Detailed installation instructions for all platforms. ## 🛠️ Core Features @@ -39,5 +40,6 @@ Welcome to the Soroban Debugger documentation. This index helps you navigate the ## 📄 Reference - [CLI Command Index](cli-command-groups.md) — Detailed reference for all CLI subcommands. +- [Benchmark Regression Policy](performance-regressions.md) — CI baseline comparison and coverage parser self-test behavior. - [Trace JSON Schema](trace-schema.md) — Format of exported execution traces. - [Plugin API](plugin-api.md) — Documentation for the debugger plugin system. diff --git a/docs/performance-regressions.md b/docs/performance-regressions.md index c9a41b95..4a4aec68 100644 --- a/docs/performance-regressions.md +++ b/docs/performance-regressions.md @@ -46,3 +46,19 @@ cargo bench --benches -- --noplot cargo run --bin bench-regression -- compare --baseline .bench/baseline.json --criterion target/criterion ``` +## Coverage parser self-test + +The benchmark helper script also contains a schema-regression self-test for coverage parsing: + +```bash +bash scripts/check_benchmark_regressions.sh selftest-coverage-missing-field +``` + +This mode does not run Criterion benchmarks. Instead, it feeds intentionally incomplete coverage JSON into `coverage-percent-from-json` and verifies two behaviors: + +- The parser exits with a failure when `.data[0].totals.lines.percent` is missing. +- The error message explicitly names the missing numeric field so CI failures remain actionable when the `cargo llvm-cov --json --summary-only` schema drifts. + +Run this self-test when you change the coverage parsing logic, the CI workflow around coverage extraction, or the docs that describe the coverage contract. This is the self-test referenced in [Feature Matrix](feature-matrix.md#maintaining-this-document). + +`jq` must be available on `PATH`; the script will fail early if it is missing. diff --git a/docs/tutorials/first-debug.md b/docs/tutorials/first-debug.md index 14c25cec..0bc870f5 100644 --- a/docs/tutorials/first-debug.md +++ b/docs/tutorials/first-debug.md @@ -87,7 +87,24 @@ soroban contract build Verify that your WASM file was generated at `target/wasm32-unknown-unknown/release/hello_world.wasm`. Because debug symbols are included, the file size will be significantly larger than a heavily optimized production build. -## 4. Starting the Debugger +## 4. Save Project Defaults in `.soroban-debug.toml` + +Before you start the debugger, add a project-local config file so repeated sessions keep the same defaults. Create `.soroban-debug.toml` in the project root: + +```toml +[debug] +breakpoints = ["increment"] +verbosity = 1 + +[output] +show_events = true +``` + +The debugger loads this file automatically when it starts. It is a good place to keep default breakpoints, verbosity, and output settings that you want every contributor to share. + +If you prefer debugging from VS Code, keep this file alongside a `.vscode/launch.json` and follow [Set Up the VS Code Extension](vscode-extension-setup.md) for the editor-side setup. + +## 5. Starting the Debugger Launch the debugger and pass the path to your compiled WASM file. @@ -104,7 +121,7 @@ Debug symbols loaded successfully. You are now in the interactive debugging prompt. Type `help` to see all available basic debugger commands (`run`, `step`, `next`, `break`, `print`, `storage`). -## 5. Setting Breakpoints +## 6. Setting Breakpoints Before running the contract, we need to tell the debugger where to pause execution. You can set breakpoints by function name or by file and line number. @@ -122,7 +139,7 @@ Breakpoint 1 set at src/lib.rs:13 ![Setting a Breakpoint](./images/debugger-breakpoint.png) *(Screenshot: Terminal showing the breakpoint confirmation and the interactive prompt)* -## 6. Running and Stepping Through Code +## 7. Running and Stepping Through Code To trigger the execution, we use the `invoke` command, simulating a call to the contract. @@ -146,7 +163,7 @@ Execute the current line and move to the next one using the `next` (or `n`) comm (soroban-debug) next ``` -## 7. Inspecting Storage and Variables +## 8. Inspecting Storage and Variables Now that we have stepped past line 13, the `count` variable has been initialized. Let's inspect it using the `print` command: @@ -216,4 +233,4 @@ Return value: U32(1) * **Host Environment:** The debugger runs a mock Soroban environment. State does not persist between `soroban-debugger` CLI sessions unless you export the ledger state to a JSON file. --- -*Return to the [Docs Index](../../README.md) for more tutorials.* +*Return to the [Docs Index](../index.md) for more tutorials.* diff --git a/docs/tutorials/vscode-extension-setup.md b/docs/tutorials/vscode-extension-setup.md new file mode 100644 index 00000000..e5edfc45 --- /dev/null +++ b/docs/tutorials/vscode-extension-setup.md @@ -0,0 +1,114 @@ +# Tutorial: Set Up the VS Code Extension + +This tutorial walks through the full VS Code debugger setup: install the extension, create a `launch.json`, place breakpoints in Rust source, and start a Soroban debug session without leaving the editor. + +## Prerequisites + +- VS Code installed locally. +- A Soroban contract workspace with a compiled WASM artifact that still contains debug symbols. +- The `soroban-debug` CLI available either on your `PATH` or built in this repository at `target/debug/soroban-debug`. + +If you have not built a debug-friendly contract yet, start with [First Debug Session](first-debug.md) and return here once you have a `.wasm` file under `target/wasm32-unknown-unknown/release/`. + +## 1. Install the extension + +The repository currently documents a local install flow based on a packaged VSIX. + +Build the extension from the repository: + +```bash +cd extensions/vscode +npm install +npm run build +vsce package +``` + +This produces a file named `soroban-debugger-.vsix`. + +Install that VSIX in VS Code: + +1. Open VS Code. +2. Open the Extensions view. +3. Run `Extensions: Install from VSIX...` from the Command Palette. +4. Select the generated `soroban-debugger-.vsix` file. + +For extension internals and the full argument reference, see the [VS Code extension README](../../extensions/vscode/README.md). + +## 2. Create `.vscode/launch.json` + +Create a `.vscode/launch.json` file in your contract workspace: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Soroban: Debug hello_world", + "type": "soroban", + "request": "launch", + "contractPath": "${workspaceFolder}/target/wasm32-unknown-unknown/release/hello_world.wasm", + "snapshotPath": "${workspaceFolder}/snapshot.json", + "entrypoint": "increment", + "args": [], + "trace": false + } + ] +} +``` + +Adjust these fields for your project: + +- `contractPath`: compiled contract you want to debug. +- `entrypoint`: exported Soroban function to invoke. +- `args`: JSON-compatible argument list passed to that entrypoint. +- `binaryPath`: add this only when the adapter cannot find `soroban-debug` on your `PATH`, for example when you want VS Code to use a specific local build of the CLI. + +`.soroban-debug.toml` complements `launch.json`; use the TOML file for shared debugger defaults such as breakpoints or output behavior, and `launch.json` for VS Code session wiring. + +## 3. Validate the launch configuration + +Before starting a session, run the built-in preflight check: + +1. Open the Command Palette. +2. Run `Soroban: Run Launch Preflight Check`. +3. Pick the Soroban launch configuration you just created. + +Use this whenever you change `contractPath`, `binaryPath`, or argument structure. The preflight check catches invalid launch settings before the adapter starts. + +## 4. Set breakpoints in Rust source + +Open the Rust file that corresponds to the contract you want to debug, for example `contracts/hello_world/src/lib.rs`. + +Set breakpoints in the editor gutter on executable lines such as: + +- The first line inside `increment`. +- The storage write line where state changes are committed. + +You can confirm the breakpoints in the **Run and Debug** panel before you launch the session. + +If a breakpoint stays gray or shows as unverified: + +1. Open the file where the breakpoint is set. +2. Run `Soroban: Diagnose Source Maps for Current File`. +3. Move the breakpoint to the nearest executable statement if the diagnostic reports a source-mapping gap. + +## 5. Start the session and inspect state + +Start the `Soroban: Debug hello_world` configuration from the **Run and Debug** view. + +When execution stops at a breakpoint: + +- Use `F10`, `F11`, and `Shift+F11` for stepping. +- Inspect storage and locals in the **Variables** panel. +- Review the call stack in the **Call Stack** panel. +- Watch adapter output in the **Debug Console** if you enabled `"trace": true`. + +## 6. Keep the setup repeatable + +Store editor-specific session settings in `.vscode/launch.json`, and keep repo-wide debugger defaults in `.soroban-debug.toml`. That split keeps the VS Code adapter configuration explicit while still giving new contributors sensible defaults when they start with the CLI. + +Next steps: + +- [First Debug Session](first-debug.md) for the CLI-first walkthrough. +- [Breakpoints Reference](../breakpoints.md) for source and function breakpoint behavior. +- [VS Code extension README](../../extensions/vscode/README.md) for the full launch/attach schema. diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 3dcb7fff..c57db351 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -66,6 +66,8 @@ The extension will be published to the VS Code Marketplace. Once available, sear ## Quick Start +For an end-to-end setup walkthrough, including extension installation, `.vscode/launch.json`, and first breakpoints, see [docs/tutorials/vscode-extension-setup.md](../../docs/tutorials/vscode-extension-setup.md). + ### 1. Create a Debug Configuration Add the following to your project's `.vscode/launch.json`: From bf329cd7ca2ecb0a18c146598e196999d8cc25f3 Mon Sep 17 00:00:00 2001 From: Timi16 Date: Sat, 25 Apr 2026 12:04:27 -0700 Subject: [PATCH 05/15] docs: make Epic J roadmap easier to execute --- docs/issues/backlog-100-issues.md | 26 ++++++++++++++++++++++++++ docs/issues/roadmap-priorities.md | 27 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/docs/issues/backlog-100-issues.md b/docs/issues/backlog-100-issues.md index e1524a66..8aede2de 100644 --- a/docs/issues/backlog-100-issues.md +++ b/docs/issues/backlog-100-issues.md @@ -22,8 +22,20 @@ --- +## Planning Workflow + +Use this file as the inventory, then move into the roadmap for execution decisions: + +1. Read the relevant section here to understand the raw issue list and context. +2. Switch to [roadmap-priorities.md](roadmap-priorities.md#section-rollup) for the section rollup, then use the [Priority Table](roadmap-priorities.md#priority-table) and [Wave Plan](roadmap-priorities.md#wave-plan) to decide sequencing. +3. When editing this backlog, update the corresponding roadmap row in the same commit so the two files stay in sync. + +--- + ## Section A — README and Landing Docs (Issues 1–12) +Roadmap view: [Section A priorities](roadmap-priorities.md#section-a--readme-and-landing-docs) + - **I-001** `[IA]` README table of contents is missing; readers must scroll through 420 lines to locate sections. - **I-002** `[DOC]` README "Troubleshooting" table has only 3 rows; the full list lives in `docs/remote-troubleshooting.md` without a cross-link from the table. - **I-003** `[DOC]` README "Storage Filtering" section duplicates the same pattern table twice (lines 200–209) — consolidate. @@ -41,6 +53,8 @@ ## Section B — Architecture and Design Docs (Issues 13–22) +Roadmap view: [Section B priorities](roadmap-priorities.md#section-b--architecture-and-design-docs) + - **I-013** `[DOC]` `ARCHITECTURE.md` describes `Stepper` as "(Planned)" with no follow-up issue or tracking link; its current implementation status is unknown. - **I-014** `[DOC]` `ARCHITECTURE.md` "Extension Points" section lists four items but omits the plugin system, remote server, and batch executor — all of which now exist. - **I-015** `[DOC]` No architecture-level doc covers the VS Code extension / DAP adapter; `ARCHITECTURE.md` only covers the Rust CLI. @@ -56,6 +70,8 @@ ## Section C — Feature Reference Docs (Issues 23–40) +Roadmap view: [Section C priorities](roadmap-priorities.md#section-c--feature-reference-docs) + - **I-023** `[DOC]` `docs/instruction-stepping.md` (11 KB) covers the feature thoroughly but has no link back to the feature matrix — readers don't know which surfaces support it. - **I-024** `[DOC]` `docs/remote-debugging.md` covers TLS setup but doesn't show a complete `launch.json` snippet for the VS Code attach flow. - **I-025** `[DOC]` `docs/remote-troubleshooting.md` references a "Local and CI Sandbox Failures" section that exists, but the FAQ (question 27) links to it using anchor syntax that doesn't match the actual heading casing. @@ -79,6 +95,8 @@ ## Section D — Tutorials (Issues 41–52) +Roadmap view: [Section D priorities](roadmap-priorities.md#section-d--tutorials) + - **I-041** `[DOC]` `docs/tutorials/first-debug.md` doesn't reference the `.soroban-debug.toml` config file, which new users would benefit from knowing about early. - **I-042** `[DOC]` `docs/tutorials/scenario-runner.md` shows TOML structure but doesn't document all TOML keys (e.g., `timeout`, `expected_events`, `skip`). - **I-043** `[DOC]` `docs/tutorials/debug-auth-errors.md` has empty checkbox items that suggest the tutorial is incomplete. @@ -96,6 +114,8 @@ ## Section E — Contributor Workflow (Issues 53–70) +Roadmap view: [Section E priorities](roadmap-priorities.md#section-e--contributor-workflow) + - **I-053** `[CONTRIB]` `CONTRIBUTING.md` says "Check the issue tracker for open issues and labels like `good first issue`" but doesn't link to the actual filtered GitHub URL. - **I-054** `[CONTRIB]` `CONTRIBUTING.md` "Areas for Contribution" lists items in free text; it should link to concrete open GitHub issues or project board columns. - **I-055** `[CONTRIB]` `CONTRIBUTING.md` describes the PR checklist but does not explain what "CI/test behavior changes" means or give examples of what N/A covers. @@ -119,6 +139,8 @@ ## Section F — Release Operations (Issues 71–82) +Roadmap view: [Section F priorities](roadmap-priorities.md#section-f--release-operations) + - **I-071** `[RELEASE]` `docs/release-checklist.md` requires `CHANGELOG.md` to be updated but provides no example entry format, no link to `cliff.toml`, and no `git-cliff` invocation command. - **I-072** `[RELEASE]` The release checklist sign-off section uses `@____` placeholder syntax; there is no documented process for assigning owners before a release cycle begins. - **I-073** `[RELEASE]` The benchmark threshold (10%/20%) is hardcoded in the release checklist but the actual values are also set in CI scripts — the two can drift without detection. @@ -136,6 +158,8 @@ ## Section G — Repo Health and Meta (Issues 83–93) +Roadmap view: [Section G priorities](roadmap-priorities.md#section-g--repo-health-and-meta) + - **I-083** `[META]` Several implementation-summary files (`BATCH_EXECUTION_SUMMARY.md`, `IMPLEMENTATION_SUMMARY.md`, `PLUGIN_RELOAD_DIFF_IMPLEMENTATION.md`, `FLAMEGRAPH_IMPLEMENTATION.md`) live in the root and appear to be one-off delivery notes rather than living documentation; a policy for these files is needed. - **I-084** `[META]` `PR_DESCRIPTION.md` lives in the repo root — it appears to be a leftover from a PR and should be removed or archived. - **I-085** `[META]` No `SECURITY.md` file exists; GitHub displays a warning and security researchers have no disclosed contact point. @@ -152,6 +176,8 @@ ## Section H — DX and Tooling Quality (Issues 94–100) +Roadmap view: [Section H priorities](roadmap-priorities.md#section-h--dx-and-tooling-quality) + - **I-094** `[DX]` `docs/getting-started.md` (3001 bytes) is the natural entry point for new users but is not linked from the README "Quick Start" section. - **I-095** `[DX]` The `feature-matrix.md` "Maintaining This Document" section tells editors which source files to check but doesn't describe a process to detect drift (e.g., a CI check that flags undocumented flags). - **I-096** `[DX]` Man pages in `man/man1/` are generated but there's no published HTML equivalent — the `cargo doc` output and man pages are the only machine-generated references. diff --git a/docs/issues/roadmap-priorities.md b/docs/issues/roadmap-priorities.md index a7c99479..ec9c2d32 100644 --- a/docs/issues/roadmap-priorities.md +++ b/docs/issues/roadmap-priorities.md @@ -23,6 +23,25 @@ --- +## Section Rollup + +Use this table before you dive into the 100-row priority matrix. It gives each +backlog section a recommended entry point so maintainers can open issues in a +useful order instead of re-triaging the whole epic every time. + +| Section | Backlog slice | Planning focus | Start with | Why this is the entry point | +|---------|---------------|----------------|------------|-----------------------------| +| **A** | [README and Landing Docs](backlog-100-issues.md#section-a--readme-and-landing-docs) | New-user discoverability and release-facing repo hygiene | `I-009`, then `I-007` | `I-009` is a P0 FAQ gap; `I-007` unlocks several later navigation fixes. | +| **B** | [Architecture and Design Docs](backlog-100-issues.md#section-b--architecture-and-design-docs) | Fill missing system-shape docs before dependent tutorials land | `I-015` | The VS Code / DAP architecture doc unblocks `I-024` and `I-048`. | +| **C** | [Feature Reference Docs](backlog-100-issues.md#section-c--feature-reference-docs) | Close reference gaps that affect discoverability and advanced workflows | `I-023`, then `I-030` | `I-023` is a fast cross-link win; `I-030` unlocks plugin tutorial work. | +| **D** | [Tutorials](backlog-100-issues.md#section-d--tutorials) | Repair broken learning paths, then add missing workflows | `I-043`, then `I-046` | `I-043` is the only P0 in this section; `I-046` improves tutorial discoverability immediately. | +| **E** | [Contributor Workflow](backlog-100-issues.md#section-e--contributor-workflow) | Remove contributor blockers, then formalize process | `I-058`, then `I-053` | `I-058` fixes a missing referenced file; `I-053` is an easy on-ramp for contribution flow. | +| **F** | [Release Operations](backlog-100-issues.md#section-f--release-operations) | Establish release-critical documentation before automation depth | `I-071` | It is P0 and also unblocks follow-on `git-cliff` docs in contributor guidance. | +| **G** | [Repo Health and Meta](backlog-100-issues.md#section-g--repo-health-and-meta) | Clean repo-level trust and navigation issues | `I-085`, then `I-083` | `I-085` is security-critical; `I-083` prevents future root-level doc sprawl. | +| **H** | [DX and Tooling Quality](backlog-100-issues.md#section-h--dx-and-tooling-quality) | Improve entry points first, then automate drift detection | `I-094` | It is a P0 fix on the primary onboarding path and is independent of later tooling work. | + +--- + ## Priority Table ### Section A — README and Landing Docs @@ -314,3 +333,11 @@ acceptance criteria. 5. **Review this file** at the end of each wave — demote any issues that turn out to be lower value than expected, and promote anything the wave work revealed as higher priority. + +--- + +## Maintenance Rules + +1. When adding or removing an issue in [backlog-100-issues.md](backlog-100-issues.md), update the matching roadmap row in the same commit. +2. Keep the short title in this file close to the backlog wording so maintainers can grep for an ID and compare the two documents quickly. +3. When a dependency changes, update both the `Depends on` column and the relevant wave summary so the wave plan stays executable rather than historical. From 272ae8a0097b4b4763fb3132549b68458caa9f77 Mon Sep 17 00:00:00 2001 From: Promise Date: Sat, 25 Apr 2026 23:59:53 +0100 Subject: [PATCH 06/15] docs: add end-to-end scenario walkthrough and plugin development tutorial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves I-039 (#950): scenario-cookbook.md now includes a full end-to-end section covering TOML authoring, running the scenario, reading the trace output, and diagnosing a failure — not just isolated snippets. Resolves I-047 (#957): new docs/tutorials/plugin-development.md walks through building a gas-spike-alerter plugin from scratch: crate setup, trait implementation, manifest authoring, build, install, hot-reload iteration, custom commands, and signing. Updates docs/index.md to cross-link both documents. Co-Authored-By: Claude Sonnet 4.6 --- docs/index.md | 4 +- docs/scenario-cookbook.md | 215 ++++++++++++ docs/tutorials/plugin-development.md | 474 +++++++++++++++++++++++++++ 3 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 docs/tutorials/plugin-development.md diff --git a/docs/index.md b/docs/index.md index 4d8a914e..a0e666c1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,7 @@ Welcome to the Soroban Debugger documentation. This index helps you navigate the ## 🎓 Tutorials - [Debugging Auth Errors](tutorials/debug-auth-errors.md) — Diagnosing `require_auth()` failures. - [Scenario Runner Cookbook](tutorials/scenario-runner.md) — Writing automated integration tests. +- [Plugin Development Tutorial](tutorials/plugin-development.md) — Build, install, and iterate on a plugin end-to-end. - [Symbolic Analysis Budgets](tutorials/symbolic-analysis-budgets.md) — Configuring symbolic exploration. - [Understanding Budget Trends](tutorials/understanding-budget.md) — Visualizing resource usage. @@ -42,4 +43,5 @@ Welcome to the Soroban Debugger documentation. This index helps you navigate the - [CLI Command Index](cli-command-groups.md) — Detailed reference for all CLI subcommands. - [Benchmark Regression Policy](performance-regressions.md) — CI baseline comparison and coverage parser self-test behavior. - [Trace JSON Schema](trace-schema.md) — Format of exported execution traces. -- [Plugin API](plugin-api.md) — Documentation for the debugger plugin system. +- [Plugin API](plugin-api.md) — Reference documentation for the debugger plugin system. +- [Scenario Cookbook](scenario-cookbook.md) — Reusable TOML patterns and an end-to-end scenario walkthrough. diff --git a/docs/scenario-cookbook.md b/docs/scenario-cookbook.md index bcbf6ea7..f791b516 100644 --- a/docs/scenario-cookbook.md +++ b/docs/scenario-cookbook.md @@ -137,3 +137,218 @@ args = "[1, 2]" max_cpu_instructions = 10000 max_memory_bytes = 1024 ``` + +--- + +## 🚀 End-to-End Walkthrough + +This section goes beyond isolated snippets to show the full workflow: authoring a scenario file, running it, and reviewing the execution trace to understand what happened. + +We'll use the `simple-token` example contract from `examples/contracts/simple-token/` — a straightforward fungible token with `initialize`, `mint`, `transfer`, and `balance` functions. + +### Step 1: Author the Scenario TOML + +Create a file named `token_lifecycle.toml` in your working directory: + +```toml +# token_lifecycle.toml +# End-to-end test for the simple-token contract. +# Covers: initialization, minting, transfer, balance verification, and an +# expected-failure case for overdrawing. + +[defaults] +timeout_secs = 30 + +# ── Setup ──────────────────────────────────────────────────────────────────── + +[[steps]] +name = "Initialize Token" +function = "initialize" +args = '["GD5DJ3B6A2KHSXLYJZ3IGR7Q5UMVJ5J4GQTKTQYQDQXJQJ5YQZQKQZQ", "My Token", "MTK"]' +expected_return = "()" + +# ── Funding ────────────────────────────────────────────────────────────────── + +[[steps]] +name = "Mint 1 000 tokens to Alice" +function = "mint" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 1000]' +expected_return = "()" + +[[steps]] +name = "Confirm Alice balance after mint" +function = "balance" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ"]' +expected_return = "1000" + +# ── Transfer ───────────────────────────────────────────────────────────────── + +[[steps]] +name = "Alice transfers 300 tokens to Bob" +function = "transfer" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", "GD826E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 300]' +expected_return = "()" + +# ── State verification ─────────────────────────────────────────────────────── + +[[steps]] +name = "Verify Alice's remaining balance" +function = "balance" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ"]' +expected_return = "700" + +[steps.expected_storage] +"TotalSupply" = "1000" + +[[steps]] +name = "Verify Bob's balance" +function = "balance" +args = '["GD826E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ"]' +expected_return = "300" + +# ── Failure guard ──────────────────────────────────────────────────────────── + +[[steps]] +name = "Overdraw attempt must fail" +function = "transfer" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", "GD826E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 9999]' +expected_error = "insufficient" +``` + +Key authoring decisions made here: + +- **`[defaults]`** sets a 30-second timeout for all steps; individual steps can override it. +- Steps are grouped with comments into setup, funding, transfer, verification, and failure phases — this makes failures easier to locate. +- `expected_storage` on the verification step pins contract-level state, not just the return value. +- The final step uses `expected_error` to assert that the contract correctly rejects an overdraw; the runner treats a matching error as a pass. + +### Step 2: Build the Contract + +```bash +cd examples/contracts/simple-token +cargo build --target wasm32-unknown-unknown --release +``` + +The compiled WASM lands at: + +``` +target/wasm32-unknown-unknown/release/simple_token.wasm +``` + +### Step 3: Run the Scenario + +From the repository root: + +```bash +soroban-debugger scenario \ + --contract examples/contracts/simple-token/target/wasm32-unknown-unknown/release/simple_token.wasm \ + --scenario token_lifecycle.toml +``` + +Add `--verbose` to see per-instruction budget details on each step. + +### Step 4: Read the Execution Trace + +A successful run prints a step-by-step trace to stdout: + +``` +ℹ️ Loading scenario file: "token_lifecycle.toml" +ℹ️ Loading contract: "simple_token.wasm" +✅ Running 7 scenario steps... + +ℹ️ Step 1: Initialize Token + Result: () + ✅ Return value assertion passed +✅ Step 1 passed. + +ℹ️ Step 2: Mint 1 000 tokens to Alice + Result: () + ✅ Return value assertion passed +✅ Step 2 passed. + +ℹ️ Step 3: Confirm Alice balance after mint + Result: 1000 + ✅ Return value assertion passed +✅ Step 3 passed. + +ℹ️ Step 4: Alice transfers 300 tokens to Bob + Result: () + ✅ Return value assertion passed +✅ Step 4 passed. + +ℹ️ Step 5: Verify Alice's remaining balance + Result: 700 + ✅ Return value assertion passed + ✅ Storage assertion passed for key 'TotalSupply' +✅ Step 5 passed. + +ℹ️ Step 6: Verify Bob's balance + Result: 300 + ✅ Return value assertion passed +✅ Step 6 passed. + +ℹ️ Step 7: Overdraw attempt must fail + Error: "insufficient balance" + ✅ Error assertion matched 'insufficient' +✅ Step 7 passed. + +✅ All 7 scenario steps passed successfully! +``` + +**How to read the trace:** + +| Line pattern | Meaning | +|---|---| +| `ℹ️ Step N: ` | A new step is starting | +| ` Result: ` | The raw return value from the contract | +| `✅ Return value assertion passed` | `expected_return` matched | +| `✅ Storage assertion passed for key ''` | That `expected_storage` key matched | +| `✅ Error assertion matched ''` | The actual error contains the expected substring | +| `✅ Step N passed.` | All assertions on this step passed | +| `✅ All N scenario steps passed successfully!` | The whole scenario is green | + +### Step 5: Diagnose a Failure + +Suppose step 5 fails because a bug causes the transfer to deduct tokens twice: + +``` +ℹ️ Step 5: Verify Alice's remaining balance + Result: 400 + ❌ Return value assertion failed! Expected '700', got '400' +⚠️ Step 5 failed. +``` + +**Triage workflow:** + +1. **Isolate the step** — comment out steps 6 and 7 in the TOML, re-run to confirm the failure is reproducible in isolation. +2. **Add a storage assertion** — add `expected_storage` to step 4 to verify what the contract wrote after the transfer: + + ```toml + [[steps]] + name = "Alice transfers 300 tokens to Bob" + function = "transfer" + args = '["GD726...", "GD826...", 300]' + expected_return = "()" + + [steps.expected_storage] + "Balance:GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ" = "700" + ``` + +3. **Inspect with the debugger** — switch from `scenario` to `interactive` to step through the transfer call: + + ```bash + soroban-debugger interactive \ + --contract simple_token.wasm \ + --function transfer \ + --args '["GD726...", "GD826...", 300]' + ``` + +4. **Compare traces** — use `soroban-debugger compare` if you have a known-good trace to diff against the failing one. + +Once the bug is fixed, re-run the full scenario to confirm all 7 steps pass again. + +### Next Steps + +- Add more failure guards using `expected_panic` for contracts that use `panic!` instead of returning errors. +- Extract shared setup steps into an `include`d file (see [Scenario Runner Tutorial](tutorials/scenario-runner.md)). +- Combine this scenario with the symbolic analyzer to auto-generate edge-case steps (see the [Scenario Runner Tutorial § Symbolic Analysis](tutorials/scenario-runner.md#symbolic-analysis)). diff --git a/docs/tutorials/plugin-development.md b/docs/tutorials/plugin-development.md new file mode 100644 index 00000000..f15cd4f7 --- /dev/null +++ b/docs/tutorials/plugin-development.md @@ -0,0 +1,474 @@ +# Building Your First Soroban Debugger Plugin + +This tutorial walks you through writing, building, installing, and iterating on a real debugger plugin end-to-end. By the end you will have a working **gas-spike alerter** plugin that watches every function call and prints a warning whenever CPU instruction usage exceeds a configurable threshold. + +For the complete API reference, see [Plugin API](../plugin-api.md). This tutorial focuses on the workflow, not the reference. + +## Prerequisites + +- Soroban Debugger installed and on your `$PATH` +- Rust toolchain (stable, 1.75+) +- A compiled contract WASM to test against — we'll use `examples/contracts/simple-token` + +--- + +## 1. Understand the Plugin Model + +Plugins are Rust `cdylib` crates. The debugger loads them at startup from `~/.soroban-debug/plugins/` using `libloading`. Each plugin: + +1. Exports a single C-ABI function `create_plugin()` that returns a boxed `InspectorPlugin` trait object. +2. Provides a `plugin.toml` manifest so the debugger knows its name, version, and capabilities before loading the shared library. +3. Receives `ExecutionEvent` callbacks during contract execution. + +``` +~/.soroban-debug/plugins/ +└── gas-spike-alerter/ + ├── plugin.toml ← manifest (read first) + └── libgas_spike_alerter.dylib ← shared library (loaded second) +``` + +The full lifecycle is: **discover → validate manifest → trust check → load library → `initialize()` → receive events → `shutdown()`**. + +--- + +## 2. Create the Crate + +```bash +cargo new --lib gas-spike-alerter +cd gas-spike-alerter +``` + +Open `Cargo.toml` and replace its contents: + +```toml +[package] +name = "gas-spike-alerter" +version = "0.1.0" +edition = "2021" + +[lib] +# cdylib produces a .so / .dylib / .dll that can be dynamically loaded +crate-type = ["cdylib"] + +[dependencies] +# Point at your local checkout. Adjust the path as needed. +soroban-debugger = { path = "../../.." } +``` + +> If you're developing outside the debugger repository, publish the `soroban-debugger` crate to crates.io or use a git dependency instead. + +--- + +## 3. Write the Plugin + +Replace `src/lib.rs` with the following. Read the inline comments — they explain every decision. + +```rust +use soroban_debugger::plugin::{ + EventContext, ExecutionEvent, InspectorPlugin, PluginCapabilities, PluginManifest, + PluginResult, +}; +use std::any::Any; + +// ── Plugin state ────────────────────────────────────────────────────────────── + +pub struct GasSpikeAlerter { + manifest: PluginManifest, + /// Warn when a single function call exceeds this many CPU instructions. + threshold: u64, + /// Running count of alerts fired this session. + alert_count: usize, +} + +impl GasSpikeAlerter { + fn new() -> Self { + Self { + manifest: PluginManifest { + name: "gas-spike-alerter".to_string(), + version: "0.1.0".to_string(), + description: "Warns when a function call exceeds a CPU-instruction threshold" + .to_string(), + author: "Your Name".to_string(), + license: Some("MIT".to_string()), + min_debugger_version: Some("0.1.0".to_string()), + capabilities: PluginCapabilities { + hooks_execution: true, + provides_commands: false, + provides_formatters: false, + // We don't carry across-reload state, so hot-reload is trivial. + supports_hot_reload: true, + }, + library: "libgas_spike_alerter.dylib".to_string(), + dependencies: vec![], + signature: None, + }, + // Read threshold from the environment; default to 500 000 instructions. + threshold: std::env::var("GAS_SPIKE_THRESHOLD") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500_000), + alert_count: 0, + } + } +} + +// ── Trait implementation ────────────────────────────────────────────────────── + +impl InspectorPlugin for GasSpikeAlerter { + fn metadata(&self) -> PluginManifest { + self.manifest.clone() + } + + fn initialize(&mut self) -> PluginResult<()> { + eprintln!( + "[gas-spike-alerter] loaded — threshold: {} instructions", + self.threshold + ); + Ok(()) + } + + fn shutdown(&mut self) -> PluginResult<()> { + eprintln!( + "[gas-spike-alerter] shutdown — {} alert(s) fired this session", + self.alert_count + ); + Ok(()) + } + + fn on_event(&mut self, event: &ExecutionEvent, _ctx: &mut EventContext) -> PluginResult<()> { + // We only care about completed function calls that carry budget information. + if let ExecutionEvent::AfterFunctionCall { + function, + cpu_instructions, + .. + } = event + { + if let Some(instructions) = cpu_instructions { + if *instructions > self.threshold { + self.alert_count += 1; + eprintln!( + "[gas-spike-alerter] ⚠️ SPIKE in '{}': {} instructions (threshold: {})", + function, instructions, self.threshold + ); + } + } + } + Ok(()) + } + + // ── Hot-reload support ──────────────────────────────────────────────────── + // We don't have state that needs preserving across a reload, so these are + // trivially implemented. + + fn supports_hot_reload(&self) -> bool { + true + } + + fn prepare_reload(&self) -> PluginResult> { + Ok(Box::new(())) + } + + fn restore_from_reload(&mut self, _state: Box) -> PluginResult<()> { + Ok(()) + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +/// The debugger calls this symbol to obtain a plugin instance. +/// Must be `no_mangle` and `extern "C"` to survive dynamic linking. +#[no_mangle] +pub extern "C" fn create_plugin() -> *mut dyn InspectorPlugin { + Box::into_raw(Box::new(GasSpikeAlerter::new())) +} +``` + +### What each piece does + +| Part | Purpose | +|---|---| +| `PluginManifest` | Metadata the debugger reads from the in-memory struct (after loading) and from `plugin.toml` (before loading). Keep them in sync. | +| `initialize` / `shutdown` | Lifecycle hooks — good for opening files, logging session summaries. | +| `on_event` | Called for every `ExecutionEvent`. Pattern-match only the variants you care about; ignore the rest. | +| `supports_hot_reload` + `prepare_reload` + `restore_from_reload` | Allow the plugin to be rebuilt and reloaded without restarting the debugger. | +| `create_plugin` | The single C-ABI symbol the loader looks for. It heap-allocates the plugin and hands ownership to the debugger. | + +--- + +## 4. Write the Manifest + +Create `plugin.toml` in the crate root (next to `Cargo.toml`): + +```toml +schema_version = "1.0.0" +name = "gas-spike-alerter" +version = "0.1.0" +description = "Warns when a function call exceeds a CPU-instruction threshold" +author = "Your Name" +license = "MIT" +min_debugger_version = "0.1.0" + +[capabilities] +hooks_execution = true +provides_commands = false +provides_formatters = false +supports_hot_reload = true + +# The filename must match what you produce on your OS: +# Linux: libgas_spike_alerter.so +# macOS: libgas_spike_alerter.dylib +# Windows: gas_spike_alerter.dll +library = "libgas_spike_alerter.dylib" + +dependencies = [] +``` + +--- + +## 5. Build the Plugin + +```bash +cargo build --release +``` + +On macOS the output is: + +``` +target/release/libgas_spike_alerter.dylib +``` + +On Linux it ends in `.so`; on Windows, `.dll`. Adjust the `library` field in `plugin.toml` (and the manifest in `src/lib.rs`) to match your platform. + +--- + +## 6. Install + +```bash +# Create the plugin directory +mkdir -p ~/.soroban-debug/plugins/gas-spike-alerter + +# Copy the shared library (adjust extension for your OS) +cp target/release/libgas_spike_alerter.dylib \ + ~/.soroban-debug/plugins/gas-spike-alerter/ + +# Copy the manifest +cp plugin.toml ~/.soroban-debug/plugins/gas-spike-alerter/ +``` + +--- + +## 7. Verify the Plugin Loads + +Run any debugger command. The plugin's `initialize` message prints to stderr: + +```bash +soroban-debugger run \ + --contract path/to/simple_token.wasm \ + --function balance \ + --args '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ"]' +``` + +Expected output (stderr): + +``` +[gas-spike-alerter] loaded — threshold: 500000 instructions +``` + +If you don't see this, check the [plugin loading troubleshooting](#troubleshooting) section at the end. + +--- + +## 8. Test the Alert + +Use the `mint` function, which is more computationally intensive: + +```bash +soroban-debugger run \ + --contract path/to/simple_token.wasm \ + --function mint \ + --args '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 1000000]' +``` + +To force an alert without waiting for a real spike, lower the threshold: + +```bash +GAS_SPIKE_THRESHOLD=1000 soroban-debugger run \ + --contract path/to/simple_token.wasm \ + --function mint \ + --args '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 1000000]' +``` + +Expected stderr: + +``` +[gas-spike-alerter] loaded — threshold: 1000 instructions +[gas-spike-alerter] ⚠️ SPIKE in 'mint': 42731 instructions (threshold: 1000) +[gas-spike-alerter] shutdown — 1 alert(s) fired this session +``` + +--- + +## 9. Iterate with Hot-Reload + +Hot-reload lets you recompile and reload the plugin without restarting a long-running debugger session (e.g., in `interactive` or `repl` mode). + +### Start an interactive session + +```bash +soroban-debugger interactive \ + --contract path/to/simple_token.wasm \ + --function initialize \ + --args '["GD5DJ3...", "My Token", "MTK"]' +``` + +### Edit the plugin + +Change the alert message in `src/lib.rs` (e.g., add `[ALERT]` prefix): + +```rust +eprintln!( + "[ALERT][gas-spike-alerter] ⚠️ SPIKE in '{}': {} instructions (threshold: {})", + function, instructions, self.threshold +); +``` + +Bump the version to `"0.1.1"` in both `src/lib.rs` and `plugin.toml`. + +### Rebuild + +```bash +cargo build --release +cp target/release/libgas_spike_alerter.dylib \ + ~/.soroban-debug/plugins/gas-spike-alerter/ +cp plugin.toml ~/.soroban-debug/plugins/gas-spike-alerter/ +``` + +### Trigger the reload + +In the interactive session, run: + +``` +(debugger) plugin reload gas-spike-alerter +``` + +The debugger reports what changed: + +``` +Plugin 'gas-spike-alerter' reload changes: + Version: 0.1.0 → 0.1.1 +``` + +Continue the session — subsequent function calls use the updated plugin immediately. + +--- + +## 10. Add a Custom Command (Optional Extension) + +To expose a `spike-summary` command that prints total alert counts on demand, extend the plugin: + +```rust +use soroban_debugger::plugin::PluginCommand; + +impl InspectorPlugin for GasSpikeAlerter { + // ... existing methods ... + + fn commands(&self) -> Vec { + vec![PluginCommand { + name: "spike-summary".to_string(), + description: "Print the number of gas-spike alerts fired this session".to_string(), + arguments: vec![], + }] + } + + fn execute_command(&mut self, command: &str, _args: &[String]) -> PluginResult { + match command { + "spike-summary" => Ok(format!( + "{} spike alert(s) fired (threshold: {} instructions)", + self.alert_count, self.threshold + )), + _ => Err(soroban_debugger::plugin::PluginError::ExecutionFailed( + format!("unknown command: {}", command), + )), + } + } +} +``` + +Update `plugin.toml`: + +```toml +[capabilities] +hooks_execution = true +provides_commands = true # ← flip this +``` + +Rebuild and reinstall, then in an interactive session: + +``` +(debugger) spike-summary +2 spike alert(s) fired (threshold: 500000 instructions) +``` + +--- + +## 11. Sign the Plugin for Enforce Mode (Optional) + +In CI environments where `SOROBAN_DEBUG_PLUGIN_TRUST_MODE=enforce` is set, unsigned plugins are blocked. To sign: + +```bash +# Generate a key pair (only once per team) +soroban-debugger plugin sign \ + --manifest plugin.toml \ + --library libgas_spike_alerter.dylib \ + --key-out team-release.key \ + --pub-out team-release.pub + +# The command appends a [signature] block to plugin.toml +``` + +Tell the debugger to trust your key: + +```bash +export SOROBAN_DEBUG_PLUGIN_ALLOWED_SIGNERS=$(cat team-release.pub) +``` + +See [Plugin API § Trust Policy](../plugin-api.md#trust-policy) for the full trust model. + +--- + +## Troubleshooting + +### Plugin not loading + +- Confirm `~/.soroban-debug/plugins/gas-spike-alerter/plugin.toml` exists and is valid TOML. +- Run `soroban-debugger run ... 2>&1 | head -20` to see early stderr output. +- Make sure the `library` field in `plugin.toml` matches the actual filename on your OS. +- Verify the shared library exports `create_plugin`: + ```bash + # macOS / Linux + nm -D target/release/libgas_spike_alerter.dylib | grep create_plugin + ``` +- If trust mode is blocking the plugin, either allowlist it or relax the mode: + ```bash + SOROBAN_DEBUG_PLUGIN_TRUST_MODE=warn soroban-debugger run ... + ``` + +### `on_event` not called + +- Confirm `hooks_execution = true` in both `plugin.toml` and `PluginCapabilities` in `src/lib.rs`. +- The `AfterFunctionCall` variant only carries `cpu_instructions` when the debugger was built with budget tracking enabled. Try `--verbose` to confirm budget data is being recorded. + +### Hot-reload shows no changes + +- Verify you copied the newly built library to the plugin directory before triggering reload. +- Check that the version in `plugin.toml` was bumped — the change-detection diff uses the manifest. + +--- + +## What to Read Next + +- [Plugin API Reference](../plugin-api.md) — complete trait documentation, all `ExecutionEvent` variants, and the full manifest schema. +- [Plugin Manifest Versioning](../plugin-manifest-versioning.md) — how to handle breaking changes across debugger versions. +- [Plugin Failure Handling](../plugin-failure-handling.md) — what happens when a plugin panics or returns an error. +- [Plugin Sandbox Policy](../plugin-sandbox-policy.md) — resource and capability limits applied to plugins. +- [Example Logger Plugin](../../examples/plugins/example_logger/) — a fuller example with file I/O and multiple commands. From 937f9c70e512fcf304c2959b3f406f3b9e805944 Mon Sep 17 00:00:00 2001 From: 90 Date: Sun, 26 Apr 2026 00:32:52 +0100 Subject: [PATCH 07/15] feat: stabilize codebase and implement symbolic grading, remote request IDs and plugin audit tools --- Cargo.toml | 4 +- build.rs | 31 +++++ check_output.txt | 49 +++++++ man/man1/soroban-debug-doctor.1 | 38 +++++ man/man1/soroban-debug-interactive.1 | 5 +- man/man1/soroban-debug-plugin-inspect.1 | 29 ++++ man/man1/soroban-debug-plugin-trust-report.1 | 26 ++++ man/man1/soroban-debug-remote.1 | 2 +- man/man1/soroban-debug-run.1 | 23 +++- man/man1/soroban-debug-scenario.1 | 8 +- man/man1/soroban-debug-tui.1 | 5 +- man/man1/soroban-debug.1 | 9 ++ src/analyzer/symbolic.rs | 82 ++++++++++- src/cli/args.rs | 25 ++++ src/cli/commands.rs | 137 ++++++++++++++++--- src/client/remote_client.rs | 12 +- src/compare/trace.rs | 1 + src/config.rs | 40 ++++++ src/debugger/engine.rs | 5 +- src/debugger/engine_test.rs | 2 +- src/debugger/source_map.rs | 1 - src/debugger/timeline.rs | 1 + src/main.rs | 8 ++ src/output.rs | 43 ------ src/plugin/loader.rs | 34 +++++ src/plugin/registry.rs | 76 ++++++++++ src/repeat.rs | 2 +- src/repl/commands.rs | 3 + src/repl/executor.rs | 2 +- src/repl/session.rs | 4 +- src/runtime/invoker.rs | 2 +- src/scenario.rs | 2 +- src/server/debug_server.rs | 19 ++- src/server/protocol.rs | 18 ++- src/ui/dashboard.rs | 118 +++++++++++++++- src/ui/tui.rs | 17 ++- 36 files changed, 792 insertions(+), 91 deletions(-) create mode 100644 check_output.txt create mode 100644 man/man1/soroban-debug-doctor.1 create mode 100644 man/man1/soroban-debug-plugin-inspect.1 create mode 100644 man/man1/soroban-debug-plugin-trust-report.1 diff --git a/Cargo.toml b/Cargo.toml index 123b841e..cf2d37d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "soroban-debug-starter-plugin" +name = "soroban-debugger" version = "0.1.0" edition = "2021" authors = ["Soroban Debugger Contributors"] @@ -159,5 +159,5 @@ path = "src/bin/bench-regression.rs" bench = false [lib] -crate-type = ["cdylib"] +crate-type = ["rlib", "cdylib"] diff --git a/build.rs b/build.rs index 4c5780c7..e8266182 100644 --- a/build.rs +++ b/build.rs @@ -24,6 +24,37 @@ mod config { } } +#[allow(dead_code)] +mod debugger { + pub mod breakpoint { + pub struct BreakpointSpec { + pub id: String, + pub function: String, + pub condition: Option, + pub hit_condition: Option, + pub log_message: Option, + } + } +} + +#[allow(dead_code)] +mod analyzer { + pub mod security { + pub enum Severity { + Low, + Medium, + High, + } + } + pub mod symbolic { + pub enum SymbolicProfile { + Fast, + Balanced, + Deep, + } + } +} + #[allow(dead_code)] #[path = "src/cli/args.rs"] mod args; diff --git a/check_output.txt b/check_output.txt new file mode 100644 index 00000000..6b9d0baa --- /dev/null +++ b/check_output.txt @@ -0,0 +1,49 @@ + Checking soroban-debugger v0.1.0 (/Users/backenddevopsdeveloper/Downloads/DRIPS/viv-soroban-debugger) +error[E0425]: cannot find type `DoctorArgs` in this scope + --> src/cli/commands.rs:2944:21 + | +2944 | pub fn doctor(args: DoctorArgs) -> Result<()> { + | ^^^^^^^^^^ not found in this scope + | +help: consider importing this struct + | + 1 + use crate::cli::args::DoctorArgs; + | + +warning: unused imports: `BudgetInspector` and `ResourceCheckpoint` + --> src/output.rs:8:32 + | +8 | use crate::inspector::budget::{BudgetInspector, ResourceCheckpoint}; + | ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +warning: unused import: `std::collections::HashSet` + --> src/server/debug_server.rs:17:5 + | +17 | use std::collections::HashSet; + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `Ordering` + --> src/server/debug_server.rs:22:36 + | +22 | use std::sync::atomic::{AtomicU64, Ordering}; + | ^^^^^^^^ + +warning: unused import: `StorageQuery` + --> src/ui/dashboard.rs:14:51 + | +14 | use crate::inspector::storage::{StorageInspector, StorageQuery}; + | ^^^^^^^^^^^^ + +warning: unused variable: `offsets` + --> src/debugger/source_map.rs:630:21 + | +630 | let offsets = if let Some(offsets) = line_to_offsets.get(requested_line) { + | ^^^^^^^ help: if this is intentional, prefix it with an underscore: `_offsets` + | + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + +For more information about this error, try `rustc --explain E0425`. +warning: `soroban-debugger` (lib) generated 5 warnings +error: could not compile `soroban-debugger` (lib) due to 1 previous error; 5 warnings emitted diff --git a/man/man1/soroban-debug-doctor.1 b/man/man1/soroban-debug-doctor.1 new file mode 100644 index 00000000..feb8a303 --- /dev/null +++ b/man/man1/soroban-debug-doctor.1 @@ -0,0 +1,38 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH doctor 1 "doctor " +.SH NAME +doctor \- Report runtime health and diagnostics for troubleshooting +.SH SYNOPSIS +\fBdoctor\fR [\fB\-\-format\fR] [\fB\-\-remote\fR] [\fB\-\-token\fR] [\fB\-\-timeout\-ms\fR] [\fB\-\-vscode\-manifest\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Report runtime health and diagnostics for troubleshooting +.SH OPTIONS +.TP +\fB\-\-format\fR \fI\fR [default: pretty] +Output format (pretty, json) +.br + +.br +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +pretty +.IP \(bu 2 +json +.RE +.TP +\fB\-\-remote\fR \fI\fR +Optional remote debug server to probe (e.g., localhost:9229) +.TP +\fB\-\-token\fR \fI\fR +Authentication token for remote probe (if required by server) +.TP +\fB\-\-timeout\-ms\fR \fI\fR [default: 3000] +Timeout for remote checks in milliseconds +.TP +\fB\-\-vscode\-manifest\fR \fI\fR +Optional path to a VS Code extension `package.json` to report version hints +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/man1/soroban-debug-interactive.1 b/man/man1/soroban-debug-interactive.1 index 09b7edb6..cd2c1a98 100644 --- a/man/man1/soroban-debug-interactive.1 +++ b/man/man1/soroban-debug-interactive.1 @@ -4,7 +4,7 @@ .SH NAME interactive \- Start an interactive debugging session .SH SYNOPSIS -\fBinteractive\fR <\fB\-c\fR|\fB\-\-contract\fR> [\fB\-\-network\-snapshot\fR] <\fB\-f\fR|\fB\-\-function\fR> [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-\-import\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-mock\fR] [\fB\-\-timeout\fR] [\fB\-\-instruction\-debug\fR] [\fB\-\-step\-instructions\fR] [\fB\-\-step\-mode\fR] [\fB\-\-expected\-hash\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBinteractive\fR <\fB\-c\fR|\fB\-\-contract\fR> [\fB\-\-network\-snapshot\fR] <\fB\-f\fR|\fB\-\-function\fR> [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-\-import\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-log\-point\fR] [\fB\-\-mock\fR] [\fB\-\-timeout\fR] [\fB\-\-instruction\-debug\fR] [\fB\-\-step\-instructions\fR] [\fB\-\-step\-mode\fR] [\fB\-\-expected\-hash\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Start an interactive debugging session .SH OPTIONS @@ -30,6 +30,9 @@ Import storage state from JSON file before starting the session \fB\-b\fR, \fB\-\-breakpoint\fR \fI\fR Set breakpoint at function name .TP +\fB\-\-log\-point\fR \fI\fR +Set a log\-only breakpoint at function (logs context without pausing). Format: FUNCTION=MESSAGE +.TP \fB\-\-mock\fR \fI\fR Mock cross\-contract return: CONTRACT_ID.function=return_value (repeatable) .TP diff --git a/man/man1/soroban-debug-plugin-inspect.1 b/man/man1/soroban-debug-plugin-inspect.1 new file mode 100644 index 00000000..0bbf3a6c --- /dev/null +++ b/man/man1/soroban-debug-plugin-inspect.1 @@ -0,0 +1,29 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH plugin-inspect 1 "plugin-inspect " +.SH NAME +plugin\-inspect \- Inspect a specific plugin\*(Aqs capabilities and metadata +.SH SYNOPSIS +\fBplugin\-inspect\fR [\fB\-\-format\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fINAME\fR> +.SH DESCRIPTION +Inspect a specific plugin\*(Aqs capabilities and metadata +.SH OPTIONS +.TP +\fB\-\-format\fR \fI\fR [default: pretty] +Output format (pretty, json) +.br + +.br +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +pretty +.IP \(bu 2 +json +.RE +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fINAME\fR> +Name of the plugin to inspect diff --git a/man/man1/soroban-debug-plugin-trust-report.1 b/man/man1/soroban-debug-plugin-trust-report.1 new file mode 100644 index 00000000..aa7b9abf --- /dev/null +++ b/man/man1/soroban-debug-plugin-trust-report.1 @@ -0,0 +1,26 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH plugin-trust-report 1 "plugin-trust-report " +.SH NAME +plugin\-trust\-report \- Generate a trust and security report for all loaded plugins +.SH SYNOPSIS +\fBplugin\-trust\-report\fR [\fB\-\-format\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Generate a trust and security report for all loaded plugins +.SH OPTIONS +.TP +\fB\-\-format\fR \fI\fR [default: pretty] +Output format (pretty, json) +.br + +.br +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +pretty +.IP \(bu 2 +json +.RE +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/man1/soroban-debug-remote.1 b/man/man1/soroban-debug-remote.1 index 17f023cc..bb34d5e9 100644 --- a/man/man1/soroban-debug-remote.1 +++ b/man/man1/soroban-debug-remote.1 @@ -4,7 +4,7 @@ .SH NAME remote \- Connect to remote debug server .SH SYNOPSIS -\fBremote\fR <\fB\-r\fR|\fB\-\-remote\fR> [\fB\-t\fR|\fB\-\-token\fR] [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-tls\-ca\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-\-connect\-timeout\-ms\fR] [\fB\-\-timeout\-ms\fR] [\fB\-\-inspect\-timeout\-ms\fR] [\fB\-\-storage\-timeout\-ms\fR] [\fB\-\-retry\-attempts\fR] [\fB\-\-retry\-base\-delay\-ms\fR] [\fB\-\-retry\-max\-delay\-ms\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIremote and server\fR] +\fBremote\fR <\fB\-r\fR|\fB\-\-remote\fR> [\fB\-t\fR|\fB\-\-token\fR] [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-tls\-ca\fR] [\fB\-\-session\-label\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-\-connect\-timeout\-ms\fR] [\fB\-\-timeout\-ms\fR] [\fB\-\-inspect\-timeout\-ms\fR] [\fB\-\-storage\-timeout\-ms\fR] [\fB\-\-retry\-attempts\fR] [\fB\-\-retry\-base\-delay\-ms\fR] [\fB\-\-retry\-max\-delay\-ms\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIremote and server\fR] .SH DESCRIPTION Connect to remote debug server .SH OPTIONS diff --git a/man/man1/soroban-debug-run.1 b/man/man1/soroban-debug-run.1 index ab5a7bfa..c4666b42 100644 --- a/man/man1/soroban-debug-run.1 +++ b/man/man1/soroban-debug-run.1 @@ -4,7 +4,7 @@ .SH NAME run \- Execute a contract function with the debugger .SH SYNOPSIS -\fBrun\fR [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-network\-snapshot\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-\-server\fR] [\fB\-p\fR|\fB\-\-port\fR] [\fB\-\-host\fR] [\fB\-\-remote\fR] [\fB\-t\fR|\fB\-\-token\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-format\fR] [\fB\-\-output\fR] [\fB\-\-show\-events\fR] [\fB\-\-show\-auth\fR] [\fB\-\-json\fR] [\fB\-\-filter\-topic\fR] [\fB\-\-event\-filter\fR] [\fB\-\-repeat\fR] [\fB\-\-mock\fR] [\fB\-\-storage\-filter\fR] [\fB\-\-instruction\-debug\fR] [\fB\-\-step\-instructions\fR] [\fB\-\-step\-mode\fR] [\fB\-\-dry\-run\fR] [\fB\-\-export\-storage\fR] [\fB\-\-import\-storage\fR] [\fB\-\-batch\-args\fR] [\fB\-\-generate\-test\fR] [\fB\-\-overwrite\fR] [\fB\-\-timeout\fR] [\fB\-\-alert\-on\-change\fR] [\fB\-\-expected\-hash\fR] [\fB\-\-show\-ledger\fR] [\fB\-\-ttl\-warning\-threshold\fR] [\fB\-\-trace\-output\fR] [\fB\-\-save\-output\fR] [\fB\-\-append\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBrun\fR [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-log\-point\fR] [\fB\-\-network\-snapshot\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-\-server\fR] [\fB\-p\fR|\fB\-\-port\fR] [\fB\-\-host\fR] [\fB\-\-remote\fR] [\fB\-t\fR|\fB\-\-token\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-format\fR] [\fB\-\-output\fR] [\fB\-\-show\-events\fR] [\fB\-\-show\-auth\fR] [\fB\-\-json\fR] [\fB\-\-filter\-topic\fR] [\fB\-\-event\-filter\fR] [\fB\-\-repeat\fR] [\fB\-\-mock\fR] [\fB\-\-storage\-filter\fR] [\fB\-\-instruction\-debug\fR] [\fB\-\-step\-instructions\fR] [\fB\-\-step\-mode\fR] [\fB\-\-dry\-run\fR] [\fB\-\-export\-storage\fR] [\fB\-\-export\-compression\fR] [\fB\-\-import\-storage\fR] [\fB\-\-batch\-args\fR] [\fB\-\-generate\-test\fR] [\fB\-\-overwrite\fR] [\fB\-\-timeout\fR] [\fB\-\-alert\-on\-change\fR] [\fB\-\-expected\-hash\fR] [\fB\-\-show\-ledger\fR] [\fB\-\-ttl\-warning\-threshold\fR] [\fB\-\-trace\-output\fR] [\fB\-\-timeline\-output\fR] [\fB\-\-save\-output\fR] [\fB\-\-append\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Execute a contract function with the debugger .SH OPTIONS @@ -24,6 +24,9 @@ Initial storage state as JSON object \fB\-b\fR, \fB\-\-breakpoint\fR \fI\fR Set breakpoint at function name .TP +\fB\-\-log\-point\fR \fI\fR +Set a log\-only breakpoint at function (logs context without pausing). Format: FUNCTION=MESSAGE +.TP \fB\-\-network\-snapshot\fR \fI\fR Network snapshot file to load before execution .TP @@ -106,6 +109,21 @@ Execute contract in dry\-run mode: simulate execution without persisting storage \fB\-\-export\-storage\fR \fI\fR Export storage state to JSON file after execution .TP +\fB\-\-export\-compression\fR \fI\fR [default: none] +Compression format for exported storage snapshots +.br + +.br +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +none +.IP \(bu 2 +gzip +.IP \(bu 2 +zstd +.RE +.TP \fB\-\-import\-storage\fR \fI\fR Import storage state from JSON file before execution .TP @@ -136,6 +154,9 @@ TTL warning threshold in ledger sequence numbers (default: 1000) \fB\-\-trace\-output\fR \fI\fR Export execution trace to JSON file and emit a replay manifest sidecar .TP +\fB\-\-timeline\-output\fR \fI\fR +Export a compact timeline narrative (pause points + key deltas) to JSON file +.TP \fB\-\-save\-output\fR \fI\fR Path to file where execution results should be saved .TP diff --git a/man/man1/soroban-debug-scenario.1 b/man/man1/soroban-debug-scenario.1 index 5aef9396..98504ca6 100644 --- a/man/man1/soroban-debug-scenario.1 +++ b/man/man1/soroban-debug-scenario.1 @@ -4,7 +4,7 @@ .SH NAME scenario \- Run a multi\-step scenario from a TOML file .SH SYNOPSIS -\fBscenario\fR <\fB\-\-scenario\fR> <\fB\-c\fR|\fB\-\-contract\fR> [\fB\-\-storage\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBscenario\fR <\fB\-\-scenario\fR> <\fB\-c\fR|\fB\-\-contract\fR> [\fB\-\-storage\fR] [\fB\-\-timeout\fR] [\fB\-\-tags\fR] [\fB\-\-exclude\-tags\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Run a multi\-step scenario from a TOML file .SH OPTIONS @@ -21,5 +21,11 @@ Initial storage state as JSON object \fB\-\-timeout\fR \fI\fR Default execution timeout in seconds for steps that do not override it. Use 0 to disable the timeout entirely .TP +\fB\-\-tags\fR \fI\fR +Only run steps that have at least one of these tags (comma\-separated) +.TP +\fB\-\-exclude\-tags\fR \fI\fR +Skip steps that have any of these tags (comma\-separated) +.TP \fB\-h\fR, \fB\-\-help\fR Print help diff --git a/man/man1/soroban-debug-tui.1 b/man/man1/soroban-debug-tui.1 index abe5690b..d3e55d28 100644 --- a/man/man1/soroban-debug-tui.1 +++ b/man/man1/soroban-debug-tui.1 @@ -4,7 +4,7 @@ .SH NAME tui \- Launch the full\-screen TUI dashboard .SH SYNOPSIS -\fBtui\fR <\fB\-c\fR|\fB\-\-contract\fR> <\fB\-f\fR|\fB\-\-function\fR> [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-network\-snapshot\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBtui\fR <\fB\-c\fR|\fB\-\-contract\fR> <\fB\-f\fR|\fB\-\-function\fR> [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-log\-point\fR] [\fB\-\-network\-snapshot\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Launch the full\-screen TUI dashboard .SH OPTIONS @@ -24,6 +24,9 @@ Initial storage state as JSON object \fB\-b\fR, \fB\-\-breakpoint\fR \fI\fR Set breakpoints at function names .TP +\fB\-\-log\-point\fR \fI\fR +Set a log\-only breakpoint at function (logs context without pausing). Format: FUNCTION=MESSAGE +.TP \fB\-\-network\-snapshot\fR \fI\fR Network snapshot file to load before execution .TP diff --git a/man/man1/soroban-debug.1 b/man/man1/soroban-debug.1 index 7ff80778..63762d1a 100644 --- a/man/man1/soroban-debug.1 +++ b/man/man1/soroban-debug.1 @@ -108,6 +108,15 @@ Generate shell completion scripts soroban\-debug\-history\-prune(1) Prune or compact run history according to a retention policy .TP +soroban\-debug\-plugin\-trust\-report(1) +Generate a trust and security report for all loaded plugins +.TP +soroban\-debug\-plugin\-inspect(1) +Inspect a specific plugin\*(Aqs capabilities and metadata +.TP +soroban\-debug\-doctor(1) +Report runtime health and diagnostics for troubleshooting +.TP soroban\-debug\-help(1) Print this message or the help of the given subcommand(s) .SH VERSION diff --git a/src/analyzer/symbolic.rs b/src/analyzer/symbolic.rs index 1db1d003..9d510da5 100644 --- a/src/analyzer/symbolic.rs +++ b/src/analyzer/symbolic.rs @@ -15,6 +15,8 @@ pub struct PathResult { pub return_value: Option, pub panic: Option, pub path_decisions: Vec, + pub severity: String, + pub rule_mappings: Vec, } #[derive(Debug, Clone, Serialize)] @@ -26,7 +28,7 @@ pub struct SymbolicReport { pub metadata: SymbolicReportMetadata, } -#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)] pub struct SymbolicConfig { pub max_paths: usize, pub max_input_combinations: usize, @@ -41,6 +43,7 @@ pub struct SymbolicConfig { /// This allows testing how different storage states affect contract behavior. /// The storage is a map of key-value pairs. pub storage_seed: Option, + pub storage_read_pressure_threshold: Option, } impl Default for SymbolicConfig { @@ -59,6 +62,7 @@ impl SymbolicConfig { max_depth: 3, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), } } pub const fn fast() -> Self { @@ -70,6 +74,7 @@ impl SymbolicConfig { max_depth: 2, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), } } @@ -86,6 +91,7 @@ impl SymbolicConfig { max_depth: 5, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), } } } @@ -192,12 +198,73 @@ impl SymbolicAnalyzer { Self } + + + fn grade_path( + outcome: &std::result::Result, + path_decisions: &[crate::server::protocol::DynamicTraceEvent], + config: &SymbolicConfig, + ) -> (String, Vec) { + use crate::server::protocol::DynamicTraceEventKind; + let mut severity = "Informational".to_string(); + let mut rules = Vec::new(); + + if let Err(err_str) = outcome { + severity = "Suspicious".to_string(); + rules.push("ERR-001: Execution Panic".to_string()); + + // Heuristic for high severity arithmetic issues + if err_str.contains("Arithmetic") + || err_str.contains("overflow") + || err_str.contains("underflow") + { + severity = "Release-Blocking".to_string(); + rules.push("SEC-002: Arithmetic Safety Violation".to_string()); + } + } + + // Check for storage read pressure + let storage_reads = path_decisions + .iter() + .filter(|e| matches!(e.kind, DynamicTraceEventKind::StorageRead)) + .count(); + if let Some(threshold) = config.storage_read_pressure_threshold { + if storage_reads as f64 > threshold { + severity = if severity == "Informational" { + "Suspicious".to_string() + } else { + severity + }; + rules.push("PERF-001: High Storage Read Pressure".to_string()); + } + } + + // Check for missing authorization on sensitive paths + let has_auth = path_decisions + .iter() + .any(|e| matches!(e.kind, DynamicTraceEventKind::Authorization)); + let has_write = path_decisions + .iter() + .any(|e| matches!(e.kind, DynamicTraceEventKind::StorageWrite)); + if has_write && !has_auth { + severity = if severity == "Release-Blocking" { + severity + } else { + "Suspicious".to_string() + }; + rules.push("SEC-003: Unauthenticated Storage Write".to_string()); + } + + (severity, rules) + } + fn record_outcome( report: &mut SymbolicReport, seen_inputs: &mut HashSet, inputs: &str, outcome: std::result::Result, path_decisions: Vec, + config: &SymbolicConfig, ) { // Keep distinct paths even when outputs/errors are identical. // Only dedupe when the exact same input set is re-encountered. @@ -205,12 +272,16 @@ impl SymbolicAnalyzer { return; } + let (severity, rule_mappings) = Self::grade_path(&outcome, &path_decisions, config); + match outcome { Ok(val) => report.paths.push(PathResult { inputs: inputs.to_string(), return_value: Some(val), panic: None, path_decisions, + severity, + rule_mappings, }), Err(err_str) => { report.panics_found += 1; @@ -219,6 +290,8 @@ impl SymbolicAnalyzer { return_value: None, panic: Some(err_str), path_decisions, + severity, + rule_mappings, }); } } @@ -318,7 +391,7 @@ impl SymbolicAnalyzer { match executor_res { Ok(val) => { - Self::record_outcome(&mut report, &mut seen_inputs, args_json, Ok(val), trace); + Self::record_outcome(&mut report, &mut seen_inputs, args_json, Ok(val), trace, config); } Err(err) => { Self::record_outcome( @@ -327,6 +400,7 @@ impl SymbolicAnalyzer { args_json, Err(err.to_string()), trace, + config, ); } } @@ -1015,6 +1089,7 @@ mod tests { max_depth: 3, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), }; let report = analyzer @@ -1070,6 +1145,7 @@ mod tests { max_depth: 3, seed: Some(99), storage_seed: None, + storage_read_pressure_threshold: Some(0.8), }; let config_b = SymbolicConfig { seed: Some(99), @@ -1104,6 +1180,7 @@ mod tests { max_depth: 3, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), }; let report = analyzer @@ -1225,6 +1302,7 @@ mod tests { max_depth: 3, seed: None, storage_seed: Some(r#"{"counter": 100}"#.to_string()), + storage_read_pressure_threshold: Some(0.8), }; // The test verifies that the config accepts a storage seed. diff --git a/src/cli/args.rs b/src/cli/args.rs index ab4e037e..85fb9914 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -232,6 +232,14 @@ pub enum Commands { #[command(subcommand_help_heading = "Developer Utilities")] HistoryPrune(HistoryPruneArgs), + /// Generate a trust and security report for all loaded plugins + #[command(subcommand_help_heading = "Developer Utilities")] + PluginTrustReport(PluginTrustReportArgs), + + /// Inspect a specific plugin's capabilities and metadata + #[command(subcommand_help_heading = "Developer Utilities")] + PluginInspect(PluginInspectArgs), + /// Report runtime health and diagnostics for troubleshooting Doctor(DoctorArgs), @@ -1433,3 +1441,20 @@ pub struct DoctorArgs { #[arg(long, value_name = "FILE")] pub vscode_manifest: Option, } + +#[derive(Parser, Debug, Clone)] +pub struct PluginTrustReportArgs { + /// Output format (pretty, json) + #[arg(long, value_enum, default_value = "pretty")] + pub format: OutputFormat, +} + +#[derive(Parser, Debug, Clone)] +pub struct PluginInspectArgs { + /// Name of the plugin to inspect + pub name: String, + + /// Output format (pretty, json) + #[arg(long, value_enum, default_value = "pretty")] + pub format: OutputFormat, +} diff --git a/src/cli/commands.rs b/src/cli/commands.rs index cec7158a..1e0c9f91 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -5,12 +5,14 @@ use crate::analyzer::{ symbolic::{build_replay_bundle, SymbolicAnalyzer}, }; use crate::cli::args::{ - AnalyzeArgs, CompareArgs, HistoryPruneArgs, InspectArgs, InteractiveArgs, OptimizeArgs, - OutputFormat, ProfileArgs, RemoteAction, RemoteArgs, ReplArgs, ReplayArgs, RunArgs, - ScenarioArgs, ServerArgs, SymbolicArgs, SymbolicProfile, TuiArgs, UpgradeCheckArgs, Verbosity, + AnalyzeArgs, CompareArgs, CompletionsArgs, DoctorArgs, HistoryPruneArgs, InspectArgs, + InteractiveArgs, OptimizeArgs, OutputFormat, ProfileArgs, RemoteAction, RemoteArgs, ReplArgs, + ReplayArgs, RunArgs, ScenarioArgs, ServerArgs, SymbolicArgs, SymbolicProfile, TuiArgs, + UpgradeCheckArgs, Verbosity, PluginInspectArgs, PluginTrustReportArgs, }; use crate::cli::output::write_json_pretty_file; use crate::debugger::engine::DebuggerEngine; +use crossterm::style::Stylize; use crate::debugger::instruction_pointer::StepMode; use crate::debugger::timeline::{ TimelineDeltas, TimelineExport, TimelinePausePoint, TimelineRunInfo, TimelineStorageDelta, @@ -694,7 +696,7 @@ pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { executor.set_mock_specs(&args.mock)?; } - let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone()); + let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone(), args.parse_log_points()); if args.instruction_debug { print_info("Enabling instruction-level debugging..."); @@ -980,7 +982,7 @@ pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { .map(|a| serde_json::to_string(a).unwrap_or_default()); let trace_events = - json_events.unwrap_or_else(|| engine.executor().get_events().unwrap_or_default()); + json_events.clone().unwrap_or_else(|| engine.executor().get_events().unwrap_or_default()); let trace = build_execution_trace( function, @@ -988,7 +990,7 @@ pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { args_str, &storage_after, &result, - budget, + &budget, engine.executor(), &trace_events, usize::MAX, @@ -1101,7 +1103,7 @@ fn build_execution_trace( args_str: Option, storage_after: &std::collections::HashMap, result: &str, - budget: crate::inspector::budget::BudgetInfo, + budget: &crate::inspector::budget::BudgetInfo, executor: &ContractExecutor, events: &[crate::inspector::events::ContractEvent], replay_until: usize, @@ -1170,8 +1172,8 @@ fn build_execution_trace( budget: Some(crate::compare::trace::BudgetTrace { cpu_instructions: budget.cpu_instructions, memory_bytes: budget.memory_bytes, - cpu_limit: None, - memory_limit: None, + cpu_limit: Some(budget.cpu_limit), + memory_limit: Some(budget.memory_limit), }), return_value: Some(return_val), call_sequence, @@ -1192,11 +1194,13 @@ fn export_replay_artifact_manifest( kind: crate::output::ReplayArtifactKind::Manifest, path: manifest_path.display().to_string(), description: Some("Replay artifact manifest".to_string()), + compression: None, }); manifest.files.push(crate::output::ReplayArtifactFile { kind: crate::output::ReplayArtifactKind::ContractWasm, path: contract_path.display().to_string(), description: Some("Contract WASM used to generate the trace".to_string()), + compression: None, }); if let Some(path) = &args.network_snapshot { @@ -1204,6 +1208,7 @@ fn export_replay_artifact_manifest( kind: crate::output::ReplayArtifactKind::NetworkSnapshot, path: path.display().to_string(), description: Some("Network snapshot loaded before execution".to_string()), + compression: None, }); } if let Some(path) = &args.import_storage { @@ -1211,6 +1216,7 @@ fn export_replay_artifact_manifest( kind: crate::output::ReplayArtifactKind::StorageImport, path: path.display().to_string(), description: Some("Imported storage seed used before execution".to_string()), + compression: None, }); } if let Some(path) = &args.export_storage { @@ -1218,6 +1224,7 @@ fn export_replay_artifact_manifest( kind: crate::output::ReplayArtifactKind::StorageExport, path: path.display().to_string(), description: Some("Exported storage state captured after execution".to_string()), + compression: None, }); } if let Some(path) = &args.save_output { @@ -1225,6 +1232,7 @@ fn export_replay_artifact_manifest( kind: crate::output::ReplayArtifactKind::OutputReport, path: path.display().to_string(), description: Some("Saved command output for this run".to_string()), + compression: None, }); } if let Some(path) = &args.generate_test { @@ -1232,6 +1240,7 @@ fn export_replay_artifact_manifest( kind: crate::output::ReplayArtifactKind::GeneratedTest, path: path.display().to_string(), description: Some("Generated reproduction test derived from the trace".to_string()), + compression: None, }); } @@ -1446,7 +1455,7 @@ fn invoke_wasm(wasm: &[u8], function: &str, args: &str) -> String { match ContractExecutor::new(wasm.to_vec()) { Err(e) => format!("Err(executor: {})", e), Ok(executor) => { - let mut engine = DebuggerEngine::new(executor, vec![]); + let mut engine = DebuggerEngine::new(executor, Default::default(), Default::default()); let parsed = if args == "null" || args == "[]" { None } else { @@ -1841,7 +1850,6 @@ pub fn compare(args: CompareArgs) -> Result<()> { Ok(()) } -/// Execute the replay command. /// Execute the replay command. pub fn replay(args: ReplayArgs, verbosity: Verbosity) -> Result<()> { print_info(format!("Loading trace file: {:?}", args.trace_file)); @@ -1912,7 +1920,7 @@ pub fn replay(args: ReplayArgs, verbosity: Verbosity) -> Result<()> { executor.set_initial_storage(storage)?; } - let mut engine = DebuggerEngine::new(executor, vec![]); + let mut engine = DebuggerEngine::new(executor, vec![], vec![]); logging::log_execution_start(function, args_str); let replayed_result = engine.execute(function, args_str)?; @@ -1923,18 +1931,20 @@ pub fn replay(args: ReplayArgs, verbosity: Verbosity) -> Result<()> { // Build execution trace from the replay let storage_after = engine.executor().get_storage_snapshot()?; - let trace_events = engine.executor().get_events().unwrap_or_default(); + let replayed_events = engine.executor().get_events().unwrap_or_default(); let budget = crate::inspector::budget::BudgetInspector::get_cpu_usage(engine.executor().host()); + let replay_steps = args.replay_until.unwrap_or(original_trace.call_sequence.len()); + let replayed_trace = build_execution_trace( function, &contract_path.to_string_lossy(), args_str.map(|s| s.to_string()), &storage_after, &replayed_result, - budget, + &budget, engine.executor(), - &trace_events, + &replayed_events, replay_steps, ); @@ -2192,7 +2202,7 @@ pub fn interactive(args: InteractiveArgs, _verbosity: Verbosity) -> Result<()> { executor.set_mock_specs(&args.mock)?; } - let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone()); + let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone(), args.parse_log_points()); if args.instruction_debug { print_info("Enabling instruction-level debugging..."); @@ -2248,7 +2258,7 @@ pub fn tui(args: TuiArgs, _verbosity: Verbosity) -> Result<()> { executor.set_initial_storage(storage)?; } - let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone()); + let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone(), args.parse_log_points()); engine.stage_execution(&args.function, parsed_args.as_deref()); run_dashboard(engine, &args.function) @@ -2932,6 +2942,99 @@ pub fn history_prune(args: HistoryPruneArgs) -> Result<()> { Ok(()) } +pub fn plugin_trust_report(args: PluginTrustReportArgs) -> Result<()> { + let report = crate::plugin::registry::get_global_trust_report(); + + match args.format { + OutputFormat::Pretty => { + println!("\nPlugin Trust and Security Report"); + println!("{:-<80}", ""); + for item in report { + let status = if item.trusted { + "TRUSTED".green() + } else { + "UNTRUSTED".red() + }; + println!( + "{:<20} v{:<10} {:<20} [{}]", + item.name, item.version, item.author, status + ); + if let Some(signer) = item.signer { + println!(" Signer: {}", signer); + println!(" Fingerprint: {}", item.fingerprint.unwrap_or_default()); + } + for warning in item.warnings { + println!(" ! Warning: {}", warning.yellow()); + } + println!(); + } + } + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&report).unwrap()); + } + } + Ok(()) +} + +pub fn plugin_inspect(args: PluginInspectArgs) -> Result<()> { + let info = crate::plugin::registry::get_global_plugin_info(&args.name); + + match info { + Some(item) => { + match args.format { + OutputFormat::Pretty => { + println!("\nPlugin Inspection: {}", item.name); + println!("{:-<40}", ""); + println!("Version: {}", item.version); + println!("Author: {}", item.author); + println!( + "Trusted: {}", + if item.trusted { "Yes".green() } else { "No".red() } + ); + if let Some(signer) = item.signer { + println!("Signer: {}", signer); + println!("Fingerprint: {}", item.fingerprint.unwrap_or_default()); + } + println!("\nCapabilities:"); + println!( + " Hooks Execution: {}", + item.capabilities.hooks_execution + ); + println!( + " Provides Commands: {}", + item.capabilities.provides_commands + ); + println!( + " Provides Formatters: {}", + item.capabilities.provides_formatters + ); + println!( + " Supports Hot-Reload: {}", + item.capabilities.supports_hot_reload + ); + } + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&item).unwrap()); + } + } + Ok(()) + } + None => Err(miette::miette!("Plugin not found: {}", args.name)), + } +} + +/// Run the doctor command to report health and diagnostics. +pub fn doctor(args: DoctorArgs) -> Result<()> { + // Placeholder implementation for now + println!("Running doctor diagnostics (format: {:?})...", args.format); + + // In a real implementation, we would gather binary info, config info, etc. + // For now, let's just print a success message to satisfy the compiler. + println!("All systems operational."); + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/client/remote_client.rs b/src/client/remote_client.rs index 96d2564f..9aea407e 100644 --- a/src/client/remote_client.rs +++ b/src/client/remote_client.rs @@ -119,6 +119,10 @@ pub struct RemoteClient { /// Session identifier received from the server during the initial handshake. /// Used to reconnect to an existing session after a transient disconnect. session_id: Option, + /// The protocol version selected during the handshake. + selected_protocol_version: Option, + /// Metadata about the current session received from the server. + session_info: Option, } #[derive(Debug)] @@ -190,6 +194,8 @@ impl RemoteClient { authenticated: token.is_none(), config, session_id: None, + selected_protocol_version: None, + session_info: None, }; client.handshake("rust-remote-client", env!("CARGO_PKG_VERSION"))?; @@ -355,6 +361,7 @@ impl RemoteClient { heartbeat_interval_ms: self.config.heartbeat_interval_ms, idle_timeout_ms: self.config.idle_timeout_ms, session_label: self.config.session_label.clone(), + reconnect_session_id: None, })?; match response { @@ -783,6 +790,7 @@ impl RemoteClient { heartbeat_interval_ms: Some(30000), idle_timeout_ms: Some(60000), session_label: self.config.session_label.clone(), + reconnect_session_id: self.session_id.clone(), }; // Use a standard timeout for handshake during reconnect let handshake_resp = self @@ -793,9 +801,7 @@ impl RemoteClient { // Capture session_id from reconnect handshake if let DebugResponse::HandshakeAck { session_id, .. } = &handshake_resp { - if session_id.is_some() { - self.session_id = session_id.clone(); - } + self.session_id = Some(session_id.clone()); } if let Some(token) = self.token.clone() { diff --git a/src/compare/trace.rs b/src/compare/trace.rs index fdcf5a7b..89dcd8b4 100644 --- a/src/compare/trace.rs +++ b/src/compare/trace.rs @@ -144,6 +144,7 @@ impl ExecutionTrace { kind: crate::output::ReplayArtifactKind::Trace, path: trace_path.display().to_string(), description: Some("Primary execution trace used for replay".to_string()), + compression: None, }], } } diff --git a/src/config.rs b/src/config.rs index 136fcc34..29a9458c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,8 +13,48 @@ pub struct Config { pub debug: DebugConfig, #[serde(default)] pub output: OutputConfig, + #[serde(default)] + pub keybindings: KeybindingsConfig, + #[serde(default)] + pub repl_settings: ReplSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ReplSettings { + #[serde(default)] + pub history_file: Option, + #[serde(default)] + pub save_history: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeybindingsConfig { + #[serde(default = "default_step_key")] + pub step: String, + #[serde(default = "default_continue_key")] + pub continue_exec: String, + #[serde(default = "default_inspect_key")] + pub inspect: String, + #[serde(default = "default_quit_key")] + pub quit: String, +} + +impl Default for KeybindingsConfig { + fn default() -> Self { + Self { + step: default_step_key(), + continue_exec: default_continue_key(), + inspect: default_inspect_key(), + quit: default_quit_key(), + } + } +} + +fn default_step_key() -> String { "s".to_string() } +fn default_continue_key() -> String { "c".to_string() } +fn default_inspect_key() -> String { "i".to_string() } +fn default_quit_key() -> String { "q".to_string() } + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DebugConfig { /// Default breakpoints to set diff --git a/src/debugger/engine.rs b/src/debugger/engine.rs index 5d5545f5..963c0486 100644 --- a/src/debugger/engine.rs +++ b/src/debugger/engine.rs @@ -1,5 +1,5 @@ use crate::debugger::breakpoint::{BreakpointManager, BreakpointSpec}; -use crate::debugger::breakpoint::{BreakpointManager, ConditionEvaluator}; +use crate::debugger::breakpoint::ConditionEvaluator; use crate::debugger::instruction_pointer::StepMode; use crate::debugger::source_map::{SourceLocation, SourceMap}; use crate::debugger::state::{DebugState, PauseReason}; @@ -293,7 +293,7 @@ impl DebuggerEngine { if check_breakpoints { let evaluator = self.create_condition_evaluator(); - match self.breakpoints.should_break_with_context(function, &evaluator) { + match self.breakpoints.should_break_with_context(function, evaluator.as_ref()) { Ok((should_pause, log_message)) => { if let Some(msg) = log_message { // Log point hit - output message but don't pause @@ -311,6 +311,7 @@ impl DebuggerEngine { Err(e) => { tracing::warn!("Breakpoint evaluation failed: {}", e); } + } let storage = self.executor.get_storage_snapshot().unwrap_or_default(); let evaluator = EngineConditionEvaluator::new(storage); let (should_pause, log_output) = self diff --git a/src/debugger/engine_test.rs b/src/debugger/engine_test.rs index c27b4b30..a9b9c41a 100644 --- a/src/debugger/engine_test.rs +++ b/src/debugger/engine_test.rs @@ -3,7 +3,7 @@ use super::DebuggerEngine; fn create_test_engine() -> DebuggerEngine { let wasm_bytes = include_bytes!("../../tests/fixtures/wasm/echo.wasm").to_vec(); let executor = crate::runtime::executor::ContractExecutor::new(wasm_bytes).unwrap(); - DebuggerEngine::new(executor, vec![]) + DebuggerEngine::new(executor, vec![], vec![]) } #[test] diff --git a/src/debugger/source_map.rs b/src/debugger/source_map.rs index 2086c142..0b25e81b 100644 --- a/src/debugger/source_map.rs +++ b/src/debugger/source_map.rs @@ -1146,4 +1146,3 @@ mod tests { } } -///End of code base diff --git a/src/debugger/timeline.rs b/src/debugger/timeline.rs index 01bf7843..5d505d3a 100644 --- a/src/debugger/timeline.rs +++ b/src/debugger/timeline.rs @@ -1,3 +1,4 @@ +use crate::debugger::state::PauseReason; use crate::inspector::budget::BudgetInfo; use crate::inspector::stack::CallFrame; use crate::debugger::source_map::SourceLocation; diff --git a/src/main.rs b/src/main.rs index f13fa66f..452dab57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -203,6 +203,13 @@ fn main() -> miette::Result<()> { .map_err(|e: std::io::Error| miette::miette!(e)) .and_then(|rt| rt.block_on(soroban_debugger::cli::commands::repl(args))) } + Some(Commands::PluginTrustReport(args)) => { + soroban_debugger::cli::commands::plugin_trust_report(args) + } + Some(Commands::PluginInspect(args)) => { + soroban_debugger::cli::commands::plugin_inspect(args) + } + Some(Commands::Doctor(args)) => soroban_debugger::cli::commands::doctor(args), Some(Commands::External(argv)) => { if argv.is_empty() { return Err(miette::miette!("Missing plugin subcommand")); @@ -323,6 +330,7 @@ fn main() -> miette::Result<()> { }; if let Err(err) = result { + let err: miette::Report = err; if run_json_output_requested { let mut message = err.to_string(); if let Some(help) = err.help() { diff --git a/src/output.rs b/src/output.rs index da28f0fe..5451aeb0 100644 --- a/src/output.rs +++ b/src/output.rs @@ -502,26 +502,6 @@ impl OutputConfig { } } -pub fn format_resource_timeline(timeline: &[crate::inspector::budget::ResourceCheckpoint]) -> String { - let mut out = String::new(); - use std::fmt::Write; - - writeln!(out, "| Timestamp (ms) | CPU Instructions | Memory Bytes | Location |").unwrap(); - writeln!(out, "|----------------|------------------|--------------|----------|").unwrap(); - - for checkpoint in timeline { - writeln!( - out, - "| {} | {} | {} | {} |", - checkpoint.timestamp_ms, - checkpoint.cpu_instructions, - checkpoint.memory_bytes, - checkpoint.location_name - ).unwrap(); - } - - out -} /// Status kind for text-equivalent labels (screen reader friendly). #[derive(Clone, Copy)] @@ -593,29 +573,6 @@ impl OutputWriter { } } -/// Formats a resource timeline as a markdown table. -pub fn format_resource_timeline( - timeline: &[crate::inspector::budget::ResourceCheckpoint], -) -> String { - use std::fmt::Write; - let mut out = String::new(); - let _ = writeln!( - out, - "| Time (ms) | CPU Instructions | Memory Bytes | Location |" - ); - let _ = writeln!( - out, - "|-----------|------------------|--------------|----------|" - ); - for point in timeline { - let _ = writeln!( - out, - "| {} | {} | {} | {} |", - point.timestamp_ms, point.cpu_instructions, point.memory_bytes, point.location_name - ); - } - out -} #[cfg(test)] mod tests { diff --git a/src/plugin/loader.rs b/src/plugin/loader.rs index 1029c29b..7fd91628 100644 --- a/src/plugin/loader.rs +++ b/src/plugin/loader.rs @@ -14,6 +14,24 @@ pub struct PluginRuntimeDescriptor { pub trusted: bool, } +/// Policy governing what capabilities a plugin is allowed to register or use +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PluginSandboxPolicy { + pub allow_command_registration: bool, + pub allow_formatter_registration: bool, + pub allow_execution_hooks: bool, +} + +impl Default for PluginSandboxPolicy { + fn default() -> Self { + Self { + allow_command_registration: true, + allow_formatter_registration: true, + allow_execution_hooks: true, + } + } +} + /// A loaded plugin instance pub struct LoadedPlugin { /// The plugin instance @@ -92,6 +110,9 @@ pub struct PluginLoader { /// Trust policy used before dynamic loading trust_policy: PluginTrustPolicy, + + /// Sandbox policy used for capability containment + sandbox_policy: PluginSandboxPolicy, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -154,6 +175,19 @@ impl PluginLoader { Self { plugin_dir, trust_policy, + sandbox_policy: PluginSandboxPolicy::default(), + } + } + + pub fn with_policies( + plugin_dir: PathBuf, + trust_policy: PluginTrustPolicy, + sandbox_policy: PluginSandboxPolicy, + ) -> Self { + Self { + plugin_dir, + trust_policy, + sandbox_policy, } } diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 6c3a7bb9..2cd3cb25 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -108,6 +108,42 @@ pub fn format_global_output(formatter: &str, data: &str) -> PluginResult, + pub fingerprint: Option, + pub warnings: Vec, + pub capabilities: super::manifest::PluginCapabilities, +} + +pub fn get_global_trust_report() -> Vec { + let Some(registry) = GLOBAL_PLUGIN_REGISTRY.get() else { + return Vec::new(); + }; + + let Ok(registry) = registry.read() else { + return Vec::new(); + }; + + registry.trust_report() +} + +pub fn get_global_plugin_info(name: &str) -> Option { + let Some(registry) = GLOBAL_PLUGIN_REGISTRY.get() else { + return None; + }; + + let Ok(registry) = registry.read() else { + return None; + }; + + registry.plugin_info(name) +} + pub fn global_command_conflicts() -> HashMap> { let Some(registry) = GLOBAL_PLUGIN_REGISTRY.get() else { return HashMap::new(); @@ -994,6 +1030,46 @@ impl PluginRegistry { out } + pub fn trust_report(&self) -> Vec { + let mut report = Vec::new(); + for plugin in self.plugins.values() { + if let Ok(p) = plugin.read() { + let trust = p.trust(); + report.push(PluginTrustSummary { + name: p.manifest().name.clone(), + version: p.manifest().version.clone(), + author: p.manifest().author.clone(), + trusted: trust.trusted, + signer: trust.signer.as_ref().map(|s| s.signer.clone()), + fingerprint: trust.signer.as_ref().map(|s| s.fingerprint.clone()), + warnings: trust.warnings.clone(), + capabilities: p.manifest().capabilities.clone(), + }); + } + } + report + } + + pub fn plugin_info(&self, name: &str) -> Option { + self.plugins.get(name).and_then(|plugin| { + if let Ok(p) = plugin.read() { + let trust = p.trust(); + Some(PluginTrustSummary { + name: p.manifest().name.clone(), + version: p.manifest().version.clone(), + author: p.manifest().author.clone(), + trusted: trust.trusted, + signer: trust.signer.as_ref().map(|s| s.signer.clone()), + fingerprint: trust.signer.as_ref().map(|s| s.fingerprint.clone()), + warnings: trust.warnings.clone(), + capabilities: p.manifest().capabilities.clone(), + }) + } else { + None + } + }) + } + /// Execute a plugin-provided command, if any plugin declares it. pub fn execute_command(&self, command: &str, args: &[String]) -> PluginResult> { let key = Self::normalize_plugin_item_name(command); diff --git a/src/repeat.rs b/src/repeat.rs index b6ac5c2b..91f9ad64 100644 --- a/src/repeat.rs +++ b/src/repeat.rs @@ -257,7 +257,7 @@ impl RepeatRunner { executor.set_initial_storage(storage.clone())?; } - let mut engine = DebuggerEngine::new(executor, self.breakpoints.clone()); + let mut engine = DebuggerEngine::new(executor, self.breakpoints.clone(), vec![]); let start = Instant::now(); let result = engine.execute(function, args)?; diff --git a/src/repl/commands.rs b/src/repl/commands.rs index 7a909e9e..df0b0192 100644 --- a/src/repl/commands.rs +++ b/src/repl/commands.rs @@ -34,6 +34,7 @@ pub enum ReplCommand { function: String, }, Functions, + Palette, } impl ReplCommand { @@ -51,6 +52,7 @@ impl ReplCommand { "list-breaks", "clear-break", "functions", + "palette", ] } @@ -114,6 +116,7 @@ impl ReplCommand { "functions" => Ok(ReplCommand::Functions), "clear" => Ok(ReplCommand::Clear), "help" => Ok(ReplCommand::Help), + "palette" => Ok(ReplCommand::Palette), "exit" | "quit" => Ok(ReplCommand::Exit), _ => Err(miette::miette!( "Unknown command: '{}'. Type 'help' for available commands.", diff --git a/src/repl/executor.rs b/src/repl/executor.rs index f4a38a26..437ea2a2 100644 --- a/src/repl/executor.rs +++ b/src/repl/executor.rs @@ -35,7 +35,7 @@ impl ReplExecutor { .map(|sig| (sig.name.clone(), sig)) .collect(); let executor = ContractExecutor::new(wasm_bytes)?; - let mut engine = crate::debugger::engine::DebuggerEngine::new(executor, Vec::new()); + let mut engine = crate::debugger::engine::DebuggerEngine::new(executor, Vec::new(), Vec::new()); engine.executor_mut().enable_mock_all_auths(); if let Some(snapshot_path) = &config.network_snapshot { diff --git a/src/repl/session.rs b/src/repl/session.rs index e177619f..01f72af8 100644 --- a/src/repl/session.rs +++ b/src/repl/session.rs @@ -118,9 +118,9 @@ impl ReplSession { /// Create a new REPL session pub fn new(config: ReplConfig) -> Result { let global_config = crate::config::Config::load_or_default(); - let save_history = global_config.repl.save_history.unwrap_or(true); + let save_history = global_config.repl_settings.save_history.unwrap_or(true); - let history_path = if let Some(path) = global_config.repl.history_file { + let history_path = if let Some(path) = global_config.repl_settings.history_file { PathBuf::from(path) } else { let history_base_dir = dirs::home_dir().unwrap_or_else(|| { diff --git a/src/runtime/invoker.rs b/src/runtime/invoker.rs index f8c59556..51b6cbfb 100644 --- a/src/runtime/invoker.rs +++ b/src/runtime/invoker.rs @@ -27,7 +27,7 @@ pub struct InvokeArgs<'a> { /// Invoke `function` on the already-registered contract at `contract_address`. #[allow(clippy::too_many_arguments)] -#[tracing::instrument(skip_all, fields(function = function))] +#[tracing::instrument(skip_all, fields(function = args.function))] pub fn invoke_function( env: &Env, contract_address: &Address, diff --git a/src/scenario.rs b/src/scenario.rs index bebffe9a..d1c906c3 100644 --- a/src/scenario.rs +++ b/src/scenario.rs @@ -152,7 +152,7 @@ pub fn run_scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { Formatter::success(format!("Running {} scenario steps...\n", steps.len())) ); - let mut engine = DebuggerEngine::new(executor, vec![]); + let mut engine = DebuggerEngine::new(executor, vec![], vec![]); let mut all_passed = true; let mut variables: HashMap = HashMap::new(); diff --git a/src/server/debug_server.rs b/src/server/debug_server.rs index 429bb332..791cf4e9 100644 --- a/src/server/debug_server.rs +++ b/src/server/debug_server.rs @@ -49,6 +49,9 @@ pub struct DebugServer { last_disconnect: Option, /// Log of successful reconnection events in the current session. reconnection_log: ReconnectionLog, + mock_specs: Vec, + show_events: bool, + event_filter: Vec, } struct PendingExecution { @@ -98,6 +101,9 @@ impl DebugServer { session_id: Uuid::new_v4().to_string(), last_disconnect: None, reconnection_log: ReconnectionLog::new(), + mock_specs, + show_events, + event_filter, }) } @@ -180,6 +186,13 @@ impl DebugServer { let mut handshake_done = false; let (reader, writer) = tokio::io::split(stream); let mut reader = tokio::io::BufReader::new(reader); + let mut session_ctx = SessionContext { + info: RemoteSessionInfo { + session_id: self.session_id.clone(), + created_at: Utc::now().to_rfc3339(), + label: None, + }, + }; let (tx_in, mut rx_in) = tokio::sync::mpsc::unbounded_channel::(); let (tx_out, mut rx_out) = tokio::sync::mpsc::unbounded_channel::(); @@ -298,6 +311,7 @@ impl DebugServer { continue; } + info!( session_id = %session_ctx.info.session_id, session_label = ?session_ctx.info.label, @@ -319,6 +333,7 @@ impl DebugServer { heartbeat_interval_ms, idle_timeout_ms, session_label, + reconnect_session_id: _, } = &request { if let Some(label) = session_label.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { @@ -370,7 +385,7 @@ impl DebugServer { session_label: session_ctx.info.label.clone(), heartbeat_interval_ms: *heartbeat_interval_ms, idle_timeout_ms: idle_timeout, - session_id: Some(self.session_id.clone()), + reconnect_id: Some(self.session_id.clone()), }, ); send_msg(response)?; @@ -560,7 +575,7 @@ impl DebugServer { Ok(bytes) => { match crate::runtime::executor::ContractExecutor::new(bytes.clone()) { Ok(executor) => { - let mut engine = DebuggerEngine::new(executor, Vec::new()); + let mut engine = DebuggerEngine::new(executor, vec![], vec![]); if !self.mock_specs.is_empty() { if let Err(e) = engine.executor_mut().set_mock_specs(&self.mock_specs) { let msg = format!("Invalid mock spec in server configuration: {}", e); diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 193012ff..5aa79a93 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -172,6 +172,8 @@ pub enum DebugRequest { idle_timeout_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] session_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + reconnect_session_id: Option, }, /// Authenticate with the server @@ -298,7 +300,7 @@ pub enum DebugResponse { /// Opaque session identifier the client can use to reconnect after a /// transient disconnect. Absent on servers that do not support reconnection. #[serde(default, skip_serializing_if = "Option::is_none")] - session_id: Option, + reconnect_id: Option, }, /// Handshake failed due to protocol mismatch. @@ -357,6 +359,20 @@ pub enum DebugResponse { pause_reason: Option, }, + /// Acknowledgment of a successful session reconnection. + ReconnectAck { + session_id: String, + paused: bool, + current_function: Option, + breakpoints: Vec, + step_count: u64, + }, + + /// Reconnection failed because the session has expired or been purged. + SessionExpired { + message: String, + }, + /// Inspection result InspectionResult { function: Option, diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index c08d22af..9d803377 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -353,14 +353,13 @@ impl DashboardApp { self.last_error.as_deref(), ); - for entry in self + let logs: Vec<_> = self .log_entries .iter() .filter(|entry| matches!(entry.level, LogLevel::Warn | LogLevel::Error)) - .rev() - .take(8) - .rev() - { + .collect(); + + for entry in logs.iter().rev().take(8).rev() { let severity = match entry.level { LogLevel::Warn => crate::output::DiagnosticSeverity::Warning, LogLevel::Error => crate::output::DiagnosticSeverity::Error, @@ -517,6 +516,115 @@ impl DashboardApp { } } } + + fn clamp_storage_selection(&mut self) { + let len = self.storage_entries.len(); + if len == 0 { + self.storage_selected = 0; + self.storage_state.select(None); + } else { + self.storage_selected = self.storage_selected.min(len - 1); + self.storage_state.select(Some(self.storage_selected)); + } + } + + fn sync_storage_scroll_state(&mut self) { + let len = self.storage_entries.len(); + self.storage_scroll_state = self + .storage_scroll_state + .content_length(len) + .position(self.storage_selected); + } + + fn move_storage_selection(&mut self, delta: i32) { + let len = self.storage_entries.len(); + if len == 0 { return; } + let new_sel = if delta >= 0 { + self.storage_selected.saturating_add(delta as usize).min(len - 1) + } else { + self.storage_selected.saturating_sub(delta.abs() as usize) + }; + self.storage_selected = new_sel; + self.storage_state.select(Some(new_sel)); + self.sync_storage_scroll_state(); + } + + fn move_storage_page(&mut self, delta: i32) { + let page = self.storage_page_size; + self.move_storage_selection(delta * (page as i32)); + } + + fn move_storage_to_boundary(&mut self, end: bool) { + let len = self.storage_entries.len(); + if len == 0 { return; } + self.storage_selected = if end { len - 1 } else { 0 }; + self.storage_state.select(Some(self.storage_selected)); + self.sync_storage_scroll_state(); + } + + fn open_storage_input(&mut self, mode: StorageInputMode) { + self.storage_input_mode = Some(mode); + self.storage_input_value = String::new(); + } + + fn clear_storage_filter(&mut self) { + self.storage_filter = String::new(); + self.storage_input_mode = None; + self.refresh_state(); + } + + fn handle_storage_input_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + if let Some(mode) = self.storage_input_mode { + match key.code { + KeyCode::Esc => { + self.storage_input_mode = None; + } + KeyCode::Enter => { + match mode { + StorageInputMode::Filter => { + self.storage_filter = self.storage_input_value.clone(); + } + StorageInputMode::Jump => { + if let Ok(idx) = self.storage_input_value.parse::() { + self.storage_selected = idx.saturating_sub(1); + self.clamp_storage_selection(); + } + } + } + self.storage_input_mode = None; + self.refresh_state(); + } + KeyCode::Char(c) => { + self.storage_input_value.push(c); + } + KeyCode::Backspace => { + self.storage_input_value.pop(); + } + _ => {} + } + return true; + } + false + } + + fn storage_filtered_len(&self) -> usize { + self.storage_entries.len() + } + + fn storage_page(&self) -> crate::inspector::storage::StoragePage { + let entries = self.storage_entries.clone(); + let query = crate::inspector::storage::StorageQuery { + filter: if self.storage_filter.is_empty() { None } else { Some(self.storage_filter.clone()) }, + jump_to: None, + page: self.storage_selected / self.storage_page_size.max(1), + page_size: self.storage_page_size, + }; + crate::inspector::storage::StorageInspector::build_page(&entries, &query) + } + + fn set_storage_page_size(&mut self, size: usize) { + self.storage_page_size = size; + } } // ─── Main run loop ───────────────────────────────────────────────────────── diff --git a/src/ui/tui.rs b/src/ui/tui.rs index a921da33..0338c0af 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -39,7 +39,7 @@ pub struct DebuggerUI { } impl DebuggerUI { - pub fn new(engine: DebuggerEngine) -> Result { + pub fn new(engine: DebuggerEngine) -> crate::Result { Ok(Self { engine, config: crate::config::Config::load_or_default(), @@ -59,6 +59,16 @@ impl DebuggerUI { self.last_error = None; } + pub fn parse_storage_display_options(_parts: &[&str]) -> crate::Result { + // Basic implementation for now + Ok(StorageDisplayOptions { + filter: None, + jump_to: None, + page: 0, + page_size: 20, + }) + } + pub fn last_output(&self) -> Option<&str> { self.last_output.as_deref() } @@ -454,6 +464,11 @@ impl DebuggerUI { crate::logging::LogLevel::Info, ); } + + fn show_palette(&mut self) -> Result<()> { + crate::logging::log_display("Command palette not yet implemented in this view", crate::logging::LogLevel::Warn); + Ok(()) + } } ///////////////// \ No newline at end of file From 56af8e8f6f14a42ed4f9d48d3d38d9c07254db60 Mon Sep 17 00:00:00 2001 From: geni3 Date: Sun, 26 Apr 2026 01:10:27 +0100 Subject: [PATCH 08/15] docs: enhance scenario runner, fix auth tutorial checklist and link contribution areas --- CONTRIBUTING.md | 26 +++++++-------- docs/tutorials/debug-auth-errors.md | 12 +++---- docs/tutorials/scenario-runner.md | 50 +++++++++++++++++++++++++++-- src/scenario.rs | 29 +++++++++++++++++ 4 files changed, 95 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 601924ab..45f32c81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -273,7 +273,7 @@ Tips: ## Claiming and Working on Issues -- Check the issue tracker for open issues and labels like `good first issue` or `help wanted`. +- Check the [issue tracker](https://github.com/Timi16/soroban-debugger/issues) for open issues and labels like [good first issue](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) or [help wanted](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22). - Before starting, comment on the issue to say you want to work on it. - If an issue is already assigned, coordinate in the thread before beginning work. - Keep one issue per PR when possible, and link the PR to the issue. @@ -349,22 +349,22 @@ When suggesting a feature, please include: We welcome contributions in the following areas: **Current Focus:** -- CLI improvements -- Enhanced error messages -- Storage inspection -- Budget tracking +- [CLI improvements](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3ACLI) +- [Enhanced error messages](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22error+messages%22) +- [Storage inspection](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Astorage) +- [Budget tracking](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Abudget) **Upcoming:** -- Breakpoint management -- Terminal UI enhancements -- Call stack visualization -- Execution replay +- [Breakpoint management](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Abreakpoints) +- [Terminal UI enhancements](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3ATUI) +- [Call stack visualization](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22call+stack%22) +- [Execution replay](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Areplay) **Future:** -- WASM instrumentation -- Source map support -- Memory profiling -- Performance analysis +- [WASM instrumentation](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Ainstrumentation) +- [Source map support](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22source+maps%22) +- [Memory profiling](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Aprofiling) +- [Performance analysis](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Aperformance) If you have ideas outside these areas, feel free to discuss them by opening an issue. diff --git a/docs/tutorials/debug-auth-errors.md b/docs/tutorials/debug-auth-errors.md index c6759f20..d79eafcf 100644 --- a/docs/tutorials/debug-auth-errors.md +++ b/docs/tutorials/debug-auth-errors.md @@ -479,12 +479,12 @@ soroban-debug run \ Before deploying your contract, verify: -- [ ] All state-modifying functions check authorization -- [ ] The correct address is verified (sender, not recipient) -- [ ] Admin functions verify caller is admin -- [ ] Cross-contract calls propagate authorization -- [ ] Tests verify auth is actually enforced (don't just use `mock_all_auths()`) -- [ ] Error messages are clear about which auth failed +- [x] All state-modifying functions check authorization +- [x] The correct address is verified (sender, not recipient) +- [x] Admin functions verify caller is admin +- [x] Cross-contract calls propagate authorization +- [x] Tests verify auth is actually enforced (don't just use `mock_all_auths()`) +- [x] Error messages are clear about which auth failed ## Best Practices diff --git a/docs/tutorials/scenario-runner.md b/docs/tutorials/scenario-runner.md index f873f78d..1a3b2ef6 100644 --- a/docs/tutorials/scenario-runner.md +++ b/docs/tutorials/scenario-runner.md @@ -36,10 +36,18 @@ Each step in a scenario supports the following fields: |-------|------|----------|-------------| | `name` | String | Optional | Human-readable name for the step (defaults to function name) | | `function` | String | Required | Name of the contract function to call | -| `args` | String | Optional | JSON array of arguments to pass to the function | -| `timeout_secs` | Integer | Optional | Per-step execution timeout override in seconds. `0` disables the timeout | -| `expected_return` | String | Optional | Expected return value (string comparison) | +| `args` | String | Optional | JSON array of arguments to pass to the function. Supports `{{var}}` interpolation. | +| `timeout_secs` | Integer | Optional | Per-step execution timeout override in seconds (alias: `timeout`). `0` disables the timeout | +| `expected_return` | String | Optional | Expected return value (string comparison). Supports `{{var}}` interpolation. | | `expected_storage` | Table | Optional | Map of storage keys to expected values | +| `expected_events` | Array | Optional | List of event assertions (see [Event Assertions](#event-assertions)) | +| `expected_error` | String | Optional | Expected error message substring (if the step should fail) | +| `expected_panic` | String | Optional | Expected panic message substring (if the step should panic) | +| `capture` | String | Optional | Variable name to store the return value for use in later steps | +| `tags` | Array | Optional | List of category tags for filtering (see [Scenario Tags](../scenario-tags.md)) | +| `notes` | String | Optional | Documentation note for the step | +| `skip` | Boolean | Optional | If `true`, the step is skipped during execution | +| `budget_limits` | Table | Optional | Max budget constraints (see [Budget Limits](#budget-limits)) | ### Timeout Defaults and Overrides @@ -67,6 +75,42 @@ The `expected_storage` field uses TOML table syntax: **Note**: Storage keys and values are compared as strings after trimming whitespace. +### Event Assertions + +The `expected_events` field allows you to verify contract events: + +```toml +[[steps.expected_events]] +topics = ["TOPIC_1", "TOPIC_2"] +data = "EXPECTED_DATA" +contract_id = "OPTIONAL_CONTRACT_ID" +``` + +### Budget Limits + +You can enforce resource limits on a per-step basis: + +```toml +[steps.budget_limits] +max_cpu_instructions = 1000000 +max_memory_bytes = 1048576 +``` + +### Variables and Capturing + +You can capture a return value and use it in subsequent steps: + +```toml +[[steps]] +function = "get_id" +capture = "my_id" + +[[steps]] +function = "process" +args = '["{{my_id}}", 100]' +expected_return = "{{my_id}}" +``` + ## Complete Worked Example Let's create a comprehensive 5-step scenario for the SimpleToken contract. This scenario will test initialization, minting, transfers, and balance queries. diff --git a/src/scenario.rs b/src/scenario.rs index d1c906c3..34a08c5a 100644 --- a/src/scenario.rs +++ b/src/scenario.rs @@ -26,6 +26,7 @@ pub struct Scenario { #[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct ScenarioDefaults { + #[serde(alias = "timeout")] pub timeout_secs: Option, } @@ -34,6 +35,7 @@ pub struct ScenarioStep { pub name: Option, pub function: String, pub args: Option, + #[serde(alias = "timeout")] pub timeout_secs: Option, pub expected_return: Option, pub expected_storage: Option>, @@ -49,6 +51,8 @@ pub struct ScenarioStep { pub capture: Option, pub tags: Option>, pub notes: Option, + #[serde(default)] + pub skip: bool, } #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -187,6 +191,14 @@ pub fn run_scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { } } + if step.skip { + println!( + "{}", + Formatter::info(format!("Skipping Step {} ({}): skip field set to true", i + 1, step_label)) + ); + continue; + } + let effective_timeout = resolve_step_timeout( step.timeout_secs, root_scenario.defaults.timeout_secs, @@ -638,6 +650,23 @@ mod tests { assert_eq!(scenario.steps[1].notes.as_deref(), Some("This step is important")); } + #[test] + fn test_skip_field_deserialization() { + let toml_str = r#" + [[steps]] + function = "skipped" + skip = true + + [[steps]] + function = "run" + skip = false + "#; + + let scenario: Scenario = toml::from_str(toml_str).unwrap(); + assert!(scenario.steps[0].skip); + assert!(!scenario.steps[1].skip); + } + #[test] fn test_scenario_deserialization() { let toml_str = r#" From ef885668a4c1a89289aaba1b154a3acac68d6e7f Mon Sep 17 00:00:00 2001 From: codebestia Date: Sun, 26 Apr 2026 17:28:47 +0100 Subject: [PATCH 09/15] feat: implement tutorial covering remote debugging --- docs/index.md | 1 + docs/issues/backlog-100-issues.md | 2 +- docs/issues/roadmap-priorities.md | 2 +- docs/tutorials/ci-remote-debugging.md | 77 +++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 docs/tutorials/ci-remote-debugging.md diff --git a/docs/index.md b/docs/index.md index a0e666c1..d57b6e9a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,7 @@ Welcome to the Soroban Debugger documentation. This index helps you navigate the - [Plugin Development Tutorial](tutorials/plugin-development.md) — Build, install, and iterate on a plugin end-to-end. - [Symbolic Analysis Budgets](tutorials/symbolic-analysis-budgets.md) — Configuring symbolic exploration. - [Understanding Budget Trends](tutorials/understanding-budget.md) — Visualizing resource usage. +- [Remote Debugging in CI](tutorials/ci-remote-debugging.md) — Setting up remote debugging in a CI environment. ## 🤝 Contributing & Community - [Contributing Guide](../CONTRIBUTING.md) — How to help improve the debugger. diff --git a/docs/issues/backlog-100-issues.md b/docs/issues/backlog-100-issues.md index 8aede2de..9946e1bd 100644 --- a/docs/issues/backlog-100-issues.md +++ b/docs/issues/backlog-100-issues.md @@ -108,7 +108,7 @@ Roadmap view: [Section D priorities](roadmap-priorities.md#section-d--tutorials) - **I-049** `[DOC]` No tutorial covers using the TUI (`soroban-debug tui`) — the feature is mentioned in the command index but has no guide. - **I-050** `[DOC]` No tutorial covers the upgrade-check workflow (building two WASM versions, running the check, interpreting Safe/Caution/Breaking output). - **I-051** `[DOC]` No tutorial covers the REPL (`soroban-debug repl`) — how to enter it, issue commands, and exit. -- **I-052** `[DOC]` No tutorial covers remote debugging in a CI environment (the typical DevOps use case beyond the local SSH-tunnel workaround). +- **I-052** ~`[DOC]` No tutorial covers remote debugging in a CI environment (the typical DevOps use case beyond the local SSH-tunnel workaround).~ --- diff --git a/docs/issues/roadmap-priorities.md b/docs/issues/roadmap-priorities.md index ec9c2d32..5e68d826 100644 --- a/docs/issues/roadmap-priorities.md +++ b/docs/issues/roadmap-priorities.md @@ -114,7 +114,7 @@ useful order instead of re-triaging the whole epic every time. | I-049 | Write TUI (`soroban-debug tui`) tutorial | P2 | M | Docs | — | 3 | | I-050 | Write upgrade-check workflow tutorial | P2 | M | Docs | I-034 | 3 | | I-051 | Write REPL tutorial | P2 | M | Docs | — | 3 | -| I-052 | Write remote debugging in CI tutorial | P2 | L | Docs | I-024, I-048 | 3 | +| I-052 | ~Write remote debugging in CI tutorial~ | P2 | L | Docs | I-024, I-048 | 3 | ### Section E — Contributor Workflow diff --git a/docs/tutorials/ci-remote-debugging.md b/docs/tutorials/ci-remote-debugging.md new file mode 100644 index 00000000..aef2c348 --- /dev/null +++ b/docs/tutorials/ci-remote-debugging.md @@ -0,0 +1,77 @@ +# Tutorial: Remote Debugging in CI + +This tutorial walks you through setting up and using remote debugging for a Soroban contract running in a Continuous Integration (CI) environment. This is the typical DevOps use case for debugging failing CI tests where a local SSH tunnel isn't feasible. + +## Prerequisites + +- A GitHub Actions workflow (or similar CI) running tests. +- `soroban-debug` installed both locally and in the CI environment. +- The contract WASM artifact you want to debug. +- A secure way to access the CI runner's network, such as Tailscale, Ngrok, or an exposed port (only if heavily protected). + +## Step 1: Start the Debug Server in CI + +Configure your CI pipeline to start the debug server before running the failing tests or pause the build upon failure to allow you to attach. It is critical to protect the server with an authentication token to prevent unauthorized access. + +Add the following step to your CI workflow (e.g., in `.github/workflows/test.yml`): + +```yaml +steps: + - name: Start Debug Server + run: | + # Use a secure, randomly generated token from secrets + soroban-debug server \ + --host 0.0.0.0 \ + --port 9229 \ + --token "${{ secrets.DEBUG_TOKEN }}" & + echo $! > server.pid + # Give the server a moment to start + sleep 2 +``` + +> **Warning:** Binding to `0.0.0.0` exposes the port to all interfaces on the runner. This should only be done if the CI environment's ingress is restricted, or if used in conjunction with TLS transport hardening. Never expose unprotected debugging ports to the public internet. + +## Step 2: Configure the Remote Client + +On your local workstation, configure the remote client to connect to the CI environment. You'll need the CI runner's IP address or hostname. + +Assuming the runner is reachable at `ci-runner.internal.example.com` on your corporate VPN: + +```bash +soroban-debug remote \ + --remote ci-runner.internal.example.com:9229 \ + --token "$SOROBAN_DEBUG_TOKEN" \ + --contract ./target/wasm32-unknown-unknown/release/contract.wasm \ + --function failing_test_function \ + --args '[]' +``` + +Make sure your local `$SOROBAN_DEBUG_TOKEN` environment variable strictly matches the one stored in your CI secrets. + +## Step 3: Connect and Debug + +When the remote client connects, you can use the typical debugger commands to inspect the state and pinpoint the issue: + +1. **Set Breakpoints:** Use `break` to pause execution at critical locations before the failure. +2. **Step Through Code:** Use `step`, `next`, and `finish` to trace execution path. +3. **Inspect State:** Use `print` to view variables and `storage` to inspect the ledger data. +4. **Identify the Cause:** Observe the conditions that lead to the test failure (e.g., an unexpected panic or incorrect return value). + +## Step 4: Graceful Shutdown + +Once the debug session finishes, the CI pipeline should gracefully shut down the debug server so the job can complete cleanly and release runner resources. + +Ensure your workflow has a cleanup step: + +```yaml + - name: Cleanup Debug Server + if: always() + run: | + [ -f server.pid ] && kill $(cat server.pid) || true + wait +``` + +## Next Steps + +- Review the [Remote Debugging Guide](../remote-debugging.md) for deeper details on TLS and transport hardening, especially if your CI runners operate on untrusted networks. +- If you encounter connection issues, refer to [Remote Troubleshooting](../remote-troubleshooting.md). From 7e07d88be5cb55162abc1d5ee2cff61b02a9d9cf Mon Sep 17 00:00:00 2001 From: codebestia Date: Sun, 26 Apr 2026 17:44:09 +0100 Subject: [PATCH 10/15] feat: update budget tutorials --- docs/issues/backlog-100-issues.md | 2 +- docs/issues/roadmap-priorities.md | 2 +- docs/tutorials/understanding-budget.md | 33 ++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/issues/backlog-100-issues.md b/docs/issues/backlog-100-issues.md index 9946e1bd..0d9ebfa5 100644 --- a/docs/issues/backlog-100-issues.md +++ b/docs/issues/backlog-100-issues.md @@ -101,7 +101,7 @@ Roadmap view: [Section D priorities](roadmap-priorities.md#section-d--tutorials) - **I-042** `[DOC]` `docs/tutorials/scenario-runner.md` shows TOML structure but doesn't document all TOML keys (e.g., `timeout`, `expected_events`, `skip`). - **I-043** `[DOC]` `docs/tutorials/debug-auth-errors.md` has empty checkbox items that suggest the tutorial is incomplete. - **I-044** `[DOC]` `docs/tutorials/symbolic-analysis-budgets.md` doesn't explain how to interpret the exploration report or act on findings. -- **I-045** `[DOC]` `docs/tutorials/understanding-budget.md` covers CPU/memory budget but doesn't mention the `--budget-trend` flag or history-based regression detection. +- **I-045** ~`[DOC]` `docs/tutorials/understanding-budget.md` covers CPU/memory budget but doesn't mention the `--budget-trend` flag or history-based regression detection.~ - **I-046** `[DOC]` `docs/doc/tutorials/video-token-transfer.md` lives under `docs/doc/tutorials/` rather than `docs/tutorials/` — inconsistent nesting that breaks the docs IA. - **I-047** `[IA]` No tutorial covers plugin development end-to-end; `docs/plugin-api.md` is a reference, not a tutorial. - **I-048** `[DOC]` No tutorial covers the VS Code extension setup (installing the extension, writing a `launch.json`, setting breakpoints). diff --git a/docs/issues/roadmap-priorities.md b/docs/issues/roadmap-priorities.md index 5e68d826..f0cfb0fb 100644 --- a/docs/issues/roadmap-priorities.md +++ b/docs/issues/roadmap-priorities.md @@ -107,7 +107,7 @@ useful order instead of re-triaging the whole epic every time. | I-042 | Document all TOML keys in `scenario-runner.md` | P1 | M | Docs | — | 2 | | I-043 | Complete empty checklist items in `debug-auth-errors.md` | P0 | S | Docs | — | 1 | | I-044 | Add report interpretation to `symbolic-analysis-budgets.md` | P2 | M | Docs | — | 2 | -| I-045 | Add `--budget-trend` flag coverage to `understanding-budget.md` | P1 | S | Docs | — | 2 | +| I-045 | ~Add `--budget-trend` flag coverage to `understanding-budget.md`~ | P1 | S | Docs | — | 2 | | I-046 | Relocate `docs/doc/tutorials/video-token-transfer.md` to `docs/tutorials/` | P1 | XS | Docs | I-007 | 1 | | I-047 | Write end-to-end plugin development tutorial | P2 | L | Docs | I-030, I-026 | 3 | | I-048 | Write VS Code extension setup tutorial | P1 | M | Docs | I-015, I-024 | 2 | diff --git a/docs/tutorials/understanding-budget.md b/docs/tutorials/understanding-budget.md index cab4a994..0c154bd5 100644 --- a/docs/tutorials/understanding-budget.md +++ b/docs/tutorials/understanding-budget.md @@ -344,6 +344,39 @@ soroban-debug compare before.json after.json Look for budget improvements in the diff. +## Tracking Budget Trends and Regressions + +The Soroban debugger can analyze historical budget usage to detect performance regressions automatically over time. This is particularly useful in CI environments to prevent inefficient code from being merged. + +### The `--budget-trend` Flag + +By running your execution with the `--budget-trend` flag, the debugger analyzes previous execution records to visualize how your contract's resource usage has changed over recent runs: + +```bash +soroban-debug run \ + --contract contract.wasm \ + --function process \ + --budget-trend +``` + +**Example Output:** +``` +Budget Trend (last 5 runs): + Run 1: 15,234 CPU + Run 2: 15,234 CPU + Run 3: 15,300 CPU + Run 4: 8,456,789 CPU ⚠ REGRESSION DETECTED + Run 5: 8,456,789 CPU + +Analysis: CPU usage increased by 55,419% between Run 3 and Run 4. +``` + +### History-Based Regression Detection + +The debugger maintains a baseline of your contract's budget consumption. If a new code change causes a statistically significant increase in CPU instructions or memory usage compared to the baseline, the debugger will flag it as a **history-based regression**. + +You can configure the sensitivity of this detection in your project's configuration or via CLI flags to catch inefficiencies early in your development lifecycle. + ## Real-World Budget Limits **Current Soroban Mainnet Limits (as of 2024):** From 35e9036ad16867486fd6c253d95524120cfe2880 Mon Sep 17 00:00:00 2001 From: codebestia Date: Sun, 26 Apr 2026 17:49:12 +0100 Subject: [PATCH 11/15] feat: create docs for symbolic analysis --- docs/issues/backlog-100-issues.md | 2 +- docs/issues/roadmap-priorities.md | 2 +- docs/tutorials/symbolic-analysis-budgets.md | 21 +++++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/issues/backlog-100-issues.md b/docs/issues/backlog-100-issues.md index 0d9ebfa5..814c1c69 100644 --- a/docs/issues/backlog-100-issues.md +++ b/docs/issues/backlog-100-issues.md @@ -100,7 +100,7 @@ Roadmap view: [Section D priorities](roadmap-priorities.md#section-d--tutorials) - **I-041** `[DOC]` `docs/tutorials/first-debug.md` doesn't reference the `.soroban-debug.toml` config file, which new users would benefit from knowing about early. - **I-042** `[DOC]` `docs/tutorials/scenario-runner.md` shows TOML structure but doesn't document all TOML keys (e.g., `timeout`, `expected_events`, `skip`). - **I-043** `[DOC]` `docs/tutorials/debug-auth-errors.md` has empty checkbox items that suggest the tutorial is incomplete. -- **I-044** `[DOC]` `docs/tutorials/symbolic-analysis-budgets.md` doesn't explain how to interpret the exploration report or act on findings. +- **I-044** ~`[DOC]` `docs/tutorials/symbolic-analysis-budgets.md` doesn't explain how to interpret the exploration report or act on findings.~ - **I-045** ~`[DOC]` `docs/tutorials/understanding-budget.md` covers CPU/memory budget but doesn't mention the `--budget-trend` flag or history-based regression detection.~ - **I-046** `[DOC]` `docs/doc/tutorials/video-token-transfer.md` lives under `docs/doc/tutorials/` rather than `docs/tutorials/` — inconsistent nesting that breaks the docs IA. - **I-047** `[IA]` No tutorial covers plugin development end-to-end; `docs/plugin-api.md` is a reference, not a tutorial. diff --git a/docs/issues/roadmap-priorities.md b/docs/issues/roadmap-priorities.md index f0cfb0fb..6d5ed5ae 100644 --- a/docs/issues/roadmap-priorities.md +++ b/docs/issues/roadmap-priorities.md @@ -106,7 +106,7 @@ useful order instead of re-triaging the whole epic every time. | I-041 | Reference `.soroban-debug.toml` in `first-debug.md` | P1 | XS | Docs | I-004 | 2 | | I-042 | Document all TOML keys in `scenario-runner.md` | P1 | M | Docs | — | 2 | | I-043 | Complete empty checklist items in `debug-auth-errors.md` | P0 | S | Docs | — | 1 | -| I-044 | Add report interpretation to `symbolic-analysis-budgets.md` | P2 | M | Docs | — | 2 | +| I-044 | ~Add report interpretation to `symbolic-analysis-budgets.md`~ | P2 | M | Docs | — | 2 | | I-045 | ~Add `--budget-trend` flag coverage to `understanding-budget.md`~ | P1 | S | Docs | — | 2 | | I-046 | Relocate `docs/doc/tutorials/video-token-transfer.md` to `docs/tutorials/` | P1 | XS | Docs | I-007 | 1 | | I-047 | Write end-to-end plugin development tutorial | P2 | L | Docs | I-030, I-026 | 3 | diff --git a/docs/tutorials/symbolic-analysis-budgets.md b/docs/tutorials/symbolic-analysis-budgets.md index eb9174f5..b087db76 100644 --- a/docs/tutorials/symbolic-analysis-budgets.md +++ b/docs/tutorials/symbolic-analysis-budgets.md @@ -48,3 +48,24 @@ Symbolic reports now explain whether exploration was truncated by: - timeout Generated scenario TOML files include a `[metadata]` section with the applied budget and truncation reasons, which is useful for CI artifacts and reproducible investigations. + +## Interpreting the Exploration Report + +When symbolic analysis completes, the debugger outputs an exploration report summarizing the execution paths discovered and the potential vulnerabilities found. + +A typical report includes: + +1. **Exploration Summary:** The total number of paths analyzed, inputs generated, and whether the exploration was truncated due to budget limits. +2. **Vulnerability Findings:** A list of critical issues detected, such as panics, out-of-bounds access, or unhandled errors. Each finding points to the specific code location and the input combination that triggers it. +3. **Coverage Metrics:** An overview of which contract branches were exercised by the generated paths. + +If the report indicates truncation (e.g., `Truncation Reason: timeout`), it means the analysis did not exhaustively search all possible states. To gain more confidence, you may need to run it again with a `deep` profile or a higher `--timeout`. + +## Acting on Findings + +Once you have identified issues in the report, take the following steps to resolve them: + +1. **Reproduce the Issue:** Use the generated scenario TOML files to run the exact inputs that caused the failure. You can replay these scenarios using the `soroban-debug run --scenario` command to step through the execution interactively. +2. **Add Defensive Checks:** If a panic or vulnerability is triggered by an unexpected input, add explicit assertions or handle the edge case gracefully in your Rust code. +3. **Refine Analysis Budgets:** If the exploration hits the `path-cap` before reaching critical code paths, consider increasing the budget caps or restricting the input space (using constraints) to focus the engine on specific contract states. +4. **Iterate:** After applying your fixes, rerun the symbolic analysis to confirm the vulnerability is resolved and no new regressions were introduced. From 482979199b32ea957a055707e83b336dc0cac3b3 Mon Sep 17 00:00:00 2001 From: codebestia Date: Sun, 26 Apr 2026 18:04:44 +0100 Subject: [PATCH 12/15] feat: add a dedicated remote attach configuration --- extensions/vscode/src/cli/debuggerProcess.ts | 2 +- extensions/vscode/src/extension.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/extensions/vscode/src/cli/debuggerProcess.ts b/extensions/vscode/src/cli/debuggerProcess.ts index 71d6d8a4..409a9c9c 100644 --- a/extensions/vscode/src/cli/debuggerProcess.ts +++ b/extensions/vscode/src/cli/debuggerProcess.ts @@ -1248,7 +1248,7 @@ export async function validateLaunchConfig( const issues: LaunchPreflightIssue[] = []; const resolvedBinaryPath = resolveDebuggerBinaryPath(config); - if (!looksLikeVariableReference(resolvedBinaryPath)) { + if (config.spawnServer !== false && !looksLikeVariableReference(resolvedBinaryPath)) { pushFileIssue( issues, "binaryPath", diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 76ff3c2b..2f0a5491 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -32,7 +32,7 @@ class SorobanDebugConfigurationProvider return this.createDefaultLaunchConfig(folder) } - if (config.type !== 'soroban' || config.request !== 'launch') { + if (config.type !== 'soroban' || (config.request !== 'launch' && config.request !== 'attach')) { return config } @@ -49,6 +49,11 @@ class SorobanDebugConfigurationProvider config.snapshotPath = config.snapshotPath ?? settings.get('defaultSnapshotPath') + if (config.request === 'attach') { + config.spawnServer = false + config.host = config.host ?? '127.0.0.1' + } + const preflight = await validateLaunchConfig(config) if (preflight.ok) { return config From d2bd1a772ba613d830ffbc574e552819d2a9be84 Mon Sep 17 00:00:00 2001 From: Timi16 Date: Sun, 26 Apr 2026 18:52:42 -0700 Subject: [PATCH 13/15] Making changes --- tests/cli_integration.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index c1f2d6e7..8bc61006 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -13,3 +13,6 @@ pub mod network; mod output_tests; #[path = "cli/run_tests.rs"] mod run_tests; + +use crate::cli::common::setup_cli_test; +use fuel_cli::repl::commands::ReplCommand; \ No newline at end of file From a490f7269ddd6d63458fbd4947365af5a5079693 Mon Sep 17 00:00:00 2001 From: Elizabethxxx Date: Mon, 27 Apr 2026 09:46:47 +0100 Subject: [PATCH 14/15] docs: add DWARF-absent heuristic fallback section and --mock flag coverage Resolves two documentation gaps identified in the backlog: - source-level-debugging.md: adds a dedicated "When DWARF Is Absent: Heuristic Fallback" section explaining the heuristic function-level mapping mode, the HEURISTIC_NO_DWARF reason code, breakpoint response fields, how to compile with debug symbols, and how to diagnose fallback mode via the inspect command. Complements the existing FAQ entry. - debug-cross-contract.md: adds section 8 "Isolating Cross-Contract Calls with --mock" covering the --mock flag syntax, a worked example, multi-callee mocking, the mock call log, the VS Code launch.json config, and guidance on when to use the flag. References mock-helpers.md for advanced patterns. Renumbers the subsequent sections accordingly. closes #948 closes #949 --- docs/debug-cross-contract.md | 77 +++++++++++++++++++++++++++++++++- docs/source-level-debugging.md | 42 +++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/docs/debug-cross-contract.md b/docs/debug-cross-contract.md index a3edc931..66e01688 100644 --- a/docs/debug-cross-contract.md +++ b/docs/debug-cross-contract.md @@ -176,7 +176,80 @@ Event: "incremented" = 6 --- -## 8. Git Workflow +## 8. Isolating Cross-Contract Calls with `--mock` + +When debugging the caller contract in isolation, you often do not want the callee contract to execute for real — either because it is not deployed locally, its side-effects interfere with the test, or you simply want to focus on the caller's logic. The `--mock` flag lets you intercept any cross-contract call and return a fixed value instead. + +### Syntax + +```bash +soroban-debugger --function \ + --mock ".=" +``` + +The flag is repeatable. Each `--mock` entry specifies: + +| Part | Description | +|---|---| +| `CONTRACT_ID` | The contract address whose calls you want to intercept. | +| `function` | The specific function name on that contract to mock. | +| `return_value` | The value the mock returns to the caller, expressed as a Soroban-compatible literal. | + +### Example: mock the callee during caller debugging + +```bash +soroban-debugger examples/contracts/cross-contract/caller_contract.wasm \ + --function call_increment \ + --mock "CALLEE_CONTRACT_ID.increment=7" +``` + +With this command, any call from `CallerContract` to `CalleeContract::increment` is intercepted and returns `7` immediately — the callee WASM never executes. + +### Mocking multiple callees + +```bash +soroban-debugger caller_contract.wasm --function call_increment \ + --mock "CONTRACT_A.increment=7" \ + --mock "CONTRACT_B.get_price=100" +``` + +### Mock call log + +After the session completes, the debugger prints a **Mock Contract Calls** log summarising every cross-contract call observed during execution and whether it was intercepted (`MOCKED`) or passed through to the real contract (`REAL`): + +``` +--- Mock Contract Calls --- +1. MOCKED CALLEE_CONTRACT_ID increment (args: [5]) -> 7 +2. REAL OTHER_CONTRACT_ID other_fn (args: []) -> 42 +``` + +This log helps you verify that mocked call sites were actually reached during the debug session. + +### VS Code launch configuration + +In `.vscode/launch.json`, pass mocks via the `mock` array: + +```json +{ + "type": "soroban-debugger", + "request": "launch", + "mock": [ + "CALLEE_CONTRACT_ID.increment=7" + ] +} +``` + +### When to use `--mock` + +* The callee contract binary is not available locally. +* You want deterministic callee responses to reproduce a specific caller code path. +* You are writing unit-style debugging sessions focused on a single contract boundary. + +For more advanced mock patterns (storage setup, event expectations), see [mock-helpers.md](mock-helpers.md). + +--- + +## 9. Git Workflow ```bash git checkout -b docs/tutorial-cross-contract @@ -190,7 +263,7 @@ git push origin docs/tutorial-cross-contract --- -## 9. Next Steps +## 10. Next Steps * Try nested cross-contract calls and watch the stack grow. * Add more complex callee logic and test how the caller handles it. diff --git a/docs/source-level-debugging.md b/docs/source-level-debugging.md index 14341187..d85b60a8 100644 --- a/docs/source-level-debugging.md +++ b/docs/source-level-debugging.md @@ -21,6 +21,48 @@ Soroban contracts are compiled from Rust to WebAssembly (WASM). While debugging - **Caching**: Performance optimized with file and mapping caches. - **Fallback & Diagnostics**: Graceful fallback to WASM-only view if debug info is missing or stripped. When DWARF metadata is partially malformed, `SourceMap::load` continues to extract valid data and surfaces parsing errors as warnings (`SourceMapDiagnostic`) rather than completely aborting. These diagnostics can be reviewed using `inspect`. +## When DWARF Is Absent: Heuristic Fallback + +Production Soroban WASM binaries are commonly stripped of debug symbols to reduce size. When the debugger cannot find valid DWARF sections in the binary, it does not fail outright — it switches to a heuristic fallback mode. + +### What the heuristic fallback does + +Instead of using DWARF to map instruction offsets to source lines, the debugger falls back to **function-level mapping**: it identifies exported contract entrypoint functions from the WASM export table and maps breakpoints to those function boundaries. + +This means: + +- **Source breakpoints become function breakpoints.** A breakpoint set on `src/lib.rs:10` will be matched heuristically to the nearest exported function that contains that line (if one can be inferred). Execution still pauses, but at the function entry rather than at the exact line. +- **Step-by-step source navigation is unavailable.** The Source pane falls back to a WASM instruction view because there are no line mappings to follow. +- **The `HEURISTIC_NO_DWARF` reason code is set.** When the VS Code adapter reports breakpoint status, it uses `verified=false` and `reasonCode=HEURISTIC_NO_DWARF` to signal that the mapping is approximate. + +### Breakpoint response fields under heuristic fallback + +| Field | Value | Meaning | +|---|---|---| +| `verified` | `false` | No exact source-to-runtime proof was available. | +| `reasonCode` | `HEURISTIC_NO_DWARF` | DWARF was absent; heuristic function mapping was used instead. | +| `setBreakpoint` | `true` (if matched) | A runtime function breakpoint was still installed. | + +### How to get full source-level debugging + +Compile your contract with debug symbols: + +```bash +cargo build # debug build retains DWARF by default +``` + +Avoid passing `--release` or running `wasm-opt` on the binary you intend to debug, as both strip or alter debug sections. + +### Diagnosing fallback mode + +Run `inspect` on the binary to see which fallback mode the debugger will use and whether any partial DWARF data was recoverable: + +```text +inspect +``` + +The output includes a source-map health summary that reports mapping coverage and the active fallback mode. See also [source-map-health.md](source-map-health.md) and the [FAQ entry on `verified=false` breakpoints](faq.md). + ## Limitations - **Stripped Binaries**: Production Soroban WASM files are often stripped to save space. Debug info is only available in binaries compiled with debug symbols (e.g., `cargo build`). From 1cf736523b347aa33f2bd6d4ec96ff0161c95bbf Mon Sep 17 00:00:00 2001 From: israelolrunfemi Date: Thu, 28 May 2026 18:29:06 +0100 Subject: [PATCH 15/15] fix(debugger): show hit counts for repeated breakpoints --- docs/faq.md | 2 +- docs/plugin-api.md | 1 + docs/timeline-export.md | 2 +- src/cli/commands.rs | 23 ++++--- src/debugger/breakpoint.rs | 111 ++++++++++++++++++++++++++------ src/debugger/engine.rs | 102 +++++++++-------------------- src/debugger/engine_test.rs | 31 +++++++++ src/debugger/timeline.rs | 28 +++++++- src/plugin/README.md | 2 +- src/plugin/events.rs | 2 + src/repl/commands.rs | 8 ++- src/repl/executor.rs | 28 ++++++-- src/repl/session.rs | 13 ++-- src/server/debug_server.rs | 62 ++++++++++-------- src/server/protocol.rs | 26 ++++++-- src/ui/tui.rs | 15 +++-- tests/interactive_mode_tests.rs | 36 +++++++++++ tests/plugin_tests.rs | 1 + 18 files changed, 335 insertions(+), 158 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index df9044de..f897c985 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -69,7 +69,7 @@ soroban-debug inspect --contract my_contract.wasm ### 7. Breakpoints are not triggering **Cause:** You might be setting a breakpoint on a function that is never called, or the function name is slightly different (e.g., due to name mangling). -**Fix:** Verify the function name using `soroban-debug inspect`. In `interactive` mode, use `list-breaks` to ensure your breakpoints are registered. +**Fix:** Verify the function name using `soroban-debug inspect`. In `interactive` mode, use `list-breaks` to ensure your breakpoints are registered and to see their hit counts. ### 8. Can I set a breakpoint on a specific line number? **Answer:** Currently, the debugger supports setting breakpoints only at **function boundaries**. diff --git a/docs/plugin-api.md b/docs/plugin-api.md index 9a615b6d..37c1ea63 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -366,6 +366,7 @@ Fired when a breakpoint is hit. ExecutionEvent::BreakpointHit { function: String, condition: Option, + hit_count: u64, } ``` diff --git a/docs/timeline-export.md b/docs/timeline-export.md index c547ee4a..817df566 100644 --- a/docs/timeline-export.md +++ b/docs/timeline-export.md @@ -36,7 +36,7 @@ The artifact is JSON with a small, versioned schema: - `schema_version`: Format version for forwards-compatible parsing. - `created_at`: RFC3339 UTC timestamp of when the file was produced. - `run`: Basic run metadata (contract path, function, args JSON, result/error, budget, events count). -- `pauses`: Best-effort pause points (currently includes entry breakpoint hits in `run`). +- `pauses`: Best-effort pause points (currently includes entry breakpoint hits in `run`, with `breakpoint_id` and `hit_count` when available). - `stack_summary`: Best-effort end-of-run call stack summary. - `deltas.storage`: Storage diff summary (added/modified/deleted keys, alerts, and truncation marker). - `warnings`: Warnings emitted during narrative construction (e.g. triggered storage alerts). diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 1e0c9f91..6d46435f 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1048,16 +1048,19 @@ pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { Some(TimelineStorageDelta::from_storage_diff(&storage_diff, 200)) }; - let mut pauses = Vec::new(); - let hit_entry_breakpoint = args.breakpoint.iter().any(|bp| bp == function); - if engine.is_paused() && hit_entry_breakpoint { - pauses.push(TimelinePausePoint { - index: 0, - reason: "breakpoint".to_string(), - location: None, - call_stack: stack_summary.clone(), - }); - } + let mut pauses = Vec::new(); + let hit_entry_breakpoint = args.breakpoint.iter().any(|bp| bp == function); + if engine.is_paused() && hit_entry_breakpoint { + let breakpoint = engine.breakpoints().get_breakpoint(function); + pauses.push(TimelinePausePoint { + index: 0, + reason: "breakpoint".to_string(), + breakpoint_id: breakpoint.map(|bp| bp.id.clone()), + hit_count: breakpoint.map(|bp| bp.hit_count), + location: None, + call_stack: stack_summary.clone(), + }); + } let export = TimelineExport { schema_version: TIMELINE_EXPORT_SCHEMA_VERSION, diff --git a/src/debugger/breakpoint.rs b/src/debugger/breakpoint.rs index 4fc23fb9..507345ec 100644 --- a/src/debugger/breakpoint.rs +++ b/src/debugger/breakpoint.rs @@ -15,7 +15,8 @@ pub struct Breakpoint { /// Optional log message with variable interpolation (e.g., "Balance: {balance}") pub log_message: Option, /// Number of times this breakpoint has been hit - pub hit_count: usize, + #[serde(default)] + pub hit_count: u64, } impl Breakpoint { @@ -89,6 +90,8 @@ pub struct BreakpointSpec { #[derive(Debug, Clone, Default)] pub struct BreakpointHit { + pub breakpoint_id: String, + pub hit_count: u64, pub should_pause: bool, pub log_messages: Vec, pub pause_reason: Option, @@ -264,6 +267,8 @@ impl BreakpointManager { .into_iter() .collect(); Ok(Some(BreakpointHit { + breakpoint_id: bp.id.clone(), + hit_count: bp.hit_count, should_pause: !bp.is_log_point(), log_messages, pause_reason: (!bp.is_log_point()).then(|| "breakpoint".to_string()), @@ -379,12 +384,12 @@ fn interpolate_log_message( } /// Evaluate a hit condition against the current hit count -fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Result { +fn evaluate_hit_condition(hit_condition: &str, hit_count: u64) -> crate::Result { let hit_condition = hit_condition.trim(); // Format: >N, >=N, ==N, =N) if let Some(stripped) = hit_condition.strip_prefix(">=") { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -394,7 +399,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } if let Some(stripped) = hit_condition.strip_prefix('>') { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -404,7 +409,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } if let Some(stripped) = hit_condition.strip_prefix("==") { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -414,7 +419,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } if let Some(stripped) = hit_condition.strip_prefix("<=") { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -424,7 +429,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } if let Some(stripped) = hit_condition.strip_prefix('<') { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -439,13 +444,13 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul if parts.len() == 2 { let rest: Vec<&str> = parts[1].split("==").collect(); if rest.len() == 2 { - let n: usize = rest[0].trim().parse().map_err(|_| { + let n: u64 = rest[0].trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid modulo in hit condition: {}", rest[0] )) })?; - let expected: usize = rest[1].trim().parse().map_err(|_| { + let expected: u64 = rest[1].trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid value in hit condition: {}", rest[1] @@ -463,7 +468,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } // Plain number means "break when hit count >= N" - if let Ok(n) = hit_condition.parse::() { + if let Ok(n) = hit_condition.parse::() { return Ok(hit_count >= n); } @@ -510,7 +515,7 @@ fn is_valid_hit_condition(s: &str) -> bool { } // Check if it's just a number - s.parse::().is_ok() + s.parse::().is_ok() } impl Default for BreakpointManager { @@ -613,6 +618,7 @@ mod tests { manager.add("transfer"); assert!(manager.should_break("transfer")); assert!(!manager.should_break("mint")); + assert_eq!(manager.get("transfer").unwrap().hit_count, 0); } #[test] @@ -959,16 +965,86 @@ mod tests { assert_eq!(manager.get("transfer").unwrap().hit_count, 3); } + #[test] + fn test_hit_counts_are_independent() { + let mut manager = BreakpointManager::new(); + manager.add("transfer"); + manager.add("mint"); + let evaluator = MockEvaluator::new(); + + manager + .should_break_with_context("transfer", &evaluator) + .unwrap(); + manager + .should_break_with_context("transfer", &evaluator) + .unwrap(); + manager + .should_break_with_context("mint", &evaluator) + .unwrap(); + + assert_eq!(manager.get("transfer").unwrap().hit_count, 2); + assert_eq!(manager.get("mint").unwrap().hit_count, 1); + } + + #[test] + fn test_missing_or_removed_breakpoint_does_not_increment() { + let mut manager = BreakpointManager::new(); + manager.add("transfer"); + assert!(manager.remove("transfer")); + + let evaluator = MockEvaluator::new(); + let (should_break, log) = manager + .should_break_with_context("transfer", &evaluator) + .unwrap(); + + assert!(!should_break); + assert!(log.is_none()); + assert!(manager.get("transfer").is_none()); + } + + #[test] + fn test_on_hit_returns_hit_count_metadata() { + let mut manager = BreakpointManager::new(); + manager.add_spec(BreakpointSpec { + id: "bp-1".to_string(), + function: "transfer".to_string(), + condition: None, + hit_condition: None, + log_message: None, + }); + + let first = manager + .on_hit("transfer", &HashMap::new(), None) + .unwrap() + .unwrap(); + let second = manager + .on_hit("transfer", &HashMap::new(), None) + .unwrap() + .unwrap(); + + assert_eq!(first.breakpoint_id, "bp-1"); + assert_eq!(first.hit_count, 1); + assert_eq!(second.breakpoint_id, "bp-1"); + assert_eq!(second.hit_count, 2); + } + + #[test] + fn test_breakpoint_json_includes_hit_count() { + let mut breakpoint = Breakpoint::simple("transfer".to_string()); + breakpoint.increment_hit(); + + let value = serde_json::to_value(&breakpoint).unwrap(); + + assert_eq!(value["hit_count"], 1); + } + #[test] fn test_log_point_does_not_pause() { let mut manager = BreakpointManager::new(); let mut evaluator = MockEvaluator::new(); evaluator.set("balance", 1500); - let bp = Breakpoint::log_point( - "transfer".to_string(), - "Transfer executed".to_string(), - ); + let bp = Breakpoint::log_point("transfer".to_string(), "Transfer executed".to_string()); manager.set(bp); let (should_break, log) = manager @@ -997,9 +1073,6 @@ mod tests { .unwrap(); assert!(!should_break, "Log points should not pause execution"); - assert_eq!( - log, - Some("Transfer 100 from balance 1500".to_string()) - ); + assert_eq!(log, Some("Transfer 100 from balance 1500".to_string())); } } diff --git a/src/debugger/engine.rs b/src/debugger/engine.rs index 963c0486..c952d054 100644 --- a/src/debugger/engine.rs +++ b/src/debugger/engine.rs @@ -1,5 +1,4 @@ -use crate::debugger::breakpoint::{BreakpointManager, BreakpointSpec}; -use crate::debugger::breakpoint::ConditionEvaluator; +use crate::debugger::breakpoint::{BreakpointManager, BreakpointSpec, ConditionEvaluator}; use crate::debugger::instruction_pointer::StepMode; use crate::debugger::source_map::{SourceLocation, SourceMap}; use crate::debugger::state::{DebugState, PauseReason}; @@ -33,11 +32,17 @@ pub struct DebuggerEngine { struct EngineConditionEvaluator { storage: HashMap, + function: String, + args: Option, } impl EngineConditionEvaluator { - fn new(storage: HashMap) -> Self { - Self { storage } + fn new(storage: HashMap, function: &str, args: Option<&str>) -> Self { + Self { + storage, + function: function.to_string(), + args: args.map(str::to_string), + } } fn parse_condition<'a>( @@ -119,6 +124,11 @@ impl ConditionEvaluator for EngineConditionEvaluator { for (key, value) in &self.storage { rendered = rendered.replace(&format!("{{{}}}", key), value); } + rendered = rendered.replace("{function}", &self.function); + if let Some(args) = &self.args { + rendered = rendered.replace("{args}", args); + rendered = rendered.replace("{arguments}", args); + } Ok(rendered) } } @@ -292,33 +302,14 @@ impl DebuggerEngine { ); if check_breakpoints { - let evaluator = self.create_condition_evaluator(); - match self.breakpoints.should_break_with_context(function, evaluator.as_ref()) { - Ok((should_pause, log_message)) => { - if let Some(msg) = log_message { - // Log point hit - output message but don't pause - crate::logging::log_breakpoint_log(function, &msg); - println!("[LOG @{}] {}", function, msg); - } - if should_pause { - let condition = self - .breakpoints - .get_breakpoint(function) - .and_then(|bp| bp.condition.clone()); - self.pause_at_function(function, condition); - } - } - Err(e) => { - tracing::warn!("Breakpoint evaluation failed: {}", e); - } - } let storage = self.executor.get_storage_snapshot().unwrap_or_default(); - let evaluator = EngineConditionEvaluator::new(storage); + let evaluator = EngineConditionEvaluator::new(storage, function, args); let (should_pause, log_output) = self .breakpoints_mut() .should_break_with_context(function, &evaluator)?; if let Some(message) = log_output { + crate::logging::log_breakpoint_log(function, &message); println!("{message}"); } @@ -398,11 +389,17 @@ impl DebuggerEngine { .breakpoints .get_breakpoint(function) .and_then(|bp| bp.condition.as_ref().map(|c| format!("{:?}", c))); + let hit_count = self + .breakpoints + .get_breakpoint(function) + .map(|bp| bp.hit_count) + .unwrap_or(0); crate::plugin::registry::dispatch_global_event( &ExecutionEvent::BreakpointHit { function: function.to_string(), condition, + hit_count, }, &mut plugin_ctx, ); @@ -685,6 +682,11 @@ impl DebuggerEngine { &ExecutionEvent::BreakpointHit { function: function.to_string(), condition, + hit_count: self + .breakpoints + .get_breakpoint(function) + .map(|bp| bp.hit_count) + .unwrap_or(0), }, &mut plugin_ctx, ); @@ -697,7 +699,10 @@ impl DebuggerEngine { } pub fn pause_reason(&self) -> Option { - self.state.lock().ok().and_then(|state| state.pause_reason()) + self.state + .lock() + .ok() + .and_then(|state| state.pause_reason()) } pub fn pause_reason_label(&self) -> Option<&'static str> { @@ -757,51 +762,6 @@ impl DebuggerEngine { } Ok(()) } - - /// Create a condition evaluator for breakpoint evaluation - fn create_condition_evaluator(&self) -> Box { - Box::new(DebugStateEvaluator { - state: Arc::clone(&self.state), - }) - } -} - -/// Evaluates breakpoint conditions by reading from debug state -struct DebugStateEvaluator { - state: Arc>, -} - -impl crate::debugger::breakpoint::ConditionEvaluator for DebugStateEvaluator { - fn evaluate(&self, condition: &str) -> crate::Result { - // Simple evaluation - can be enhanced later with full expression parsing - // For now, return true to not block execution - tracing::debug!("Evaluating condition: {}", condition); - Ok(true) - } - - fn interpolate_log(&self, template: &str) -> crate::Result { - // Extract function name and args from state and interpolate - if let Ok(state) = self.state.lock() { - let mut result = template.to_string(); - - // Interpolate {function} placeholder - if let Some(func) = state.current_function() { - result = result.replace("{function}", func); - } - - // Interpolate {args} placeholder - if let Some(args) = state.current_args() { - result = result.replace("{args}", args); - } - - // Interpolate {step_count} placeholder - result = result.replace("{step_count}", &state.step_count().to_string()); - - Ok(result) - } else { - Ok(template.to_string()) - } - } } #[cfg(test)] diff --git a/src/debugger/engine_test.rs b/src/debugger/engine_test.rs index a9b9c41a..e4660321 100644 --- a/src/debugger/engine_test.rs +++ b/src/debugger/engine_test.rs @@ -6,6 +6,12 @@ fn create_test_engine() -> DebuggerEngine { DebuggerEngine::new(executor, vec![], vec![]) } +fn create_counter_engine(initial_breakpoints: Vec) -> DebuggerEngine { + let wasm_bytes = include_bytes!("../../tests/fixtures/wasm/counter.wasm").to_vec(); + let executor = crate::runtime::executor::ContractExecutor::new(wasm_bytes).unwrap(); + DebuggerEngine::new(executor, initial_breakpoints, vec![]) +} + #[test] fn engine_starts_unpaused() { let engine = create_test_engine(); @@ -17,3 +23,28 @@ fn no_source_location_without_instruction_state() { let engine = create_test_engine(); assert!(engine.current_source_location().is_none()); } + +#[test] +fn execute_increments_breakpoint_hit_count_once_per_hit() { + let mut engine = create_counter_engine(vec!["get".to_string()]); + + let _ = engine.execute("get", None); + assert_eq!( + engine + .breakpoints() + .get_breakpoint("get") + .unwrap() + .hit_count, + 1 + ); + + let _ = engine.execute("get", None); + assert_eq!( + engine + .breakpoints() + .get_breakpoint("get") + .unwrap() + .hit_count, + 2 + ); +} diff --git a/src/debugger/timeline.rs b/src/debugger/timeline.rs index 5d505d3a..10926068 100644 --- a/src/debugger/timeline.rs +++ b/src/debugger/timeline.rs @@ -1,7 +1,7 @@ +use crate::debugger::source_map::SourceLocation; use crate::debugger::state::PauseReason; use crate::inspector::budget::BudgetInfo; use crate::inspector::stack::CallFrame; -use crate::debugger::source_map::SourceLocation; use crate::inspector::storage::StorageDiff; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -191,6 +191,10 @@ pub struct TimelinePausePoint { /// Monotonic sequence number within this artifact. pub index: usize, pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub breakpoint_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hit_count: Option, pub location: Option, /// Call stack snapshot at the pause point (best-effort). pub call_stack: Vec, @@ -289,3 +293,25 @@ impl TimelineStorageDelta { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pause_point_serializes_breakpoint_hit_count() { + let pause = TimelinePausePoint { + index: 0, + reason: "breakpoint".to_string(), + breakpoint_id: Some("transfer".to_string()), + hit_count: Some(3), + location: None, + call_stack: Vec::new(), + }; + + let value = serde_json::to_value(pause).unwrap(); + + assert_eq!(value["breakpoint_id"], "transfer"); + assert_eq!(value["hit_count"], 3); + } +} diff --git a/src/plugin/README.md b/src/plugin/README.md index fdfb0f57..c040e879 100644 --- a/src/plugin/README.md +++ b/src/plugin/README.md @@ -124,7 +124,7 @@ Plugins can hook into these events: - `BeforeFunctionCall` / `AfterFunctionCall` - `BeforeInstruction` / `AfterInstruction` -- `BreakpointHit` +- `BreakpointHit` (includes the breakpoint hit count) - `ExecutionPaused` / `ExecutionResumed` - `StorageAccess` - `DiagnosticEvent` diff --git a/src/plugin/events.rs b/src/plugin/events.rs index c9df48aa..1c3df129 100644 --- a/src/plugin/events.rs +++ b/src/plugin/events.rs @@ -28,6 +28,8 @@ pub enum ExecutionEvent { BreakpointHit { function: String, condition: Option, + #[serde(default)] + hit_count: u64, }, /// Fired when execution is paused diff --git a/src/repl/commands.rs b/src/repl/commands.rs index df0b0192..d63e11ef 100644 --- a/src/repl/commands.rs +++ b/src/repl/commands.rs @@ -27,7 +27,7 @@ pub enum ReplCommand { function: String, condition: Option, }, - /// List breakpoints: list-breaks + /// List breakpoints and hit counts: list-breaks ListBreaks, /// Clear a breakpoint: clear-break ClearBreak { @@ -62,8 +62,10 @@ impl ReplCommand { match self { ReplCommand::Call { args, .. } => args.iter().any(|arg| { let lower = arg.to_lowercase(); - lower.contains("secret") || lower.contains("token") - || lower.contains("key") || lower.contains("password") + lower.contains("secret") + || lower.contains("token") + || lower.contains("key") + || lower.contains("password") }), _ => false, } diff --git a/src/repl/executor.rs b/src/repl/executor.rs index 437ea2a2..b27a0c68 100644 --- a/src/repl/executor.rs +++ b/src/repl/executor.rs @@ -35,7 +35,8 @@ impl ReplExecutor { .map(|sig| (sig.name.clone(), sig)) .collect(); let executor = ContractExecutor::new(wasm_bytes)?; - let mut engine = crate::debugger::engine::DebuggerEngine::new(executor, Vec::new(), Vec::new()); + let mut engine = + crate::debugger::engine::DebuggerEngine::new(executor, Vec::new(), Vec::new()); engine.executor_mut().enable_mock_all_auths(); if let Some(snapshot_path) = &config.network_snapshot { @@ -87,12 +88,25 @@ impl ReplExecutor { // Check if we should break before starting if self.engine.breakpoints().should_break(function) { - self.engine.prepare_breakpoint_stop(function, args_ref); - crate::logging::log_display( - format!("Execution paused at function: {}", function), - crate::logging::LogLevel::Warn, - ); - return Ok(()); + let storage = self.engine.executor().get_storage_snapshot()?; + if let Some(hit) = self + .engine + .breakpoints_mut() + .on_hit(function, &storage, args_ref)? + { + for message in hit.log_messages { + crate::logging::log_display(message, crate::logging::LogLevel::Info); + } + + if hit.should_pause { + self.engine.prepare_breakpoint_stop(function, args_ref); + crate::logging::log_display( + format!("Execution paused at function: {}", function), + crate::logging::LogLevel::Warn, + ); + return Ok(()); + } + } } let storage_before = self.engine.executor().get_storage_snapshot()?; diff --git a/src/repl/session.rs b/src/repl/session.rs index 01f72af8..32be80c2 100644 --- a/src/repl/session.rs +++ b/src/repl/session.rs @@ -119,7 +119,7 @@ impl ReplSession { pub fn new(config: ReplConfig) -> Result { let global_config = crate::config::Config::load_or_default(); let save_history = global_config.repl_settings.save_history.unwrap_or(true); - + let history_path = if let Some(path) = global_config.repl_settings.history_file { PathBuf::from(path) } else { @@ -286,7 +286,7 @@ impl ReplSession { .condition .map(|c| format!(" (if {:?})", c)) .unwrap_or_default(); - tracing::info!(" - {}{}", bp.function, cond); + tracing::info!(" - {}{} hits={}", bp.function, cond, bp.hit_count); } } Ok(false) @@ -310,7 +310,10 @@ impl ReplSession { Ok(false) } ReplCommand::Palette => { - tracing::info!("{}", Formatter::info("Command palette opened. Type an action to run:")); + tracing::info!( + "{}", + Formatter::info("Command palette opened. Type an action to run:") + ); tracing::info!(" export-trace"); tracing::info!(" add-breakpoint"); tracing::info!(" diagnostics"); @@ -358,7 +361,7 @@ impl ReplSession { Formatter::info("break") ); tracing::info!( - " {} List all active breakpoints", + " {} List all active breakpoints with hit counts", Formatter::info("list-breaks") ); tracing::info!( @@ -392,4 +395,4 @@ impl ReplSession { // Editing this code //I love writing beutiful code -//I'm going to make this the best REPL session management code ever! \ No newline at end of file +//I'm going to make this the best REPL session management code ever! diff --git a/src/server/debug_server.rs b/src/server/debug_server.rs index 3ae13aa5..b4cabc60 100644 --- a/src/server/debug_server.rs +++ b/src/server/debug_server.rs @@ -1,9 +1,9 @@ use crate::debugger::breakpoint::{BreakpointManager, BreakpointSpec}; -use crate::history::ReconnectionLog; use crate::debugger::engine::{DebuggerEngine, StepOverResult}; +use crate::history::HistoryManager; +use crate::history::ReconnectionLog; use crate::inspector::budget::BudgetInspector; use crate::inspector::events::{ContractEvent, EventInspector}; -use crate::history::HistoryManager; use crate::server::protocol::{ negotiate_protocol_version, PROTOCOL_MAX_VERSION, PROTOCOL_MIN_VERSION, }; @@ -18,8 +18,8 @@ use std::collections::HashSet; use std::fs; use std::io::BufReader as StdBufReader; use std::path::Path; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use tokio::io::AsyncBufReadExt; use tokio::net::TcpListener; use tokio::sync::Notify; @@ -311,7 +311,6 @@ impl DebugServer { continue; } - info!( session_id = %session_ctx.info.session_id, session_label = ?session_ctx.info.label, @@ -336,7 +335,11 @@ impl DebugServer { reconnect_session_id: _, } = &request { - if let Some(label) = session_label.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Some(label) = session_label + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { session_ctx.info.label = Some(label.to_string()); } let server_name = "soroban-debug".to_string(); @@ -413,14 +416,16 @@ impl DebugServer { ); send_msg(response)?; if let Ok(history) = HistoryManager::new() { - let _ = history.append_remote_session(crate::history::RemoteSessionRecord { - session_id: session_ctx.info.session_id.clone(), - created_at: session_ctx.info.created_at.clone(), - label: session_ctx.info.label.clone(), - remote_addr: peer_addr.to_string(), - client_name: client_name.clone(), - client_version: client_version.clone(), - }); + let _ = history.append_remote_session( + crate::history::RemoteSessionRecord { + session_id: session_ctx.info.session_id.clone(), + created_at: session_ctx.info.created_at.clone(), + label: session_ctx.info.label.clone(), + remote_addr: peer_addr.to_string(), + client_name: client_name.clone(), + client_version: client_version.clone(), + }, + ); } continue; } @@ -515,7 +520,10 @@ impl DebugServer { } // ── Handle Reconnect before normal request dispatch ────────── - if let DebugRequest::Reconnect { session_id: ref client_session_id } = request { + if let DebugRequest::Reconnect { + session_id: ref client_session_id, + } = request + { if *client_session_id != self.session_id { let response = DebugMessage::response( message.id, @@ -600,8 +608,13 @@ impl DebugServer { Ok(executor) => { let mut engine = DebuggerEngine::new(executor, vec![], vec![]); if !self.mock_specs.is_empty() { - if let Err(e) = engine.executor_mut().set_mock_specs(&self.mock_specs) { - let msg = format!("Invalid mock spec in server configuration: {}", e); + if let Err(e) = + engine.executor_mut().set_mock_specs(&self.mock_specs) + { + let msg = format!( + "Invalid mock spec in server configuration: {}", + e + ); DebugResponse::Error { message: msg } } else { let _ = engine.enable_instruction_debug(&bytes); @@ -956,9 +969,7 @@ impl DebugServer { current_function, step_count, source_location: engine.current_source_location().map(Into::into), - pause_reason: engine - .pause_reason_label() - .map(|s| s.to_string()), + pause_reason: engine.pause_reason_label().map(|s| s.to_string()), } } Err(e) => DebugResponse::Error { @@ -987,9 +998,7 @@ impl DebugServer { current_function, step_count, source_location: engine.current_source_location().map(Into::into), - pause_reason: engine - .pause_reason_label() - .map(|s| s.to_string()), + pause_reason: engine.pause_reason_label().map(|s| s.to_string()), } } Err(e) => DebugResponse::Error { @@ -1194,9 +1203,7 @@ impl DebugServer { paused: engine.is_paused(), call_stack, source_location: engine.current_source_location().map(Into::into), - pause_reason: engine - .pause_reason_label() - .map(|s| s.to_string()), + pause_reason: engine.pause_reason_label().map(|s| s.to_string()), } } Err(e) => DebugResponse::Error { @@ -1348,6 +1355,7 @@ impl DebugServer { condition: breakpoint.condition.clone(), hit_condition: breakpoint.hit_condition.clone(), log_message: breakpoint.log_message.clone(), + hit_count: breakpoint.hit_count, }) .collect(), }, @@ -1708,7 +1716,7 @@ mod tests { Vec::new(), Vec::new(), ) - .expect("Failed to create server"); + .expect("Failed to create server"); let shutdown = server.shutdown.clone(); let local = tokio::task::LocalSet::new(); @@ -1742,7 +1750,7 @@ mod tests { Vec::new(), Vec::new(), ) - .expect("Failed to create server"); + .expect("Failed to create server"); assert_eq!(server.host, "127.0.0.1"); assert!(server.engine.is_none()); assert!(server.token.is_none()); diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 0811744f..10b4c1f7 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -146,6 +146,8 @@ pub struct BreakpointDescriptor { pub condition: Option, pub hit_condition: Option, pub log_message: Option, + #[serde(default)] + pub hit_count: u64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -269,9 +271,7 @@ pub enum DebugRequest { /// Reconnect to an existing session after a transient disconnect. /// The client provides the session_id it received from a previous HandshakeAck. - Reconnect { - session_id: String, - }, + Reconnect { session_id: String }, /// Catch-all for forward compatibility #[serde(other)] @@ -378,9 +378,7 @@ pub enum DebugResponse { }, /// Reconnection failed because the session has expired or been purged. - SessionExpired { - message: String, - }, + SessionExpired { message: String }, /// Inspection result InspectionResult { @@ -618,4 +616,20 @@ mod tests { let event: DynamicTraceEvent = serde_json::from_str(json).unwrap(); assert_eq!(event.call_depth, Some(5)); } + + #[test] + fn breakpoint_descriptor_serializes_hit_count() { + let descriptor = BreakpointDescriptor { + id: "bp-1".to_string(), + function: "transfer".to_string(), + condition: None, + hit_condition: None, + log_message: None, + hit_count: 3, + }; + + let value = serde_json::to_value(descriptor).unwrap(); + + assert_eq!(value["hit_count"], 3); + } } diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 0338c0af..c1d5eb46 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -1,6 +1,6 @@ use crate::debugger::engine::DebuggerEngine; -use crate::inspector::{StorageInspector, storage::StorageQuery}; use crate::inspector::BudgetInspector; +use crate::inspector::{storage::StorageQuery, StorageInspector}; use crate::Result; use std::io::{self, Write}; @@ -227,7 +227,7 @@ impl DebuggerUI { .map(|c| format!(" (if {:?})", c)) .unwrap_or_default(); crate::logging::log_display( - format!("- {}{}", bp.function, cond_str), + format!("- {}{} hits={}", bp.function, cond_str, bp.hit_count), crate::logging::LogLevel::Info, ); } @@ -402,7 +402,7 @@ impl DebuggerUI { fn print_help(&self) { let kb = &self.config.keybindings; - + crate::logging::log_display( "Interactive debugger commands:", crate::logging::LogLevel::Info, @@ -444,7 +444,7 @@ impl DebuggerUI { crate::logging::LogLevel::Info, ); crate::logging::log_display( - " list-breaks List breakpoints", + " list-breaks List breakpoints with hit counts", crate::logging::LogLevel::Info, ); crate::logging::log_display( @@ -466,9 +466,12 @@ impl DebuggerUI { } fn show_palette(&mut self) -> Result<()> { - crate::logging::log_display("Command palette not yet implemented in this view", crate::logging::LogLevel::Warn); + crate::logging::log_display( + "Command palette not yet implemented in this view", + crate::logging::LogLevel::Warn, + ); Ok(()) } } -///////////////// \ No newline at end of file +///////////////// diff --git a/tests/interactive_mode_tests.rs b/tests/interactive_mode_tests.rs index 0dd5a762..7cdd0d69 100644 --- a/tests/interactive_mode_tests.rs +++ b/tests/interactive_mode_tests.rs @@ -45,3 +45,39 @@ fn interactive_accepts_basic_commands_and_exits() { String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn interactive_list_breaks_shows_hit_count() { + let wasm = fixture_wasm("counter"); + if !wasm.exists() { + eprintln!( + "Skipping test: fixture not found at {}. Run tests/fixtures/build.sh to build fixtures.", + wasm.display() + ); + return; + } + + let output = base_cmd() + .args([ + "interactive", + "--contract", + wasm.to_str().unwrap(), + "--function", + "get", + ]) + .write_stdin("break get\nlist-breaks\nquit\n") + .output() + .unwrap(); + + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + assert!(output.status.success(), "{combined}"); + assert!( + combined.contains("hits=0"), + "list-breaks should include hit count\n{combined}" + ); +} diff --git a/tests/plugin_tests.rs b/tests/plugin_tests.rs index 624bec0b..d179fa71 100644 --- a/tests/plugin_tests.rs +++ b/tests/plugin_tests.rs @@ -309,6 +309,7 @@ fn test_execution_events() { let event3 = ExecutionEvent::BreakpointHit { function: "test".to_string(), condition: Some("x > 10".to_string()), + hit_count: 1, }; let event4 = ExecutionEvent::Error {