diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 33621de6756..55da319da58 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -9,8 +9,8 @@ import os.log /// generation already superseded by a `stop`/`clear`/`reset`, even when a /// restart happens in the same `@MainActor` turn (a plain boolean gate /// can't, because the restart re-opens the gate before the stale, -/// previously-enqueued completion task runs). Shared by the shielded and -/// platform-address sync paths. +/// previously-enqueued completion task runs). Shared by the shielded, +/// platform-address, and DPNS sync paths. final class SyncGenerationCounter: @unchecked Sendable { private let lock = NSLock() private var value: UInt64 = 0 @@ -83,6 +83,72 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { } } +/// Timing + FFI-result record of one native manager teardown, returned by +/// [`PlatformWalletManager/shutdown()`] so the host can log it (the SDK has +/// no dependency on any app-side logger). +/// +/// Deliberately NOT a worker-level shutdown report: the Rust FFI returns +/// `Success` for a live handle even when a worker missed its join budget +/// (that outcome is a Rust-side WARN, not an error), so Swift cannot know +/// clean-vs-timed-out per worker. These metrics report only what Swift +/// observes — the result code and wall time of each FFI call, and the +/// thread the teardown ran on. +public struct PlatformWalletShutdownMetrics: Sendable { + public struct Step: Sendable { + /// FFI entry point, e.g. "spv_stop", "destroy". + public let name: String + /// Raw `PlatformWalletResultCode` of the call (0 = success). + public let ffiCode: Int32 + public let milliseconds: Int + + public init(name: String, ffiCode: Int32, milliseconds: Int) { + self.name = name + self.ffiCode = ffiCode + self.milliseconds = milliseconds + } + } + + /// The six teardown calls in execution order (5× sync stop + destroy). + public let steps: [Step] + public let totalMilliseconds: Int + /// Whether the blocking native teardown ran off the main thread. The + /// whole point of `shutdown()` is that this is `true`. + public let ranOffMainThread: Bool + + public init(steps: [Step], totalMilliseconds: Int, ranOffMainThread: Bool) { + self.steps = steps + self.totalMilliseconds = totalMilliseconds + self.ranOffMainThread = ranOffMainThread + } +} + +/// Native entry points used by the production teardown orchestration. +/// +/// Keeping the functions injectable as a group lets tests exercise the real +/// ordering, timing, result mapping, and logging in +/// [`PlatformWalletManager.performNativeTeardown`] without calling Rust. The +/// unchecked conformance is intentional: these closures are immutable C-entry +/// wrappers, and the whole value is copied onto the dedicated destroy queue. +struct PlatformWalletNativeTeardownCalls: @unchecked Sendable { + typealias Call = @Sendable (Handle) -> PlatformWalletFFIResult + + let spvStop: Call + let platformAddressSyncStop: Call + let shieldedSyncStop: Call + let dashPaySyncStop: Call + let dpnsSyncStop: Call + let destroy: Call + + static let live = PlatformWalletNativeTeardownCalls( + spvStop: platform_wallet_manager_spv_stop, + platformAddressSyncStop: platform_wallet_manager_platform_address_sync_stop, + shieldedSyncStop: platform_wallet_manager_shielded_sync_stop, + dashPaySyncStop: platform_wallet_manager_dashpay_sync_stop, + dpnsSyncStop: platform_wallet_manager_dpns_sync_stop, + destroy: platform_wallet_manager_destroy + ) +} + /// The one thing SwiftUI needs for all wallet operations. /// /// Owns the Rust-side `PlatformWalletManager` handle which drives: @@ -216,6 +282,12 @@ public class PlatformWalletManager: ObservableObject { /// sync-status UI. nonisolated let platformAddressSyncGeneration = SyncGenerationCounter() + /// Generation guard for DPNS marketplace completion events. A native + /// completion can already be queued for the main actor when shutdown + /// begins; bumping this generation when the handle is consumed prevents + /// that trailing callback from publishing after the manager is stopped. + nonisolated let dpnsSyncGeneration = SyncGenerationCounter() + /// All wallets currently held by the Rust-side /// `PlatformWalletManager`, keyed by the 32-byte wallet id. /// @@ -271,6 +343,30 @@ public class PlatformWalletManager: ObservableObject { /// Background task that polls SPV progress. private var progressPollTask: Task? + /// The single in-flight (or completed) [`shutdown()`] operation. Set + /// exactly once by the first caller that takes a live handle; later + /// callers await the same task and receive the same metrics. MainActor + /// isolation serializes the check-and-set (no suspension point between + /// them), so no lock is needed. A shutdown before configuration remains + /// an uncached no-op because the manager can still be configured later. + private var shutdownTask: Task? + + /// Test seam for the individual native calls. Production keeps `.live`; + /// tests replace the function table while still running the production + /// teardown orchestration end-to-end. + internal var nativeTeardownCalls = PlatformWalletNativeTeardownCalls.live + + /// Dedicated serial queue for the blocking native teardown. The Rust + /// `destroy` runs `block_on(shutdown())` on the calling thread and can + /// legitimately take tens of seconds when an in-flight sync pass ignores + /// cancellation, so it must park a plain GCD thread — never the main + /// thread, and never a Swift Concurrency cooperative-pool thread (which + /// is why this is a DispatchQueue and not `Task.detached`). + nonisolated static let destroyQueue = DispatchQueue( + label: "org.dash.platform-wallet.destroy", + qos: .userInitiated + ) + // MARK: - Init /// Empty init for `@StateObject` usage. Call [`configure`] before @@ -285,28 +381,185 @@ public class PlatformWalletManager: ObservableObject { deinit { progressPollTask?.cancel() + // Emergency fallback ONLY. The supported teardown path is an explicit + // `await shutdown()` before dropping the last reference — it takes the + // handle exactly once and runs the blocking native teardown off-main + // deterministically. Reaching this branch means a code path dropped a + // configured manager without shutting it down; schedule the same + // teardown fire-and-forget on the dedicated queue rather than + // blocking whatever thread ARC happens to release on (the destroy's + // Rust-side `block_on(shutdown())` can take tens of seconds when a + // sync pass is wedged — running it inline here is the historical + // network-switch UI freeze). + // + // Safe without `self`: `Handle` is a registry key from a monotonic + // counter (never reused), destroying an already-removed handle is an + // Ok no-op, and Rust owns the persistence/event callback contexts + // (handed over retained at `configure`, with a `release_fn`) — a + // straggling worker keeps its handler alive through that retain and + // Rust releases it when the worker exits. if handle != NULL_HANDLE { - // Stop the network event source first as defense in depth for - // the teardown order; Rust's destroy path provides the - // authoritative join barrier. - platform_wallet_manager_spv_stop(handle).discard() - platform_wallet_manager_platform_address_sync_stop(handle).discard() - platform_wallet_manager_shielded_sync_stop(handle).discard() - platform_wallet_manager_dashpay_sync_stop(handle).discard() - platform_wallet_manager_dpns_sync_stop(handle).discard() - // Rust OWNS the persistence/event callback handlers (they were - // handed over retained at `configure`, with a `release_fn`): - // any worker that outlives destroy keeps its handler alive - // through that retain and Rust releases it when the worker - // exits. Nothing to leak, retain, or gate on here — ARC - // releasing this class's own references below is always safe. - let destroyResult = PlatformWalletResult(platform_wallet_manager_destroy(handle)) - if !destroyResult.isSuccess { + let h = handle + let calls = nativeTeardownCalls + Self.log.warning( + "PlatformWalletManager deallocated without shutdown(); scheduling fallback native teardown off-main for handle \(h, privacy: .public)" + ) + Self.destroyQueue.async { + _ = Self.performNativeTeardown(h, calls: calls) + } + } + } + + // MARK: - Shutdown + + /// Tear down the native manager without blocking the main thread. + /// + /// Takes ownership of the FFI handle exactly once on the main actor + /// (zeroing [`handle`] and flipping [`isConfigured`] so every later + /// operation fails fast through `ensureConfigured()`), then runs the + /// full native teardown — the same five sync stops plus + /// `platform_wallet_manager_destroy` the old `deinit` performed, in the + /// same order — on [`destroyQueue`]. The Rust destroy `block_on`s its + /// bounded lifecycle shutdown on that queue's thread, which can take + /// tens of seconds when an in-flight sync pass ignores cancellation; + /// the caller awaits a continuation instead of blocking. + /// + /// Idempotent: the first caller starts the teardown, every later caller + /// awaits the same task and receives the same metrics. Cancellation of + /// a calling task does not interrupt the teardown (`Task<_, Never>.value` + /// is a non-throwing await), so the native teardown always runs to + /// completion once started. + /// + /// Never throws by design: `platform_wallet_manager_destroy` returns + /// `Success` for a live handle even when a worker misses its join budget + /// (a Rust-side WARN, not an error), so a thrown error would carry no + /// actionable signal. Per-step FFI codes travel in the returned metrics + /// for the host to log. + @discardableResult + public func shutdown() async -> PlatformWalletShutdownMetrics { + if let task = shutdownTask { + return await task.value + } + guard handle != NULL_HANDLE else { + // Never configured (or a test double without a handle): nothing + // to tear down. Do not cache this no-op: a manager may still be + // configured later, and that live handle must then be torn down. + return PlatformWalletShutdownMetrics( + steps: [], + totalMilliseconds: 0, + ranOffMainThread: false) + } + + // Take-once: from this point every FFI entry gated on + // `ensureConfigured()` / `handle != NULL_HANDLE` rejects cleanly, + // and the generation bumps drop any trailing sync event the main + // actor delivers after this turn. + let h = handle + handle = NULL_HANDLE + isConfigured = false + progressPollTask?.cancel() + shieldedSyncGeneration.bump() + platformAddressSyncGeneration.bump() + dpnsSyncGeneration.bump() + + let calls = nativeTeardownCalls + let task = Task { + await withCheckedContinuation { (continuation: CheckedContinuation) in + Self.destroyQueue.async { + continuation.resume(returning: Self.performNativeTeardown(h, calls: calls)) + } + } + } + shutdownTask = task + return await task.value + } + + /// The blocking native teardown body, shared by [`shutdown()`] and the + /// `deinit` fallback: exactly the five sync stops plus destroy the old + /// synchronous `deinit` ran, in the same order, each timed and its FFI + /// code recorded. + /// + /// The stops are kept even though Rust's `shutdown()` inside destroy is + /// a superset — they preserve the historical teardown order as defense + /// in depth (`spv_stop` is itself a blocking, abort-escalating join, so + /// it too must run on this queue, never the main thread). Rust's destroy + /// path provides the authoritative join barrier. + nonisolated static func performNativeTeardown( + _ handle: Handle, + calls: PlatformWalletNativeTeardownCalls = .live + ) -> PlatformWalletShutdownMetrics { + let offMain = !Thread.isMainThread + let totalStart = CFAbsoluteTimeGetCurrent() + var steps: [PlatformWalletShutdownMetrics.Step] = [] + steps.reserveCapacity(6) + + func run(_ name: String, _ call: (Handle) -> PlatformWalletFFIResult) { + let start = CFAbsoluteTimeGetCurrent() + let result = PlatformWalletResult(call(handle)) + let ms = Int((CFAbsoluteTimeGetCurrent() - start) * 1000) + steps.append(.init(name: name, ffiCode: result.code.rawValue, milliseconds: ms)) + if !result.isSuccess { Self.log.error( - "Platform wallet manager teardown failed with \(String(describing: destroyResult.code), privacy: .public): \(destroyResult.message ?? "", privacy: .public)" + "native teardown step \(name, privacy: .public) failed with \(String(describing: result.code), privacy: .public): \(result.message ?? "", privacy: .public)" ) } } + + // Stop the network event source first as defense in depth for the + // teardown order; Rust's destroy path provides the authoritative + // join barrier. + run("spv_stop", calls.spvStop) + run("platform_address_sync_stop", calls.platformAddressSyncStop) + run("shielded_sync_stop", calls.shieldedSyncStop) + run("dashpay_sync_stop", calls.dashPaySyncStop) + run("dpns_sync_stop", calls.dpnsSyncStop) + // Rust OWNS the persistence/event callback handlers (handed over + // retained at `configure`, with a `release_fn`): any worker that + // outlives destroy keeps its handler alive through that retain and + // Rust releases it when the worker exits. Nothing to leak, retain, + // or gate on here. + run("destroy", calls.destroy) + + let metrics = PlatformWalletShutdownMetrics( + steps: steps, + totalMilliseconds: Int((CFAbsoluteTimeGetCurrent() - totalStart) * 1000), + ranOffMainThread: offMain + ) + let stepSummary = steps + .map { "\($0.name)=\($0.milliseconds)ms(code \($0.ffiCode))" } + .joined(separator: " ") + Self.log.info( + "native teardown finished in \(metrics.totalMilliseconds, privacy: .public)ms offMain=\(offMain, privacy: .public): \(stepSummary, privacy: .public)" + ) + return metrics + } + + /// Test-only factory: a manager carrying a fake non-null handle and an + /// injected native-call table, so shutdown tests exercise the real + /// take-once / exactly-once / idempotency and teardown-orchestration paths + /// without calling FFI. + /// Internal on purpose — never call from production code (`configure` + /// is the only production path that assigns a handle). + static func makeForTesting( + handle: Handle, + calls: PlatformWalletNativeTeardownCalls + ) -> PlatformWalletManager { + let manager = PlatformWalletManager() + try! manager.configureForTesting(handle: handle, calls: calls) + return manager + } + + /// Test-only equivalent of a successful native configuration. Keeping it + /// separate from the factory lets tests cover shutdown-before-configure. + func configureForTesting( + handle: Handle, + calls: PlatformWalletNativeTeardownCalls + ) throws { + try ensureConfigurationAllowed() + precondition(handle != NULL_HANDLE) + self.handle = handle + isConfigured = true + nativeTeardownCalls = calls } // MARK: - Configuration @@ -317,7 +570,7 @@ public class PlatformWalletManager: ObservableObject { /// Spawns a background task that polls SPV sync progress every /// second and publishes it to [`spvProgress`]. public func configure(sdk: SDK, modelContainer: ModelContainer? = nil) throws { - precondition(!isConfigured, "PlatformWalletManager already configured") + try ensureConfigurationAllowed() guard let sdkHandle = sdk.handle else { throw PlatformWalletError.invalidParameter("SDK has no handle") } @@ -344,6 +597,7 @@ public class PlatformWalletManager: ObservableObject { modelContainer: ModelContainer? = nil, network: Network? = nil ) throws { + try ensureConfigurationAllowed() var handle: Handle = NULL_HANDLE let handler: PlatformWalletPersistenceHandler? @@ -429,6 +683,19 @@ public class PlatformWalletManager: ObservableObject { startProgressPolling() } + /// A manager owns at most one configured native lifetime. A no-op shutdown + /// before first configuration is allowed, but once a live handle has been + /// consumed its cached shutdown result makes the instance terminal; a new + /// native handle must be owned by a new manager. + private func ensureConfigurationAllowed() throws { + precondition(!isConfigured, "PlatformWalletManager already configured") + guard shutdownTask == nil else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager cannot be configured after shutdown" + ) + } + } + /// Access the persistence handler for loading cached data. public var persistence: PlatformWalletPersistenceHandler? { persistenceHandler diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDpnsSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDpnsSync.swift index 892deac4926..414e470961c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDpnsSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDpnsSync.swift @@ -116,8 +116,14 @@ func dpnsMarketplaceSyncCompletedCallback( syncUnixSeconds: syncUnixSeconds, walletResults: results ) + + // Snapshot before the main-actor hop. Shutdown bumps the generation as it + // consumes the native handle, invalidating a completion Rust had already + // dispatched but Swift has not published yet. + let generation = handler.manager?.dpnsSyncGeneration.current() ?? 0 + Task { @MainActor [weak manager = handler.manager] in - manager?.handleDpnsSyncCompleted(event) + manager?.handleDpnsSyncCompleted(event, generation: generation) } } @@ -133,7 +139,11 @@ extension PlatformWalletManager { // cadence (60s) than DashPay's 15s because marketplace state changes // are rare. - func handleDpnsSyncCompleted(_ event: DpnsSyncEvent) { + func handleDpnsSyncCompleted(_ event: DpnsSyncEvent, generation: UInt64) { + // The generation rejects callbacks queued before shutdown. The + // configured-state check also rejects a callback native teardown + // dispatches after shutdown already bumped the counter. + guard isConfigured, generation == dpnsSyncGeneration.current() else { return } lastDpnsSyncEvent = event } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DpnsMarketplaceDecodingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DpnsMarketplaceDecodingTests.swift index da58c4ab955..68969574477 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DpnsMarketplaceDecodingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DpnsMarketplaceDecodingTests.swift @@ -178,6 +178,23 @@ final class DpnsMarketplaceDecodingTests: XCTestCase { @MainActor final class DpnsMarketplaceManagerWrapperTests: XCTestCase { + private nonisolated static func noOpTeardownCalls() -> PlatformWalletNativeTeardownCalls { + let success: PlatformWalletNativeTeardownCalls.Call = { _ in + PlatformWalletFFIResult( + code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, + message: nil + ) + } + return PlatformWalletNativeTeardownCalls( + spvStop: success, + platformAddressSyncStop: success, + shieldedSyncStop: success, + dashPaySyncStop: success, + dpnsSyncStop: success, + destroy: success + ) + } + private func assertInvalidHandle( file: StaticString = #filePath, line: UInt = #line, @@ -215,7 +232,10 @@ final class DpnsMarketplaceManagerWrapperTests: XCTestCase { } func testEventExtensionPublishesOwnedWalletResults() async { - let manager = PlatformWalletManager() + let manager = PlatformWalletManager.makeForTesting( + handle: 72, + calls: Self.noOpTeardownCalls() + ) let handler = PlatformWalletEventHandler(manager: manager) let eventExtension = handler.makeCallbacksExtension() XCTAssertEqual( @@ -255,5 +275,6 @@ final class DpnsMarketplaceManagerWrapperTests: XCTestCase { let event = manager.lastDpnsSyncEvent XCTAssertEqual(event?.syncUnixSeconds, 123) XCTAssertEqual(event?.result(for: walletId)?.errorMessage, "ephemeral error") + await manager.shutdown() } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DpnsSyncGenerationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DpnsSyncGenerationTests.swift new file mode 100644 index 00000000000..a431fa5c962 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DpnsSyncGenerationTests.swift @@ -0,0 +1,83 @@ +import XCTest +import DashSDKFFI +@testable import SwiftDashSDK + +/// Regression coverage for DPNS completion callbacks that cross the native +/// teardown → main-actor boundary. +@MainActor +final class DpnsSyncGenerationTests: XCTestCase { + + private nonisolated static func noOpCalls() -> PlatformWalletNativeTeardownCalls { + let success: PlatformWalletNativeTeardownCalls.Call = { _ in + PlatformWalletFFIResult( + code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, + message: nil + ) + } + return PlatformWalletNativeTeardownCalls( + spvStop: success, + platformAddressSyncStop: success, + shieldedSyncStop: success, + dashPaySyncStop: success, + dpnsSyncStop: success, + destroy: success + ) + } + + private func makeManager() -> PlatformWalletManager { + PlatformWalletManager.makeForTesting(handle: 71, calls: Self.noOpCalls()) + } + + private func makeEvent(_ timestamp: UInt64) -> DpnsSyncEvent { + DpnsSyncEvent(syncUnixSeconds: timestamp, walletResults: []) + } + + func testCurrentGenerationCompletionPublishesWhileConfigured() async { + let manager = makeManager() + let generation = manager.dpnsSyncGeneration.current() + + manager.handleDpnsSyncCompleted(makeEvent(1_000), generation: generation) + + XCTAssertEqual(manager.lastDpnsSyncEvent?.syncUnixSeconds, 1_000) + await manager.shutdown() + } + + func testStaleCompletionIsDroppedAfterGenerationBump() async { + let manager = makeManager() + let staleGeneration = manager.dpnsSyncGeneration.current() + manager.dpnsSyncGeneration.bump() + + manager.handleDpnsSyncCompleted(makeEvent(2_000), generation: staleGeneration) + + XCTAssertNil(manager.lastDpnsSyncEvent) + await manager.shutdown() + } + + /// A generation check alone is insufficient: native teardown can dispatch + /// a callback after shutdown has bumped the counter. Its snapshot then + /// matches the current generation, so terminal `isConfigured` must reject + /// it as well. + func testPostShutdownCurrentGenerationCompletionIsDropped() async { + let manager = makeManager() + let beforeShutdown = manager.dpnsSyncGeneration.current() + + await manager.shutdown() + + let afterShutdown = manager.dpnsSyncGeneration.current() + XCTAssertNotEqual(beforeShutdown, afterShutdown) + manager.handleDpnsSyncCompleted(makeEvent(3_000), generation: afterShutdown) + XCTAssertNil(manager.lastDpnsSyncEvent) + } + + func testStaleCompletionDoesNotOverwritePublishedEvent() async { + let manager = makeManager() + let staleGeneration = manager.dpnsSyncGeneration.current() + manager.handleDpnsSyncCompleted(makeEvent(4_000), generation: staleGeneration) + manager.dpnsSyncGeneration.bump() + + manager.handleDpnsSyncCompleted(makeEvent(5_000), generation: staleGeneration) + + XCTAssertEqual(manager.lastDpnsSyncEvent?.syncUnixSeconds, 4_000) + await manager.shutdown() + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift new file mode 100644 index 00000000000..081b070a955 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift @@ -0,0 +1,248 @@ +import XCTest +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for `PlatformWalletManager.shutdown()` — the explicit, off-main +/// replacement for the old synchronous `deinit` teardown. +/// +/// Tests inject the six individual native functions, not the teardown result. +/// The production `performNativeTeardown` orchestration therefore remains +/// under test: order, handle propagation, result mapping, off-main execution, +/// take-once behavior, and idempotency are all exercised without calling FFI. +@MainActor +final class PlatformWalletShutdownTests: XCTestCase { + + private final class TeardownRecorder: @unchecked Sendable { + private let lock = NSLock() + private let failingStep: String? + private let firstCallGate: DispatchSemaphore? + private var invocations: [(name: String, handle: Handle, ranOnMainThread: Bool)] = [] + + init(failingStep: String? = nil, firstCallGate: DispatchSemaphore? = nil) { + self.failingStep = failingStep + self.firstCallGate = firstCallGate + } + + func record(name: String, handle: Handle) -> PlatformWalletFFIResult { + if name == "spv_stop" { + firstCallGate?.wait() + } + lock.withLock { + invocations.append((name, handle, Thread.isMainThread)) + } + let code = name == failingStep + ? PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_INVALID_HANDLE + : PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS + return PlatformWalletFFIResult(code: code, message: nil) + } + + var names: [String] { lock.withLock { invocations.map(\.name) } } + var handles: [Handle] { lock.withLock { invocations.map(\.handle) } } + var mainThreadFlags: [Bool] { lock.withLock { invocations.map(\.ranOnMainThread) } } + func count(named name: String) -> Int { + lock.withLock { invocations.count { $0.name == name } } + } + } + + private static let expectedOrder = [ + "spv_stop", + "platform_address_sync_stop", + "shielded_sync_stop", + "dashpay_sync_stop", + "dpns_sync_stop", + "destroy", + ] + + private nonisolated static func makeCalls( + recorder: TeardownRecorder + ) -> PlatformWalletNativeTeardownCalls { + PlatformWalletNativeTeardownCalls( + spvStop: { recorder.record(name: "spv_stop", handle: $0) }, + platformAddressSyncStop: { + recorder.record(name: "platform_address_sync_stop", handle: $0) + }, + shieldedSyncStop: { recorder.record(name: "shielded_sync_stop", handle: $0) }, + dashPaySyncStop: { recorder.record(name: "dashpay_sync_stop", handle: $0) }, + dpnsSyncStop: { recorder.record(name: "dpns_sync_stop", handle: $0) }, + destroy: { recorder.record(name: "destroy", handle: $0) } + ) + } + + private func drainDestroyQueue() { + PlatformWalletManager.destroyQueue.sync {} + } + + // MARK: - Idempotency + + func testConcurrentShutdownRunsTeardownExactlyOnce() async { + let recorder = TeardownRecorder() + let manager = PlatformWalletManager.makeForTesting( + handle: 42, + calls: Self.makeCalls(recorder: recorder) + ) + + async let first = manager.shutdown() + async let second = manager.shutdown() + let (m1, m2) = await (first, second) + + XCTAssertEqual(recorder.names, Self.expectedOrder) + XCTAssertEqual(recorder.handles, Array(repeating: 42, count: 6)) + XCTAssertEqual(recorder.count(named: "destroy"), 1) + XCTAssertEqual(m1.totalMilliseconds, m2.totalMilliseconds) + XCTAssertEqual(m1.steps.map(\.name), m2.steps.map(\.name)) + + let third = await manager.shutdown() + XCTAssertEqual(recorder.names, Self.expectedOrder, "a late caller must not start teardown again") + XCTAssertEqual(third.totalMilliseconds, m1.totalMilliseconds) + } + + // MARK: - Take-once handle + + func testShutdownConsumesHandleExactlyOnce() async { + let recorder = TeardownRecorder() + let manager = PlatformWalletManager.makeForTesting( + handle: 7, + calls: Self.makeCalls(recorder: recorder) + ) + XCTAssertEqual(manager.handle, 7) + XCTAssertTrue(manager.isConfigured) + + await manager.shutdown() + + XCTAssertEqual(manager.handle, NULL_HANDLE) + XCTAssertFalse(manager.isConfigured) + XCTAssertThrowsError(try manager.ensureConfigured()) + XCTAssertEqual(recorder.count(named: "destroy"), 1) + } + + func testShutdownWithoutHandleIsANoOp() async { + let manager = PlatformWalletManager() + let metrics = await manager.shutdown() + XCTAssertTrue(metrics.steps.isEmpty) + let again = await manager.shutdown() + XCTAssertTrue(again.steps.isEmpty) + } + + func testShutdownBeforeConfigurationDoesNotSuppressLaterTeardown() async throws { + let recorder = TeardownRecorder() + let manager = PlatformWalletManager() + + let noOp = await manager.shutdown() + XCTAssertTrue(noOp.steps.isEmpty) + + try manager.configureForTesting( + handle: 17, + calls: Self.makeCalls(recorder: recorder) + ) + + let metrics = await manager.shutdown() + XCTAssertEqual(recorder.names, Self.expectedOrder) + XCTAssertEqual(recorder.handles, Array(repeating: 17, count: 6)) + XCTAssertEqual(metrics.steps.map(\.name), Self.expectedOrder) + } + + /// A completed real shutdown makes this manager terminal. Reconfiguration + /// must fail before another native handle or callback context is installed. + func testConfigurationAfterRealShutdownIsRejected() async { + let recorder = TeardownRecorder() + let calls = Self.makeCalls(recorder: recorder) + let manager = PlatformWalletManager.makeForTesting(handle: 17, calls: calls) + + let first = await manager.shutdown() + XCTAssertEqual(first.steps.map(\.name), Self.expectedOrder) + + XCTAssertThrowsError(try manager.configureForTesting(handle: 18, calls: calls)) { error in + guard let walletError = error as? PlatformWalletError else { + return XCTFail("unexpected error: \(error)") + } + guard case .invalidHandle(let message) = walletError else { + return XCTFail("expected invalidHandle, got \(walletError)") + } + XCTAssertTrue(message.contains("cannot be configured after shutdown")) + } + + XCTAssertEqual(manager.handle, NULL_HANDLE) + XCTAssertFalse(manager.isConfigured) + let repeated = await manager.shutdown() + XCTAssertEqual(repeated.steps.map(\.name), Self.expectedOrder) + XCTAssertEqual(recorder.names, Self.expectedOrder, "rejected configuration must not add teardown calls") + } + + // MARK: - Caller cancellation + + func testCallerCancellationDoesNotInterruptTeardown() async { + let gate = DispatchSemaphore(value: 0) + let recorder = TeardownRecorder(firstCallGate: gate) + let manager = PlatformWalletManager.makeForTesting( + handle: 9, + calls: Self.makeCalls(recorder: recorder) + ) + + let caller = Task { await manager.shutdown() } + caller.cancel() + gate.signal() + + let metrics = await caller.value + XCTAssertEqual(recorder.names, Self.expectedOrder) + XCTAssertEqual(recorder.count(named: "destroy"), 1) + XCTAssertEqual(metrics.steps.map(\.name), Self.expectedOrder) + } + + // MARK: - Deinit interplay + + func testDeinitAfterShutdownRunsNoSecondTeardown() async { + let recorder = TeardownRecorder() + var manager: PlatformWalletManager? = PlatformWalletManager.makeForTesting( + handle: 11, + calls: Self.makeCalls(recorder: recorder) + ) + + await manager?.shutdown() + XCTAssertEqual(recorder.names, Self.expectedOrder) + + manager = nil + drainDestroyQueue() + XCTAssertEqual(recorder.names, Self.expectedOrder) + XCTAssertEqual(recorder.count(named: "destroy"), 1) + } + + func testDeinitFallbackRunsTeardownOffMainExactlyOnce() { + let recorder = TeardownRecorder() + var manager: PlatformWalletManager? = PlatformWalletManager.makeForTesting( + handle: 13, + calls: Self.makeCalls(recorder: recorder) + ) + withExtendedLifetime(manager) {} + + manager = nil + drainDestroyQueue() + + XCTAssertEqual(recorder.names, Self.expectedOrder) + XCTAssertEqual(recorder.mainThreadFlags, Array(repeating: false, count: 6)) + XCTAssertEqual(recorder.handles, Array(repeating: 13, count: 6)) + XCTAssertEqual(recorder.count(named: "destroy"), 1) + } + + // MARK: - Production orchestration + + /// The injected calls still run through `performNativeTeardown`, proving + /// its exact order and association of each native result with its metric. + func testNativeTeardownOrderAndResultMapping() async { + let recorder = TeardownRecorder(failingStep: "shielded_sync_stop") + let manager = PlatformWalletManager.makeForTesting( + handle: 21, + calls: Self.makeCalls(recorder: recorder) + ) + + let metrics = await manager.shutdown() + + XCTAssertEqual(recorder.names, Self.expectedOrder) + XCTAssertEqual(recorder.handles, Array(repeating: 21, count: 6)) + XCTAssertEqual(metrics.steps.map(\.name), Self.expectedOrder) + XCTAssertEqual( + metrics.steps.map(\.ffiCode), + [0, 0, PlatformWalletResultCode.errorInvalidHandle.rawValue, 0, 0, 0] + ) + XCTAssertTrue(metrics.ranOffMainThread) + } +}