diff --git a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift index 7eeea3521..1d2ec65f1 100644 --- a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift +++ b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift @@ -17,6 +17,7 @@ import Combine import Foundation +import OSLog import SwiftDashSDK private let kMaxProgressDelta = 0.1 // 10% @@ -178,7 +179,14 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { private var lastPeakDate: Date? private var cancellables = Set() - private var observers: [SyncingActivityMonitorObserver] = [] + /// Weak, because this is a singleton that outlives every observer and a + /// strong array here turns any missed `remove(observer:)` into an + /// unbounded leak — one did, silently, until a large-wallet scan had + /// accumulated 3321 live `SyncModelImpl`s and the per-tick fan-out over + /// them became the dominant cost. Every observer is owned by whoever + /// created it (a view model, a `UIView`, a `@StateObject`), so the monitor + /// has no reason to keep any of them alive. + private let observers = NSHashTable.weakObjects() private let observersLock = NSLock() override init() { @@ -199,22 +207,21 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { public func add(observer: SyncingActivityMonitorObserver) { observersLock.lock() defer { observersLock.unlock() } - observers.append(observer) + observers.add(observer) } @objc(removeObserver:) public func remove(observer: SyncingActivityMonitorObserver) { observersLock.lock() defer { observersLock.unlock() } - if let idx = observers.firstIndex(where: { $0 === observer }) { - observers.remove(at: idx) - } + observers.remove(observer) } private func observerSnapshot() -> [SyncingActivityMonitorObserver] { observersLock.lock() defer { observersLock.unlock() } - return observers + // `allObjects` already drops entries whose object has gone away. + return observers.allObjects.compactMap { $0 as? SyncingActivityMonitorObserver } } deinit { @@ -304,9 +311,37 @@ extension SyncingActivityMonitor { applyProgressWithPeakSmoothing(sdkProgress) isSyncing = (mapped == .syncing) + let wasDone = state == .syncDone state = mapped + + // `syncDone` is derived purely from the SPV network phases — it knows + // nothing about how much of what was scanned is durably persisted. + // The two can be far apart: the durable watermark is what a relaunch + // resumes from, and what the transaction list is built out of, so a + // wallet can report "synced" while rows are still materializing. + // + // Logged at the transition rather than gated on, because whether the + // watermark reliably reaches the tip is exactly what is unproven. One + // line per completion answers it from an ordinary session. + if mapped == .syncDone && !wasDone { + logDurableWatermarkAtCompletion(scannedTip: sdkSyncProgress.headers?.currentHeight ?? 0) + } + } + + /// One-shot read of the persisted sync height at the moment the UI first + /// calls a sync complete, next to the height that was actually scanned. + /// A single fetch on a state transition — not a per-tick cost. + private func logDurableWatermarkAtCompletion(scannedTip: UInt32) { + guard let durable = SwiftDashSDKWalletSource.persistedSyncedHeight() else { + Self.logger.warning("⛓️ SYNCSTATE :: reported done at tip \(scannedTip, privacy: .public); durable watermark unavailable") + return + } + let behind = scannedTip > durable ? scannedTip - durable : 0 + Self.logger.info("⛓️ SYNCSTATE :: reported done — scanned tip \(scannedTip, privacy: .public), durable watermark \(durable, privacy: .public), behind by \(behind, privacy: .public) block(s)") } + private static let logger = Logger(subsystem: "org.dashfoundation.dash", category: "sync-state") + /// Map SwiftDashSDK's per-phase progress to the snapshot fields the /// existing UI consumers expect. The phase priority order /// (headers → filterHeaders → filters → masternodes → finished) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift index 8702934be..3c9285424 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift @@ -552,6 +552,19 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { @MainActor private func applyProgress(_ p: PlatformSpvSyncProgress) { + // Everything below runs on the main actor, once per progress tick + // (~1Hz for as long as any sync phase advances), and reaches + // synchronous FFI and a main-context SwiftData fetch. Nothing here has + // ever been timed, so a uniform app-wide stutter during sync has no + // way of being attributed. Measured here rather than assumed. + let tickStartedAt = Date() + defer { + let heldMs = Int(Date().timeIntervalSince(tickStartedAt) * 1000) + if heldMs >= 50 { + Self.logger.warning( + "🛰️ SPVCOORD :: progress tick held the main thread \(heldMs, privacy: .public)ms") + } + } let mappedState = mapState(p.overallState) let translated = SPVSyncProgress( state: mappedState, @@ -603,6 +616,17 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { /// publisher was removed in the SDK refactor, so the bridge replaces it. @MainActor private func refreshBalanceBridge() { + // Timed separately from the tick: this is the FFI half, and knowing + // which half is expensive decides whether the fix is throttling the + // tick or moving the read off the main actor. + let startedAt = Date() + defer { + let ms = Int(Date().timeIntervalSince(startedAt) * 1000) + if ms >= 50 { + Self.logger.warning( + "🛰️ SPVCOORD :: balance bridge held the main thread \(ms, privacy: .public)ms") + } + } guard let wallet = SwiftDashSDKHost.shared.wallet else { SwiftDashSDKWalletState.shared.clearBalance() return diff --git a/DashWallet/Sources/UI/Home/Views/Cells/SyncingHeaderView.swift b/DashWallet/Sources/UI/Home/Views/Cells/SyncingHeaderView.swift index 3caca48bb..e6c91a687 100644 --- a/DashWallet/Sources/UI/Home/Views/Cells/SyncingHeaderView.swift +++ b/DashWallet/Sources/UI/Home/Views/Cells/SyncingHeaderView.swift @@ -21,7 +21,16 @@ import DashUIKit // MARK: - SyncingHeaderView struct SyncingHeaderView: View { - @ObservedObject private var model = SyncModelImpl() + // `@StateObject`, not `@ObservedObject`: this is the transaction list's + // section header, so SwiftUI re-initializes the struct on every list + // update — and during a sync those never stop. `@ObservedObject` does not + // own its value, so each re-init built another `SyncModelImpl`, whose + // `init` registers with `SyncingActivityMonitor.shared` and with + // `NotificationCenter`. Both hold it, `deinit` never ran, and the monitor + // then called every accumulated instance on each progress tick — each one + // republishing and invalidating this view again. A large-wallet scan + // reached 3321 live instances that way. + @StateObject private var model = SyncModelImpl() var onFilterTap: () -> Void var onSyncTap: () -> Void diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index 2a685a4d0..56f932ef4 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -247,7 +247,13 @@ struct HomeViewContent: View { #endif @ObservedObject var viewModel: HomeViewModel - @ObservedObject private var balanceModel = BalanceModel() + // Owned here, so `@StateObject`: `BalanceModel.init` registers with + // `SyncingActivityMonitor.shared`, which holds its observers strongly, so + // an instance built by a struct re-init can never be released. `HomeView` + // is re-created far less often than the list header that hit this hard + // (see `SyncingHeaderView`), but four live models were still found in a + // single session. + @StateObject private var balanceModel = BalanceModel() #if DASHPAY @ObservedObject var joinDPViewModel: JoinDashPayViewModel #endif diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index e17d32a88..e7fba9386 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -44,6 +44,23 @@ enum TransactionFilterCategory: CaseIterable { class HomeViewModel: ObservableObject { private var cancellableBag = Set() private let queue = DispatchQueue(label: "HomeViewModel", qos: .userInitiated) + /// Whether a reconcile pass is queued or running. `queue` is serial, so + /// without this every trigger enqueues another full pass and they drain + /// back-to-back — each one re-reading the window and republishing the whole + /// list to the main thread for a result the pass behind it is about to + /// replace. Main-actor state, mutated only from `reloadTxDataSource` and + /// `finishReloadPass`. + private var reloadPassInFlight = false + /// A trigger that arrived while a pass was in flight. Collapsed to a + /// single follow-up pass, so a burst of N notifications costs two passes + /// (the one running plus one more that sees all of it), not N. + private var reloadPassRequestedAgain = false + /// Triggers that arrived while a pass was in flight. They are not served + /// by that pass — it is already past reading its inputs — but by the one + /// follow-up pass `finishReloadPass()` schedules, however many arrived. + /// Logged so the next session's numbers say how much this actually + /// absorbs. + private var reloadPassCoalesced = 0 private var timeSkewDialogShown: Bool = false /// Session guard so the proactive CoinJoin-sweep popup shows at most once /// per launch (re-evaluated each launch while a leftover balance exists). @@ -246,6 +263,11 @@ class HomeViewModel: ObservableObject { // start immediately. self.reloadTxsAndShortcuts() + // A fixture-backed model has no live data to observe. Subscribing + // anyway is what kept the onboarding demos reacting to every persister + // save and balance change for the whole session. + guard transactionSource.isLiveWalletSource else { return } + self.observeCoinJoinSweep() self.observeWallet() self.observeNetworkChange() @@ -553,6 +575,18 @@ class HomeViewModel: ObservableObject { // once up front, asynchronously, keeps the pass free-running. let dispatch = { @MainActor [weak self] in guard let self else { return } + // A pass already owns the queue: record that the world changed + // again and let it re-run once when it lands. Enqueueing here + // instead is what produced bursts of identical rebuilds — the + // reported duration of each includes the wait behind the ones + // before it, so a backlog reads as a slow pass and every entry in + // it republishes the full list. + guard !self.reloadPassInFlight else { + self.reloadPassRequestedAgain = true + self.reloadPassCoalesced += 1 + return + } + self.reloadPassInFlight = true // Pair each request with the filter selection that was live when // it was made. Reading it inside the pass instead would let a pass // triggered by one change render a selection the user made after @@ -561,6 +595,7 @@ class HomeViewModel: ObservableObject { let startedAt = Date() self.queue.async { [weak self] in self?.performReload(selectedFilters: selectedFilters, startedAt: startedAt) + self?.finishReloadPass() } } if Thread.isMainThread { @@ -570,7 +605,34 @@ class HomeViewModel: ObservableObject { } } + /// Release the pass and, if the world moved while it ran, run exactly one + /// more. Called outside `performReload` so every early return in it — an + /// unbound host, a nil delta — still releases. + private func finishReloadPass() { + DispatchQueue.main.async { + MainActor.assumeIsolated { [weak self] in + guard let self else { return } + let coalesced = self.reloadPassCoalesced + self.reloadPassCoalesced = 0 + self.reloadPassInFlight = false + if coalesced > 0 { + DWLogger.log( + "HomeViewModel: coalesced \(coalesced) reconcile trigger(s) into one follow-up pass" + ) + } + guard self.reloadPassRequestedAgain else { return } + self.reloadPassRequestedAgain = false + self.reloadTxDataSource() + } + } + } + private func performReload(selectedFilters: Set, startedAt: Date) { + // `startedAt` is stamped when the pass was REQUESTED, on the main + // actor. `workStartedAt` is when it actually got the queue. Reporting + // only their sum reads a backlog as a slow pass — which is exactly how + // a burst of triggers used to look like one 21-second rebuild. + let workStartedAt = Date() DWLogger.log("HomeViewModel: Starting timeline reconcile") // --- Stage 1: bring the wrapped-row window up to date. Every @@ -624,7 +686,11 @@ class HomeViewModel: ObservableObject { } // --- Stage 3: rebuild the visible list from the in-memory window. - rebuildTimelineItems(selectedFilters: selectedFilters, startedAt: startedAt, pageCompleted: false) + rebuildTimelineItems( + selectedFilters: selectedFilters, + startedAt: startedAt, + workStartedAt: workStartedAt, + pageCompleted: false) } /// Replace the window cache with a freshly fetched first page. @@ -697,6 +763,7 @@ class HomeViewModel: ObservableObject { let startedAt = Date() queue.async { [weak self] in guard let self else { return } + let workStartedAt = Date() guard self.hasOlderHistory, self.windowOldestDayStart > 0, let page = self.transactionSource.olderTimelinePage( endingBefore: self.windowOldestDayStart, @@ -716,7 +783,11 @@ class HomeViewModel: ObservableObject { // its rows' `lastUpdated` can postdate window updates the next // delta still has to pick up. DWLogger.log("HomeViewModel: Timeline paged to \(self.windowTxs.count) rows, older history: \(self.hasOlderHistory)") - self.rebuildTimelineItems(selectedFilters: selectedFilters, startedAt: startedAt, pageCompleted: true) + self.rebuildTimelineItems( + selectedFilters: selectedFilters, + startedAt: startedAt, + workStartedAt: workStartedAt, + pageCompleted: true) } } @@ -749,6 +820,7 @@ class HomeViewModel: ObservableObject { private func rebuildTimelineItems( selectedFilters: Set, startedAt: Date, + workStartedAt: Date, pageCompleted: Bool ) { let transactions = timelineInputTransactions() @@ -884,8 +956,10 @@ class HomeViewModel: ObservableObject { return TransactionGroup(id: key, date: first.date, items: items) }.sorted { $0.date > $1.date } - let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000) - DWLogger.log("HomeViewModel: Timeline rebuild complete in \(elapsedMs)ms, \(array.count) groups, \(self.txByHash.count) items cached, window \(windowTxs.count) rows") + let now = Date() + let elapsedMs = Int(now.timeIntervalSince(startedAt) * 1000) + let workMs = Int(now.timeIntervalSince(workStartedAt) * 1000) + DWLogger.log("HomeViewModel: Timeline rebuild complete in \(elapsedMs)ms (queued \(max(0, elapsedMs - workMs))ms, work \(workMs)ms), \(array.count) groups, \(self.txByHash.count) items cached, window \(windowTxs.count) rows") publishTimeline( array, @@ -942,7 +1016,13 @@ class HomeViewModel: ObservableObject { pageCompleted: Bool ) { let canLoadMore = hasOlderHistory + let groupCount = array.count DispatchQueue.main.async { + // The one part of a reconcile that is unavoidably main-thread: + // assigning `txItems` republishes the whole list and SwiftUI diffs + // it. Timed so the cost of republishing is a number rather than an + // inference. + let publishStartedAt = Date() self.txItems = array self.hasLoadedInitialTxItems = true if self.canLoadMoreHistory != canLoadMore { @@ -958,6 +1038,10 @@ class HomeViewModel: ObservableObject { if self.hasMasternodeHistory != hasMasternodes { self.hasMasternodeHistory = hasMasternodes } + let publishMs = Int(Date().timeIntervalSince(publishStartedAt) * 1000) + if publishMs >= 50 { + DWLogger.log("HomeViewModel: publish held the main thread \(publishMs)ms for \(groupCount) groups") + } } } @@ -1659,6 +1743,16 @@ struct WalletTimelineDelta { protocol TransactionSource { var allTransactions: Array { get } + /// Whether this source is backed by the real wallet. + /// + /// A fixture source has nothing to learn from a persister save or a + /// balance change, so a view model built on one must not subscribe to + /// them: the onboarding demo screens outlive their own presentation, and + /// two such view models were found still rebuilding their four-row + /// fixture window on every notification twenty minutes into a session, + /// long after onboarding had finished. + var isLiveWalletSource: Bool { get } + /// The newest rows as a day-completed window of about `targetRowCount`. /// Nil when the source has no active wallet yet. func timelineWindow(targetRowCount: Int) -> WalletTimelineWindow? @@ -1680,6 +1774,10 @@ protocol TransactionSource { /// `allTransactions` set is one complete, already-loaded window; there is /// nothing older to page in and no store to answer deltas or gates from. extension TransactionSource { + /// Live unless a source opts out, so adding this cannot silently + /// disconnect a real one. + var isLiveWalletSource: Bool { true } + func timelineWindow(targetRowCount: Int) -> WalletTimelineWindow? { WalletTimelineWindow( walletId: Data(), @@ -2416,6 +2514,21 @@ class SwiftDashSDKWalletSource: TransactionSource { /// cheap enough to block a worker queue on). `ModelContainer` is /// `Sendable`, so the caller then opens its own `ModelContext` on its /// own thread and all SwiftData work stays there. + /// The durable sync watermark the persister last wrote for the active + /// wallet — the height a relaunch resumes from, and the boundary below + /// which the transaction list is materialized. Distinct from the height + /// the SPV scan has reached, which is what the sync UI reports. + /// + /// One bounded fetch; call it on a state transition, not per tick. + static func persistedSyncedHeight() -> UInt32? { + guard let (container, walletId) = hostHandles() else { return nil } + let context = ModelContext(container) + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + descriptor.fetchLimit = 1 + return (try? context.fetch(descriptor))?.first?.syncedHeight + } + private static func hostHandles() -> (container: ModelContainer, walletId: Data)? { onMain { guard let container = SwiftDashSDKHost.shared.modelContainer, diff --git a/DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift b/DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift index db6b6eeef..8ef5c1c92 100644 --- a/DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift +++ b/DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift @@ -21,6 +21,10 @@ import Foundation /// `.sdk`-shaped wrappers — no DashSync objects, no persisted rows. Amounts, /// directions and date spacing mirror the legacy `DWTransactionStub` fixture. class StubTransactionSource: TransactionSource { + /// Fixtures — nothing here reacts to the wallet, so a view model built on + /// this must not wire itself to live notifications. + var isLiveWalletSource: Bool { false } + /// (duffs, isSent) fixture rows, newest first. private static let fixtures: [(amount: Int64, sent: Bool)] = [ (314_000_000, true), // 3.14 sent diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift index a0aecd858..87d296ea2 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift @@ -59,14 +59,28 @@ enum CoreToShieldedAmountPolicy { /// (50_000 duffs) × 1000 credits/duff. static let assetLockBaseCostCredits: UInt64 = 50_000_000 + /// Cached: this is read from `amountValidationMessage` and `canContinue`, + /// both of which a SwiftUI `body` evaluates — so an uncached property + /// crosses into Rust on every render and every keystroke. The value is a + /// pure function of the protocol version and the fixed `(transfer, 2)` + /// shape, so it cannot change within a launch. + private static var cachedPoolFeeCredits: UInt64?? + static var poolFeeCredits: UInt64? { + if let cached = cachedPoolFeeCredits { return cached } guard let shieldedFee = try? PlatformWalletManager.estimateShieldedFee( kind: .transfer, numActions: 2) - else { return nil } + else { + // Not cached: a failed estimate is a transient FFI condition, and + // caching it would keep the screen permanently unusable. + return nil + } let (total, overflow) = shieldedFee.addingReportingOverflow(assetLockBaseCostCredits) - return overflow ? nil : total + let value: UInt64? = overflow ? nil : total + cachedPoolFeeCredits = .some(value) + return value } /// Pool fee in whole duffs, rounded UP so a duff-denominated lock always