diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
index df1b4701cf..a3269e2319 100644
--- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs
+++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
@@ -34,7 +34,7 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::Arc;
+use std::sync::{Arc, Mutex};
use dashcore::blockdata::transaction::{txout::TxOut, OutPoint};
use dashcore::ScriptBuf;
@@ -294,10 +294,16 @@ async fn run_wallet_event_adapter
(
P: PlatformWalletPersistence + 'static,
{
tracing::debug!("wallet-event adapter task started");
- let mut fault = AdapterFaultState::default();
+ // Both live behind handles rather than as locals because the commit runs
+ // on a blocking thread (see the `spawn_blocking` below) and has to be able
+ // to carry its state across drains. Moving them into the closure by value
+ // would lose a wallet's frozen watermark if that thread ever panicked —
+ // un-freezing a wallet that failed verification is the one outcome the
+ // fail-closed guard exists to prevent.
+ let fault = Arc::new(Mutex::new(AdapterFaultState::default()));
// One-shot latch so the hard "watermark frozen" line hits logcat exactly
// once per session rather than once per faulted batch.
- let mut freeze_logged = false;
+ let freeze_logged = Arc::new(AtomicBool::new(false));
loop {
// Block for the first event of a batch. Everything already sitting in
@@ -360,14 +366,118 @@ async fn run_wallet_event_adapter
(
// Commit the folded batch. The channel is lossless, so the only way a
// watermark is held back is a rejected `store()` (the fail-closed
// backstop inside `commit_batch`).
- let diag = commit_batch(
- &*persister,
- batch,
- folded,
- &mut fault,
- &sync_fault,
- &mut freeze_logged,
- );
+ // Commit on a blocking thread, never on the async worker.
+ //
+ // `store()` is synchronous and, for the SQLite backend, commits a real
+ // transaction per call — its own docs warn that a slow write blocks
+ // every other wallet accessor for its duration. Called inline here it
+ // blocked a tokio worker instead: a field restore showed one drain of
+ // 512 folded events park the runtime long enough for the metrics tick
+ // covering it to report a 1.4s mean poll, with the whole sync stalled
+ // for minutes at a time and the durable watermark left hundreds of
+ // thousands of blocks behind the chain tip.
+ //
+ // The handle is awaited rather than raced against `cancel`: a store
+ // that has started must be allowed to finish, and dropping the handle
+ // would not stop the thread anyway. Shutdown is observed at the next
+ // `recv` instead.
+ // Captured before the batch moves into the closure: if the commit
+ // thread panics, these are the wallets whose rows have an unknown fate
+ // and whose watermark must therefore be frozen.
+ let batch_wallet_ids: Vec = batch.keys().copied().collect();
+ // Filled by `commit_batch` as each wallet's `store()` returns. Lives
+ // out here so a panicking commit thread cannot take it down with it:
+ // what it holds is the difference between "this wallet's rows are
+ // accounted for" and "nobody knows".
+ let settled: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let settled_for_commit = Arc::clone(&settled);
+ let persister_for_commit = Arc::clone(&persister);
+ let sync_fault_for_commit = Arc::clone(&sync_fault);
+ let fault_for_commit = Arc::clone(&fault);
+ let freeze_for_commit = Arc::clone(&freeze_logged);
+ let committed = tokio::task::spawn_blocking(move || {
+ // The lock is uncontended by construction — this task is the only
+ // writer, and one drain commits at a time — so it never blocks;
+ // it exists to carry the state, not to arbitrate.
+ let mut fault = fault_for_commit
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let mut freeze_logged = freeze_for_commit.load(Ordering::Relaxed);
+ let mut settled = settled_for_commit
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let diag = commit_batch(
+ &*persister_for_commit,
+ batch,
+ folded,
+ &mut fault,
+ &sync_fault_for_commit,
+ &mut freeze_logged,
+ &mut settled,
+ );
+ freeze_for_commit.store(freeze_logged, Ordering::Relaxed);
+ diag
+ })
+ .await;
+
+ let diag = match committed {
+ Ok(diag) => diag,
+ // The commit thread panicked, so `commit_batch` never reached the
+ // `store()` rejection arm that would have frozen the affected
+ // wallets. Freeze them here instead.
+ //
+ // Before this call moved off the runtime a panic unwound the whole
+ // adapter task, which stopped every later watermark advance by
+ // killing the writer. `spawn_blocking` turns that into a recoverable
+ // `JoinError`, and simply continuing would let the NEXT batch
+ // persist a higher `synced_height` for a wallet whose rows from this
+ // batch may never have landed — the exact hole the fail-closed rule
+ // exists to prevent. Faulting per wallet rather than stopping the
+ // adapter keeps the existing design: a wallet whose commit is in
+ // doubt freezes, its siblings keep syncing.
+ Err(join_error) => {
+ // `commit_batch` walks the batch serially, so a panic partitions
+ // it: wallets whose `store()` already returned are settled — their
+ // rows were accepted or rejected, and a rejection already faulted
+ // them from inside. Freezing those too would strip a healthy
+ // wallet's watermark for the rest of the session over a sibling's
+ // bad batch.
+ //
+ // What is left — the wallet that panicked, plus every wallet the
+ // loop never reached — has no known outcome, and its events are
+ // gone from the lossless channel. Those must freeze, or a later
+ // batch advances their watermark past rows that may never have
+ // landed.
+ let settled_ids = settled
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .iter()
+ .copied()
+ .collect::>();
+ let unsettled: Vec = batch_wallet_ids
+ .iter()
+ .copied()
+ .filter(|id| !settled_ids.contains(id))
+ .collect();
+ {
+ let mut fault = fault
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ for wallet_id in &unsettled {
+ fault.fault_wallet(*wallet_id, &sync_fault);
+ }
+ }
+ tracing::error!(
+ error = %join_error,
+ folded,
+ settled = settled_ids.len(),
+ frozen = unsettled.len(),
+ "wallet-event commit thread failed; freezing the wallets whose \
+ rows have an unknown outcome"
+ );
+ continue;
+ }
+ };
// One structured line per drain via the `log` facade so a tester
// logcat is unambiguous about whether the watermark is advancing.
@@ -411,6 +521,7 @@ fn commit_batch
(
fault: &mut AdapterFaultState,
sync_fault: &AtomicBool,
freeze_logged: &mut bool,
+ settled: &mut Vec,
) -> BatchDiagnostics
where
P: PlatformWalletPersistence + ?Sized,
@@ -459,7 +570,15 @@ where
asset_locks: (!Merge::is_empty(&asset_locks)).then_some(asset_locks),
..PlatformWalletChangeSet::default()
};
- match persister.store(wallet_id, cs) {
+ let store_result = persister.store(wallet_id, cs);
+ // Recorded only once the store has RETURNED, and whether it accepted
+ // or rejected — both are answers. A wallet whose store panicked never
+ // reaches this line, and one the loop never got to is never pushed at
+ // all, so what is missing from `settled` is exactly the set whose
+ // outcome the caller cannot reason about. See the `JoinError` arm in
+ // `run_wallet_event_adapter`.
+ settled.push(wallet_id);
+ match store_result {
Ok(()) => {
if let Some(h) = offered_height {
diag.record_persisted(h);
@@ -1880,6 +1999,17 @@ mod tests {
struct ProbePersister {
obs: UnboundedSender,
fail_once: Mutex>,
+ /// Wallets whose NEXT `store()` panics instead of returning. Models a
+ /// backend that dies mid-write — the case that used to unwind the whole
+ /// adapter task and now surfaces as a `JoinError`.
+ panic_once: Mutex>,
+ /// Held closed to keep a `store()` call parked. The SQLite backend
+ /// commits a real transaction per call, so a slow disk parks the caller
+ /// for real; this makes that duration controllable.
+ block_until: Mutex