mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 11:25:20 +00:00
Harden Nostr envelope migration compatibility
This commit is contained in:
@@ -12,8 +12,11 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let now: @MainActor () -> Date
|
||||
let sendPrivateEnvelopeBatch: @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool
|
||||
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||
/// Emits whether a relay that carries private messages is up
|
||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
@@ -23,6 +26,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
/// serialize behind each other; `live` passes the process-wide one.
|
||||
let ackPacer: AckPacer
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
notificationCenter: NotificationCenter,
|
||||
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
||||
@@ -30,11 +34,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
|
||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool,
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
||||
ackPacer: AckPacer? = nil,
|
||||
now: @escaping @MainActor () -> Date = Date.init
|
||||
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
||||
) {
|
||||
self.notificationCenter = notificationCenter
|
||||
self.loadFavorites = loadFavorites
|
||||
@@ -42,8 +49,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
||||
self.currentIdentity = currentIdentity
|
||||
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||
self.sendEvent = sendEvent
|
||||
self.now = now
|
||||
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
||||
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
|
||||
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||
scheduleAfter: scheduleAfter
|
||||
)
|
||||
self.relayConnectivity = relayConnectivity
|
||||
// Default pacer drives its throttle through the same injected
|
||||
// scheduler, so tests that step scheduleAfter manually keep
|
||||
@@ -60,12 +71,18 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
||||
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
||||
events,
|
||||
terminalFailure: terminalFailure
|
||||
)
|
||||
},
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
||||
ackPacer: NostrTransport.sharedAckPacer
|
||||
ackPacer: NostrTransport.sharedAckPacer,
|
||||
envelopeRetryQueue: NostrTransport.sharedEnvelopeRetryQueue
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -131,7 +148,37 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
static let sharedAckPacer = AckPacer()
|
||||
// Geohash acknowledgements use short-lived NostrTransport instances, so
|
||||
// the retry owner must be process-wide. A per-transport cap would still be
|
||||
// globally unbounded under outage as throwaway instances accumulated.
|
||||
@MainActor
|
||||
private static let sharedEnvelopeRetryQueue = NostrPrivateEnvelopeRetryQueue(
|
||||
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
||||
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
||||
events,
|
||||
terminalFailure: terminalFailure
|
||||
)
|
||||
},
|
||||
registerPendingPrivateEnvelope: {
|
||||
NostrRelayManager.registerPendingPrivateEnvelope(id: $0)
|
||||
},
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
)
|
||||
|
||||
@MainActor
|
||||
static func resetControlRetriesForPanicWipe() {
|
||||
sharedEnvelopeRetryQueue.removeAll()
|
||||
}
|
||||
|
||||
private enum PrivateEnvelopeFailurePolicy {
|
||||
case userMessage(messageID: String)
|
||||
case retry(retryKey: String)
|
||||
}
|
||||
|
||||
private let dependencies: Dependencies
|
||||
private let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||
private var favoriteStatusObserver: NSObjectProtocol?
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
@@ -148,7 +195,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
idBridge: NostrIdentityBridge,
|
||||
dependencies: Dependencies? = nil
|
||||
) {
|
||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
self.dependencies = resolvedDependencies
|
||||
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
|
||||
|
||||
setupObservers()
|
||||
|
||||
@@ -175,6 +224,18 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
func debugEnqueueControlRetry(key: String, events: [NostrEvent]) {
|
||||
envelopeRetryQueue.enqueue(key: key, events: events, registerPending: false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var debugControlRetryCount: Int {
|
||||
envelopeRetryQueue.debugPendingCount
|
||||
}
|
||||
#endif
|
||||
|
||||
private func setupObservers() {
|
||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||
forName: .favoriteStatusChanged,
|
||||
@@ -264,7 +325,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .userMessage(messageID: messageID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +356,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +395,13 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: identity,
|
||||
registerPending: true,
|
||||
failurePolicy: .userMessage(messageID: messageID)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,21 +424,89 @@ extension NostrTransport {
|
||||
|
||||
/// Creates and sends a BitChat private-envelope event over Nostr.
|
||||
@MainActor
|
||||
private func sendPrivateEnvelope(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
||||
private func sendPrivateEnvelope(
|
||||
content: String,
|
||||
recipientHex: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
registerPending: Bool = false,
|
||||
failurePolicy: PrivateEnvelopeFailurePolicy
|
||||
) {
|
||||
guard let events = try? NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: content,
|
||||
recipientPubkey: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
now: dependencies.now()
|
||||
senderIdentity: senderIdentity
|
||||
) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session)
|
||||
return
|
||||
}
|
||||
let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
|
||||
handlePrivateEnvelopeFailure(
|
||||
events: events,
|
||||
registerPending: registerPending,
|
||||
policy: failurePolicy
|
||||
)
|
||||
}
|
||||
guard accepted else {
|
||||
SecureLogger.error(
|
||||
"NostrTransport: private-envelope migration pair was not accepted for relay delivery",
|
||||
category: .session
|
||||
)
|
||||
handlePrivateEnvelopeFailure(
|
||||
events: events,
|
||||
registerPending: registerPending,
|
||||
policy: failurePolicy
|
||||
)
|
||||
return
|
||||
}
|
||||
registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handlePrivateEnvelopeFailure(
|
||||
events: [NostrEvent],
|
||||
registerPending: Bool,
|
||||
policy: PrivateEnvelopeFailurePolicy
|
||||
) {
|
||||
switch policy {
|
||||
case .userMessage(let messageID):
|
||||
deliverTransportEvent(.messageDeliveryStatusUpdated(
|
||||
messageID: messageID,
|
||||
status: .failed(reason: String(
|
||||
localized: "content.delivery.reason.not_delivered",
|
||||
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
|
||||
))
|
||||
))
|
||||
case .retry(let retryKey):
|
||||
envelopeRetryQueue.enqueue(
|
||||
key: retryKey,
|
||||
events: events,
|
||||
registerPending: registerPending
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func registerPendingPrivateEnvelopesIfNeeded(
|
||||
_ events: [NostrEvent],
|
||||
registerPending: Bool
|
||||
) {
|
||||
guard registerPending else { return }
|
||||
for event in events {
|
||||
if registerPending {
|
||||
dependencies.registerPendingPrivateEnvelope(event.id)
|
||||
}
|
||||
dependencies.sendEvent(event)
|
||||
dependencies.registerPendingPrivateEnvelope(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func privateEnvelopeRetryKey(content: String, recipientHex: String) -> String {
|
||||
"\(recipientHex.lowercased()):\(Data(content.utf8).sha256Fingerprint())"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deliverTransportEvent(_ event: TransportEvent) {
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
} else {
|
||||
delegate?.receiveTransportEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +524,14 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
return
|
||||
}
|
||||
sendPrivateEnvelope(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: ack,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
|
||||
case .deliveredDirect(let messageID, let peerID):
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
@@ -388,17 +542,40 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
sendPrivateEnvelope(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: ack,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
|
||||
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: identity,
|
||||
registerPending: true,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
|
||||
case .readGeohash(let messageID, let recipientHex, let identity):
|
||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: identity,
|
||||
registerPending: true,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,3 +595,122 @@ extension NostrTransport {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded retry owner for non-user private control payloads. It is separate
|
||||
/// from `NostrTransport` so a scheduled retry from a short-lived geohash
|
||||
/// transport remains valid after that transport deinitializes. Scheduler
|
||||
/// callbacks retain this queue, never the transport; an evicted key simply
|
||||
/// becomes a harmless no-op when its already-scheduled callback fires.
|
||||
@MainActor
|
||||
final class NostrPrivateEnvelopeRetryQueue {
|
||||
private struct PendingRetry {
|
||||
let events: [NostrEvent]
|
||||
let registerPending: Bool
|
||||
var attempt: Int
|
||||
var isScheduled: Bool
|
||||
}
|
||||
|
||||
private let sendPrivateEnvelopeBatch: @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool
|
||||
private let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||
private let scheduleAfter: @Sendable (
|
||||
TimeInterval,
|
||||
@escaping @Sendable () -> Void
|
||||
) -> Void
|
||||
private var pending: [String: PendingRetry] = [:]
|
||||
private var insertionOrder: [String] = []
|
||||
|
||||
init(
|
||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool,
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
||||
scheduleAfter: @escaping @Sendable (
|
||||
TimeInterval,
|
||||
@escaping @Sendable () -> Void
|
||||
) -> Void
|
||||
) {
|
||||
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
||||
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||
self.scheduleAfter = scheduleAfter
|
||||
}
|
||||
|
||||
func enqueue(key: String, events: [NostrEvent], registerPending: Bool) {
|
||||
guard pending[key] == nil else { return }
|
||||
if pending.count >= TransportConfig.nostrPrivateEnvelopeRetryQueueCap,
|
||||
let evictedKey = insertionOrder.first {
|
||||
insertionOrder.removeFirst()
|
||||
pending.removeValue(forKey: evictedKey)
|
||||
// These are control payloads, never user-authored messages. Keep
|
||||
// the bounded-loss decision explicit rather than silently growing
|
||||
// memory during a prolonged outage.
|
||||
SecureLogger.warning(
|
||||
"📮 Private control retry queue full — evicted oldest whole migration pair",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
pending[key] = PendingRetry(
|
||||
events: events,
|
||||
registerPending: registerPending,
|
||||
attempt: 0,
|
||||
isScheduled: false
|
||||
)
|
||||
insertionOrder.append(key)
|
||||
schedule(key: key)
|
||||
}
|
||||
|
||||
private func schedule(key: String) {
|
||||
guard var item = pending[key], !item.isScheduled else { return }
|
||||
item.isScheduled = true
|
||||
pending[key] = item
|
||||
let exponent = min(item.attempt, 5)
|
||||
let delay = min(2.0 * pow(2.0, Double(exponent)), 60.0)
|
||||
scheduleAfter(delay) { [self] in
|
||||
Task { @MainActor [self] in
|
||||
self.retry(key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retry(key: String) {
|
||||
guard var item = pending[key] else { return }
|
||||
item.isScheduled = false
|
||||
pending[key] = item
|
||||
|
||||
let accepted = sendPrivateEnvelopeBatch(item.events) { [self] in
|
||||
self.enqueue(
|
||||
key: key,
|
||||
events: item.events,
|
||||
registerPending: item.registerPending
|
||||
)
|
||||
}
|
||||
if accepted {
|
||||
remove(key: key)
|
||||
if item.registerPending {
|
||||
for event in item.events {
|
||||
registerPendingPrivateEnvelope(event.id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item.attempt += 1
|
||||
pending[key] = item
|
||||
schedule(key: key)
|
||||
}
|
||||
}
|
||||
|
||||
private func remove(key: String) {
|
||||
pending.removeValue(forKey: key)
|
||||
insertionOrder.removeAll { $0 == key }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
pending.removeAll()
|
||||
insertionOrder.removeAll()
|
||||
}
|
||||
|
||||
var debugPendingCount: Int { pending.count }
|
||||
func debugContains(key: String) -> Bool { pending[key] != nil }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user