Skip to content

fix(swift-sdk): stop SPV off the main thread - #4994

Draft
llbartekll wants to merge 1 commit into
v4.2-devfrom
fix/swift-sdk-async-stop-spv
Draft

llbartekll wants to merge 1 commit into
v4.2-devfrom
fix/swift-sdk-async-stop-spv

Conversation

@llbartekll

@llbartekll llbartekll commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

PlatformWalletManager.stopSpv() blocks its caller for as long as the native stop takes. That stop waits for the SPV run loop to finish its current sync tick and drain its tasks. In dash-spv, DashSpvClient::stop() waits for the sync_coordinator lock, which the run() loop holds for a whole tick, and then up to TASK_JOIN_TIMEOUT (5 s) for tasks to exit. rs-platform-wallet bounds the whole stop at 15 s plus a 2 s abort grace.

Dash Wallet iOS calls stopSpv() from the main actor on every network switch, and the UI froze for 10–16 s. A sample of the app during a switch had 2962 of 2967 main-thread samples inside PlatformWalletManager.stopSpv() → platform_wallet_manager_spv_stop → block_on. The dash-spv log for the same stop shows Shutting down SyncCoordinator 10 s after the stop request, then Shutdown timeout after 5s, 1 tasks may not have completed cleanly.

What was done?

  • Async stopSpv() alongside the existing sync one. This follows the async createWallet / loadFromPersistor pattern: in an async context overload resolution picks it; sync contexts keep the sync variant.
    • It runs the same native stop (through the existing nativeTeardownCalls.spvStop seam) on a new per-manager serial spvStopQueue. That is a plain GCD thread, not the main thread and not the cooperative pool.
    • It deliberately does not use destroyQueue: that queue is process-wide, and a 15 s stop there would hold up other managers' creates, loads and teardowns.
  • Admitted like the other async native entry points (admitNativeOp / finishNativeOp). shutdown() waits for an in-flight stop before destroying the handle, and a stop requested once shutdown has begun throws invalidHandle before any native work.
  • startSpv(config:) throws walletOperation while an async stop is in flight. The main actor is no longer blocked during the stop, so without this guard a start issued meanwhile would race it.
  • The result is mapped on the queue, so the Rust-owned message never crosses the continuation.

Existing callers are unchanged. SwiftExampleApp and IntegrationTestEnv call the sync stopSpv() inside sync closures and keep the sync overload; the integration tests that already try await it now get the async one.

Not in scope: how long the dash-spv stop itself takes (the tick that holds the coordinator lock, the 5 s task join). That belongs in rust-dashcore.

How Has This Been Tested?

  • New SwiftTests/SwiftDashSDKTests/PlatformWalletStopSpvTests.swift. It uses makeForTesting with a gated fake spvStop, the same approach as PlatformWalletCreateWalletTests:
    • the native stop runs off the main thread while the main actor keeps running;
    • startSpv is refused while an async stop is in flight;
    • shutdown() during an in-flight stop waits for it: every teardown step except the early shielded stop runs after the stop ends;
    • stopSpv() after shutdown() throws invalidHandle without the native call.
  • Run with xcodebuild test -scheme SwiftDashSDK on an iOS 26.5 simulator, together with PlatformWalletCreateWalletTests and PlatformWalletLoadFromPersistorTests: 20/20 passed.
    • Mutation check: with the startSpv guard and the admission removed, 3 of the 4 new tests fail.
    • Not run locally: swift test on macOS. The disk could not fit a cold darwin build of the FFI, so CI will be the first macOS / Swift 6 run.
  • Pending: a simulator smoke with Dash Wallet iOS (fix(ios): stop Core SPV off the main thread dashwallet-ios#1154) — a network switch with a sample of the main thread and the app's main-thread stall monitor.

Breaking Changes

None. The async overload is an addition, and the new startSpv refusal can only trigger while an async stop is running, which was impossible before.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed
  • If I added or changed GroveDB structure, I described it in the area's structure.rs, regenerated grovedb-structure.json, and checked the structure viewer link posted on this pull request

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

`PlatformWalletManager.stopSpv()` blocks its caller for as long as the
native stop takes, and that stop waits for the SPV run loop to finish its
current sync tick and drain its tasks: up to the 15 s stop budget plus a
2 s abort grace. Called from the main actor (as Dash Wallet iOS does during
a network switch), it froze the UI for 10–16 s. A `sample` of a switch
showed ~100% of main-thread samples inside `platform_wallet_manager_spv_stop`.

Add an async `stopSpv()` overload (the same pattern as the async
`createWallet` / `loadFromPersistor`). It runs the same native stop on a
new per-manager serial queue instead of the calling thread:
- admitted like the other async native entry points, so `shutdown()` waits
  for an in-flight stop before destroying the handle, and a stop requested
  once shutdown has begun throws `invalidHandle` before any native work;
- `startSpv(config:)` throws `walletOperation` while an async stop is in
  flight, since the main actor is no longer blocked during the stop and a
  start issued meanwhile would race it;
- not the process-wide `destroyQueue`, so a slow stop does not hold up
  other managers' creates, loads and teardowns.

Existing sync callers keep the sync overload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

Copy link
Copy Markdown
Collaborator

🕓 Review not started yet because this PR is a draft.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit 65209c1. Normal review starts when eligible; priority review starts as soon as a slot is available.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants