diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 33d002060..d54ece214 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 026B0383557D0181E034B64E /* SDKIdentityProfileSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E62262597A8B22870184A36 /* SDKIdentityProfileSheet.swift */; }; 02EDCC7635BCD84768554D9C /* StorageModelListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A762BDA473947F1A7EF199 /* StorageModelListViews.swift */; }; 02EED2FC7EAC55B96ECEAAD6 /* DWLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 59C18FCF827D815007BAEF29 /* DWLogger.m */; }; + 02EED2FC7EAC55B96ECEAB01 /* MainThreadStallMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59C18FCF827D815007BAEB01 /* MainThreadStallMonitor.swift */; }; 0308E777B29CC6DE1DB30FDD /* PlatformSyncStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A58BF094C42E410DD1A71B6 /* PlatformSyncStatusScreen.swift */; }; 06132521E7B4D21B4A1597E7 /* PlatformSendExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DCDC3A2B92601BB1E61FD9 /* PlatformSendExecutor.swift */; }; 06E16A070A3C48883BBDA137 /* BIP70InteractiveCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36BCF99A4D30077C9C9B0EB /* BIP70InteractiveCoordinator.swift */; }; @@ -665,6 +666,7 @@ 630227C0414D5C74BA95BB8F /* BIP70Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94606903417F75BE99D4B31C /* BIP70Error.swift */; }; 63869E343191C55BDBBEED72 /* SwiftDashSDKInsightClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7BE8E4519203200CDFB460 /* SwiftDashSDKInsightClient.swift */; }; 66B50619EC898EEE8C8FE13E /* DWLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 59C18FCF827D815007BAEF29 /* DWLogger.m */; }; + 66B50619EC898EEE8C8FEB01 /* MainThreadStallMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59C18FCF827D815007BAEB01 /* MainThreadStallMonitor.swift */; }; 6715F4CB33E5F472FEC43B7E /* DWParsedPaymentURI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24DC91C11E382E22D1C17F05 /* DWParsedPaymentURI.swift */; }; 6C4AA7FD67FEE5A9E5CF9781 /* WalletsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76AB0835FF44EA88F36991E0 /* WalletsViewModel.swift */; }; 6D5D6EFD5F40D11B2FB6E8A1 /* PaymentsLandingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */; }; @@ -2853,6 +2855,7 @@ 52B5469F5588395FA0668C4B /* SwapTransactionStatusHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapTransactionStatusHostingController.swift; sourceTree = ""; }; 5822AAB9B5EA605FF82B009A /* BIP70PaymentOutputFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BIP70PaymentOutputFactory.swift; sourceTree = ""; }; 59C18FCF827D815007BAEF29 /* DWLogger.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = DWLogger.m; sourceTree = ""; }; + 59C18FCF827D815007BAEB01 /* MainThreadStallMonitor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainThreadStallMonitor.swift; sourceTree = ""; }; 59C9D64AB542D03FE3704D02 /* ShieldedWithdrawalStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShieldedWithdrawalStore.swift; sourceTree = ""; }; 5A1EC0FE2E29A10000000001 /* ShieldedActivityHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldedActivityHistory.swift; sourceTree = ""; }; 5A1EC0FE2E29A20000000011 /* PlatformAddressActivityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformAddressActivityStore.swift; sourceTree = ""; }; @@ -6177,6 +6180,7 @@ B1700000B1700000B1700001 /* NetworkStatusService.swift */, 7E31813A79237F4E037063AA /* SwiftDashSDK */, 59C18FCF827D815007BAEF29 /* DWLogger.m */, + 59C18FCF827D815007BAEB01 /* MainThreadStallMonitor.swift */, E70A780D1543343311C16402 /* Authentication */, 7FA744C4A6023E26A3B5B860 /* DWLogger.h */, 0448A6769BCF20987BE9960C /* DiagnosticLogExporter.swift */, @@ -10304,6 +10308,7 @@ E5B6555D068FA44028784652 /* SwiftDashSDKWalletState.swift in Sources */, DABF195F497554C7CFE816CA /* WalletEnvironment.swift in Sources */, 02EED2FC7EAC55B96ECEAAD6 /* DWLogger.m in Sources */, + 02EED2FC7EAC55B96ECEAB01 /* MainThreadStallMonitor.swift in Sources */, 4B8A9492829B622FCDAF4F19 /* SecureTimeService.swift in Sources */, B56469D438692B0A0B771EA6 /* PinPromptView.swift in Sources */, A6AAB271A1ADA69C272DA1D4 /* PinPromptPresenter.swift in Sources */, @@ -11329,6 +11334,7 @@ BCBED608CAAABA10839A0ADE /* SwiftDashSDKWalletState.swift in Sources */, BB779AE4D9B43803EAF0CB6F /* WalletEnvironment.swift in Sources */, 66B50619EC898EEE8C8FE13E /* DWLogger.m in Sources */, + 66B50619EC898EEE8C8FEB01 /* MainThreadStallMonitor.swift in Sources */, 94CE86155CB8D4DBCB18F6F7 /* SecureTimeService.swift in Sources */, EB070F38F6A474B4A1A7489F /* PinPromptView.swift in Sources */, 3116F910F54D310002090CBB /* PinPromptPresenter.swift in Sources */, diff --git a/DashWallet/AppDelegate.m b/DashWallet/AppDelegate.m index b912345ef..151e20cd0 100644 --- a/DashWallet/AppDelegate.m +++ b/DashWallet/AppDelegate.m @@ -110,6 +110,11 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( } [DWLogger sharedInstance]; +#if DEBUG + // Etap-C diagnostic: logs main-runloop stalls >=250ms through DWLogger + // so hangs can be attributed to bootstrap stages by timestamp. + [DWMainThreadStallMonitor start]; +#endif /* DEBUG */ [FIRApp configure]; [ExploreDashObjcWrapper configure]; [CurrencyExchangerObjcWrapper startExchangeRateFetching]; diff --git a/DashWallet/Sources/Infrastructure/MainThreadStallMonitor.swift b/DashWallet/Sources/Infrastructure/MainThreadStallMonitor.swift new file mode 100644 index 000000000..8ac5762a9 --- /dev/null +++ b/DashWallet/Sources/Infrastructure/MainThreadStallMonitor.swift @@ -0,0 +1,83 @@ +// +// Created by Dash Core Group. +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// DEBUG-only main-runloop stall detector backing the wallet-lifecycle +/// performance work: it turns "the spinner froze" into a `DWLogger` line with +/// a duration, in the same rolling file the stage timings go to, so stalls +/// can be attributed to a bootstrap stage by timestamp. +/// +/// Design: a single ping-pong. The monitor thread posts ONE block to the main +/// queue and parks on a semaphore until the main thread runs it; the measured +/// round-trip IS the stall duration (a healthy runloop answers in +/// microseconds). The next ping is only posted after the previous one was +/// answered, plus a fixed pause — so at most one ping is ever pending, a +/// single long stall produces a single log line after it resolves (never a +/// burst of queued-up reports), and the log line can only underestimate a +/// stall by at most the inter-ping pause. +/// +/// Thresholds follow the Instruments/MetricKit hang convention: report from +/// 250 ms (microhang); the etap-C acceptance gate reads ≥500 ms as a hang. +@objc(DWMainThreadStallMonitor) +final class MainThreadStallMonitor: NSObject { + /// Latencies below this are a healthy runloop; from here up they are + /// logged. Matches the Instruments "microhang" floor. + static let reportThreshold: TimeInterval = 0.25 + /// Pause between an answered ping and the next one. Bounds both the + /// sampling overhead and the maximum underestimate of a stall. + static let interPingPause: TimeInterval = 0.1 + + /// Pure classification, unit-testable without threads: milliseconds to + /// report for a measured ping round-trip, or nil when the latency is + /// below the reporting threshold. + static func stallMilliseconds(forLatency latency: TimeInterval) -> Int? { + guard latency >= reportThreshold else { return nil } + return Int(latency * 1000) + } + + private static var started = false + + /// Idempotent; a no-op outside DEBUG builds. Called once from + /// `application(_:didFinishLaunchingWithOptions:)`. + @objc static func start() { + #if DEBUG + dispatchPrecondition(condition: .onQueue(.main)) + guard !started else { return } + started = true + + let thread = Thread { + while true { + let semaphore = DispatchSemaphore(value: 0) + let pinged = CFAbsoluteTimeGetCurrent() + DispatchQueue.main.async { + semaphore.signal() + } + semaphore.wait() + let latency = CFAbsoluteTimeGetCurrent() - pinged + if let ms = stallMilliseconds(forLatency: latency) { + DWLogger.log("⏱️ MAINSTALL ~\(ms)ms") + } + Thread.sleep(forTimeInterval: interPingPause) + } + } + thread.name = "org.dashfoundation.dash.main-stall-monitor" + thread.qualityOfService = .utility + thread.start() + #endif + } +} diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift index dabf5b77a..c7b2f4379 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift @@ -456,7 +456,7 @@ final class SwiftDashSDKHost { let resolvedWallet: ManagedPlatformWallet Self.logger.info("🪺 HOST :: stage 4/4 restoring wallet for \(network.rawValue, privacy: .public)") do { - resolvedWallet = try loadPersistedWallet(manager: handles.manager, network: network) + resolvedWallet = try await loadPersistedWallet(manager: handles.manager, network: network) } catch HostError.walletNotFound { // Reinstall recovery (C6-C): the SwiftData store dies with the app // but WalletStorage mnemonics live in the keychain — rebuild the @@ -677,8 +677,13 @@ final class SwiftDashSDKHost { targetManager = manager isTemporary = false } else { + // Wall-clock only (this line always runs on the MainActor); + // per-stage thread attribution lives in the stage 1-4 logs. + let prepStarted = CFAbsoluteTimeGetCurrent() (targetManager, isTemporary) = try await managerForStoredWalletOperation( network: targetNetwork) + let prepMs = Int((CFAbsoluteTimeGetCurrent() - prepStarted) * 1000) + DWLogger.log("HOST mirror-prep for \(targetNetwork.rawValue) total \(prepMs)ms") } do { _ = try await createAndPersist( @@ -894,14 +899,59 @@ final class SwiftDashSDKHost { await stopAsync() } - return try makeRuntime(for: network) + return try await makeRuntime(for: network) + } + + /// Dedicated queue parking the blocking SDK construction + /// (`dash_sdk_create_trusted`: tokio runtime + TLS + DapiClient build, + /// ~1-2s). A plain GCD queue, never `Task.detached` — the blocking FFI + /// would park a cooperative-pool thread. Serial on purpose: runtime + /// bootstraps are already serialized by the lifecycle queue, so + /// concurrency here would buy nothing. + private nonisolated static let sdkBuildQueue = DispatchQueue( + label: "org.dashfoundation.dash.sdk-build", + qos: .userInitiated) + + /// Build the `SDK` off the main thread and hand it back at the + /// suspension point. Safe because `SDK` is `@unchecked Sendable`, its + /// init touches nothing main-bound (one blocking FFI plus thread-safe + /// UserDefaults reads), and after the continuation resumes the instance + /// is only ever used from the MainActor. A thrown init constructs no + /// object; once built, `SDK.deinit` releases the native handle if a + /// later bootstrap stage throws. + private nonisolated static func buildSDKOffMain( + network: Network, + platformVersion: UInt32 + ) async throws -> SDK { + try await withCheckedThrowingContinuation { continuation in + sdkBuildQueue.async { + let started = CFAbsoluteTimeGetCurrent() + do { + let sdk = try SDK(network: network, platformVersion: platformVersion) + let ms = Int((CFAbsoluteTimeGetCurrent() - started) * 1000) + // Logged HERE, on the build queue, so offMain= is + // evidence of where the work actually ran — a log after + // the await would always print from the MainActor. + DWLogger.log("HOST stage 1/4 SDK created for \(network.rawValue) in \(ms)ms offMain=\(!Thread.isMainThread)") + continuation.resume(returning: sdk) + } catch { + continuation.resume(throwing: error) + } + } + } } /// Build a configured manager/container pair without replacing the /// published app runtime. Full-device wipe uses this for the inactive /// network so each network-scoped SwiftData store is deleted through a /// manager configured for that same network. - private func makeRuntime(for network: Network) throws -> RuntimeHandles { + /// + /// Async since etap C: stage 1 (SDK construction) runs on + /// [`sdkBuildQueue`] instead of blocking the MainActor; stages 2-4 + /// (ModelContainer, configure, and the caller's loadFromPersistor) + /// stay on the MainActor — their measured cost decides whether they + /// ever follow (see the stage timing logs). + private func makeRuntime(for network: Network) async throws -> RuntimeHandles { guard network != .regtest else { throw HostError.unsupportedNetwork(network) } @@ -912,16 +962,11 @@ final class SwiftDashSDKHost { let newSDK: SDK do { let platformVersion = Self.platformVersion(for: network) - // Timed because it is main-thread work: SDK creation prefetches - // quorums over the network (~1-2s observed). Known stage-1 - // limitation — the switch overlay covers it; the measurement is - // the data for deciding whether to move it off-main later. - let started = CFAbsoluteTimeGetCurrent() - newSDK = try SDK(network: network, platformVersion: platformVersion) - let ms = Int((CFAbsoluteTimeGetCurrent() - started) * 1000) + newSDK = try await Self.buildSDKOffMain( + network: network, + platformVersion: platformVersion) Self.logger.info( "🪺 HOST :: stage 1/4 SDK created for \(network.rawValue, privacy: .public), protocol \(platformVersion == 0 ? "auto-detect" : "pinned v\(platformVersion)", privacy: .public)") - DWLogger.log("HOST stage 1/4 SDK created for \(network.rawValue) in \(ms)ms") } catch { Self.logger.error("🪺 HOST :: SDK init failed: \(String(describing: error), privacy: .public)") throw HostError.sdkInitFailed(error) @@ -930,11 +975,18 @@ final class SwiftDashSDKHost { let container: ModelContainer do { Self.logger.info("🪺 HOST :: stage 2/4 obtaining ModelContainer for \(network.rawValue, privacy: .public)") + // Timed for the same reason as stage 1: main-thread work whose + // real cost decides whether it ever needs to move off-main. The + // cached (reused) path should be ~0ms; only the first build of a + // network's container in the process pays the store-open cost. + let started = CFAbsoluteTimeGetCurrent() let cached = try modelContainerCache.value(for: network.networkName) { try buildModelContainer(for: network) } + let ms = Int((CFAbsoluteTimeGetCurrent() - started) * 1000) container = cached.value Self.logger.info("🪺 HOST :: stage 2/4 ModelContainer \(cached.reused ? "reused" : "created", privacy: .public) for \(network.rawValue, privacy: .public)") + DWLogger.log("HOST stage 2/4 ModelContainer \(cached.reused ? "reused" : "created") for \(network.rawValue) in \(ms)ms") } catch { Self.logger.error("🪺 HOST :: ModelContainer build failed: \(String(describing: error), privacy: .public)") throw HostError.modelContainerFailed(error) @@ -943,9 +995,11 @@ final class SwiftDashSDKHost { let newManager = PlatformWalletManager() do { Self.logger.info("🪺 HOST :: stage 3/4 configuring manager for \(network.rawValue, privacy: .public)") + let started = CFAbsoluteTimeGetCurrent() try newManager.configure(sdk: newSDK, modelContainer: container) + let ms = Int((CFAbsoluteTimeGetCurrent() - started) * 1000) Self.logger.info("🪺 HOST :: stage 3/4 manager configured for \(network.rawValue, privacy: .public)") - DWLogger.log("HOST stage 3/4 manager configured for \(network.rawValue)") + DWLogger.log("HOST stage 3/4 manager configured for \(network.rawValue) in \(ms)ms") } catch { Self.logger.error("🪺 HOST :: configure failed: \(String(describing: error), privacy: .public)") throw HostError.configureFailed(error) @@ -983,9 +1037,17 @@ final class SwiftDashSDKHost { return (manager, false) } - let handles = try makeRuntime(for: network) + let handles = try await makeRuntime(for: network) do { - _ = try handles.manager.loadFromPersistor() + // Stage 4 of the detached-manager bootstrap: cost scales with the + // number of persisted wallets on `network` (~400ms per wallet + // measured). The async SDK overload runs the bulk restore and + // per-wallet lookups off-main; only the keychain-unlock epilogue + // remains on the MainActor (timed separately by the SDK). + let started = CFAbsoluteTimeGetCurrent() + let restored = try await handles.manager.loadFromPersistor() + let ms = Int((CFAbsoluteTimeGetCurrent() - started) * 1000) + DWLogger.log("HOST stage 4/4 loadFromPersistor for \(network.rawValue) restored=\(restored.count) in \(ms)ms") } catch { // The detached manager is already fully configured; rethrowing // without an explicit shutdown would leave its native teardown to @@ -998,11 +1060,19 @@ final class SwiftDashSDKHost { return (handles.manager, true) } + /// Async since etap C: the launch/switch/refresh bootstrap awaits the + /// SDK's off-main load (same overload the mirror leg uses), so the + /// ~400ms-per-wallet restore no longer stalls the MainActor. The + /// caller (`start`) is a lifecycle-queue op, so the added suspension + /// cannot interleave with other lifecycle operations. private func loadPersistedWallet( manager: PlatformWalletManager, network: Network - ) throws -> ManagedPlatformWallet { - let restored = try manager.loadFromPersistor() + ) async throws -> ManagedPlatformWallet { + let loadStarted = CFAbsoluteTimeGetCurrent() + let restored = try await manager.loadFromPersistor() + let loadMs = Int((CFAbsoluteTimeGetCurrent() - loadStarted) * 1000) + DWLogger.log("HOST stage 4/4 loadFromPersistor for \(network.rawValue) restored=\(restored.count) in \(loadMs)ms") if let resolved = resolveActiveWallet(in: manager, network: network) { Self.logger.info("🪺 HOST :: reusing persisted wallet; restored=\(restored.count, privacy: .public)") // Off the load path. `PlatformWalletManager` is `@MainActor`, so diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift index 9ac0f30e4..44c076ab3 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift @@ -267,23 +267,18 @@ final class WalletsViewModel: ObservableObject { // Blocking overlay from the FIRST moment: without this phase the app // looked frozen until the post-add switch finally raised the window. - // The create FFI itself now runs off-main (async SDK createWallet), - // but the mirror-network leg still blocks the MainActor for its - // SDK build + loadFromPersistor — TODO(etap-C). + // No paint-a-frame sleep needed anymore: on EVERY add path the first + // provisioning work after the cheap duplicate guard is an await into + // off-main work (the async create, or the mirror leg's off-main SDK + // build), so the overlay window commits during that suspension — + // confirmed by the stall monitor showing no >=250ms gap between the + // add beginning and its first suspension. WalletLifecycleOverlayPresenter.shared.ensureActive() let state = WalletLifecycleTransitionState.shared guard state.tryBegin(.addingWallet(isImport: isImported)) else { errorMessage = SwiftDashSDKWalletRuntime.SwitchError.switchInProgress.localizedDescription return nil } - // Give UIKit one runloop turn to commit the overlay window before the - // provisioning starts (the Obj-C wipe's 0.1 s dispatch_after trick). - // Still required even with the off-main create: on the mirror-repair - // path the FIRST provisioning work is the other network's synchronous - // SDK build on the MainActor, with no suspension point before it — - // without this sleep the window would not be committed until that - // block ends. Remove together with etap C. - try? await Task.sleep(for: .milliseconds(100)) let opID = String(UUID().uuidString.prefix(8)) let started = CFAbsoluteTimeGetCurrent() diff --git a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift index 397557021..36e8144ee 100644 --- a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift +++ b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift @@ -112,6 +112,13 @@ final class SwiftDashSDKCoreLifecycleTests: XCTestCase { XCTAssertEqual(events, ["before", "awaitable", "after", "throwing", "tail"]) } + func testStallMonitorClassifiesLatenciesAroundTheMicrohangFloor() { + XCTAssertNil(MainThreadStallMonitor.stallMilliseconds(forLatency: 0.0001)) + XCTAssertNil(MainThreadStallMonitor.stallMilliseconds(forLatency: 0.249)) + XCTAssertEqual(MainThreadStallMonitor.stallMilliseconds(forLatency: 0.25), 250) + XCTAssertEqual(MainThreadStallMonitor.stallMilliseconds(forLatency: 1.512), 1512) + } + func testProcessCacheReusesValuesPerNetworkAndSeparatesNetworks() { final class Token {}