Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CChar>?] = config.peers.map { strdup($0) }
defer { peerCStrings.forEach { if let p = $0 { free(p) } } }
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading