From 0a5bafa2ea185bd9cf07a35057e601e2228d1548 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 7 Jul 2026 07:09:39 +0700 Subject: [PATCH 1/2] fix(platform-wallet): poll Sync Now FFI passes on big-stack threads to stop SIGBUS crash The shielded, platform-address, and identity-token sync_now FFI entry points polled their whole sync future via runtime().block_on on the host's calling thread. iOS dispatch/concurrency threads have ~512 KB of stack, and the notes-sync / proof-verification recursion now exceeds it, so tapping "Sync Now" under Shielded Sync Status aborted the app with SIGBUS "Thread stack size exceeded" (reported by TestFlight users on the latest build; reproduced on-sim with a matching crash log). Move the polling off the calling thread the same way dashpay_sync already does: block_on_worker dispatches the pass onto the runtime's 8 MB-stack workers. The platform-address pass can't satisfy block_on_worker's Send + 'static bounds (rustc issue #100013), so add a run_on_big_stack_thread escape hatch that creates and polls the future entirely on a scoped 8 MB OS thread. Verified on-sim against wallets with 1,142 synced notes: repeated Shielded/DashPay/Platform Sync Now taps complete without crashing. Co-Authored-By: Claude Fable 5 --- .../src/identity_sync.rs | 9 +++++-- .../src/platform_address_sync.rs | 14 +++++++++-- .../rs-platform-wallet-ffi/src/runtime.rs | 25 +++++++++++++++++++ .../src/shielded_sync.rs | 18 ++++++++++--- 4 files changed, 58 insertions(+), 8 deletions(-) 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..69041abd39d 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,7 +190,17 @@ 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); PlatformWalletFFIResult::ok() diff --git a/packages/rs-platform-wallet-ffi/src/runtime.rs b/packages/rs-platform-wallet-ffi/src/runtime.rs index 965ef9f5ed5..9df0c53bc67 100644 --- a/packages/rs-platform-wallet-ffi/src/runtime.rs +++ b/packages/rs-platform-wallet-ffi/src/runtime.rs @@ -60,6 +60,31 @@ 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. +/// +/// 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. +pub(crate) fn run_on_big_stack_thread(f: impl FnOnce() -> T + Send) -> T { + std::thread::scope(|scope| { + std::thread::Builder::new() + .name("pw-ffi-bigstack".into()) + .stack_size(WORKER_STACK_BYTES) + .spawn_scoped(scope, f) + .expect("failed to spawn big-stack FFI thread") + .join() + .expect("big-stack FFI thread panicked") + }) +} + #[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 { From b97316fddb41906f2daf460d83cf795f606b383b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 7 Jul 2026 12:55:24 +0700 Subject: [PATCH 2/2] fix(platform-wallet): surface big-stack thread spawn failure as FFI error Review follow-up: run_on_big_stack_thread now returns io::Result instead of panicking on spawn failure, so the extern "C" address-sync entry point reports OS resource pressure through PlatformWalletFFIResult rather than aborting the host. A panic inside the closure still propagates (same convention as block_on_worker). Adds unit tests for the return-value round-trip and deep recursion past host-thread stack sizes. Co-Authored-By: Claude Fable 5 --- .../src/platform_address_sync.rs | 10 +++- .../rs-platform-wallet-ffi/src/runtime.rs | 53 ++++++++++++++++--- 2 files changed, 54 insertions(+), 9 deletions(-) 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 69041abd39d..e8fcf9febcb 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs @@ -200,9 +200,15 @@ pub unsafe extern "C" fn platform_wallet_manager_platform_address_sync_sync_now( // `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 9df0c53bc67..ee96010db00 100644 --- a/packages/rs-platform-wallet-ffi/src/runtime.rs +++ b/packages/rs-platform-wallet-ffi/src/runtime.rs @@ -62,7 +62,9 @@ where /// 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. +/// 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 @@ -73,18 +75,55 @@ where /// 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. -pub(crate) fn run_on_big_stack_thread(f: impl FnOnce() -> T + Send) -> T { +/// +/// 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| { - std::thread::Builder::new() + let handle = std::thread::Builder::new() .name("pw-ffi-bigstack".into()) .stack_size(WORKER_STACK_BYTES) - .spawn_scoped(scope, f) - .expect("failed to spawn big-stack FFI thread") - .join() - .expect("big-stack FFI thread panicked") + .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;