diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 0e433d368e..2bfae8c149 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -155,10 +155,47 @@ public class PlatformWalletManager: ObservableObject { if handle != NULL_HANDLE { platform_wallet_manager_platform_address_sync_stop(handle).discard() platform_wallet_manager_shielded_sync_stop(handle).discard() - platform_wallet_manager_destroy(handle).discard() + // `destroy` joins every coordinator OS thread with a 30 s + // deadline. On `errorShutdownIncomplete` one or more + // coordinators are still alive and still hold the + // `Arc` / `Arc` whose context + // pointer is `Unmanaged.passUnretained(handler).toOpaque()` + // for the Swift handler objects below — freeing those Swift + // objects now would dangle that pointer and the next + // callback would be a use-after-free. Stash them in a + // process-global leak slot so any final callback still + // sees valid memory. We accept the bounded leak (two small + // objects per non-clean shutdown); a clean shutdown returns + // success and the handlers are released as usual. + let destroyResult = PlatformWalletResult( + platform_wallet_manager_destroy(handle) + ) + if destroyResult.code == .errorShutdownIncomplete { + PlatformWalletManager._leakedContextLock.lock() + if let h = persistenceHandler { + PlatformWalletManager._leakedContext.append(h) + } + if let h = eventHandler { + PlatformWalletManager._leakedContext.append(h) + } + PlatformWalletManager._leakedContextLock.unlock() + } } } + /// Lock guarding `_leakedContext`. Both are `nonisolated` so the + /// (implicitly nonisolated) `deinit` can append to the leak slot + /// without an isolation hop. + nonisolated static let _leakedContextLock = NSLock() + /// Process-global retention list for persister / event-handler + /// objects we cannot release at `deinit` because a lingering Rust + /// coordinator thread (`errorShutdownIncomplete` from `destroy`) + /// may still callback through their `Unmanaged.passUnretained` + /// context pointer. `AnyObject` keeps the existential opaque so + /// `PlatformWalletPersistenceHandler` / `PlatformWalletEventHandler` + /// don't need to be `Sendable` to be retained here. + nonisolated(unsafe) static var _leakedContext: [AnyObject] = [] + // MARK: - Configuration /// Configure the manager with an SDK and an optional SwiftData diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 31ef07ad4a..c8ddfd2b8f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -39,11 +39,18 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// outcome. Do NOT auto-retry — a retry would rebuild the bundle and /// could double-execute if the original landed. case errorShieldedSpendUnconfirmed = 18 - /// A destroy/stop/clear completed but a background coordinator did not - /// exit cleanly (timed out or ended non-cleanly). The host should defer - /// freeing its callback context — a lingering coordinator may still fire - /// one final callback through it — and, on the clear path, must NOT - /// commit its own persistence wipe (the Rust store was left intact). + /// A destroy / stop / clear completed but one or more background + /// coordinator threads did not exit cleanly before the FFI's 30 s + /// join deadline. The host MUST keep its callback context (the + /// persister + event-handler Swift objects whose + /// `Unmanaged.passUnretained` opaque pointers were handed to Rust) + /// alive past `platform_wallet_manager_destroy` — a lingering + /// coordinator thread still holds an `Arc` / + /// `Arc` and may fire one final callback through + /// that context. On the clear path, the host must also NOT commit + /// its own persistence wipe — the Rust store was left intact so it + /// can be retried once the pass settles. The manager IS torn down; + /// do NOT retry `destroy`. case errorShutdownIncomplete = 19 case notFound = 98 case errorUnknown = 99 @@ -185,11 +192,16 @@ public enum PlatformWalletError: LocalizedError { /// notes reserved wallet-side (a shield reserves nothing) until the /// next sync reconciles the outcome. Do NOT auto-retry. case shieldedSpendUnconfirmed(String) - /// A destroy / stop / clear completed but a background coordinator did - /// not exit cleanly. The host should defer freeing its callback context - /// (a lingering coordinator may still fire one final callback) and, on - /// the clear path, must NOT commit its own persistence wipe — the Rust - /// store was left intact so it can be retried once the pass settles. + /// A destroy / stop / clear completed but a background coordinator + /// OS thread did not exit cleanly — `platform_wallet_manager_destroy` + /// returned without proving every coordinator had exited. The manager + /// is torn down, but the host must not free the Swift-side persister / + /// event-handler context until that thread has had a chance to wind + /// down — `PlatformWalletManager.deinit` retains those handlers in a + /// process-global leak slot on this code so a final, lingering Rust + /// callback finds valid memory. On the clear path, the host must also + /// NOT commit its own persistence wipe — the Rust store was left + /// intact so it can be retried once the pass settles. case shutdownIncomplete(String) case notFound(String) case unknown(String)