Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ import DashSDKFFI
/// Use as a root `@StateObject` and pass via `.environmentObject(_:)`.
/// Views observe `@Published` properties directly — no coordinator
/// class in the middle.
/// Lock-guarded monotonic generation counter, safe to read and bump from
/// any thread. Used to drop shielded sync completion events that belong
/// to a generation already superseded by a `stop`/`clear`, even when a
/// restart happens in the same `@MainActor` turn (a plain boolean gate
/// can't, because the restart re-opens the gate before the stale,
/// previously-enqueued completion task runs).
final class ShieldedSyncGenerationCounter: @unchecked Sendable {
private let lock = NSLock()
private var value: UInt64 = 0
func current() -> UInt64 { lock.withLock { value } }
@discardableResult func bump() -> UInt64 { lock.withLock { value &+= 1; return value } }
}

@MainActor
public class PlatformWalletManager: ObservableObject {
// MARK: - Published observables
Expand Down Expand Up @@ -46,21 +59,26 @@ public class PlatformWalletManager: ObservableObject {
/// Last completed shielded sync event emitted by Rust.
@Published public internal(set) var lastShieldedSyncEvent: ShieldedSyncEvent?

/// When true, `handleShieldedSyncCompleted` drops incoming events
/// instead of publishing them. Set by `stopShieldedSync` /
/// `clearShielded` (after the Rust drain returns) and cleared by any
/// sync-start (`startShieldedSync` / `syncShieldedNow`). The Rust
/// quiesce barrier guarantees no persistence after stop/clear, but
/// the completion callback is re-dispatched onto this `@MainActor`,
/// so a final, already-dispatched event can land just after stop/
/// clear returns; this gate keeps the published `lastShieldedSyncEvent`
/// honest for every SDK consumer (not just the example app). Both
/// stop/clear are synchronous on the main actor, so the flag is set
/// before the enqueued trailing-event task can run.
/// Monotonic generation for shielded sync passes. Each `stop`/`clear`
/// bumps it; the FFI completion callback snapshots the generation at
/// enqueue time and `handleShieldedSyncCompleted` drops any event whose
/// snapshot no longer matches the current generation.
///
/// The Rust quiesce barrier guarantees no persistence after stop/clear,
/// but the completion callback is re-dispatched onto this `@MainActor`,
/// so a final, already-dispatched event can land just after stop/clear
/// returns. A plain boolean gate is bypassable: a caller can stop (set
/// the flag) and restart (clear the flag) in the same actor turn, which
/// re-opens the gate before the stale, previously-enqueued completion
/// task runs — so the old event leaks into the new run. Tying
/// suppression to a generation closes that race: the stale task carries
/// the pre-stop generation, the restart does not reset the counter, so
/// the snapshot mismatches and the event is dropped even on a same-turn
/// restart.
///
/// `internal` (not `private`) because the shielded lifecycle methods
/// that read/write it live in an extension in a separate file.
var suppressShieldedCompletionEvents: Bool = false
/// `nonisolated` + lock-guarded so the FFI callback thread can snapshot
/// it without hopping onto the main actor first.
nonisolated let shieldedSyncGeneration = ShieldedSyncGenerationCounter()

/// All wallets currently held by the Rust-side
/// `PlatformWalletManager`, keyed by the 32-byte wallet id.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ public struct ShieldedSyncEvent: Sendable {
}

extension PlatformWalletManager {
func handleShieldedSyncCompleted(_ event: ShieldedSyncEvent) {
// Drop a trailing event that the Rust drain already dispatched
// but the main actor only delivers after stop/clear returned
// (see `suppressShieldedCompletionEvents`). Any sync-start clears
// the flag, so legitimate events are never suppressed.
guard !suppressShieldedCompletionEvents else { return }
func handleShieldedSyncCompleted(_ event: ShieldedSyncEvent, generation: UInt64) {
// Drop a trailing event that the Rust drain already dispatched but
// the main actor only delivers after stop/clear returned. The FFI
// callback snapshots `shieldedSyncGeneration` at enqueue time; a
// stop/clear bumps the counter, so a stale event's snapshot no
// longer matches and is dropped — even if a restart happened in the
// same actor turn (the restart does not reset the counter).
guard generation == shieldedSyncGeneration.current() else { return }
lastShieldedSyncEvent = event
}

Expand Down Expand Up @@ -181,8 +183,10 @@ extension PlatformWalletManager {
if let intervalSeconds {
try setShieldedSyncInterval(seconds: intervalSeconds)
}
// A new sync run should publish its completion events again.
suppressShieldedCompletionEvents = false
// No generation reset needed: events emitted by this new run
// snapshot the current generation, so they pass the guard. A
// trailing event from a prior, stopped run still carries the older
// generation and is dropped.
try platform_wallet_manager_shielded_sync_start(handle).check()
}

Expand All @@ -193,9 +197,10 @@ extension PlatformWalletManager {
)
}
try platform_wallet_manager_shielded_sync_stop(handle).check()
// The Rust drain returned; suppress any trailing completion
// event the main actor delivers after this point.
suppressShieldedCompletionEvents = true
// The Rust drain returned; bump the generation so any trailing
// completion event the main actor delivers after this point is
// dropped (its snapshot predates this bump).
shieldedSyncGeneration.bump()
}

/// Reset the Rust-side shielded state on this manager:
Expand All @@ -219,10 +224,11 @@ extension PlatformWalletManager {
)
}
try platform_wallet_manager_shielded_clear(handle).check()
// The Rust drain returned; suppress any trailing completion
// event the main actor delivers after Clear (it would otherwise
// briefly repopulate the mirror the host is about to wipe).
suppressShieldedCompletionEvents = true
// The Rust drain returned; bump the generation so any trailing
// completion event the main actor delivers after Clear is dropped
// (it would otherwise briefly repopulate the mirror the host is
// about to wipe).
shieldedSyncGeneration.bump()
}

public func isShieldedSyncRunning() throws -> Bool {
Expand Down Expand Up @@ -273,9 +279,9 @@ extension PlatformWalletManager {
"PlatformWalletManager not configured"
)
}
// A user-initiated sync should publish its completion event even
// if a prior stop/clear had suppressed events.
suppressShieldedCompletionEvents = false
// No generation reset needed: this run's completion event snapshots
// the current generation and passes the guard, while a trailing
// event from a prior stopped run still carries the older generation.
let handle = self.handle
try await Task.detached(priority: .userInitiated) {
try platform_wallet_manager_shielded_sync_sync_now(handle).check()
Expand Down Expand Up @@ -586,7 +592,12 @@ func shieldedSyncCompletedCallback(
walletResults: results
)

// Snapshot the generation now, on the FFI callback thread, BEFORE the
// event is enqueued onto the main actor. A subsequent stop/clear bumps
// the counter, so this trailing event is dropped when it finally runs.
let generation = handler.manager?.shieldedSyncGeneration.current() ?? 0

Task { @MainActor [weak manager = handler.manager] in
manager?.handleShieldedSyncCompleted(event)
manager?.handleShieldedSyncCompleted(event, generation: generation)
}
}
Loading