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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion crates/rust-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
183 changes: 183 additions & 0 deletions crates/rust-client/src/transaction/debug_executor.rs
Original file line number Diff line number Diff line change
@@ -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<H: BaseHost, D: DebugHandler> BaseHost for DebugRoutingHost<'_, H, D> {
fn get_label_and_source_file(
&self,
location: &Location,
) -> (SourceSpan, Option<Arc<SourceFile>>) {
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<H: Host, D: DebugHandler + Send> Host for DebugRoutingHost<'_, H, D> {
fn get_mast_forest(&self, node_digest: &Word) -> impl FutureMaybeSend<Option<Arc<MastForest>>> {
self.inner.get_mast_forest(node_digest)
}

fn on_event(
&mut self,
process: &ProcessorState,
) -> impl FutureMaybeSend<Result<Vec<AdviceMutation>, 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<W> {
processor: FastProcessor,
_writer: PhantomData<W>,
}

impl<W> ProgramExecutor for RoutedDebugExecutor<W>
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<H: Host + Send>(
self,
program: &Program,
host: &mut H,
) -> impl FutureMaybeSend<Result<ExecutionOutput, ExecutionError>> {
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<String> = 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::<CapturingWriter>::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"
);
}
}
75 changes: 75 additions & 0 deletions crates/rust-client/src/transaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<W>(
&mut self,
account_id: AccountId,
tx_script: TransactionScript,
advice_inputs: AdviceInputs,
foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
) -> 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::<RoutedDebugExecutor<W>>()
.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<W>(
&mut self,
account_id: AccountId,
transaction_request: TransactionRequest,
) -> Result<TransactionResult, ClientError>
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::<RoutedDebugExecutor<W>>()
.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")]
Expand Down
21 changes: 21 additions & 0 deletions docs/external/src/rust-client/debug-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ConsoleWriter>(
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.
Loading