diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index b5551dae598..b9f9014c3f7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -782,6 +782,24 @@ public class PlatformWalletManager: ObservableObject { qos: .userInitiated ) + /// Runs the blocking native stop behind the async [`stopSpv()`]. The + /// Rust stop waits for the SPV run loop to finish its current sync tick + /// and drain its tasks — up to its 15 s budget plus a 2 s abort grace — + /// so it must park a plain GCD thread: never the main thread, and never + /// a Swift Concurrency cooperative-pool thread. Per-manager, not + /// [`destroyQueue`]: a slow SPV stop must not hold up other managers' + /// creates, loads and teardowns on that process-wide queue. Internal so + /// the SPV extension can dispatch to it. + let spvStopQueue = DispatchQueue( + label: "org.dash.platform-wallet.spv-stop", + qos: .userInitiated + ) + + /// Async SPV stops between admission and completion. + /// [`startSpv(config:)`] refuses to start while one is in flight. + /// Internal so the SPV extension can maintain it. + var spvStopsInFlight = 0 + // MARK: - Init /// Empty init for `@StateObject` usage. Call [`configure`] before diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift index 4fa271e5bb1..be69b8f66b0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift @@ -272,7 +272,15 @@ extension PlatformWalletManager { /// /// Spawns the sync loop on the shared tokio runtime and returns /// immediately. Use [`stopSpv`] to cancel. + /// + /// Throws `walletOperation` while an async [`stopSpv()`] is still + /// tearing the previous client down: the main actor is free during that + /// stop, so a start issued meanwhile would otherwise race it. public func startSpv(config: PlatformSpvStartConfig) throws { + guard spvStopsInFlight == 0 else { + throw PlatformWalletError.walletOperation( + "SPV stop in progress; await stopSpv() before starting SPV") + } // Peer array: allocate contiguous C strings. let peerCStrings: [UnsafeMutablePointer?] = config.peers.map { strdup($0) } defer { peerCStrings.forEach { if let p = $0 { free(p) } } } @@ -310,6 +318,45 @@ extension PlatformWalletManager { try platform_wallet_manager_spv_stop(handle).check() } + /// Off-main variant of [`stopSpv()`]: the same native stop, run on + /// [`spvStopQueue`] instead of the calling thread. In an `async` context + /// overload resolution prefers this variant; sync contexts keep the sync + /// one. + /// + /// The native stop waits for the SPV run loop to finish its current sync + /// tick and drain its tasks — up to its 15 s budget plus a 2 s abort + /// grace — so on the main actor the blocking variant freezes the UI for + /// that long. + /// + /// Admitted like the other async native entry points: [`shutdown()`] + /// waits for an in-flight stop before destroying the handle, and a stop + /// requested once a shutdown has begun throws `invalidHandle` before any + /// native work. [`startSpv(config:)`] throws while a stop is in flight. + public func stopSpv() async throws { + // Admission and the in-flight count change on the main actor with no + // suspension in between, so neither `shutdown()`'s drain nor + // `startSpv`'s guard can miss this stop. + try admitNativeOp("stopSpv") + defer { finishNativeOp() } + spvStopsInFlight += 1 + defer { spvStopsInFlight -= 1 } + + let h = handle + let stop = nativeTeardownCalls.spvStop + // Map the result on the queue: the raw result's Rust-owned message + // never crosses the continuation. + let failure: PlatformWalletError? = await withCheckedContinuation { continuation in + spvStopQueue.async { + let result = PlatformWalletResult(stop(h)) + continuation.resume( + returning: result.isSuccess ? nil : PlatformWalletError(result: result)) + } + } + if let failure { + throw failure + } + } + /// Clear all persisted SPV storage (headers, filters, state). public func clearSpvStorage() throws { try platform_wallet_manager_spv_clear_storage(handle).check() diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletStopSpvTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletStopSpvTests.swift new file mode 100644 index 00000000000..fa262756350 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletStopSpvTests.swift @@ -0,0 +1,192 @@ +import XCTest +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the async off-main `stopSpv()` overload. +/// +/// Tests inject the native SPV stop through the teardown table, so the +/// production orchestration stays under test: the native stop runs off the +/// main thread, a start is refused while it runs, `shutdown()` waits for an +/// in-flight stop before tearing the manager down, and a stop after shutdown +/// is rejected before any native work. +@MainActor +final class PlatformWalletStopSpvTests: XCTestCase { + + private final class EventLog: @unchecked Sendable { + private let lock = NSLock() + private var entries: [String] = [] + func append(_ event: String) { lock.withLock { entries.append(event) } } + var events: [String] { lock.withLock { entries } } + } + + /// Fake native SPV stop: records which thread ran it and, for the first + /// call only, blocks on `gate` so a test can act while the stop is in + /// flight. Later calls (the teardown's own SPV stop step) return at once. + private final class StopRecorder: @unchecked Sendable { + private let lock = NSLock() + private let gate: DispatchSemaphore? + private let eventLog: EventLog + private var mainThreadFlags: [Bool] = [] + + init(gate: DispatchSemaphore?, eventLog: EventLog) { + self.gate = gate + self.eventLog = eventLog + } + + func stop() -> PlatformWalletFFIResult { + let isFirst = lock.withLock { () -> Bool in + mainThreadFlags.append(Thread.isMainThread) + return mainThreadFlags.count == 1 + } + eventLog.append("spv_stop:begin") + if isFirst { gate?.wait() } + eventLog.append("spv_stop:end") + return PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) + } + + var count: Int { lock.withLock { mainThreadFlags.count } } + var ranOnMainThread: [Bool] { lock.withLock { mainThreadFlags } } + } + + private nonisolated static func makeTeardownCalls( + recorder: StopRecorder, + log: EventLog + ) -> PlatformWalletNativeTeardownCalls { + func step(_ name: String) -> PlatformWalletNativeTeardownCalls.Call { + { _ in + log.append("teardown:\(name)") + return PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) + } + } + return PlatformWalletNativeTeardownCalls( + spvStop: { _ in recorder.stop() }, + platformAddressSyncStop: step("platform_address_sync_stop"), + shieldedSyncStop: step("shielded_sync_stop"), + dashPaySyncStop: step("dashpay_sync_stop"), + dpnsSyncStop: step("dpns_sync_stop"), + destroy: step("destroy") + ) + } + + private func makeManager(handle: Handle, recorder: StopRecorder, log: EventLog) -> PlatformWalletManager { + PlatformWalletManager.makeForTesting( + handle: handle, + calls: Self.makeTeardownCalls(recorder: recorder, log: log)) + } + + private func waitUntilStopStarted(_ recorder: StopRecorder) async throws { + while recorder.count == 0 { + try await Task.sleep(for: .milliseconds(5)) + } + } + + // MARK: - Off-main execution + + /// The native stop runs off the main thread, and the main actor keeps + /// running work while that stop is blocked — this test's own polling + /// loop runs on the main actor for the whole blocked window. + func testStopRunsOffMainWhileTheMainActorStaysFree() async throws { + let gate = DispatchSemaphore(value: 0) + let log = EventLog() + let recorder = StopRecorder(gate: gate, eventLog: log) + let manager = makeManager(handle: 21, recorder: recorder, log: log) + + let stopTask = Task { try await manager.stopSpv() } + try await waitUntilStopStarted(recorder) + + var mainActorTurns = 0 + for _ in 0..<3 { + try await Task.sleep(for: .milliseconds(5)) + mainActorTurns += 1 + } + XCTAssertEqual(mainActorTurns, 3, "the main actor must stay free while the native stop blocks") + XCTAssertFalse(log.events.contains("spv_stop:end"), "the native stop must still be blocked") + + gate.signal() + try await stopTask.value + + XCTAssertEqual(recorder.ranOnMainThread, [false], "the native stop must run off the main thread") + await manager.shutdown() + } + + // MARK: - Start while stopping + + /// With the main actor free during the stop, a start issued meanwhile + /// must be refused rather than race the teardown of the old client. + func testStartSpvIsRefusedWhileAnAsyncStopIsInFlight() async throws { + let gate = DispatchSemaphore(value: 0) + let log = EventLog() + let recorder = StopRecorder(gate: gate, eventLog: log) + let manager = makeManager(handle: 22, recorder: recorder, log: log) + + let stopTask = Task { try await manager.stopSpv() } + try await waitUntilStopStarted(recorder) + + XCTAssertThrowsError( + try manager.startSpv(config: PlatformSpvStartConfig(dataDir: "/tmp/unused", network: .testnet)) + ) { error in + guard case PlatformWalletError.walletOperation = error else { + return XCTFail("expected walletOperation, got \(error)") + } + } + + gate.signal() + try await stopTask.value + XCTAssertEqual(manager.spvStopsInFlight, 0, "the in-flight count must be released after the stop") + await manager.shutdown() + } + + // MARK: - Shutdown interplay + + /// A shutdown during an in-flight stop waits for it: every teardown step + /// except the early shielded stop runs after the admitted SPV stop ends. + func testShutdownWaitsForAnInFlightStopBeforeTearingDown() async throws { + let gate = DispatchSemaphore(value: 0) + let log = EventLog() + let recorder = StopRecorder(gate: gate, eventLog: log) + let manager = makeManager(handle: 23, recorder: recorder, log: log) + + let stopTask = Task { try await manager.stopSpv() } + try await waitUntilStopStarted(recorder) + + let shutdownTask = Task { await manager.shutdown() } + try await Task.sleep(for: .milliseconds(30)) + XCTAssertFalse( + log.events.contains("teardown:destroy"), + "shutdown must not destroy the handle while an admitted stop runs") + + gate.signal() + try await stopTask.value + let metrics = await shutdownTask.value + + let afterShieldedStop = log.events.filter { $0 != "teardown:shielded_sync_stop" } + XCTAssertEqual( + afterShieldedStop.prefix(2), ["spv_stop:begin", "spv_stop:end"], + "unexpected event order: \(log.events)") + XCTAssertEqual(afterShieldedStop.last, "teardown:destroy") + XCTAssertEqual(metrics.steps.count, 6) + } + + /// A stop requested after shutdown is rejected up front, before any + /// native work. + func testStopAfterShutdownThrowsWithoutInvokingNativeCall() async { + let log = EventLog() + let recorder = StopRecorder(gate: nil, eventLog: log) + let manager = makeManager(handle: 24, recorder: recorder, log: log) + + await manager.shutdown() + let stopsDuringTeardown = recorder.count + + do { + try await manager.stopSpv() + XCTFail("expected a throw after shutdown") + } catch let error as PlatformWalletError { + guard case .invalidHandle = error else { + return XCTFail("expected invalidHandle, got \(error)") + } + } catch { + XCTFail("unexpected error: \(error)") + } + XCTAssertEqual(recorder.count, stopsDuringTeardown, "the native stop must never be reached after shutdown") + } +}