diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bdd863667..86b9bbe6dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ * [BREAKING][param][store] `Store::insert_block_header` now takes a `nodes` argument and persists the header with its MMR authentication nodes in a single transaction; the standalone `Store::insert_partial_blockchain_nodes` is removed. Header-only inserts (e.g. genesis) pass an empty slice ([#2294](https://github.com/0xMiden/rust-sdk/pull/2294)). +### Features + +* [FEATURE][rust] Added the `debug-output` feature: `Client::execute_program_with_debugger` / `execute_transaction_with_debugger` run a script/transaction with its MASM `debug.*` / `trace.*` output routed to a caller-supplied `fmt::Write` sink instead of stdout (a no-op on `wasm32-unknown-unknown`), letting consumers surface debug output on such targets ([#2302](https://github.com/0xMiden/rust-sdk/pull/2302)). + ### Fixes * [FIX][rust] Storing an authenticated block header now persists the header and its MMR authentication nodes in a single store transaction, so an interrupted write can no longer leave a tracked block without the MMR nodes needed to rebuild the `PartialMmr` ([#2294](https://github.com/0xMiden/rust-sdk/pull/2294)). diff --git a/Makefile b/Makefile index d4becdf3c2..487383ea8a 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ ifneq ($(BUILD_TARGET),) TARGET_FLAG = --target $(BUILD_TARGET) endif -FEATURES_CLIENT=--features "std dap" +FEATURES_CLIENT=--features "std dap debug-output" WARNINGS=RUSTDOCFLAGS="-D warnings" TEST_MIDEN_NOTE_TRANSPORT_URL?=http://127.0.0.1:57292 diff --git a/crates/rust-client/Cargo.toml b/crates/rust-client/Cargo.toml index f64fa4a614..4613b920e9 100644 --- a/crates/rust-client/Cargo.toml +++ b/crates/rust-client/Cargo.toml @@ -48,7 +48,10 @@ crate-type = ["lib"] # `std`; the duplication in the `std` feature list is intentional so # each consumer can pick the smallest feature set they need. concurrent = ["miden-tx/concurrent"] -dap = ["dep:miden-debug", "dep:miden-processor", "std"] +dap = ["dep:miden-debug", "dep:miden-processor", "std"] +# `execute_*_with_debugger` + `RoutedDebugExecutor`: route MASM `debug.*` output to a custom writer +# (no_std-compatible, unlike `dap`, so it works on wasm where stdout debug output is a no-op). +debug-output = ["dep:miden-processor"] default = ["std"] std = [ "concurrent", diff --git a/crates/rust-client/src/transaction/debug_executor.rs b/crates/rust-client/src/transaction/debug_executor.rs new file mode 100644 index 0000000000..64086deca4 --- /dev/null +++ b/crates/rust-client/src/transaction/debug_executor.rs @@ -0,0 +1,183 @@ +//! Internal plumbing for +//! [`Client::execute_program_with_debugger`](crate::Client::execute_program_with_debugger): +//! routes MASM `debug.*` / `trace.*` output to a caller-provided [`fmt::Write`] sink instead of +//! stdout (a no-op on `wasm32-unknown-unknown`). + +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::fmt; +use core::marker::PhantomData; + +use miden_processor::advice::{AdviceInputs, AdviceMutation}; +use miden_processor::event::EventError; +use miden_processor::mast::MastForest; +use miden_processor::operation::DebugOptions; +use miden_processor::{ + BaseHost, + DebugError, + DebugHandler, + DefaultDebugHandler, + ExecutionError, + ExecutionOptions, + ExecutionOutput, + FastProcessor, + FutureMaybeSend, + Host, + ProcessorState, + Program, + StackInputs, + TraceError, +}; +use miden_protocol::Word; +use miden_protocol::assembly::debuginfo::Location; +use miden_protocol::assembly::{SourceFile, SourceSpan}; +use miden_protocol::vm::{EventId, EventName}; +use miden_tx::ProgramExecutor; + +/// Wraps a host, delegating everything except the debug/trace hooks, which go to `handler`. The VM +/// only invokes those hooks in debug/tracing mode, so wrapping a non-debug host is a no-op. +struct DebugRoutingHost<'inner, H, D: DebugHandler> { + inner: &'inner mut H, + handler: D, +} + +impl BaseHost for DebugRoutingHost<'_, H, D> { + fn get_label_and_source_file( + &self, + location: &Location, + ) -> (SourceSpan, Option>) { + self.inner.get_label_and_source_file(location) + } + + fn resolve_event(&self, event_id: EventId) -> Option<&EventName> { + self.inner.resolve_event(event_id) + } + + fn on_debug( + &mut self, + process: &ProcessorState, + options: &DebugOptions, + ) -> Result<(), DebugError> { + self.handler.on_debug(process, options) + } + + fn on_trace(&mut self, process: &ProcessorState, trace_id: u32) -> Result<(), TraceError> { + self.handler.on_trace(process, trace_id) + } +} + +impl Host for DebugRoutingHost<'_, H, D> { + fn get_mast_forest(&self, node_digest: &Word) -> impl FutureMaybeSend>> { + self.inner.get_mast_forest(node_digest) + } + + fn on_event( + &mut self, + process: &ProcessorState, + ) -> impl FutureMaybeSend, EventError>> { + self.inner.on_event(process) + } +} + +/// A [`ProgramExecutor`] running on [`FastProcessor`] that routes `debug.*` / `trace.*` output to a +/// [`DefaultDebugHandler`] backed by the writer `W` (default-constructed per execution; hence the +/// `Default` bound, plus `Sync`/`Send` for the handler and returned future). +pub(crate) struct RoutedDebugExecutor { + processor: FastProcessor, + _writer: PhantomData, +} + +impl ProgramExecutor for RoutedDebugExecutor +where + W: fmt::Write + Default + Send + Sync + 'static, +{ + fn new( + stack_inputs: StackInputs, + advice_inputs: AdviceInputs, + options: ExecutionOptions, + ) -> Self { + let processor = FastProcessor::new_with_options(stack_inputs, advice_inputs, options) + .expect("constructing FastProcessor failed due to invalid advice inputs"); + Self { processor, _writer: PhantomData } + } + + fn execute( + self, + program: &Program, + host: &mut H, + ) -> impl FutureMaybeSend> { + async move { + let handler = DefaultDebugHandler::new(W::default()); + let mut routing_host = DebugRoutingHost { inner: host, handler }; + FastProcessor::execute(self.processor, program, &mut routing_host).await + } + } +} + +#[cfg(test)] +mod tests { + use alloc::string::{String, ToString}; + use alloc::vec; + use core::cell::RefCell; + use core::fmt; + + use miden_processor::mast::{BasicBlockNodeBuilder, MastForest, MastForestContributor}; + use miden_processor::operation::{Decorator, Operation}; + use miden_processor::{DefaultHost, ExecutionOptions, Program, StackInputs}; + + use super::*; + + // Per-thread buffer (each `#[tokio::test]` runs on its own thread, so the tests don't race). + // The executor default-constructs its writer, so the sink is reached via a thread-local. + std::thread_local! { + static CAPTURED: RefCell = const { RefCell::new(String::new()) }; + } + + #[derive(Default)] + struct CapturingWriter; + + impl fmt::Write for CapturingWriter { + fn write_str(&mut self, s: &str) -> fmt::Result { + CAPTURED.with(|c| c.borrow_mut().push_str(s)); + Ok(()) + } + } + + /// A one-block program (`noop`) with a `debug.stack` decorator attached before the block. + fn debug_program() -> Program { + let mut forest = MastForest::new(); + let decorator_id = forest.add_decorator(Decorator::Debug(DebugOptions::StackAll)).unwrap(); + let block_id = BasicBlockNodeBuilder::new(vec![Operation::Noop], vec![]) + .with_before_enter(vec![decorator_id]) + .add_to_forest(&mut forest) + .unwrap(); + forest.make_root(block_id); + Program::new(forest.into(), block_id) + } + + async fn run_captured(debug_mode: bool) -> String { + CAPTURED.with(|c| c.borrow_mut().clear()); + let program = debug_program(); + let mut host = DefaultHost::default(); + let options = ExecutionOptions::default().with_debugging(debug_mode); + let executor = RoutedDebugExecutor::::new( + StackInputs::default(), + AdviceInputs::default(), + options, + ); + executor.execute(&program, &mut host).await.expect("execution should succeed"); + CAPTURED.with(|c| c.borrow().to_string()) + } + + #[tokio::test] + async fn routes_debug_output_only_in_debug_mode() { + assert!( + run_captured(true).await.to_lowercase().contains("stack"), + "`debug.stack` output should reach the routed writer in debug mode" + ); + assert!( + run_captured(false).await.is_empty(), + "no debug output should be produced when debug mode is off" + ); + } +} diff --git a/crates/rust-client/src/transaction/mod.rs b/crates/rust-client/src/transaction/mod.rs index bd2b32a75c..c03c364e97 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -118,6 +118,10 @@ pub use batch::{BatchBuilder, BatchBuilderError}; #[cfg(feature = "dap")] mod dap_executor; +#[cfg(feature = "debug-output")] +mod debug_executor; +#[cfg(feature = "debug-output")] +use debug_executor::RoutedDebugExecutor; mod prover; pub use prover::TransactionProver; @@ -569,6 +573,77 @@ where .await?) } + /// Like [`Client::execute_program`], but routes the executed script's MASM `debug.*` / + /// `trace.*` output to the writer `W` instead of stdout (which is a no-op on + /// `wasm32-unknown-unknown`). `W` is default-constructed per execution. Output is only + /// produced when the client was built in debug mode. + #[cfg(feature = "debug-output")] + pub async fn execute_program_with_debugger( + &mut self, + account_id: AccountId, + tx_script: TransactionScript, + advice_inputs: AdviceInputs, + foreign_accounts: BTreeMap, + ) -> Result<[Felt; MIN_STACK_DEPTH], ClientError> + where + W: core::fmt::Write + Default + Send + Sync + 'static, + { + let (data_store, block_ref) = + self.prepare_program_execution(account_id, foreign_accounts).await?; + + Ok(self + .build_executor(&data_store)? + .with_program_executor::>() + .execute_tx_view_script(account_id, block_ref, tx_script, advice_inputs) + .await?) + } + + /// Like [`Client::execute_transaction`], but routes MASM `debug.*` / `trace.*` output to the + /// writer `W` (see [`Client::execute_program_with_debugger`]). + #[cfg(feature = "debug-output")] + pub async fn execute_transaction_with_debugger( + &mut self, + account_id: AccountId, + transaction_request: TransactionRequest, + ) -> Result + where + W: core::fmt::Write + Default + Send + Sync + 'static, + { + let account: Account = self.get_native_account_record(account_id).await?.try_into()?; + + let prep = self.prepare_transaction(&account, transaction_request).await?; + + let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone()); + data_store.register_note_scripts(prep.output_note_scripts()); + for fpi_account in &prep.foreign_account_inputs { + data_store.mast_store().load_account_code(fpi_account.code()); + } + data_store.register_foreign_account_inputs(prep.foreign_account_inputs); + + data_store.mast_store().load_account_code(account.code()); + + let mut notes = prep.notes; + if prep.ignore_invalid_notes { + notes = self + .get_valid_input_notes( + &account, + notes, + prep.tx_args.clone(), + &prep.output_recipients, + ) + .await?; + } + + let executed_transaction = self + .build_executor(&data_store)? + .with_program_executor::>() + .execute_transaction(account_id, prep.block_num, notes, prep.tx_args) + .await?; + + validate_executed_transaction(&executed_transaction, &prep.output_recipients)?; + TransactionResult::new(executed_transaction, prep.future_notes) + } + /// Executes the provided transaction script with a DAP debug adapter listening for /// connections, allowing interactive debugging via any DAP-compatible client. #[cfg(feature = "dap")] diff --git a/docs/external/src/rust-client/debug-output.md b/docs/external/src/rust-client/debug-output.md index e2f3679442..0f172fb479 100644 --- a/docs/external/src/rust-client/debug-output.md +++ b/docs/external/src/rust-client/debug-output.md @@ -53,3 +53,24 @@ Stack state in interval [0, 2] before step 2419: Under tests, pass `--no-capture` (`cargo nextest`, used by `make test`) or `--nocapture` (`cargo test`) to see the output. ::: + +## Routing debug output to a custom sink + +Standard output is a no-op on some targets (notably `wasm32-unknown-unknown`, which has no stdout). +Enable the `debug-output` feature and run execution through `Client::execute_program_with_debugger` +(or `execute_transaction_with_debugger`), parameterized by your own `fmt::Write` sink `W`. `W` is +default-constructed per execution; output is still only produced when the client is in debug mode. + +```rust +// `ConsoleWriter: fmt::Write + Default` — here it forwards to the browser console. +client + .execute_program_with_debugger::( + account_id, + tx_script, + AdviceInputs::default(), + BTreeMap::new(), + ) + .await?; +``` + +This is what `@miden-sdk/miden-sdk` uses to surface `debug.*` output in the browser console.