Skip to content

Commit 2ed8c1d

Browse files
committed
fix(swift-sdk): keep deep core diagnostics off startup
1 parent 7add260 commit 2ed8c1d

7 files changed

Lines changed: 179 additions & 288 deletions

packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -192,13 +192,13 @@ enum CoreWalletDiagnosticAnalyzer {
192192
case invalidAccountType = "invalid_account_type"
193193
}
194194

195-
let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo
195+
/// Only the fields required by the restore summary. Keeping this
196+
/// intentionally small prevents the launch-time callback from doing
197+
/// any diagnostic outpoint/script materialization or fingerprinting.
198+
let amount: UInt64
196199
let accountType: UInt32?
197200
let standardTag: UInt8?
198201
let rejectionReason: RejectionReason?
199-
let isCoinbase: Bool
200-
let isConfirmed: Bool
201-
let isInstantLocked: Bool
202202
}
203203

204204
struct RestoreBufferSummary: Sendable {
@@ -237,23 +237,23 @@ enum CoreWalletDiagnosticAnalyzer {
237237
let emittedCoinJoin = emittedCandidates.filter { $0.accountType == 1 }
238238
return RestoreBufferSummary(
239239
candidateCount: candidates.count,
240-
candidateValueDuffs: diagnosticSaturatingSum(candidates.map(\.txo.amount)),
240+
candidateValueDuffs: diagnosticSaturatingSum(candidates.map(\.amount)),
241241
candidateBip44Count: candidateBip44.count,
242242
candidateBip44ValueDuffs: diagnosticSaturatingSum(
243-
candidateBip44.map(\.txo.amount)
243+
candidateBip44.map(\.amount)
244244
),
245245
candidateCoinJoinCount: candidateCoinJoin.count,
246246
candidateCoinJoinValueDuffs: diagnosticSaturatingSum(
247-
candidateCoinJoin.map(\.txo.amount)
247+
candidateCoinJoin.map(\.amount)
248248
),
249249
builtCount: emittedCount,
250250
emittedCandidates: emittedCandidates,
251-
emittedValueDuffs: diagnosticSaturatingSum(emittedCandidates.map(\.txo.amount)),
251+
emittedValueDuffs: diagnosticSaturatingSum(emittedCandidates.map(\.amount)),
252252
emittedBip44Count: emittedBip44.count,
253-
emittedBip44ValueDuffs: diagnosticSaturatingSum(emittedBip44.map(\.txo.amount)),
253+
emittedBip44ValueDuffs: diagnosticSaturatingSum(emittedBip44.map(\.amount)),
254254
emittedCoinJoinCount: emittedCoinJoin.count,
255255
emittedCoinJoinValueDuffs: diagnosticSaturatingSum(
256-
emittedCoinJoin.map(\.txo.amount)
256+
emittedCoinJoin.map(\.amount)
257257
),
258258
missingAccountCount: candidates.filter {
259259
$0.rejectionReason == .missingAccount

packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,11 @@ public class PlatformWalletManager: ObservableObject {
467467
/// data (for create, the caller would roll back its mnemonic and
468468
/// orphan the persisted rows).
469469
private var activeNativeOpCount = 0
470+
/// Read-only Core diagnostics have their own admission count. They must
471+
/// keep the manager handle alive until their FFI reads finish, but they do
472+
/// not make synchronous create/load/delete operations unsafe and therefore
473+
/// must not participate in `ensureSyncNativeOpAllowed`.
474+
private var activeCoreDiagnosticsNativeOpCount = 0
470475
private var nativeOpDrainContinuations: [CheckedContinuation<Void, Never>] = []
471476

472477
/// Admission + bookkeeping shared by the async native entrypoints:
@@ -484,22 +489,32 @@ public class PlatformWalletManager: ObservableObject {
484489

485490
private func finishNativeOp() {
486491
activeNativeOpCount -= 1
487-
if activeNativeOpCount == 0, !nativeOpDrainContinuations.isEmpty {
488-
let waiters = nativeOpDrainContinuations
489-
nativeOpDrainContinuations.removeAll()
490-
waiters.forEach { $0.resume() }
491-
}
492+
resumeNativeOpDrainIfIdle()
492493
}
493494

494-
/// Diagnostics use the same admission/drain contract as other background
495-
/// native work: once admitted, shutdown cannot consume the manager handle
496-
/// until the read-only snapshot has finished on `destroyQueue`.
495+
/// Diagnostics have independent admission bookkeeping: shutdown drains
496+
/// them, while synchronous wallet operations ignore them.
497497
func admitCoreDiagnosticsNativeOp() throws {
498-
try admitNativeOp("coreWalletDiagnostics")
498+
guard !shutdownRequested else {
499+
throw PlatformWalletError.invalidHandle(
500+
"manager shutdown is in progress; coreWalletDiagnostics rejected")
501+
}
502+
activeCoreDiagnosticsNativeOpCount += 1
499503
}
500504

501505
func finishCoreDiagnosticsNativeOp() {
502-
finishNativeOp()
506+
activeCoreDiagnosticsNativeOpCount -= 1
507+
resumeNativeOpDrainIfIdle()
508+
}
509+
510+
private func resumeNativeOpDrainIfIdle() {
511+
guard activeNativeOpCount == 0,
512+
activeCoreDiagnosticsNativeOpCount == 0,
513+
!nativeOpDrainContinuations.isEmpty
514+
else { return }
515+
let waiters = nativeOpDrainContinuations
516+
nativeOpDrainContinuations.removeAll()
517+
waiters.forEach { $0.resume() }
503518
}
504519

505520
/// Test seam for the individual native calls. Production keeps `.live`;
@@ -635,7 +650,7 @@ public class PlatformWalletManager: ObservableObject {
635650
ranOffMainThread: false)
636651
}
637652
shutdownRequested = true
638-
if activeNativeOpCount == 0 { break }
653+
if activeNativeOpCount == 0, activeCoreDiagnosticsNativeOpCount == 0 { break }
639654
await withCheckedContinuation { continuation in
640655
nativeOpDrainContinuations.append(continuation)
641656
}
@@ -1298,8 +1313,6 @@ public class PlatformWalletManager: ObservableObject {
12981313
/// `createWallet` flow.
12991314
@discardableResult
13001315
public func loadFromPersistor() throws -> [ManagedPlatformWallet] {
1301-
let diagnosticPersistenceHandler = persistenceHandler
1302-
defer { diagnosticPersistenceHandler?.clearStartupCoreDiagnosticSnapshots() }
13031316
// Same synchronous-admission gate as the sync creates: rejected
13041317
// during the shutdown drain AND while an async native op is in
13051318
// flight — a second Rust loader running concurrently with the one
@@ -1382,13 +1395,6 @@ public class PlatformWalletManager: ObservableObject {
13821395
}
13831396
}
13841397

1385-
for managedWallet in restored {
1386-
emitCoreWalletDiagnosticsSynchronously(
1387-
for: managedWallet.walletId,
1388-
checkpoint: .startupPostRestore
1389-
)
1390-
}
1391-
13921398
// Kick off a background catch-up pass for every persisted
13931399
// asset lock at `statusRaw < 2`. Closes the SPV-restart gap:
13941400
// the wallet's in-memory transactions map was just
@@ -1530,13 +1536,12 @@ public class PlatformWalletManager: ObservableObject {
15301536
/// and once admitted the teardown waits for the full transaction.
15311537
@discardableResult
15321538
public func loadFromPersistor() async throws -> [ManagedPlatformWallet] {
1533-
let handler = persistenceHandler
1534-
defer { handler?.clearStartupCoreDiagnosticSnapshots() }
15351539
try ensureConfigured()
15361540
try admitNativeOp("loadFromPersistor")
15371541
defer { finishNativeOp() }
15381542

15391543
let h = handle
1544+
let handler = persistenceHandler
15401545
let calls = nativeLoadCalls
15411546

15421547
// Direct continuation for the same FIFO reason as the async
@@ -1609,13 +1614,6 @@ public class PlatformWalletManager: ObservableObject {
16091614
]
16101615
)
16111616

1612-
for managedWallet in restored {
1613-
await emitCoreWalletDiagnostics(
1614-
for: managedWallet.walletId,
1615-
checkpoint: .startupPostRestore
1616-
)
1617-
}
1618-
16191617
catchUpStuckAssetLocks(wallets: restored)
16201618
return restored
16211619
}

packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift

Lines changed: 7 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@ import DashSDKFFI
33
import Foundation
44
import SwiftData
55

6-
/// Named checkpoints make two exports from the same device directly
7-
/// comparable without putting any user-controlled text in the log.
6+
/// Deep Core diagnostics are emitted only on explicit log export.
87
enum CoreWalletDiagnosticCheckpoint: String, Sendable {
9-
case startupPreRestore = "startup_pre_restore"
10-
case startupPostRestore = "startup_post_restore"
118
case preExport = "pre_export"
129
}
1310

@@ -154,26 +151,6 @@ func diagnosticTxoFingerprint(
154151
return data
155152
}
156153

157-
/// Canonical material for one exact `UtxoRestoreEntryFFI` row. The general
158-
/// DB↔memory UTXO query cannot observe these three flags, so they live only in
159-
/// this restore-specific fingerprint instead of creating false memory diffs.
160-
func diagnosticRestoreTxoFingerprint(
161-
_ candidate: CoreWalletDiagnosticAnalyzer.RestoreCandidate
162-
) -> Data {
163-
var data = diagnosticTxoFingerprint(
164-
outpoint: candidate.txo.outpoint,
165-
amount: candidate.txo.amount,
166-
height: candidate.txo.height,
167-
scriptPubKey: candidate.txo.scriptPubKey,
168-
isLocked: candidate.txo.isLocked,
169-
account: candidate.txo.account
170-
)
171-
data.append(candidate.isCoinbase ? 1 : 0)
172-
data.append(candidate.isConfirmed ? 1 : 0)
173-
data.append(candidate.isInstantLocked ? 1 : 0)
174-
return data
175-
}
176-
177154
extension PlatformWalletPersistenceHandler {
178155
/// Main-actor-friendly entry point used by manual log export. The handler's
179156
/// serial queue owns the ModelContext; only a Sendable value snapshot is
@@ -185,18 +162,6 @@ extension PlatformWalletPersistenceHandler {
185162
await withCheckedContinuation { continuation in
186163
serialQueue.async { [self] in
187164
let snapshot = autoreleasepool { () -> CoreWalletDatabaseDiagnosticSnapshot? in
188-
if checkpoint == .startupPostRestore,
189-
let cached = startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) {
190-
SDKLogger.event(
191-
"core_db_startup_snapshot_reused",
192-
category: .persistence,
193-
fields: [
194-
"checkpoint": .publicText(checkpoint.rawValue),
195-
"wallet_reference": .reference(walletId),
196-
]
197-
)
198-
return cached
199-
}
200165
return emitCoreWalletDatabaseDiagnosticsOnQueue(
201166
walletId: walletId,
202167
checkpoint: checkpoint
@@ -207,44 +172,25 @@ extension PlatformWalletPersistenceHandler {
207172
}
208173
}
209174

210-
/// Synchronous companion for the legacy synchronous restore overload.
175+
/// Synchronous companion used by focused persistence tests.
211176
func emitCoreWalletDatabaseDiagnostics(
212177
walletId: Data,
213178
checkpoint: CoreWalletDiagnosticCheckpoint
214179
) -> CoreWalletDatabaseDiagnosticSnapshot? {
215180
onQueue {
216-
if checkpoint == .startupPostRestore,
217-
let cached = startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) {
218-
SDKLogger.event(
219-
"core_db_startup_snapshot_reused",
220-
category: .persistence,
221-
fields: [
222-
"checkpoint": .publicText(checkpoint.rawValue),
223-
"wallet_reference": .reference(walletId),
224-
]
225-
)
226-
return cached
227-
}
228181
return emitCoreWalletDatabaseDiagnosticsOnQueue(
229182
walletId: walletId,
230183
checkpoint: checkpoint
231184
)
232185
}
233186
}
234187

235-
/// Must be called while `serialQueue` is held. `loadWalletList` uses this
236-
/// directly, avoiding a recursive `serialQueue.sync` deadlock.
188+
/// Must be called while `serialQueue` is held.
237189
@discardableResult
238190
func emitCoreWalletDatabaseDiagnosticsOnQueue(
239191
walletId: Data,
240192
checkpoint: CoreWalletDiagnosticCheckpoint
241193
) -> CoreWalletDatabaseDiagnosticSnapshot? {
242-
// A previous restore can fail after the pre-snapshot was cached but
243-
// before post-restore consumes it. Never let a later attempt compare
244-
// Rust against that stale value.
245-
if checkpoint == .startupPreRestore {
246-
startupCoreDiagnosticSnapshots.removeValue(forKey: walletId)
247-
}
248194
do {
249195
let walletDescriptor = FetchDescriptor<PersistentWallet>(
250196
predicate: PersistentWallet.predicate(walletId: walletId)
@@ -530,9 +476,6 @@ extension PlatformWalletPersistenceHandler {
530476
assetLocks: assetLocks,
531477
assetLocksAvailable: assetLocksAvailable
532478
)
533-
if checkpoint == .startupPreRestore {
534-
startupCoreDiagnosticSnapshots[walletId] = snapshot
535-
}
536479
return snapshot
537480
} catch {
538481
SDKLogger.event(
@@ -571,20 +514,10 @@ extension PlatformWalletPersistenceHandler {
571514
rejection = nil
572515
}
573516
return CoreWalletDiagnosticAnalyzer.RestoreCandidate(
574-
txo: CoreWalletDatabaseDiagnosticSnapshot.Txo(
575-
outpoint: PersistentTxo.makeOutpoint(txid: row.txid, vout: row.vout),
576-
amount: row.amount,
577-
height: row.height,
578-
scriptPubKey: row.scriptPubKey,
579-
isLocked: row.isLocked,
580-
account: Self.diagnosticAccountKey(row.account)
581-
),
517+
amount: row.amount,
582518
accountType: row.account?.accountType,
583519
standardTag: row.account?.standardTag,
584-
rejectionReason: rejection,
585-
isCoinbase: row.isCoinbase,
586-
isConfirmed: row.isConfirmed,
587-
isInstantLocked: row.isInstantLocked
520+
rejectionReason: rejection
588521
)
589522
}
590523
// A validation error deallocates the compact buffer and aborts the
@@ -595,7 +528,6 @@ extension PlatformWalletPersistenceHandler {
595528
emittedCount: emittedCount,
596529
errored: errored
597530
)
598-
let emittedMaterials = summary.emittedCandidates.map(diagnosticRestoreTxoFingerprint)
599531
let hasRejectedRows = summary.missingAccountCount > 0
600532
|| summary.invalidTxidCount > 0
601533
|| summary.invalidAccountTypeCount > 0
@@ -616,7 +548,7 @@ extension PlatformWalletPersistenceHandler {
616548
),
617549
"candidate_value_duffs": .unsignedInteger(summary.candidateValueDuffs),
618550
"built_count": .integer(Int64(summary.builtCount)),
619-
"checkpoint": .publicText(CoreWalletDiagnosticCheckpoint.startupPreRestore.rawValue),
551+
"checkpoint": .publicText("restore_buffer"),
620552
"emitted_count": .integer(Int64(summary.emittedCandidates.count)),
621553
"emitted_bip44_count": .integer(Int64(summary.emittedBip44Count)),
622554
"emitted_bip44_value_duffs": .unsignedInteger(
@@ -626,7 +558,6 @@ extension PlatformWalletPersistenceHandler {
626558
"emitted_coinjoin_value_duffs": .unsignedInteger(
627559
summary.emittedCoinJoinValueDuffs
628560
),
629-
"emitted_fingerprint": .reference(diagnosticFingerprint(emittedMaterials)),
630561
"emitted_value_duffs": .unsignedInteger(summary.emittedValueDuffs),
631562
"errored": .boolean(errored),
632563
"skipped_invalid_account_type_count": .integer(
@@ -1026,7 +957,7 @@ extension PlatformWalletManager {
1026957
await emitCoreWalletDiagnostics(for: walletId, checkpoint: .preExport)
1027958
}
1028959

1029-
func emitCoreWalletDiagnostics(
960+
private func emitCoreWalletDiagnostics(
1030961
for walletId: Data,
1031962
checkpoint: CoreWalletDiagnosticCheckpoint
1032963
) async {
@@ -1096,29 +1027,6 @@ extension PlatformWalletManager {
10961027
}
10971028
}
10981029

1099-
/// Blocking variant used only by the already-blocking synchronous restore
1100-
/// API. New application code should use the async public entry point.
1101-
func emitCoreWalletDiagnosticsSynchronously(
1102-
for walletId: Data,
1103-
checkpoint: CoreWalletDiagnosticCheckpoint
1104-
) {
1105-
guard walletId.count == 32,
1106-
let handler = persistence,
1107-
let database = handler.emitCoreWalletDatabaseDiagnostics(
1108-
walletId: walletId,
1109-
checkpoint: checkpoint
1110-
),
1111-
isConfigured,
1112-
handle != NULL_HANDLE
1113-
else { return }
1114-
Self.emitCoreMemoryDiagnostics(
1115-
managerHandle: handle,
1116-
managedWallet: wallets[walletId],
1117-
database: database,
1118-
checkpoint: checkpoint
1119-
)
1120-
}
1121-
11221030
private nonisolated static func emitCoreMemoryDiagnostics(
11231031
managerHandle: Handle,
11241032
managedWallet: ManagedPlatformWallet?,

0 commit comments

Comments
 (0)