diff --git a/packages/rs-platform-wallet-ffi/src/identity_sync.rs b/packages/rs-platform-wallet-ffi/src/identity_sync.rs index 36bbdfb7115..a05a543185c 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_sync.rs @@ -18,7 +18,7 @@ use platform_wallet::{IdentityTokenSyncInfo, IdentityTokenSyncState}; use crate::error::*; use crate::handle::*; -use crate::runtime::runtime; +use crate::runtime::{block_on_worker, runtime}; use crate::{check_ptr, unwrap_option_or_return}; /// Flattened per-(identity, token) row mirroring @@ -170,7 +170,12 @@ pub unsafe extern "C" fn platform_wallet_manager_identity_sync_sync_now( handle: Handle, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - runtime().block_on(manager.identity_sync().sync_now()); + // `block_on_worker`, NOT `runtime().block_on`: the pass + // verifies GroveDB proofs whose recursion blows the ~512 KB + // stack of the host's dispatch/concurrency calling thread + // (same SIGBUS as the shielded/dashpay Sync Now buttons). + let mgr = manager.identity_sync_arc(); + block_on_worker(async move { mgr.sync_now().await }); }); unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() diff --git a/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs b/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs index cfd87cfee0f..e8fcf9febcb 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs @@ -8,7 +8,7 @@ use dash_sdk::platform::address_sync::AddressSyncMetrics; use crate::error::*; use crate::handle::*; use crate::platform_address_types::AddressSyncConfigFFI; -use crate::runtime::runtime; +use crate::runtime::{run_on_big_stack_thread, runtime}; use crate::{check_ptr, unwrap_option_or_return}; /// Flattened sync metrics for one wallet result in a platform-address sync pass. @@ -190,9 +190,25 @@ pub unsafe extern "C" fn platform_wallet_manager_platform_address_sync_sync_now( handle: Handle, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - runtime().block_on(manager.platform_address_sync().sync_now()); + // Big-stack polling, NOT bare `runtime().block_on`: the pass + // verifies GroveDB proofs whose recursion blows the ~512 KB + // stack of the host's dispatch/concurrency calling thread + // (same SIGBUS as the shielded/dashpay Sync Now buttons). + // `run_on_big_stack_thread` rather than `block_on_worker` + // because this future trips rustc's implied-lifetime-bound + // limitation (rust-lang/rust issue #100013) against + // `block_on_worker`'s `Send + 'static` bounds. + run_on_big_stack_thread(|| { + runtime().block_on(manager.platform_address_sync().sync_now()); + }) }); - unwrap_option_or_return!(option); + let spawn_result = unwrap_option_or_return!(option); + if let Err(e) = spawn_result { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("failed to spawn big-stack thread for address sync: {e}"), + ); + } PlatformWalletFFIResult::ok() } diff --git a/packages/rs-platform-wallet-ffi/src/runtime.rs b/packages/rs-platform-wallet-ffi/src/runtime.rs index 965ef9f5ed5..ee96010db00 100644 --- a/packages/rs-platform-wallet-ffi/src/runtime.rs +++ b/packages/rs-platform-wallet-ffi/src/runtime.rs @@ -60,6 +60,70 @@ where rt.block_on(async move { rt.spawn(future).await.expect("tokio worker panicked") }) } +/// Run `f` to completion on a freshly spawned scoped OS thread with the +/// same 8 MB stack the runtime workers get, blocking the caller until it +/// returns. Errors (instead of panicking) if the OS refuses to spawn +/// the thread, so `extern "C"` callers can surface the failure through +/// their `PlatformWalletFFIResult` rather than aborting the host. +/// +/// Escape hatch for call sites that need big-stack polling but whose +/// future cannot satisfy [`block_on_worker`]'s `Send + 'static` bounds +/// (e.g. rustc's implied-lifetime-bound limitation, rust-lang/rust +/// issue #100013). The closure typically wraps +/// `runtime().block_on(...)` — the future is then created *and* polled +/// entirely on the big-stack thread, so no `Send`/`'static` proof is +/// needed for the future itself. Prefer [`block_on_worker`] where it +/// compiles: it reuses pooled runtime workers instead of paying a +/// thread spawn per call. +/// +/// A panic inside `f` is propagated as a panic here, matching +/// [`block_on_worker`]'s "tokio worker panicked" convention — a panic +/// in the pass is a bug, not a recoverable condition. +pub(crate) fn run_on_big_stack_thread(f: impl FnOnce() -> T + Send) -> std::io::Result { + std::thread::scope(|scope| { + let handle = std::thread::Builder::new() + .name("pw-ffi-bigstack".into()) + .stack_size(WORKER_STACK_BYTES) + .spawn_scoped(scope, f)?; + Ok(handle.join().expect("big-stack FFI thread panicked")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_on_big_stack_thread_round_trips_return_value() { + let out = run_on_big_stack_thread(|| 41 + 1).expect("spawn should succeed"); + assert_eq!(out, 42); + } + + /// The whole point of the helper: recursion far past the ~512 KB + /// host-thread stacks (and the 2 MB default test-thread stack) + /// must complete on the 8 MB thread. + #[test] + fn run_on_big_stack_thread_survives_deep_recursion() { + #[inline(never)] + fn recurse(depth: u32) -> u64 { + // ~1 KB frame the optimizer can't elide. + let frame = std::hint::black_box([depth as u64; 128]); + if depth == 0 { + frame[0] + } else { + recurse(depth - 1) + std::hint::black_box(frame[127]) + } + } + + // ~1000 frames * >1 KB each (debug frames run several KB with + // the black_box copies) lands well past the ~512 KB iOS host + // stacks this helper exists for, while staying comfortably + // under WORKER_STACK_BYTES. + let out = run_on_big_stack_thread(|| recurse(1_000)).expect("spawn should succeed"); + assert!(out > 0); + } +} + #[cfg(feature = "tokio-metrics")] mod metrics { use std::time::Duration; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 2d58d8165f6..2424e0cc2d0 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -20,7 +20,7 @@ use zeroize::Zeroizing; use crate::error::*; use crate::handle::*; use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; -use crate::runtime::runtime; +use crate::runtime::{block_on_worker, runtime}; use crate::shielded_types::ShieldedSyncWalletResultFFI; use crate::{check_ptr, unwrap_option_or_return}; use rs_sdk_ffi::{ @@ -170,7 +170,15 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( handle: Handle, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - runtime().block_on(manager.shielded_sync().sync_now(true)); + let mgr = manager.shielded_sync_arc(); + // `block_on_worker`, NOT `runtime().block_on`: the host calls + // this from a dispatch/concurrency thread with ~512 KB of + // stack, and polling the whole notes-sync future there blows + // it (SIGBUS "Thread stack size exceeded" observed on-device + // and on-sim 2026-07-07 from the Sync Now button). The worker + // dispatch moves the compute onto the runtime's 8 MB-stack + // threads (see runtime.rs) — same fix as dashpay_sync. + block_on_worker(async move { mgr.sync_now(true).await }); }); unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() @@ -548,9 +556,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_wallet( let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { // Per-wallet sync_wallet is exclusively a user-initiated - // entry point — same `force=true` reasoning as + // entry point — same `force=true` reasoning and same + // `block_on_worker` stack-size requirement as // `platform_wallet_manager_shielded_sync_sync_now`. - runtime().block_on(manager.shielded_sync().sync_wallet(&wallet_id, true)) + let mgr = manager.shielded_sync_arc(); + block_on_worker(async move { mgr.sync_wallet(&wallet_id, true).await }) }); let result = unwrap_option_or_return!(option); match result {