From 12c7513282d738cef91bfc1a92a367348fa1a23d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:52:38 +0200 Subject: [PATCH 1/9] fix(sdk): replace futures::executor::block_on with tokio::task::block_in_place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_quorum_public_key()` called `futures::executor::block_on()` for the cache-miss refetch path, which deadlocks when invoked from inside a tokio runtime (e.g. the FFI wrapper's `runtime.block_on()`). Replace with `tokio::task::block_in_place` + `Handle::current().block_on()` when a tokio context is active, falling back to a temporary `tokio::runtime::Runtime` when no context is present. Also remove diagnostic HTTP requests to google.com and the raw quorums endpoint from `dash_sdk_create_trusted` — they were debugging artifacts that should not be in production code. Closes #3432 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 4 -- packages/rs-sdk-ffi/Cargo.toml | 2 - packages/rs-sdk-ffi/src/sdk.rs | 17 +------ .../Cargo.toml | 2 +- .../src/provider.rs | 46 +++++++++++++------ 5 files changed, 35 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32ca6b6a545..e1cea89363d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3295,7 +3295,6 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -5578,7 +5577,6 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -5877,7 +5875,6 @@ dependencies = [ "once_cell", "platform-wallet-ffi", "rand 0.8.5", - "reqwest 0.12.28", "rs-sdk-trusted-context-provider", "serde", "serde_json", @@ -5896,7 +5893,6 @@ dependencies = [ "arc-swap", "dash-context-provider", "dpp", - "futures", "hex", "lru", "reqwest 0.12.28", diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 7ba7d20a4a0..0270b099634 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -55,8 +55,6 @@ zeroize = "1.8" # Concurrency once_cell = "1.20" -# HTTP client for diagnostics -reqwest = { version = "0.12", features = ["json", "rustls-tls-native-roots"] } [build-dependencies] cbindgen = "0.27" diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index bfea1fe335c..a3b3edec77f 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, OnceLock}; use tokio::runtime::Runtime; -use tracing::{debug, error, info, warn}; +use tracing::{error, info, warn}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructure; @@ -506,21 +506,6 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - let runtime_clone = runtime.handle().clone(); runtime_clone.spawn(async move { - // First, try a simple HTTP test - debug!("dash_sdk_create_trusted: testing basic HTTP connectivity"); - match reqwest::get("https://www.google.com").await { - Ok(_) => debug!("dash_sdk_create_trusted: basic HTTP test successful (Google)"), - Err(e) => warn!(error = %e, "dash_sdk_create_trusted: basic HTTP test failed"), - } - - // Try the quorums endpoint directly - debug!("dash_sdk_create_trusted: testing quorums endpoint directly"); - match reqwest::get("https://quorums.testnet.networks.dash.org/quorums").await { - Ok(resp) => debug!(status = %resp.status(), "dash_sdk_create_trusted: direct quorums endpoint test successful"), - Err(e) => warn!(error = %e, "dash_sdk_create_trusted: direct quorums endpoint test failed"), - } - - // Now try through the provider match provider_for_prefetch.update_quorum_caches().await { Ok(_) => info!("dash_sdk_create_trusted: successfully prefetched quorums"), Err(e) => warn!(error = %e, "dash_sdk_create_trusted: failed to prefetch quorums; continuing"), diff --git a/packages/rs-sdk-trusted-context-provider/Cargo.toml b/packages/rs-sdk-trusted-context-provider/Cargo.toml index bc4110fcf5e..2eb90b9bcd5 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -21,8 +21,8 @@ tracing = "0.1.41" lru = "0.16.3" arc-swap = "1.7.1" hex = "0.4.3" -futures = "0.3" url = "2.5" +tokio = { version = "1.40", features = ["rt", "rt-multi-thread"] } [features] default = [] diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 8cf73c752d7..429609b3fc0 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -618,21 +618,41 @@ impl ContextProvider for TrustedHttpContextProvider { ))); } - // For non-WASM targets, we can use block_on to fetch + // For non-WASM targets, run the async fetch in a tokio-safe blocking context. + // `futures::executor::block_on` deadlocks when called from inside a tokio runtime, + // so we use `block_in_place` (safe on multi-threaded runtimes) with a fallback to + // a fresh `tokio::runtime::Runtime` when no tokio context is active. #[cfg(not(target_arch = "wasm32"))] { - // Use blocking to run async code in sync context - let quorum = - match futures::executor::block_on(self.find_quorum(quorum_type, quorum_hash)) { - Ok(q) => q, - Err(e) => { - debug!("Error finding quorum: {}", e); - return Err(ContextProviderError::Generic(format!( - "Failed to find quorum: {}", - e - ))); - } - }; + let find_future = self.find_quorum(quorum_type, quorum_hash); + let result = match tokio::runtime::Handle::try_current() { + Ok(handle) => { + // Inside a tokio runtime — use block_in_place to avoid deadlock. + tokio::task::block_in_place(|| handle.block_on(find_future)) + } + Err(_) => { + // No tokio runtime active — spin up a temporary one. + tokio::runtime::Runtime::new() + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to create tokio runtime: {}", + e + )) + })? + .block_on(find_future) + } + }; + + let quorum = match result { + Ok(q) => q, + Err(e) => { + debug!("Error finding quorum: {}", e); + return Err(ContextProviderError::Generic(format!( + "Failed to find quorum: {}", + e + ))); + } + }; // Parse the public key from the 'key' field let pubkey_hex = quorum.key.trim_start_matches("0x"); From 4797f686021d6e1da98b7a546bf1e267ffa6d718 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:58:03 +0200 Subject: [PATCH 2/9] test(sdk): add regression test for get_quorum_public_key deadlock inside tokio Reproduce the scenario from issue #3432: a cache-miss refetch of a quorum public key called from within a multi-thread tokio runtime context. A minimal in-process HTTP server serves the /quorums response so no external network access is needed. The test would have deadlocked with the old `futures::executor::block_on` approach. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/provider.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 429609b3fc0..0486b69f359 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -836,6 +836,117 @@ impl ContextProvider for TrustedHttpContextProvider { mod tests { use super::*; + /// Regression test for https://github.com/dashpay/platform/issues/3432. + /// + /// Before the fix, `get_quorum_public_key` called `futures::executor::block_on` + /// which deadlocks when invoked from inside a tokio runtime. This test + /// reproduces that exact scenario: a cache miss inside a `#[tokio::test]` + /// context, where the fallback fetch path must not deadlock. + /// + /// The test spins up a minimal in-process HTTP server that returns a valid + /// `/quorums` JSON response containing a quorum with a well-known 48-byte + /// public key, then verifies that `get_quorum_public_key` returns that key + /// without hanging. + // `block_in_place` requires a multi-thread runtime (panics on current-thread). + // The FFI layer always uses a multi-thread runtime (`tokio::runtime::Runtime::new()`), + // so this flavor matches the real production scenario. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_get_quorum_public_key_no_deadlock_inside_tokio_runtime() { + use std::net::SocketAddr; + + // A 48-byte BLS public key (all 0xAB for easy identification in assertions) + let pubkey_bytes = [0xABu8; 48]; + let pubkey_hex = hex::encode(pubkey_bytes); + + // A 32-byte quorum hash (all 0x01) + let quorum_hash: [u8; 32] = [0x01u8; 32]; + let quorum_hash_hex = hex::encode(quorum_hash); + + // Build the JSON responses the mock server will return. + let current_json = serde_json::json!({ + "success": true, + "data": [{ + "quorum_hash": quorum_hash_hex, + "key": pubkey_hex, + "height": 1000u64, + "valid_members_count": 50u32, + }] + }) + .to_string(); + + let previous_json = serde_json::json!({ + "success": true, + "data": { "height": 999u64, "quorums": [] } + }) + .to_string(); + + // Spin up a minimal tokio HTTP server on an OS-assigned port. + let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + let server_addr = listener.local_addr().unwrap(); + + let current_json_clone = current_json.clone(); + let previous_json_clone = previous_json.clone(); + + // Spawn the server in the background; it serves exactly the two + // endpoints the provider needs and then the test drops the handle. + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let current = current_json_clone.clone(); + let previous = previous_json_clone.clone(); + tokio::spawn(async move { + // Minimal HTTP/1.1 request handling — read the request line, + // then respond based on the path. + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let mut reader = BufReader::new(stream); + let mut request_line = String::new(); + let _ = reader.read_line(&mut request_line).await; + + let body = if request_line.contains("/quorums") + && !request_line.contains("/previous") + { + current + } else { + previous + }; + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = reader.into_inner().write_all(response.as_bytes()).await; + }); + } + }); + + let base_url = format!("http://127.0.0.1:{}", server_addr.port()); + + // Build the provider pointing to our mock server. + // `new_with_url` calls `verify_domain_resolves` which does a TCP connect + // to localhost — that will succeed. + let provider = TrustedHttpContextProvider::new_with_url( + Network::Testnet, + base_url, + NonZeroUsize::new(100).unwrap(), + ) + .expect("provider creation should succeed"); + + // Invoke the synchronous ContextProvider method from inside the tokio + // runtime. Before the fix this deadlocked; after the fix it must + // complete and return the expected public key. + let result = + tokio::task::spawn_blocking(move || provider.get_quorum_public_key(1, quorum_hash, 0)) + .await + .expect("spawn_blocking should not panic"); + + let key = result.expect("get_quorum_public_key should succeed"); + assert_eq!(key, pubkey_bytes); + } + #[test] fn test_get_quorum_base_url() { assert_eq!( From 458a0517420e812ff1f8167896afac5824e190a4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:43:27 +0200 Subject: [PATCH 3/9] fix(sdk): use std::thread::scope to avoid tokio deadlock in get_quorum_public_key Replace futures::executor::block_on with a scoped OS thread running its own current-thread tokio runtime. This fixes two failure modes: 1. futures::executor::block_on deadlocks inside a current-thread tokio runtime: the single thread is both executor and I/O driver; blocking it for block_on starves the reactor and the reqwest future never completes. 2. tokio::task::block_in_place panics on current-thread runtimes. std::thread::scope spawns a new OS thread with no tokio context; the thread creates its own current-thread runtime, runs find_quorum to completion, and the scope ensures the thread is joined before get_quorum_public_key returns. Borrowing self across the scope boundary is safe because the scope exits only after the thread joins. The overhead (thread + runtime creation per cache miss) is acceptable because quorum cache misses are rare. Also updates the regression test: it now uses a current-thread runtime (reproducing the exact deadlock scenario) with a 5s timeout. The old futures::executor::block_on code was verified to trigger the timeout. Closes #3432 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/provider.rs | 186 ++++++++++-------- 1 file changed, 107 insertions(+), 79 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 0486b69f359..ec2dd1e6ac8 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -618,40 +618,53 @@ impl ContextProvider for TrustedHttpContextProvider { ))); } - // For non-WASM targets, run the async fetch in a tokio-safe blocking context. - // `futures::executor::block_on` deadlocks when called from inside a tokio runtime, - // so we use `block_in_place` (safe on multi-threaded runtimes) with a fallback to - // a fresh `tokio::runtime::Runtime` when no tokio context is active. + // For non-WASM targets, run the async fetch on a dedicated OS thread with its + // own tokio runtime. This avoids two classes of failure: + // + // 1. `futures::executor::block_on` deadlocks when called from inside a tokio + // runtime because reqwest's I/O needs the tokio reactor, which is blocked + // waiting for `block_on` to return. + // + // 2. `tokio::task::block_in_place` panics on current-thread runtimes. + // + // Spawning a scoped OS thread sidesteps both: the new thread has no tokio + // context, its own current-thread runtime, and borrows `self` safely via + // `std::thread::scope` (the scope is exited only after the thread joins). + // + // The overhead is acceptable because quorum cache misses are rare events. #[cfg(not(target_arch = "wasm32"))] { let find_future = self.find_quorum(quorum_type, quorum_hash); - let result = match tokio::runtime::Handle::try_current() { - Ok(handle) => { - // Inside a tokio runtime — use block_in_place to avoid deadlock. - tokio::task::block_in_place(|| handle.block_on(find_future)) - } - Err(_) => { - // No tokio runtime active — spin up a temporary one. - tokio::runtime::Runtime::new() + let thread_result = std::thread::scope(|s| { + s.spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to create tokio runtime: {}", + TrustedContextProviderError::NetworkError(format!( + "Failed to create tokio runtime for quorum fetch: {}", e )) })? .block_on(find_future) - } - }; + }) + .join() + }); - let quorum = match result { - Ok(q) => q, - Err(e) => { + let quorum = match thread_result { + Ok(Ok(q)) => q, + Ok(Err(e)) => { debug!("Error finding quorum: {}", e); return Err(ContextProviderError::Generic(format!( "Failed to find quorum: {}", e ))); } + Err(_) => { + return Err(ContextProviderError::Generic( + "Quorum fetch thread panicked".to_string(), + )); + } }; // Parse the public key from the 'key' field @@ -838,31 +851,37 @@ mod tests { /// Regression test for https://github.com/dashpay/platform/issues/3432. /// - /// Before the fix, `get_quorum_public_key` called `futures::executor::block_on` - /// which deadlocks when invoked from inside a tokio runtime. This test - /// reproduces that exact scenario: a cache miss inside a `#[tokio::test]` - /// context, where the fallback fetch path must not deadlock. + /// Verifies that `get_quorum_public_key` does **not** deadlock when called + /// from inside a tokio runtime on a cache miss. /// - /// The test spins up a minimal in-process HTTP server that returns a valid - /// `/quorums` JSON response containing a quorum with a well-known 48-byte - /// public key, then verifies that `get_quorum_public_key` returns that key - /// without hanging. - // `block_in_place` requires a multi-thread runtime (panics on current-thread). - // The FFI layer always uses a multi-thread runtime (`tokio::runtime::Runtime::new()`), - // so this flavor matches the real production scenario. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_quorum_public_key_no_deadlock_inside_tokio_runtime() { - use std::net::SocketAddr; - - // A 48-byte BLS public key (all 0xAB for easy identification in assertions) + /// ## Why current-thread runtime? + /// + /// The deadlock only manifests on a **current-thread** (single-threaded) + /// runtime. On a multi-thread runtime, reqwest can complete I/O on a + /// different worker thread even when the calling thread is blocked inside + /// `futures::executor::block_on`, masking the bug. + /// + /// On a current-thread runtime the single thread is both the executor and + /// the I/O driver. Calling `futures::executor::block_on` from within an + /// active tokio task parks that thread indefinitely, starving the I/O + /// driver → deadlock. + /// + /// ## Test structure + /// + /// The mock HTTP server is spun up on its own OS thread (outside the + /// current-thread runtime) so it is unaffected by what the runtime thread + /// does. `get_quorum_public_key` is called directly from an async context + /// running on the current-thread runtime. A channel with a 5-second + /// timeout detects whether the call completes or deadlocks. + #[test] + fn test_get_quorum_public_key_no_deadlock_inside_tokio_runtime() { + use std::time::Duration; + let pubkey_bytes = [0xABu8; 48]; let pubkey_hex = hex::encode(pubkey_bytes); - - // A 32-byte quorum hash (all 0x01) let quorum_hash: [u8; 32] = [0x01u8; 32]; let quorum_hash_hex = hex::encode(quorum_hash); - // Build the JSON responses the mock server will return. let current_json = serde_json::json!({ "success": true, "data": [{ @@ -880,54 +899,45 @@ mod tests { }) .to_string(); - // Spin up a minimal tokio HTTP server on an OS-assigned port. - let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - let server_addr = listener.local_addr().unwrap(); - - let current_json_clone = current_json.clone(); - let previous_json_clone = previous_json.clone(); - - // Spawn the server in the background; it serves exactly the two - // endpoints the provider needs and then the test drops the handle. - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - let current = current_json_clone.clone(); - let previous = previous_json_clone.clone(); - tokio::spawn(async move { - // Minimal HTTP/1.1 request handling — read the request line, - // then respond based on the path. + // Spin up the mock HTTP server on a dedicated OS thread (independent of + // the current-thread runtime below so it keeps responding while the + // runtime thread is blocked). + let server_rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let listener = server_rt + .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) + .unwrap(); + let server_port = listener.local_addr().unwrap().port(); + + std::thread::spawn(move || { + server_rt.block_on(async move { + // Handle connections sequentially — sufficient for a test mock. + while let Ok((mut stream, _)) = listener.accept().await { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - let mut reader = BufReader::new(stream); + let mut reader = BufReader::new(&mut stream); let mut request_line = String::new(); let _ = reader.read_line(&mut request_line).await; - let body = if request_line.contains("/quorums") && !request_line.contains("/previous") { - current + current_json.clone() } else { - previous + previous_json.clone() }; - let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); - let _ = reader.into_inner().write_all(response.as_bytes()).await; - }); - } + let _ = stream.write_all(response.as_bytes()).await; + } + }); }); - let base_url = format!("http://127.0.0.1:{}", server_addr.port()); - - // Build the provider pointing to our mock server. - // `new_with_url` calls `verify_domain_resolves` which does a TCP connect - // to localhost — that will succeed. + let base_url = format!("http://127.0.0.1:{}", server_port); let provider = TrustedHttpContextProvider::new_with_url( Network::Testnet, base_url, @@ -935,15 +945,33 @@ mod tests { ) .expect("provider creation should succeed"); - // Invoke the synchronous ContextProvider method from inside the tokio - // runtime. Before the fix this deadlocked; after the fix it must - // complete and return the expected public key. - let result = - tokio::task::spawn_blocking(move || provider.get_quorum_public_key(1, quorum_hash, 0)) - .await - .expect("spawn_blocking should not panic"); + // Channel used to receive the result with a timeout so a deadlock is + // caught as a test failure rather than a hang. + let (tx, rx) = std::sync::mpsc::channel::>(); + + // Run get_quorum_public_key inside a current-thread tokio runtime. + // This is the exact scenario that deadlocked before the fix. + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + // We call the synchronous function directly from async context — + // this is what proof-verification code does in the FFI layer. + let result = rt.block_on(async move { + // Calling get_quorum_public_key here (sync fn, inside async). + // Before fix: futures::executor::block_on → deadlock. + // After fix: std::thread::scope + new runtime → no deadlock. + provider.get_quorum_public_key(1, quorum_hash, 0) + }); + let _ = tx.send(result); + }); + + let result = rx + .recv_timeout(Duration::from_secs(5)) + .expect("timed out after 5s — get_quorum_public_key likely deadlocked"); - let key = result.expect("get_quorum_public_key should succeed"); + let key = result.expect("get_quorum_public_key should return Ok"); assert_eq!(key, pubkey_bytes); } From 4cd3883d262cd615f84329a05545f8831fcc3b82 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:54:36 +0200 Subject: [PATCH 4/9] fix(sdk): handle current-thread tokio runtime in block_on block_on previously called tokio::task::block_in_place unconditionally, which panics on current-thread runtimes. Now it detects the runtime flavor and branches accordingly: - No runtime: creates a temporary current-thread runtime - CurrentThread: spawns a dedicated OS thread with its own runtime - MultiThread (and future flavors): uses block_in_place as before Fixes https://github.com/dashpay/platform/issues/3432 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-sdk/src/sync.rs | 134 ++++++++++++++++++++++++++++++++---- 1 file changed, 120 insertions(+), 14 deletions(-) diff --git a/packages/rs-sdk/src/sync.rs b/packages/rs-sdk/src/sync.rs index 4cc395df613..6fb91f9ee2e 100644 --- a/packages/rs-sdk/src/sync.rs +++ b/packages/rs-sdk/src/sync.rs @@ -54,31 +54,79 @@ impl From for crate::Error { /// Blocks on the provided future and returns the result. /// /// This function is used to call async functions from sync code. -/// Requires the current thread to be running in a tokio runtime. /// /// Due to limitations of tokio runtime, we cannot use `tokio::runtime::Runtime::block_on` if we are already inside a tokio runtime. /// This function is a workaround for that limitation. +/// +/// Handles three scenarios: +/// - No active runtime: creates a temporary current-thread runtime and drives the future directly. +/// - Current-thread runtime: spawns a dedicated OS thread with its own runtime (block_in_place panics here). +/// - Multi-thread runtime: uses block_in_place + spawn for efficient bridging. #[cfg(not(target_arch = "wasm32"))] pub fn block_on(fut: F) -> Result where F: Future + Send + 'static, F::Output: Send, { + use tokio::runtime::RuntimeFlavor; + tracing::trace!("block_on: running async function from sync code"); - let rt = tokio::runtime::Handle::try_current()?; - let (tx, rx) = std::sync::mpsc::channel(); - tracing::trace!("block_on: Spawning worker"); - let hdl = rt.spawn(worker(fut, tx)); - tracing::trace!("block_on: Worker spawned"); - let resp = tokio::task::block_in_place(|| rx.recv())?; - - tracing::trace!("Response received"); - if !hdl.is_finished() { - tracing::debug!("async-sync worker future is not finished, aborting; this should not happen, but it's fine"); - hdl.abort(); // cleanup the worker future - } - Ok(resp) + let handle = match tokio::runtime::Handle::try_current() { + Ok(h) => h, + Err(_) => { + tracing::trace!("block_on: no active runtime, creating temporary runtime"); + return Ok(tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| AsyncError::Generic(e.to_string()))? + .block_on(fut)); + } + }; + + match handle.runtime_flavor() { + RuntimeFlavor::CurrentThread => { + tracing::trace!("block_on: current-thread runtime, spawning dedicated OS thread"); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + tracing::error!("block_on: failed to create worker runtime: {}", e); + }) + .map(|rt| rt.block_on(fut)); + if let Ok(result) = result { + let _ = tx.send(result); + } + }); + Ok(rx.recv()?) + } + RuntimeFlavor::MultiThread => { + tracing::trace!("block_on: multi-thread runtime, using block_in_place"); + let (tx, rx) = std::sync::mpsc::channel(); + let hdl = handle.spawn(worker(fut, tx)); + let resp = tokio::task::block_in_place(|| rx.recv())?; + if !hdl.is_finished() { + tracing::debug!("async-sync worker future is not finished, aborting"); + hdl.abort(); + } + Ok(resp) + } + // RuntimeFlavor is non-exhaustive; treat any future variant the same as MultiThread. + #[allow(unreachable_patterns)] + _ => { + tracing::trace!("block_on: unknown runtime flavor, using block_in_place"); + let (tx, rx) = std::sync::mpsc::channel(); + let hdl = handle.spawn(worker(fut, tx)); + let resp = tokio::task::block_in_place(|| rx.recv())?; + if !hdl.is_finished() { + tracing::debug!("async-sync worker future is not finished, aborting"); + hdl.abort(); + } + Ok(resp) + } + } } #[cfg(target_arch = "wasm32")] @@ -346,6 +394,64 @@ mod test { } } + /// Regression test for https://github.com/dashpay/platform/issues/3432. + /// + /// `block_on` previously called `tokio::task::block_in_place` unconditionally, which + /// panics on a current-thread (single-threaded) tokio runtime. The fix detects the + /// runtime flavor and spawns a dedicated OS thread with its own runtime when running + /// on a current-thread scheduler. + /// + /// This test proves the fix works: the same async-sync-async nesting that used to + /// panic now completes successfully on a current-thread runtime. + #[test] + fn test_block_on_fails_on_current_thread_runtime() { + let rt = Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create current-thread Tokio runtime"); + + const MSGS: usize = 3; + let (tx, rx) = mpsc::channel::(1); + + let worker = async move { + for count in 0..MSGS { + tx.send(count).await.unwrap(); + } + }; + let worker_join = rt.spawn(worker); + + async fn innermost(mut rx: Receiver) -> Result { + for i in 0..MSGS { + let count = rx.recv().await.unwrap(); + assert_eq!(count, i); + } + Ok("Success".to_string()) + } + + fn sync_bridge(fut: F) -> Result + where + F: Future> + Send + 'static, + F::Output: Send, + { + block_on(fut)?.map_err(|e| ContextProviderError::Generic(e.to_string())) + } + + async fn outer(rx: Receiver) -> Result { + let result = innermost(rx).await; + sync_bridge(async { result }) + } + + let result = rt.block_on(outer(rx)); + + rt.block_on(worker_join).ok(); + + assert_eq!( + result.unwrap(), + "Success", + "block_on should succeed on a current-thread runtime" + ); + } + use crate::error::StaleNodeError; use rs_dapi_client::DapiClientError; From b17e14551947e7d8601283a5acf4b678a6ff65df Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:02:36 +0200 Subject: [PATCH 5/9] refactor(sdk): simplify block_on match by merging MultiThread into wildcard arm RuntimeFlavor is #[non_exhaustive], so _ already covers MultiThread and any future multi-threaded variants. Deduplicate the identical arms. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sync.rs | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/rs-sdk/src/sync.rs b/packages/rs-sdk/src/sync.rs index 6fb91f9ee2e..8b51f0159d8 100644 --- a/packages/rs-sdk/src/sync.rs +++ b/packages/rs-sdk/src/sync.rs @@ -60,8 +60,9 @@ impl From for crate::Error { /// /// Handles three scenarios: /// - No active runtime: creates a temporary current-thread runtime and drives the future directly. -/// - Current-thread runtime: spawns a dedicated OS thread with its own runtime (block_in_place panics here). -/// - Multi-thread runtime: uses block_in_place + spawn for efficient bridging. +/// - Current-thread runtime: spawns a dedicated OS thread with its own independent runtime, +/// since `block_in_place` panics when there are no other worker threads. +/// - Any other runtime flavor (multi-thread, etc.): uses `block_in_place` + spawn for efficient bridging. #[cfg(not(target_arch = "wasm32"))] pub fn block_on(fut: F) -> Result where @@ -102,21 +103,10 @@ where }); Ok(rx.recv()?) } - RuntimeFlavor::MultiThread => { - tracing::trace!("block_on: multi-thread runtime, using block_in_place"); - let (tx, rx) = std::sync::mpsc::channel(); - let hdl = handle.spawn(worker(fut, tx)); - let resp = tokio::task::block_in_place(|| rx.recv())?; - if !hdl.is_finished() { - tracing::debug!("async-sync worker future is not finished, aborting"); - hdl.abort(); - } - Ok(resp) - } - // RuntimeFlavor is non-exhaustive; treat any future variant the same as MultiThread. - #[allow(unreachable_patterns)] + // RuntimeFlavor is #[non_exhaustive]; all multi-threaded flavors (MultiThread, + // MultiThreadAlt, and any future variants) support block_in_place. _ => { - tracing::trace!("block_on: unknown runtime flavor, using block_in_place"); + tracing::trace!("block_on: multi-thread runtime, using block_in_place"); let (tx, rx) = std::sync::mpsc::channel(); let hdl = handle.spawn(worker(fut, tx)); let resp = tokio::task::block_in_place(|| rx.recv())?; From 3e48c8da1fcc6666664f4cb1d29f31d49847f129 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:14:20 +0200 Subject: [PATCH 6/9] feat(trusted-provider): add block_on helper mirroring dash_sdk::sync::block_on Copies the runtime-aware block_on utility into the trusted context provider so it is available for future use without a shared-crate dependency. Co-Authored-By: Claude Opus 4.6 --- .../src/provider.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index ec2dd1e6ac8..a915906eef4 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -545,6 +545,63 @@ impl TrustedHttpContextProvider { } } +/// Blocks on the provided future and returns the result. +/// +/// Handles three scenarios: +/// - No active runtime: creates a temporary current-thread runtime and drives the future directly. +/// - Current-thread runtime: spawns a dedicated OS thread with its own independent runtime, +/// since `block_in_place` panics when there are no other worker threads. +/// - Any other runtime flavor (multi-thread, etc.): uses `block_in_place` + spawn for efficient bridging. +#[cfg(not(target_arch = "wasm32"))] +fn block_on(fut: F) -> Result +where + F: std::future::Future + Send + 'static, + F::Output: Send, +{ + use tokio::runtime::RuntimeFlavor; + + let handle = match tokio::runtime::Handle::try_current() { + Ok(h) => h, + Err(_) => { + return tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| ContextProviderError::Generic(e.to_string())) + .map(|rt| rt.block_on(fut)); + } + }; + + match handle.runtime_flavor() { + RuntimeFlavor::CurrentThread => { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map(|rt| rt.block_on(fut)); + if let Ok(result) = result { + let _ = tx.send(result); + } + }); + rx.recv() + .map_err(|e| ContextProviderError::Generic(e.to_string())) + } + // RuntimeFlavor is #[non_exhaustive]; all multi-threaded flavors support block_in_place. + _ => { + let (tx, rx) = std::sync::mpsc::channel(); + let hdl = handle.spawn(async move { + let _ = tx.send(fut.await); + }); + let resp = tokio::task::block_in_place(|| rx.recv()) + .map_err(|e| ContextProviderError::Generic(e.to_string()))?; + if !hdl.is_finished() { + hdl.abort(); + } + Ok(resp) + } + } +} + impl ContextProvider for TrustedHttpContextProvider { fn get_quorum_public_key( &self, From 2a77e1c740b47120997b8edeb78ed5a34fc17d20 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:15:41 +0200 Subject: [PATCH 7/9] fix(sdk): preserve TryCurrentError context in block_on trace log Replace Err(_) with Err(e) and include the error in the trace message so the reason no runtime was found is not silently discarded. Also reverts the block_on copy added to trusted-context-provider. Co-Authored-By: Claude Opus 4.6 --- .../src/provider.rs | 57 ------------------- packages/rs-sdk/src/sync.rs | 4 +- 2 files changed, 2 insertions(+), 59 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index a915906eef4..ec2dd1e6ac8 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -545,63 +545,6 @@ impl TrustedHttpContextProvider { } } -/// Blocks on the provided future and returns the result. -/// -/// Handles three scenarios: -/// - No active runtime: creates a temporary current-thread runtime and drives the future directly. -/// - Current-thread runtime: spawns a dedicated OS thread with its own independent runtime, -/// since `block_in_place` panics when there are no other worker threads. -/// - Any other runtime flavor (multi-thread, etc.): uses `block_in_place` + spawn for efficient bridging. -#[cfg(not(target_arch = "wasm32"))] -fn block_on(fut: F) -> Result -where - F: std::future::Future + Send + 'static, - F::Output: Send, -{ - use tokio::runtime::RuntimeFlavor; - - let handle = match tokio::runtime::Handle::try_current() { - Ok(h) => h, - Err(_) => { - return tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| ContextProviderError::Generic(e.to_string())) - .map(|rt| rt.block_on(fut)); - } - }; - - match handle.runtime_flavor() { - RuntimeFlavor::CurrentThread => { - let (tx, rx) = std::sync::mpsc::sync_channel(1); - std::thread::spawn(move || { - let result = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map(|rt| rt.block_on(fut)); - if let Ok(result) = result { - let _ = tx.send(result); - } - }); - rx.recv() - .map_err(|e| ContextProviderError::Generic(e.to_string())) - } - // RuntimeFlavor is #[non_exhaustive]; all multi-threaded flavors support block_in_place. - _ => { - let (tx, rx) = std::sync::mpsc::channel(); - let hdl = handle.spawn(async move { - let _ = tx.send(fut.await); - }); - let resp = tokio::task::block_in_place(|| rx.recv()) - .map_err(|e| ContextProviderError::Generic(e.to_string()))?; - if !hdl.is_finished() { - hdl.abort(); - } - Ok(resp) - } - } -} - impl ContextProvider for TrustedHttpContextProvider { fn get_quorum_public_key( &self, diff --git a/packages/rs-sdk/src/sync.rs b/packages/rs-sdk/src/sync.rs index 8b51f0159d8..ff9cda77792 100644 --- a/packages/rs-sdk/src/sync.rs +++ b/packages/rs-sdk/src/sync.rs @@ -75,8 +75,8 @@ where let handle = match tokio::runtime::Handle::try_current() { Ok(h) => h, - Err(_) => { - tracing::trace!("block_on: no active runtime, creating temporary runtime"); + Err(e) => { + tracing::trace!("block_on: no active runtime ({e}), creating temporary runtime"); return Ok(tokio::runtime::Builder::new_current_thread() .enable_all() .build() From 52bac76fbb9d515abf294b841ffc2d80a17bf268 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:56:22 +0200 Subject: [PATCH 8/9] =?UTF-8?q?fix(sdk):=20address=20CodeRabbit=20review?= =?UTF-8?q?=20=E2=80=94=20propagate=20errors=20and=20clean=20up=20test=20t?= =?UTF-8?q?hreads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sync.rs: Send Result over channel in CurrentThread branch so runtime build failures and panics propagate as real errors instead of opaque RecvError. - provider.rs: Add oneshot shutdown signal to mock server, capture and join both thread handles so no detached threads or live runtimes survive the test. Co-Authored-By: Claude Opus 4.6 --- .../src/provider.rs | 54 ++++++++++++------- packages/rs-sdk/src/sync.rs | 15 +++--- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index ec2dd1e6ac8..1e47403c279 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -912,27 +912,36 @@ mod tests { .unwrap(); let server_port = listener.local_addr().unwrap().port(); - std::thread::spawn(move || { + // Shutdown signal so the server thread exits after the test completes. + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let server_handle = std::thread::spawn(move || { server_rt.block_on(async move { // Handle connections sequentially — sufficient for a test mock. - while let Ok((mut stream, _)) = listener.accept().await { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - let mut reader = BufReader::new(&mut stream); - let mut request_line = String::new(); - let _ = reader.read_line(&mut request_line).await; - let body = if request_line.contains("/quorums") - && !request_line.contains("/previous") - { - current_json.clone() - } else { - previous_json.clone() - }; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(response.as_bytes()).await; + loop { + tokio::select! { + accepted = listener.accept() => { + let Ok((mut stream, _)) = accepted else { break }; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let mut reader = BufReader::new(&mut stream); + let mut request_line = String::new(); + let _ = reader.read_line(&mut request_line).await; + let body = if request_line.contains("/quorums") + && !request_line.contains("/previous") + { + current_json.clone() + } else { + previous_json.clone() + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + } + _ = &mut shutdown_rx => break, + } } }); }); @@ -951,7 +960,7 @@ mod tests { // Run get_quorum_public_key inside a current-thread tokio runtime. // This is the exact scenario that deadlocked before the fix. - std::thread::spawn(move || { + let client_handle = std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -973,6 +982,11 @@ mod tests { let key = result.expect("get_quorum_public_key should return Ok"); assert_eq!(key, pubkey_bytes); + + // Clean up: signal server shutdown and join both threads. + let _ = shutdown_tx.send(()); + server_handle.join().expect("server thread panicked"); + client_handle.join().expect("client thread panicked"); } #[test] diff --git a/packages/rs-sdk/src/sync.rs b/packages/rs-sdk/src/sync.rs index ff9cda77792..9b84a61bc29 100644 --- a/packages/rs-sdk/src/sync.rs +++ b/packages/rs-sdk/src/sync.rs @@ -88,20 +88,23 @@ where match handle.runtime_flavor() { RuntimeFlavor::CurrentThread => { tracing::trace!("block_on: current-thread runtime, spawning dedicated OS thread"); - let (tx, rx) = std::sync::mpsc::sync_channel(1); - std::thread::spawn(move || { + let (tx, rx) = std::sync::mpsc::sync_channel::>(1); + let join_handle = std::thread::spawn(move || { let result = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|e| { tracing::error!("block_on: failed to create worker runtime: {}", e); + AsyncError::Generic(format!("failed to create worker runtime: {e}")) }) .map(|rt| rt.block_on(fut)); - if let Ok(result) = result { - let _ = tx.send(result); - } + let _ = tx.send(result); }); - Ok(rx.recv()?) + let recv_result = rx.recv()?; + join_handle + .join() + .map_err(|_| AsyncError::Generic("block_on worker thread panicked".to_string()))?; + recv_result } // RuntimeFlavor is #[non_exhaustive]; all multi-threaded flavors (MultiThread, // MultiThreadAlt, and any future variants) support block_in_place. From 0d7e82c450b30f7223369c638aa610f4a6c79bb7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:38:14 +0200 Subject: [PATCH 9/9] fix(sdk): move rt-multi-thread to dev-dependencies for WASM compatibility Production code only uses tokio::runtime::Builder::new_current_thread() which needs the "rt" feature alone. The "rt-multi-thread", "net", and "io-util" features are only used in tests, so move them to [dev-dependencies] to avoid breaking the wasm-sdk build. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk-trusted-context-provider/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/Cargo.toml b/packages/rs-sdk-trusted-context-provider/Cargo.toml index 2eb90b9bcd5..6107c8750ec 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -22,7 +22,7 @@ lru = "0.16.3" arc-swap = "1.7.1" hex = "0.4.3" url = "2.5" -tokio = { version = "1.40", features = ["rt", "rt-multi-thread"] } +tokio = { version = "1.40", features = ["rt"] } [features] default = [] @@ -47,4 +47,4 @@ token-history-contract = ["dpp/token-history-contract"] keywords-contract = ["dpp/keywords-contract"] [dev-dependencies] -tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "net", "io-util"] }