From 99ac609c3caa921649a1803446b334a959f4e63d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 23 Jun 2026 10:25:10 -0500 Subject: [PATCH 1/2] fix(swift-sdk): mirror PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE The Rust FFI gained `ErrorShutdownIncomplete = 19` so callers can know that `platform_wallet_manager_destroy` returned without proving every background coordinator thread exited. The Swift mirror was missing the case, so `init(ffi:)` fell through to `errorUnknown` and Swift callers had no way to tell the lifecycle-specific shutdown-incomplete from a generic unknown failure. Add `errorShutdownIncomplete = 19` to `PlatformWalletResultCode`, map `PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE` in `init(ffi:)`, and add a matching `PlatformWalletError.shutdownIncomplete` variant so the error sums round-trip cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PlatformWallet/PlatformWalletResult.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2c311f91e9..4bf06d778f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -39,6 +39,15 @@ 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 + /// 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. The manager IS torn down; do NOT retry `destroy`. + case errorShutdownIncomplete = 19 case notFound = 98 case errorUnknown = 99 @@ -82,6 +91,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedBroadcastUnconfirmed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_SPEND_UNCONFIRMED: self = .errorShieldedSpendUnconfirmed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE: + self = .errorShutdownIncomplete case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -177,6 +188,14 @@ 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) + /// `platform_wallet_manager_destroy` returned without proving that + /// every background coordinator OS thread 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. + case shutdownIncomplete(String) case notFound(String) case unknown(String) @@ -192,6 +211,7 @@ public enum PlatformWalletError: LocalizedError { .arithmeticOverflow(let m), .noSelectableInputs(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), + .shutdownIncomplete(let m), .notFound(let m), .unknown(let m): return m } @@ -222,6 +242,7 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) case .errorShieldedSpendUnconfirmed: self = .shieldedSpendUnconfirmed(detail) + case .errorShutdownIncomplete: self = .shutdownIncomplete(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } From c8576abb9648a68d2144109aa6f190068650be58 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 23 Jun 2026 10:29:08 -0500 Subject: [PATCH 2/2] fix(swift-sdk): retain callback context on errorShutdownIncomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `platform_wallet_manager_destroy` now returns `errorShutdownIncomplete` when one or more background coordinator OS threads did not exit before the 30 s join deadline. A lingering coordinator still holds an `Arc` / `Arc` whose context pointer is `Unmanaged.passUnretained(handler).toOpaque()` for the manager's `persistenceHandler` / `eventHandler` Swift objects — releasing those at `deinit` would dangle that pointer and the next callback would be a use-after-free, which is exactly the hazard the new result code was introduced to prevent. Capture the destroy result in `deinit` and, on `errorShutdownIncomplete`, move the handler references into a process-global retention slot so any final coordinator callback still resolves to live Swift memory. The clean path is unchanged: a `success` destroy releases the handlers as usual. The leak is bounded by the (rare) number of non-clean shutdowns and only retains two small objects per occurrence. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PlatformWalletManager.swift | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) 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