diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index c9265e89..4a3ecdb4 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -39,8 +39,22 @@ jobs: ${{ runner.os }}-${{ matrix.name }}- - name: Run Tests + # BITCHAT_PERF_LOG captures the PERF[...] lines that + # PerformanceBaselineTests reports (swift test --parallel swallows + # stdout of passing tests, so the floor gate reads this file instead). + env: + BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log run: swift test --parallel --quiet --enable-code-coverage --package-path ${{ matrix.path }} + # Order-of-magnitude performance regression gate (app tests only — the + # package matrix entries write no PERF lines and the gate would skip + # anyway). Floors are deliberately generous (~25% of healthy local + # throughput, see bitchatTests/Performance/perf-floors.json) so this + # catches algorithmic regressions, never runner variance. + - name: Performance floor gate + if: matrix.name == 'app' + run: ./scripts/check-perf-floors.sh perf-output.log + # Informational only: surfaces per-file and total line coverage in the # job log so coverage trends are visible on every PR. No thresholds — # this must never be the reason a build goes red. diff --git a/Package.swift b/Package.swift index d7bc2cb6..3f6e6b8f 100644 --- a/Package.swift +++ b/Package.swift @@ -53,11 +53,17 @@ let package = Package( path: "bitchatTests", exclude: [ "Info.plist", - "README.md" + "README.md", + // CI perf gate data (read by scripts/check-perf-floors.sh), + // not a test resource. + "Performance/perf-floors.json" ], resources: [ .process("Localization"), - .process("Noise") + // Only the vector fixture: declaring the whole "Noise" + // directory would claim its .swift test files as resources + // and silently drop them from compilation. + .process("Noise/NoiseTestVectors.json") ] ) ] diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 071115d5..a706cfac 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -105,7 +105,7 @@ final class AppRuntime: ObservableObject { started = true NotificationDelegate.shared.runtime = self - VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) + VerificationService.shared.configure(with: chatViewModel.meshService) announceInitialTorStatusIfNeeded() Task(priority: .utility) { [weak self] in diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 73b57b34..88d72085 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -16,6 +16,7 @@ // import BitFoundation +import BitLogger import Combine import Foundation @@ -185,6 +186,39 @@ final class Conversation: ObservableObject, Identifiable { indexByMessageID.removeAll() } + // MARK: Diagnostics + + /// Appends human-readable invariant violations for this conversation + /// (empty when healthy): the ID index must be the exact inverse of the + /// messages array, the cap must hold, and timestamps must be + /// non-decreasing (equal timestamps keep arrival order, so only strict + /// inversions are violations). O(messages); allocates only on violation. + fileprivate func collectInvariantViolations(into violations: inout [String], label: String) { + if indexByMessageID.count != messages.count { + violations.append("\(label): index has \(indexByMessageID.count) entries for \(messages.count) messages") + } + if messages.count > cap { + violations.append("\(label): \(messages.count) messages exceeds cap \(cap)") + } + var previousTimestamp: Date? + for position in messages.indices { + let message = messages[position] + // Count equality + every message resolving to its own position + // proves the index is exactly the inverse map (no stale extras). + if let index = indexByMessageID[message.id] { + if index != position { + violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)") + } + } else { + violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index") + } + if let previousTimestamp, message.timestamp < previousTimestamp { + violations.append("\(label): timestamp order violated at \(position)") + } + previousTimestamp = message.timestamp + } + } + // MARK: Internals static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool { @@ -293,6 +327,16 @@ final class ConversationStore: ObservableObject { /// copies. private var conversationIDsByMessageID: [String: Set] = [:] + /// Monotonic count of messages inserted into any conversation (appends, + /// upsert-appends, migration inserts). Field-observability only: the + /// periodic store audit folds the delta into its heartbeat line so logs + /// carry throughput context. Never read on a hot path. + private(set) var appendCount: Int = 0 + + /// Sample counter for the mirrored-republish debug log in the ID-only + /// `setDeliveryStatus` fan-out (first + every Nth occurrence). + private var mirroredRepublishLogCount = 0 + let changes = PassthroughSubject() // MARK: Intent API @@ -379,6 +423,16 @@ final class ConversationStore: ObservableObject { guard let conversation = conversationsByID[id], conversation.message(withID: messageID)?.deliveryStatus == status, conversation.republishMessage(withID: messageID) else { continue } + // Field proof the mirrored-copy republish path actually fires; + // sampled (first + every Nth) so mirrored chats can't spam logs. + mirroredRepublishLogCount += 1 + if mirroredRepublishLogCount == 1 + || mirroredRepublishLogCount.isMultiple(of: TransportConfig.conversationStoreMirroredRepublishLogInterval) { + SecureLogger.debug( + "mirrored republish #\(mirroredRepublishLogCount) for \(messageID.prefix(8))… in \(id.auditDescription)", + category: .session + ) + } changes.send(.statusChanged(id, messageID: messageID, status)) } return true @@ -567,10 +621,89 @@ final class ConversationStore: ObservableObject { } } + // MARK: Diagnostics + + /// Total messages across all conversations. O(#conversations) — heartbeat + /// logging only, never a hot path. + var totalMessageCount: Int { + conversationsByID.values.reduce(0) { $0 + $1.messages.count } + } + + /// Number of distinct message IDs in the store-level membership map. + var messageIDMapCount: Int { + conversationIDsByMessageID.count + } + + /// Verifies the store's correctness invariants and returns human-readable + /// violations (empty = healthy). Intended for a periodic field audit: + /// O(total messages) and allocation-free while healthy. Checks: + /// - the `conversationIDs` ordering array matches `conversationsByID` + /// - per conversation: ID index exact, cap held, timestamp order + /// (see `Conversation.collectInvariantViolations`) + /// - the message-ID → conversation map matches reality exactly: every + /// mapped membership points at a live conversation actually holding + /// the message, and total memberships equal total messages (with the + /// forward check, equality proves no conversation message is missing + /// from the map) + /// - `unreadConversations` only references existing conversations + /// - `selectedConversationID`, when set, references an existing + /// conversation (`select(_:)` creates on selection and + /// `removeConversation`/`clearAll` clear it, so existence is the + /// invariant for both the channel-derived and direct-peer cases) + func auditInvariants() -> [String] { + var violations: [String] = [] + + if conversationIDs.count != conversationsByID.count { + violations.append("conversationIDs lists \(conversationIDs.count) conversations but dictionary holds \(conversationsByID.count)") + } + for id in conversationIDs where conversationsByID[id] == nil { + violations.append("conversationIDs lists \(id.auditDescription) but no conversation exists") + } + + var totalMessages = 0 + for (id, conversation) in conversationsByID { + totalMessages += conversation.messages.count + conversation.collectInvariantViolations(into: &violations, label: id.auditDescription) + } + + var totalMappedMemberships = 0 + for (messageID, ids) in conversationIDsByMessageID { + totalMappedMemberships += ids.count + if ids.isEmpty { + violations.append("message map: \(messageID.prefix(8))… has an empty membership set") + } + for id in ids { + guard let conversation = conversationsByID[id] else { + violations.append("message map: \(messageID.prefix(8))… claims unknown conversation \(id.auditDescription)") + continue + } + if !conversation.containsMessage(withID: messageID) { + violations.append("message map: \(messageID.prefix(8))… not present in claimed conversation \(id.auditDescription)") + } + } + } + if totalMappedMemberships != totalMessages { + violations.append("message map holds \(totalMappedMemberships) memberships but conversations hold \(totalMessages) messages") + } + + for id in unreadConversations where conversationsByID[id] == nil { + violations.append("unreadConversations contains unknown conversation \(id.auditDescription)") + } + + if let selected = selectedConversationID, conversationsByID[selected] == nil { + violations.append("selectedConversationID \(selected.auditDescription) has no conversation") + } + + return violations + } + // MARK: Internals private func registerMessageID(_ messageID: String, in id: ConversationID) { conversationIDsByMessageID[messageID, default: []].insert(id) + // Single choke point for every successful insertion (append, upsert + // append, migration insert) — the audit heartbeat's throughput delta. + appendCount += 1 } private func unregisterMessageID(_ messageID: String, from id: ConversationID) { @@ -675,6 +808,96 @@ extension ConversationStore { } } +// MARK: - Diagnostics support + +extension ConversationID { + /// Short, log-safe description for audit/diagnostic lines. Direct + /// conversations truncate the handle so full peer keys never hit logs. + fileprivate var auditDescription: String { + switch self { + case .mesh: + return "mesh" + case .geohash(let geohash): + return "geo:\(geohash)" + case .direct(let handle): + return "direct:\(handle.id.prefix(13))…" + } + } +} + +#if DEBUG +// Test-only corruption hooks for `auditInvariants()` tests. The store is the +// sole writer by design — `Conversation`'s mutators are fileprivate and the +// store's backing collections are private — so the inconsistent states the +// audit exists to catch CANNOT be manufactured through the intent API. These +// DEBUG-only hooks deliberately bypass that lockdown to inject exactly those +// impossible states. Never call them outside tests. +extension Conversation { + /// Points an existing message's index entry at the wrong position + /// (positions 0 and 1 swap their index entries). Requires >= 2 messages. + func _testCorruptIndexEntries() { + guard messages.count >= 2 else { return } + indexByMessageID[messages[0].id] = 1 + indexByMessageID[messages[1].id] = 0 + } + + /// Drops a message's index entry entirely (count mismatch + missing). + func _testRemoveIndexEntry(forMessageID messageID: String) { + indexByMessageID.removeValue(forKey: messageID) + } + + /// Swaps the first and last messages while keeping the index consistent, + /// so ONLY the timestamp-order invariant is violated (requires the two + /// messages to have distinct timestamps). + func _testCorruptOrderingPreservingIndex() { + guard messages.count >= 2 else { return } + messages.swapAt(0, messages.count - 1) + indexByMessageID[messages[0].id] = 0 + indexByMessageID[messages[messages.count - 1].id] = messages.count - 1 + } +} + +extension ConversationStore { + /// Adds a map membership that the conversation does not actually hold. + func _testRegisterPhantomMessageID(_ messageID: String, in id: ConversationID) { + conversationIDsByMessageID[messageID, default: []].insert(id) + } + + /// Drops a real map membership (conversation message missing from map). + func _testUnregisterMessageID(_ messageID: String, from id: ConversationID) { + conversationIDsByMessageID[messageID]?.remove(id) + if conversationIDsByMessageID[messageID]?.isEmpty == true { + conversationIDsByMessageID.removeValue(forKey: messageID) + } + } + + /// Appends past the conversation cap, bypassing trim (map kept exact so + /// only the cap invariant is violated). + func _testAppendBypassingCap(_ message: BitchatMessage, to id: ConversationID) { + let conversation = conversation(for: id) + conversation._testAppendBypassingTrim(message) + conversationIDsByMessageID[message.id, default: []].insert(id) + } + + /// Marks a nonexistent conversation unread without creating it. + func _testInsertUnreadConversationID(_ id: ConversationID) { + unreadConversations.insert(id) + } + + /// Sets the selection directly, without `select(_:)`'s create-on-select. + func _testSetSelectedConversationID(_ id: ConversationID?) { + selectedConversationID = id + } +} + +extension Conversation { + fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) { + messages.append(message) + indexByMessageID[message.id] = messages.count - 1 + } +} +#endif + // MARK: - Public timeline derived views extension ConversationStore { diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 5b8842a3..aa9562c4 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -66,6 +66,9 @@ struct NostrRelayManagerDependencies { var makeSession: () -> NostrRelaySessionProtocol var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void var now: () -> Date + /// Uniform random value in [0, 1) used to jitter reconnect backoff. + /// Injectable so tests can pin or sweep the jitter deterministically. + var jitterUnit: () -> Double } private extension NostrRelayManagerDependencies { @@ -93,7 +96,8 @@ private extension NostrRelayManagerDependencies { scheduleAfter: { delay, action in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) }, - now: Date.init + now: Date.init, + jitterUnit: { Double.random(in: 0..<1) } ) } } @@ -140,7 +144,16 @@ final class NostrRelayManager: ObservableObject { private var hasLocationPermission: Bool = false private var connections: [String: NostrRelayConnectionProtocol] = [:] private var subscriptions: [String: Set] = [:] // relay URL -> active subscription IDs - private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) + // Not-yet-flushed REQs per relay, bounded by a per-relay cap (oldest by + // insertion order evicted) and an age sweep on connect attempts. Dicts are + // unordered, so each entry carries an insertion sequence and queue time. + private struct PendingSubscription { + let messageString: String // encoded REQ JSON + let queuedAt: Date + let sequence: UInt64 + } + private var pendingSubscriptions: [String: [String: PendingSubscription]] = [:] // relay URL -> (subscription id -> pending REQ) + private var pendingSubscriptionSequence: UInt64 = 0 private var messageHandlers: [String: (NostrEvent) -> Void] = [:] private struct InboundEventKey: Hashable { let subscriptionID: String @@ -432,9 +445,7 @@ final class NostrRelayManager: ObservableObject { existingSet.insert(url) } for url in urls { - var map = self.pendingSubscriptions[url] ?? [:] - map[id] = messageString - self.pendingSubscriptions[url] = map + queuePendingSubscription(id: id, messageString: messageString, for: url) } // Initialize EOSE tracking if requested if let onEOSE = onEOSE { @@ -550,6 +561,7 @@ final class NostrRelayManager: ObservableObject { private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) { guard dependencies.activationAllowed() else { return } + sweepStalePendingSubscriptions() let targets = allowedRelayList(from: relayUrls).filter { connections[$0] == nil && !isPermanentlyFailed($0) } @@ -627,11 +639,60 @@ final class NostrRelayManager: ObservableObject { private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool { guard !requestState.relayURLs.isEmpty else { return true } return requestState.relayURLs.allSatisfy { url in - pendingSubscriptions[url]?[id] == requestState.messageString || + pendingSubscriptions[url]?[id]?.messageString == requestState.messageString || subscriptions[url]?.contains(id) == true } } + private func queuePendingSubscription(id: String, messageString: String, for url: String) { + var map = pendingSubscriptions[url] ?? [:] + pendingSubscriptionSequence &+= 1 + map[id] = PendingSubscription( + messageString: messageString, + queuedAt: dependencies.now(), + sequence: pendingSubscriptionSequence + ) + // Bound per-relay pending REQs; evict oldest by insertion order. The + // durable intent stays in subscriptionRequestState, so an evicted REQ + // is still replayed if its subscription is active when the relay + // (re)connects. + var evictedCount = 0 + while map.count > TransportConfig.nostrPendingSubscriptionsPerRelayCap, + let oldest = map.min(by: { $0.value.sequence < $1.value.sequence }) { + map.removeValue(forKey: oldest.key) + evictedCount += 1 + } + if evictedCount > 0 { + // Bounds proof: the cap eviction actually removed entries. + SecureLogger.warning( + "📋 Evicted \(evictedCount) pending sub(s) over cap for \(url)", + category: .session + ) + } + pendingSubscriptions[url] = map + } + + /// Drop pending REQs older than the TTL. Runs on connect attempts (the + /// natural maintenance path: connect/ensureConnections/reconnects all + /// funnel through connectToRelays) so stale entries for relays that never + /// come up cannot accumulate without bound. + private func sweepStalePendingSubscriptions() { + let now = dependencies.now() + for (url, map) in pendingSubscriptions { + let fresh = map.filter { + now.timeIntervalSince($0.value.queuedAt) <= TransportConfig.nostrPendingSubscriptionTTLSeconds + } + guard fresh.count != map.count else { continue } + // Bounds proof: the age sweep actually removed entries. Warning + // (not debug) — stale pending REQs mean a relay never came up. + SecureLogger.warning( + "📋 Swept \(map.count - fresh.count) stale pending sub(s) for \(url)", + category: .session + ) + pendingSubscriptions[url] = fresh.isEmpty ? nil : fresh + } + } + private func startEOSETracking(id: String, relayURLs: Set, callback: @escaping () -> Void) { eoseTrackerEpoch += 1 let epoch = eoseTrackerEpoch @@ -770,7 +831,7 @@ final class NostrRelayManager: ObservableObject { /// active subscription targeting this relay must be re-sent. private func flushPendingSubscriptions(for relayUrl: String) { guard let connection = connections[relayUrl] else { return } - var toSend = pendingSubscriptions[relayUrl] ?? [:] + var toSend = (pendingSubscriptions[relayUrl] ?? [:]).mapValues(\.messageString) for (id, state) in subscriptionRequestState where state.relayURLs.contains(relayUrl) && toSend[id] == nil { toSend[id] = state.messageString } @@ -987,16 +1048,26 @@ final class NostrRelayManager: ObservableObject { return } - // Calculate backoff interval - let backoffInterval = min( + // Calculate backoff interval with ±jitterRatio random jitter so relays + // that dropped together don't all reconnect at the same instant. + let baseBackoffInterval = min( initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)), maxBackoffInterval ) - + let jitterRatio = TransportConfig.nostrRelayBackoffJitterRatio + let jitterFactor = 1.0 + (dependencies.jitterUnit() * 2.0 - 1.0) * jitterRatio + let backoffInterval = baseBackoffInterval * jitterFactor + let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval) relays[index].nextReconnectTime = nextReconnectTime - - + + // Reconnects are bounded by maxReconnectAttempts and exponentially + // backed off, so this is low-frequency: plain debug, no sampling. + SecureLogger.debug( + "🔄 Reconnect \(relayUrl) in \(String(format: "%.1f", backoffInterval))s (base \(String(format: "%.1f", baseBackoffInterval))s, attempt \(relays[index].reconnectAttempts)/\(maxReconnectAttempts))", + category: .session + ) + // Schedule reconnection with exponential backoff let gen = connectionGeneration dependencies.scheduleAfter(backoffInterval) { [weak self] in @@ -1054,6 +1125,11 @@ final class NostrRelayManager: ObservableObject { pendingSubscriptions[relayUrl]?.count ?? 0 } + func debugPendingSubscriptionIDs(for relayUrl: String) -> Set { + guard let map = pendingSubscriptions[relayUrl] else { return [] } + return Set(map.keys) + } + var debugDuplicateInboundEventDropCount: Int { duplicateInboundEventDropCount } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index d5c0635e..596415f4 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -91,6 +91,10 @@ final class BLEService: NSObject { private var pendingNoiseSessionQueues = BLENoiseSessionQueues() // Queue for notifications that failed due to full queue private var pendingNotifications = BLEOutboundNotificationBuffer() + // Backpressure logging fires per fragment during media transfers + // (hundreds of lines per image); sampled via this counter, which is + // only touched inside collectionsQueue barriers (no sync needed). + var notificationBackpressureLogCount = 0 // Accumulate long write chunks per central until a full frame decodes private var pendingWriteBuffers = BLEInboundWriteBuffer() @@ -401,10 +405,17 @@ final class BLEService: NSObject { } // MARK: Identity - - var myPeerID = PeerID(str: "") - var myNickname: String = "anon" - + + /// Derived from the Noise identity fingerprint; rotated only via + /// `refreshPeerIdentity()` (e.g. panic reset). Externally read-only — + /// no out-of-band mutation may bypass that derivation. + private(set) var myPeerID = PeerID(str: "") + /// Externally read-only; mutate via `setNickname(_:)`, which also + /// broadcasts the change to peers. + private(set) var myNickname: String = "anon" + + /// Sole mutator for `myNickname`: updates the stored value and force-sends + /// an announce so peers learn the new name. func setNickname(_ nickname: String) { self.myNickname = nickname // Send announce to notify peers of nickname change (force send) @@ -573,8 +584,40 @@ final class BLEService: NSObject { initiateNoiseHandshake(with: peerID) } - func getNoiseService() -> NoiseEncryptionService { - return noiseService + // MARK: Noise identity/session access (narrow Transport wrappers) + + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { + noiseService.getPeerPublicKeyData(peerID) + } + + func noiseIdentityFingerprint() -> String { + noiseService.getIdentityFingerprint() + } + + func noiseStaticPublicKeyData() -> Data { + noiseService.getStaticPublicKeyData() + } + + func noiseSigningPublicKeyData() -> Data { + noiseService.getSigningPublicKeyData() + } + + func noiseSignData(_ data: Data) -> Data? { + noiseService.signData(data) + } + + func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { + noiseService.verifySignature(signature, for: data, publicKey: publicKey) + } + + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) { + // `onPeerAuthenticated` is additive (the encryption service keeps an + // array of handlers); `onHandshakeRequired` is a single slot. + noiseService.onPeerAuthenticated = onPeerAuthenticated + noiseService.onHandshakeRequired = onHandshakeRequired } func getCurrentBluetoothState() -> CBManagerState { @@ -885,7 +928,7 @@ final class BLEService: NSObject { ) if case let .enqueued(count) = result { - SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session) + self.logBackpressureSampled("📋 Queued \(context) packet for retry (pending=\(count))") return } @@ -2006,11 +2049,17 @@ extension BLEService: CBPeripheralManagerDelegate { } func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { - SecureLogger.debug("📤 Peripheral manager ready to send more notifications", category: .session) - drainPendingNotifications(logPrefix: "✅ Sent") } + private func logBackpressureSampled(_ message: @autoclosure () -> String) { + notificationBackpressureLogCount += 1 + if notificationBackpressureLogCount == 1 || + notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) { + SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session) + } + } + private func drainPendingNotifications(logPrefix: String) { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self, @@ -2021,11 +2070,7 @@ extension BLEService: CBPeripheralManagerDelegate { let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic) if sentCount > 0 { - SecureLogger.debug("\(logPrefix) \(sentCount) pending notifications from retry queue", category: .session) - } - - if !self.pendingNotifications.isEmpty { - SecureLogger.debug("📋 Still have \(self.pendingNotifications.count) pending notifications", category: .session) + self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)") } } } @@ -2043,7 +2088,7 @@ extension BLEService: CBPeripheralManagerDelegate { guard success else { let remaining = Array(pending.dropFirst(index)) pendingNotifications.prepend(remaining) - SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session) + logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items") break } diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index f6785c3a..6e11c00f 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -34,7 +34,17 @@ final class FavoritesPersistenceService: ObservableObject { static let shared = FavoritesPersistenceService() - init(keychain: KeychainManagerProtocol = KeychainManager()) { + /// Default keychain for the `shared` singleton. Under test this is an + /// in-memory keychain so touching `shared` never blocks on securityd + /// (`SecItemCopyMatching` can hang in test environments) and never reads + /// or writes the developer's real keychain. Production behavior is + /// unchanged. Tests that need their own instance keep injecting a mock + /// via `init(keychain:)`. + private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol { + TestEnvironment.isRunningTests ? PreviewKeychainManager() : KeychainManager() + } + + init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) { self.keychain = keychain loadFavorites() diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 2f00955c..af0b3291 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -6,6 +6,13 @@ import Foundation @MainActor final class MessageRouter { private let transports: [Transport] + private let now: () -> Date + + /// Invoked whenever a retained private message is dropped without a + /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) + /// so the UI can surface the failure instead of leaving the message in a + /// stale "sending/sent" state forever. + var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? // Outbox entry with timestamp for TTL-based eviction private struct QueuedMessage { @@ -25,8 +32,9 @@ final class MessageRouter { // get a delivery ack (e.g. peer on an old client that doesn't ack). private static let maxSendAttempts = 8 - init(transports: [Transport]) { + init(transports: [Transport], now: @escaping () -> Date = Date.init) { self.transports = transports + self.now = now // Observe favorites changes to learn Nostr mapping and flush queued messages NotificationCenter.default.addObserver( @@ -72,7 +80,7 @@ final class MessageRouter { return } - let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date(), sendAttempts: 1) + let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1) if let transport = reachableTransport(for: peerID) { // Reachability without a connection is a freshness heuristic (e.g. // the mesh retention window), so the send can silently go nowhere. @@ -108,6 +116,7 @@ final class MessageRouter { if queue.count > Self.maxMessagesPerPeer { let evicted = queue.removeFirst() SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session) + onMessageDropped?(evicted.messageID, peerID) } outbox[peerID] = queue } @@ -142,13 +151,14 @@ final class MessageRouter { guard let queued = outbox[peerID], !queued.isEmpty else { return } SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) - let now = Date() + let now = now() var remaining: [QueuedMessage] = [] for message in queued { // Skip expired messages (TTL exceeded) if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session) + onMessageDropped?(message.messageID, peerID) continue } @@ -161,6 +171,7 @@ final class MessageRouter { // bounded by attempt count for peers that never ack. guard message.sendAttempts < Self.maxSendAttempts else { SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) + onMessageDropped?(message.messageID, peerID) continue } SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) @@ -186,12 +197,21 @@ final class MessageRouter { /// Periodically clean up expired messages from all outboxes func cleanupExpiredMessages() { - let now = Date() + let now = now() for peerID in Array(outbox.keys) { - outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds } + var expiredMessageIDs: [String] = [] + outbox[peerID]?.removeAll { message in + guard now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds else { return false } + expiredMessageIDs.append(message.messageID) + return true + } if outbox[peerID]?.isEmpty == true { outbox.removeValue(forKey: peerID) } + for messageID in expiredMessageIDs { + SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + onMessageDropped?(messageID, peerID) + } } } } diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index d38adfc4..380593fb 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -142,17 +142,9 @@ final class NostrTransport: Transport, @unchecked Sendable { func getFingerprint(for peerID: PeerID) -> String? { nil } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func triggerHandshake(with peerID: PeerID) { /* no-op */ } - - // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation - private static var cachedNoiseService: NoiseEncryptionService? - func getNoiseService() -> NoiseEncryptionService { - if let noiseService = Self.cachedNoiseService { - return noiseService - } - let noiseService = NoiseEncryptionService(keychain: keychain) - Self.cachedNoiseService = noiseService - return noiseService - } + + // Nostr does not use Noise sessions here; the inert Transport defaults + // for the noise* identity hooks apply. // Public broadcast not supported over Nostr here func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 11c50d60..4633698e 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -8,6 +8,7 @@ import BitLogger import BitFoundation +import Combine import Foundation import SwiftUI @@ -16,8 +17,16 @@ import SwiftUI /// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the /// `privateChats` / `unreadMessages` properties below are read-only views /// derived from it. +@MainActor final class PrivateChatManager: ObservableObject { - @Published var selectedPeer: PeerID? = nil + /// Read-only mirror of `ConversationStore.selectedPrivatePeerID` — the + /// store is the sole owner of conversation selection. Kept `@Published` + /// so existing observers (`objectWillChange` forwarding into + /// `ChatViewModel`) keep firing on selection changes. Mutate via + /// `startChat(with:)` / `endChat()`, which route through the store's + /// `setSelectedPrivatePeer` intent. + @Published private(set) var selectedPeer: PeerID? = nil + private var selectedPeerMirrorCancellable: AnyCancellable? = nil private var selectedPeerFingerprint: String? = nil var sentReadReceipts: Set = [] // Made accessible for ChatViewModel @@ -27,13 +36,30 @@ final class PrivateChatManager: ObservableObject { weak var messageRouter: MessageRouter? // Peer service for looking up peer info during consolidation weak var unifiedPeerService: UnifiedPeerService? - /// Single source of truth for message state; injected by the - /// bootstrapper (`wireServiceGraph`). - var conversationStore: ConversationStore? + /// Single source of truth for message and selection state; injected by + /// the bootstrapper (`wireServiceGraph`). + var conversationStore: ConversationStore? { + didSet { bindSelectionMirror() } + } init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) { self.meshService = meshService self.conversationStore = conversationStore + bindSelectionMirror() // didSet does not fire during init + } + + /// Keeps `selectedPeer` in lock-step with the store's selection axis + /// (including store-internal handoffs such as conversation migration). + private func bindSelectionMirror() { + guard let store = conversationStore else { + selectedPeerMirrorCancellable = nil + return + } + selectedPeerMirrorCancellable = store.$selectedPrivatePeerID + .sink { [weak self] peerID in + guard let self, self.selectedPeer != peerID else { return } + self.selectedPeer = peerID + } } // MARK: - Derived message state (read-only compat views) @@ -191,10 +217,14 @@ final class PrivateChatManager: ObservableObject { } } - /// Start a private chat with a peer + /// Start a private chat with a peer. Selection is mutated through the + /// store's intent (the store owns it); the manager keeps its side + /// effects (fingerprint tracking, read receipts, unread clearing). @MainActor func startChat(with peerID: PeerID) { - selectedPeer = peerID + // Also creates the conversation if needed and updates the derived + // `selectedConversationID`; `selectedPeer` mirrors the change. + conversationStore?.setSelectedPrivatePeer(peerID) // Store fingerprint for persistence across reconnections if let fingerprint = meshService?.getFingerprint(for: peerID) { @@ -203,14 +233,12 @@ final class PrivateChatManager: ObservableObject { // Mark messages as read markAsRead(from: peerID) - - // Initialize chat if needed - conversationStore?.conversation(for: .directPeer(peerID)) } - /// End the current private chat + /// End the current private chat (selection returns to the active public + /// channel's conversation). func endChat() { - selectedPeer = nil + conversationStore?.setSelectedPrivatePeer(nil) selectedPeerFingerprint = nil } diff --git a/bitchat/Services/TestEnvironment.swift b/bitchat/Services/TestEnvironment.swift new file mode 100644 index 00000000..86854a6c --- /dev/null +++ b/bitchat/Services/TestEnvironment.swift @@ -0,0 +1,25 @@ +// +// TestEnvironment.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Process-level test-environment detection for singletons that must swap a +/// real OS-backed dependency (keychain, persistent defaults, notifications) +/// for an in-memory one under test. Mirrors the detection already used by +/// `NotificationService` and `LocationStateManager`. +enum TestEnvironment { + /// True when running under XCTest / Swift Testing or in CI. + static let isRunningTests: Bool = { + let env = ProcessInfo.processInfo.environment + return NSClassFromString("XCTestCase") != nil || + env["XCTestConfigurationFilePath"] != nil || + env["XCTestBundlePath"] != nil || + env["GITHUB_ACTIONS"] != nil || + env["CI"] != nil + }() +} diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 72c6d542..36ca832b 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -61,7 +61,27 @@ protocol Transport: AnyObject { func getFingerprint(for peerID: PeerID) -> String? func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState func triggerHandshake(with peerID: PeerID) - func getNoiseService() -> NoiseEncryptionService + + // Noise identity/session access. Narrow, purpose-named wrappers so the + // underlying NoiseEncryptionService (and its peer-binding/session + // orchestration) is never exposed outside the transport. + /// The remote static public key of the Noise session with `peerID`, if established. + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? + /// Fingerprint of our own Noise static identity key. + func noiseIdentityFingerprint() -> String + /// Our Noise static public key (Curve25519 key agreement). + func noiseStaticPublicKeyData() -> Data + /// Our Noise signing public key (Ed25519). + func noiseSigningPublicKeyData() -> Data + /// Signs `data` with our Noise signing key. + func noiseSignData(_ data: Data) -> Data? + /// Verifies an Ed25519 `signature` over `data` against `publicKey`. + func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool + /// Registers session-lifecycle callbacks (peer authenticated / handshake required). + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) // Messaging func sendMessage(_ content: String, mentions: [String]) @@ -85,6 +105,19 @@ protocol Transport: AnyObject { } extension Transport { + // Noise identity hooks default to inert for transports that do not carry + // Noise sessions (e.g. NostrTransport). + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil } + func noiseIdentityFingerprint() -> String { "" } + func noiseStaticPublicKeyData() -> Data { Data() } + func noiseSigningPublicKeyData() -> Data { Data() } + func noiseSignData(_ data: Data) -> Data? { nil } + func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { false } + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) {} + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 7a6e069d..02ccfe4e 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -41,6 +41,9 @@ enum TransportConfig { static let blePendingNotificationsCapCount: Int = 128 static let bleNotificationRetryDelayMs: Int = 25 static let bleNotificationRetryMaxAttempts: Int = 80 + // Sample interval for notification backpressure logs (fire per fragment + // during media transfers). + static let bleBackpressureLogInterval: Int = 25 // Nostr static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second @@ -50,6 +53,14 @@ enum TransportConfig { // Sample interval for per-event debug logs on the inbound hot path. static let nostrInboundEventLogInterval: Int = 100 + // Conversation store diagnostics (field observability) + // Sample interval for the periodic store-audit "OK" heartbeat line + // (first + every Nth audit); violations always log at error level. + static let conversationStoreAuditLogInterval: Int = 10 + // Sample interval for the mirrored-republish debug line in the ID-only + // delivery fan-out (first + every Nth republish). + static let conversationStoreMirroredRepublishLogInterval: Int = 25 + // UI thresholds static let uiProcessedNostrEventsCap: Int = 2000 static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60 @@ -148,11 +159,19 @@ enum TransportConfig { static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0 static let nostrRelayBackoffMultiplier: Double = 2.0 static let nostrRelayMaxReconnectAttempts: Int = 10 + // Reconnect delays get ±20% random jitter so relays that dropped together + // (e.g. a network blip) don't thundering-herd the same reconnect instant. + static let nostrRelayBackoffJitterRatio: Double = 0.2 static let nostrRelayDefaultFetchLimit: Int = 100 // How many consecutive Tor-readiness waits (each bounded by TorManager's // bootstrap deadline) to attempt before unblocking pending EOSE callers. static let nostrTorReadyMaxWaitAttempts: Int = 3 static let nostrPendingSendQueueCap: Int = 200 + // Pending (not-yet-flushed) REQs are bounded per relay: oldest-by-insertion + // eviction at the cap, plus an age sweep on connect attempts. Durable + // subscription intent survives in subscriptionRequestState either way. + static let nostrPendingSubscriptionsPerRelayCap: Int = 64 + static let nostrPendingSubscriptionTTLSeconds: TimeInterval = 600.0 // Fallback deadline for treating a subscription's initial fetch as complete // when a relay never sends EOSE (generous to cover Tor circuit setup). static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0 diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index cf3ab4de..ae983cca 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -4,9 +4,11 @@ import Foundation final class VerificationService { static let shared = VerificationService() - // Injected Noise service from the running transport (do NOT create new BLEService) - private var noise: NoiseEncryptionService? - func configure(with noise: NoiseEncryptionService) { self.noise = noise } + // Injected running transport (do NOT create new BLEService). Noise + // identity operations go through the transport's narrow noise* wrappers + // so the raw NoiseEncryptionService is never exposed. + private var transport: Transport? + func configure(with transport: Transport) { self.transport = transport } /// Encapsulates the data encoded into a verification QR struct VerificationQR: Codable { @@ -77,16 +79,16 @@ final class VerificationService { if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 { return c.value } - guard let noise = noise else { return nil } - let noiseKey = noise.getStaticPublicKeyData().hexEncodedString() - let signKey = noise.getSigningPublicKeyData().hexEncodedString() + guard let transport = transport else { return nil } + let noiseKey = transport.noiseStaticPublicKeyData().hexEncodedString() + let signKey = transport.noiseSigningPublicKeyData().hexEncodedString() let ts = Int64(Date().timeIntervalSince1970) var nonce = Data(count: 16) _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "") let msg = payload.canonicalBytes() - guard let sig = noise.signData(msg) else { return nil } + guard let sig = transport.noiseSignData(msg) else { return nil } let signed = VerificationQR(v: payload.v, noiseKeyHex: payload.noiseKeyHex, signKeyHex: payload.signKeyHex, @@ -108,8 +110,8 @@ final class VerificationService { if now - Double(qr.ts) > maxAge { return nil } // Verify signature using embedded ed25519 signKey guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil } - guard let noise = noise else { return nil } - let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey) + guard let transport = transport else { return nil } + let ok = transport.noiseVerifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey) return ok ? qr : nil } @@ -133,7 +135,7 @@ final class VerificationService { let nk = noiseKeyHex.data(using: .utf8) ?? Data() msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(nonceA) - guard let noise = noise, let sig = noise.signData(msg) else { return nil } + guard let transport = transport, let sig = transport.noiseSignData(msg) else { return nil } var tlv = Data() tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255)) tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255)) @@ -178,7 +180,7 @@ final class VerificationService { let nk = noiseKeyHex.data(using: .utf8) ?? Data() msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(nonceA) - guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false } - return noise.verifySignature(signature, for: msg, publicKey: pub) + guard let transport = transport, let pub = Data(hexString: signerPublicKeyHex) else { return false } + return transport.noiseVerifySignature(signature, for: msg, publicKey: pub) } } diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 645ac1ab..46e0787a 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -45,9 +45,9 @@ protocol ChatPeerIdentityContext: AnyObject { /// `peerID`'s chat. (Single mutation path into the owner's /// `sentReadReceipts`; this coordinator never touches the raw set.) func syncReadReceiptsForSentMessages(for peerID: PeerID) - /// Re-targets the private chat session in the chat manager (no store-sync side effects). + /// Re-targets the private chat session: selection mutates through the + /// `ConversationStore` intent (the store owns selection). func beginPrivateChatSession(with peerID: PeerID) - func synchronizeConversationSelectionStore() func markPrivateMessagesAsRead(from peerID: PeerID) // MARK: Unified peer service @@ -154,15 +154,19 @@ extension ChatViewModel: ChatPeerIdentityContext { } func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { - meshService.getNoiseService().hasEstablishedSession(with: peerID) + if case .established = meshService.getNoiseSessionState(for: peerID) { return true } + return false } func hasNoiseSession(with peerID: PeerID) -> Bool { - meshService.getNoiseService().hasSession(with: peerID) + switch meshService.getNoiseSessionState(for: peerID) { + case .established, .handshaking: return true + case .none, .handshakeQueued, .failed: return false + } } func noiseIdentityFingerprint() -> String { - meshService.getNoiseService().getIdentityFingerprint() + meshService.noiseIdentityFingerprint() } func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { @@ -372,7 +376,6 @@ final class ChatPeerIdentityCoordinator { context.selectedPrivateChatFingerprint = nil } context.beginPrivateChatSession(with: peerID) - context.synchronizeConversationSelectionStore() context.markPrivateMessagesAsRead(from: peerID) } @@ -564,7 +567,11 @@ private extension ChatPeerIdentityCoordinator { @MainActor func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { - if context.selectedPrivateChatPeer == oldPeerID { + // Capture before the migration: the store hands its selection off to + // `newPeerID` during `migrateChatState`, and the manager's selection + // mirrors the store, so the old peer ID is no longer selected after. + let wasSelected = context.selectedPrivateChatPeer == oldPeerID + if wasSelected { SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) } else if !context.privateMessages(for: oldPeerID).isEmpty { SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) @@ -572,7 +579,7 @@ private extension ChatPeerIdentityCoordinator { migrateChatState(from: oldPeerID, to: newPeerID) - if context.selectedPrivateChatPeer == oldPeerID { + if wasSelected { context.selectedPrivateChatPeer = newPeerID } diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index f803c89b..31c38f7b 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -96,7 +96,7 @@ extension ChatViewModel: ChatTransportEventContext { } func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { - meshService.getNoiseService().getPeerPublicKeyData(peerID) + meshService.noiseSessionPublicKeyData(for: peerID) } func flushRouterOutbox(for peerID: PeerID) { diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index 356fbd7f..e82ebd35 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -98,13 +98,14 @@ extension ChatViewModel: ChatVerificationContext { onPeerAuthenticated: @escaping (PeerID, String) -> Void, onHandshakeRequired: @escaping (PeerID) -> Void ) { - let noiseService = meshService.getNoiseService() - noiseService.onPeerAuthenticated = onPeerAuthenticated - noiseService.onHandshakeRequired = onHandshakeRequired + meshService.installNoiseSessionCallbacks( + onPeerAuthenticated: onPeerAuthenticated, + onHandshakeRequired: onHandshakeRequired + ) } func noiseStaticPublicKeyData() -> Data { - meshService.getNoiseService().getStaticPublicKeyData() + meshService.noiseStaticPublicKeyData() } func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 2a15d59d..8bf4222a 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -204,7 +204,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } else { privateChatManager.endChat() } - synchronizeConversationSelectionStore() } } /// Read-only derived view of the store's unread direct conversations. @@ -243,7 +242,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele if let mapped = peerIdentityStore.stablePeerID(forShortID: shortPeerID) { return mapped } // Fallback: derive from active Noise session if available if shortPeerID.id.count == 16, - let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) { + let key = meshService.noiseSessionPublicKeyData(for: shortPeerID) { let stable = PeerID(hexData: key) peerIdentityStore.setStablePeerID(stable, forShortID: shortPeerID) return stable @@ -407,6 +406,23 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Single-writer: mutate only via `setPublicBatching(_:)` below. @Published private(set) var isBatchingPublic: Bool = false + // Backing store for `sentReadReceipts` persistence. `.standard` in + // production; injectable so tests can use a scratch suite that does not + // leak state between runs. + let readReceiptsDefaults: UserDefaults + + /// Default read-receipt persistence store. Production uses `.standard`. + /// Under test, a dedicated scratch suite is used instead — wiped at first + /// use per process — so back-to-back local test runs never see each + /// other's persisted receipts (and tests never pollute `.standard`). + static let defaultReadReceiptsDefaults: UserDefaults = { + guard TestEnvironment.isRunningTests else { return .standard } + let suiteName = "chat.bitchat.tests.readReceipts" + guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard } + scratch.removePersistentDomain(forName: suiteName) + return scratch + }() + // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed var sentReadReceipts: Set = [] { // messageID set @@ -414,9 +430,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Only persist if there are changes guard oldValue != sentReadReceipts else { return } - // Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read) + // Persist whenever it changes (no manual synchronize/verify re-read) if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { - UserDefaults.standard.set(data, forKey: "sentReadReceipts") + readReceiptsDefaults.set(data, forKey: "sentReadReceipts") } else { SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session) } @@ -429,6 +445,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Track app startup phase to prevent marking old messages as unread var isStartupPhase = true + + // ConversationStore field audit bookkeeping (see auditConversationStore()): + // runs on the read-receipt cleanup cadence, heartbeat sampled first + + // every `TransportConfig.conversationStoreAuditLogInterval`th audit. + private var storeAuditCount = 0 + private var storeAuditLastAppendCount = 0 // Announce Tor initial readiness once per launch to avoid duplicates var torInitialReadyAnnounced: Bool = false @@ -536,10 +558,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele /// Moves the open private chat to `newPeerID` when the current selection is /// one of the peer IDs being migrated away (side-effectful: re-targets the - /// private chat session and resyncs the conversation stores). + /// private chat session — fingerprint refresh, read receipts). + /// + /// Note: when this runs after a store `migrateConversation`, the store has + /// already handed the selection itself off to `newPeerID` (and the manager + /// mirrors it), so a selection that reads `newPeerID` is also re-targeted + /// to run the session side effects. Selections on unrelated peers are + /// untouched. @MainActor func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) { - guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return } + guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) + || selectedPrivateChatPeer == newPeerID else { return } selectedPrivateChatPeer = newPeerID } @@ -757,7 +786,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele conversations: ConversationStore? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, - locationManager: LocationChannelManager = .shared + locationManager: LocationChannelManager = .shared, + readReceiptsDefaults: UserDefaults? = nil ) { let conversations = conversations ?? ConversationStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() @@ -784,7 +814,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele self.autocompleteService = services.autocompleteService self.deduplicationService = services.deduplicationService self.publicMessagePipeline = services.publicMessagePipeline - self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts() + let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults + self.readReceiptsDefaults = readReceiptsDefaults + self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults) // Republish on every store change so SwiftUI observers of the // view model refresh. This replaces the UI-update role of the old @@ -803,7 +835,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele .store(in: &cancellables) ChatViewModelBootstrapper(viewModel: self).configure() - synchronizeConversationSelectionStore() } // MARK: - Deinitialization @@ -1140,8 +1171,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele bleService.resetIdentityForPanic(currentNickname: nickname) } - synchronizeConversationSelectionStore() - // No need to force UserDefaults synchronization // Reinitialize Nostr with new identity @@ -1262,13 +1291,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Message Handling - /// Pushes the private-chat manager's selection into the store, which - /// derives `selectedConversationID` from it and the active channel. - @MainActor - func synchronizeConversationSelectionStore() { - conversations.setSelectedPrivatePeer(privateChatManager.selectedPeer) - } - /// Invalidates the derived `messages` cache and notifies observers. /// (Formerly pulled the channel's timeline into a stored `messages` /// array; `messages` is now derived from the `ConversationStore`, so @@ -1466,6 +1488,35 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor func cleanupOldReadReceipts() { deliveryCoordinator.cleanupOldReadReceipts() + auditConversationStore() + } + + /// Periodic on-device verification of the `ConversationStore`'s + /// correctness invariants, piggybacked on the read-receipt cleanup + /// cadence (peer-list updates) so no extra timer exists. Loud on + /// violation (one error line each), near-silent when healthy (sampled + /// heartbeat: first + every Nth audit). The audit is O(total messages) + /// and allocation-free while healthy — measured ~0.5 ms at 5k messages + /// (see `PerformanceBaselineTests.testConversationStoreAudit`), cheap + /// relative to its cadence, so it always runs. + @MainActor + private func auditConversationStore() { + storeAuditCount += 1 + let violations = conversations.auditInvariants() + guard violations.isEmpty else { + for violation in violations { + SecureLogger.error("🚨 ConversationStore invariant violated: \(violation)", category: .session) + } + return + } + let appendCount = conversations.appendCount + if storeAuditCount == 1 || storeAuditCount.isMultiple(of: TransportConfig.conversationStoreAuditLogInterval) { + SecureLogger.debug( + "Store audit OK: \(conversations.conversationsByID.count) conversations, \(conversations.totalMessageCount) messages, map=\(conversations.messageIDMapCount), appends since last audit=\(appendCount - storeAuditLastAppendCount)", + category: .session + ) + } + storeAuditLastAppendCount = appendCount } func parseMentions(from content: String) -> [String] { diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 3ec15fa8..e2615cf9 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -78,6 +78,33 @@ private extension ChatViewModelBootstrapper { viewModel.privateChatManager.messageRouter = viewModel.messageRouter viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter + // Surface silent outbox drops (attempt cap, TTL expiry, overflow + // eviction) as a visible failure. The store's no-downgrade rule does + // not cover `.failed` over confirmed receipts, so guard here: a drop + // of an already-delivered/read message (e.g. a stale retained copy) + // must not downgrade its status. + viewModel.messageRouter.onMessageDropped = { [weak viewModel] messageID, peerID in + guard let viewModel else { return } + switch viewModel.conversations.deliveryStatus(forMessageID: messageID) { + case .delivered, .read: + // Field proof of the no-downgrade guard: the drop arrived + // after a confirmed receipt, so the `.failed` write is + // deliberately skipped. + SecureLogger.warning( + "📤 Router dropped message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… → .failed skipped (already delivered/read)", + category: .session + ) + default: + SecureLogger.warning( + "📤 Router dropped message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… → marked failed", + category: .session + ) + viewModel.conversations.setDeliveryStatus( + .failed(reason: "Not delivered"), + forMessageID: messageID + ) + } + } viewModel.commandProcessor.contextProvider = viewModel viewModel.commandProcessor.meshService = viewModel.meshService viewModel.participantTracker.configure(context: viewModel) @@ -91,17 +118,9 @@ private extension ChatViewModelBootstrapper { .store(in: &viewModel.cancellables) // Private message state flows through the single-writer - // `ConversationStore` intents and its `changes` subject; only the - // selection still originates in `PrivateChatManager`. - viewModel.privateChatManager.$selectedPeer - .receive(on: DispatchQueue.main) - .sink { [weak viewModel] _ in - Task { @MainActor [weak viewModel] in - viewModel?.synchronizeConversationSelectionStore() - } - } - .store(in: &viewModel.cancellables) - + // `ConversationStore` intents and its `changes` subject; selection + // is owned by the store too (`PrivateChatManager.selectedPeer` is a + // read-only mirror), so no selection bridge is needed here. viewModel.participantTracker.objectWillChange .sink { [weak viewModel] _ in viewModel?.objectWillChange.send() @@ -175,8 +194,6 @@ private extension ChatViewModelBootstrapper { if viewModel.hasTrackedPrivateChatSelection { viewModel.updatePrivateChatPeerIfNeeded() } - - viewModel.synchronizeConversationSelectionStore() } } .store(in: &viewModel.cancellables) diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 3e01bfd8..c64f6fd5 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -12,10 +12,23 @@ import BitFoundation struct TextMessageView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme @EnvironmentObject private var conversationUIModel: ConversationUIModel - + let message: BitchatMessage + /// Value snapshot of the message's mutable delivery status, captured at + /// construction. `BitchatMessage` is a reference type mutated in place by + /// `ConversationStore`, and SwiftUI compares reference-typed view fields + /// by identity — so a status-only change (e.g. delivered → read) on the + /// SAME instance would otherwise compare "unchanged" and this row's body + /// would be skipped even though the parent list re-rendered. Snapshotting + /// the enum makes the change visible to SwiftUI's structural diff. + private let deliveryStatus: DeliveryStatus? @State private var expandedMessageIDs: Set = [] - + + init(message: BitchatMessage) { + self.message = message + self.deliveryStatus = message.deliveryStatus + } + var body: some View { VStack(alignment: .leading, spacing: 0) { // Precompute heavy token scans once per row @@ -31,7 +44,7 @@ struct TextMessageView: View { // Delivery status indicator for private messages if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - let status = message.deliveryStatus { + let status = deliveryStatus { DeliveryStatusView(status: status) .padding(.leading, 4) } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index cc88fd03..4279aaa6 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -13,11 +13,24 @@ struct MediaMessageView: View { @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage let media: BitchatMessage.Media + /// Value snapshot of the message's mutable delivery status, captured at + /// construction (see `TextMessageView.deliveryStatus`): `BitchatMessage` + /// is a reference type mutated in place, and SwiftUI compares reference + /// fields by identity, so without the snapshot a status-only change + /// (send progress, delivered → read) would not re-render this row. + private let deliveryStatus: DeliveryStatus? @Binding var imagePreviewURL: URL? + init(message: BitchatMessage, media: BitchatMessage.Media, imagePreviewURL: Binding) { + self.message = message + self.media = media + self.deliveryStatus = message.deliveryStatus + self._imagePreviewURL = imagePreviewURL + } + var body: some View { - let state = mediaSendState(for: message) + let state = mediaSendState(for: deliveryStatus) let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil @@ -27,7 +40,7 @@ struct MediaMessageView: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - let status = message.deliveryStatus { + let status = deliveryStatus { DeliveryStatusView(status: status) .padding(.leading, 4) } @@ -63,10 +76,10 @@ struct MediaMessageView: View { .padding(.vertical, 4) } - private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) { + private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) { var isSending = false var progress: Double? - if let status = message.deliveryStatus { + if let status = deliveryStatus { switch status { case .sending: isSending = true diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 1308c637..0b70f0e4 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -298,6 +298,47 @@ struct AppArchitectureTests { #expect(inboxModel.messages(for: selectedPeerID).map(\.id) == ["dm-sel-1"]) } + @Test("PrivateInboxModel republishes read receipts for the selected DM (ephemeral- and stable-keyed)") + @MainActor + func privateInboxModelRepublishesReadReceiptsForSelectedConversation() { + // A DM's messages can live under BOTH .directPeer(ephemeral) and + // .directPeer(stableKey) (mirroring shares one BitchatMessage + // instance); the view's read-receipt update must fire no matter + // which of the two keys the selection holds. + let ephemeralPeerID = PeerID(str: "abcdef1234567890") + let stablePeerID = PeerID(str: String(repeating: "ab", count: 32)) + + for selectedPeerID in [ephemeralPeerID, stablePeerID] { + let store = ConversationStore() + let inboxModel = PrivateInboxModel(conversations: store) + store.setSelectedPrivatePeer(selectedPeerID) + + // One shared instance mirrored into both direct conversations, + // exactly like `mirrorToEphemeralIfNeeded`. + let message = makeArchitectureMessage( + id: "dm-read-1", + isPrivate: true, + senderPeerID: ephemeralPeerID + ) + store.append(message, to: .directPeer(ephemeralPeerID)) + store.upsertByID(message, in: .directPeer(stablePeerID)) + + var emissions = 0 + let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 } + defer { cancellable.cancel() } + + // ID-only intent — the exact call `ChatDeliveryCoordinator` + // makes when a READ ack arrives. + let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100)) + #expect(store.setDeliveryStatus(read, forMessageID: "dm-read-1")) + + // The fan-out emits .statusChanged for both containing + // conversations; exactly the selected one republishes the model. + #expect(emissions == 1) + #expect(inboxModel.messages(for: selectedPeerID).first?.deliveryStatus == read) + } + } + @Test("PublicChatModel ignores appends to background conversations") @MainActor func publicChatModelIsolatesBackgroundConversations() { diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index ebceeb30..b756999b 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -66,7 +66,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = [] private(set) var syncedReadReceiptPeers: [PeerID] = [] private(set) var begunChatSessions: [PeerID] = [] - private(set) var selectionStoreSyncCount = 0 private(set) var markedReadPeers: [PeerID] = [] @discardableResult @@ -83,7 +82,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { begunChatSessions.append(peerID) } - func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 } func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) } // Unified peer service @@ -283,7 +281,6 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"]) #expect(context.selectedPrivateChatFingerprint == "fp-alice") #expect(context.begunChatSessions == [peerID]) - #expect(context.selectionStoreSyncCount == 1) #expect(context.markedReadPeers == [peerID]) // Established session: no second handshake. diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index bf859abb..78365b66 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -349,6 +349,80 @@ struct ChatViewModelDeliveryStatusTests { #expect(isRead(viewModel.privateChats[peerID]?.last?.deliveryStatus)) } + // MARK: - MessageRouter Drop Wiring Tests + + /// Drives a real outbox drop (per-peer overflow eviction with no + /// reachable transport) and proves the bootstrapper wiring marks the + /// dropped message `.failed` in the conversation store. + @Test @MainActor + func messageRouterDrop_marksMessageFailedInStore() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let droppedID = "router-drop-0" + + let message = BitchatMessage( + id: droppedID, + sender: viewModel.nickname, + content: "Will be dropped", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + viewModel.seedPrivateChat([message], for: peerID) + + // No transport is reachable, so every send is queued; the 101st + // enqueue for this peer evicts the oldest queued message. + viewModel.messageRouter.sendPrivate("Will be dropped", to: peerID, recipientNickname: "Peer", messageID: droppedID) + for i in 1...100 { + viewModel.messageRouter.sendPrivate("Filler \(i)", to: peerID, recipientNickname: "Peer", messageID: "router-drop-\(i)") + } + + let status = viewModel.conversations.deliveryStatus(forMessageID: droppedID) + #expect({ + if case .failed = status { return true } + return false + }()) + } + + /// The store's no-downgrade rule does not cover `.failed` over confirmed + /// receipts, so the wiring guards it: a drop of an already-delivered + /// message must not downgrade its status. + @Test @MainActor + func messageRouterDrop_doesNotDowngradeDeliveredStatus() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let droppedID = "router-drop-delivered" + + let message = BitchatMessage( + id: droppedID, + sender: viewModel.nickname, + content: "Already delivered", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .delivered(to: "Peer", at: Date()) + ) + viewModel.seedPrivateChat([message], for: peerID) + + // Same eviction-driven drop as above, but the store already recorded + // a delivery confirmation for the message. + viewModel.messageRouter.sendPrivate("Already delivered", to: peerID, recipientNickname: "Peer", messageID: droppedID) + for i in 1...100 { + viewModel.messageRouter.sendPrivate("Filler \(i)", to: peerID, recipientNickname: "Peer", messageID: "router-keep-\(i)") + } + + let status = viewModel.conversations.deliveryStatus(forMessageID: droppedID) + #expect({ + if case .delivered = status { return true } + return false + }()) + } + // MARK: - Status Rank Tests (for deduplication) @Test @MainActor diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift index 52598db9..3c9ef6fd 100644 --- a/bitchatTests/ConversationStoreTests.swift +++ b/bitchatTests/ConversationStoreTests.swift @@ -754,4 +754,180 @@ struct ConversationStoreTests { #expect(publishedIDs.isEmpty) #expect(statusChangedIDs.isEmpty) } + + // MARK: - Invariant audit (field observability) + + /// A store exercised through every intent family: public + geohash + + /// mirrored direct conversations, out-of-order and equal-timestamp + /// appends, upserts, delivery updates, removal, migration, unread, and + /// selection. Used as the healthy baseline for audit tests. + @MainActor + private static func makeExercisedStore() -> ConversationStore { + let store = ConversationStore() + let stable = makeDirectConversationID("stable") + let ephemeral = makeDirectConversationID("ephemeral") + + store.append(makeMessage(id: "m1", timestamp: 10), to: .mesh) + store.append(makeMessage(id: "m3", timestamp: 30), to: .mesh) + store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh) // out of order + store.append(makeMessage(id: "m2b", timestamp: 20), to: .mesh) // equal timestamp + store.append(makeMessage(id: "g1", timestamp: 5), to: .geohash("u4pruyd")) + store.upsertByID(makeMessage(id: "g1", timestamp: 5, content: "edited"), in: .geohash("u4pruyd")) + + // Mirrored private copy (shared instance) across two direct chats. + let mirrored = makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent) + store.upsertByID(mirrored, in: stable) + store.upsertByID(mirrored, in: ephemeral) + store.setDeliveryStatus(.delivered(to: "bob", at: Date(timeIntervalSince1970: 2)), forMessageID: "dm-1") + + store.append(makeMessage(id: "dm-2", timestamp: 3, isPrivate: true), to: ephemeral) + store.migrateConversation(from: ephemeral, to: stable) + store.append(makeMessage(id: "dm-gone", timestamp: 4, isPrivate: true), to: stable) + store.removeMessage(withID: "dm-gone", from: stable) + + store.markUnread(stable) + store.setActiveChannel(.mesh) + return store + } + + @Test("audit reports no violations for a healthy, well-exercised store") + @MainActor + func auditHealthyStoreIsClean() { + let store = Self.makeExercisedStore() + #expect(store.auditInvariants().isEmpty) + + // Selection through both axes stays healthy. + store.setSelectedPrivatePeer(PeerID(str: "peer-stable")) + #expect(store.auditInvariants().isEmpty) + store.setSelectedPrivatePeer(nil) + #expect(store.auditInvariants().isEmpty) + } + + @Test("audit flags index entries pointing at the wrong position") + @MainActor + func auditFlagsCorruptIndexEntries() { + let store = Self.makeExercisedStore() + store.conversation(for: .mesh)._testCorruptIndexEntries() + + let violations = store.auditInvariants() + #expect(!violations.isEmpty) + #expect(violations.contains { $0.contains("mesh") && $0.contains("indexed at") }) + } + + @Test("audit flags a message missing from the per-conversation index") + @MainActor + func auditFlagsMissingIndexEntry() { + let store = Self.makeExercisedStore() + store.conversation(for: .mesh)._testRemoveIndexEntry(forMessageID: "m1") + + let violations = store.auditInvariants() + #expect(violations.contains { $0.contains("missing from index") }) + #expect(violations.contains { $0.contains("index has") }) // count mismatch + } + + @Test("audit flags timestamp-order violations") + @MainActor + func auditFlagsOrderingViolation() { + let store = Self.makeExercisedStore() + store.conversation(for: .mesh)._testCorruptOrderingPreservingIndex() + + let violations = store.auditInvariants() + #expect(violations.contains { $0.contains("timestamp order violated") }) + // The hook keeps the index consistent: no index violations leak in. + #expect(!violations.contains { $0.contains("indexed at") || $0.contains("missing from index") }) + } + + @Test("audit flags map memberships the conversation does not hold") + @MainActor + func auditFlagsPhantomMapMembership() { + let store = Self.makeExercisedStore() + store._testRegisterPhantomMessageID("ghost", in: .mesh) + + let violations = store.auditInvariants() + #expect(violations.contains { $0.contains("not present in claimed conversation") }) + #expect(violations.contains { $0.contains("memberships but conversations hold") }) + } + + @Test("audit flags map memberships claiming unknown conversations") + @MainActor + func auditFlagsUnknownConversationMembership() { + let store = Self.makeExercisedStore() + store._testRegisterPhantomMessageID("ghost", in: makeDirectConversationID("nope")) + + let violations = store.auditInvariants() + #expect(violations.contains { $0.contains("claims unknown conversation") }) + } + + @Test("audit flags conversation messages missing from the map") + @MainActor + func auditFlagsMissingMapMembership() { + let store = Self.makeExercisedStore() + store._testUnregisterMessageID("m1", from: .mesh) + + let violations = store.auditInvariants() + #expect(violations.contains { $0.contains("memberships but conversations hold") }) + } + + @Test("audit flags a conversation exceeding its cap") + @MainActor + func auditFlagsCapViolation() { + let store = ConversationStore() + let conversation = store.conversation(for: .mesh) + for index in 0.. NoiseEncryptionService { - return NoiseEncryptionService(keychain: mockKeychain) - } - func getFingerprint(for peerID: String) -> String? { return nil } diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 5ab7d7dc..0cdbe7a0 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -109,8 +109,34 @@ final class MockTransport: Transport { triggeredHandshakes.append(peerID) } - func getNoiseService() -> NoiseEncryptionService { - NoiseEncryptionService(keychain: mockKeychain) + // Noise identity wrappers backed by a mock-keychain encryption service + // (mirrors the previous `getNoiseService()` placeholder behavior: a real + // identity, but no peer sessions). Exposed so tests can assert against + // the same identity the wrappers use. + private(set) lazy var mockNoiseService = NoiseEncryptionService(keychain: mockKeychain) + + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { + mockNoiseService.getPeerPublicKeyData(peerID) + } + + func noiseIdentityFingerprint() -> String { + mockNoiseService.getIdentityFingerprint() + } + + func noiseStaticPublicKeyData() -> Data { + mockNoiseService.getStaticPublicKeyData() + } + + func noiseSigningPublicKeyData() -> Data { + mockNoiseService.getSigningPublicKeyData() + } + + func noiseSignData(_ data: Data) -> Data? { + mockNoiseService.signData(data) + } + + func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { + mockNoiseService.verifySignature(signature, for: data, publicKey: publicKey) } // MARK: - Messaging diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index 6ba8dbfa..9c5f2dc1 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -14,6 +14,18 @@ import BitFoundation // MARK: - Test Vector Support +/// Official Noise test vectors (NoiseTestVectors.json) for +/// `Noise_XX_25519_ChaChaPoly_SHA256` — the exact protocol this app speaks +/// (see `NoiseProtocolName` / `NoisePattern.XX`). Embedded byte-for-byte from +/// the two canonical community vector suites: +/// - cacophony: https://raw.githubusercontent.com/haskell-cryptography/cacophony/master/vectors/cacophony.txt +/// (6 messages: full XX handshake transcript + 3 transport messages, with +/// `handshake_hash`) +/// - snow: https://raw.githubusercontent.com/mcginty/snow/main/tests/vectors/snow.txt +/// (5 messages: full XX handshake transcript + 2 transport messages) +/// Plain XX has no PSKs and no pre-message keys; prologue is part of both +/// vectors and is mixed via `NoiseHandshakeState(prologue:)`. Fixed ephemerals +/// come in through the `predeterminedEphemeralKey` test seam. struct NoiseTestVector: Codable { let protocol_name: String let init_prologue: String @@ -586,9 +598,16 @@ struct NoiseProtocolTests { @Test func noiseTestVectors() throws { // Load test vectors from bundle let testVectors = try loadTestVectors() - + #expect(!testVectors.isEmpty, "No Noise test vectors loaded from fixture") + + // Every embedded vector must target the exact protocol the app uses. + let appProtocolName = NoiseProtocolName(pattern: NoisePattern.XX.patternName).fullName + for (index, testVector) in testVectors.enumerated() { print("Running test vector \(index + 1): \(testVector.protocol_name)") + #expect( + testVector.protocol_name == appProtocolName, + "Vector \(index + 1) targets \(testVector.protocol_name), app speaks \(appProtocolName)") try runTestVector(testVector) } } @@ -612,8 +631,13 @@ struct NoiseProtocolTests { } private func loadTestVectors() throws -> [NoiseTestVector] { - // Try to load from test bundle + // SwiftPM puts processed resources in the module bundle; the Xcode + // test target puts them in the test bundle itself. + #if SWIFT_PACKAGE + let testBundle = Bundle.module + #else let testBundle = Bundle(for: MockKeychain.self) + #endif guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json") else { throw NSError( diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index f8954db5..57714e45 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -38,15 +38,40 @@ final class PerformanceBaselineTests: XCTestCase { } /// Reports one human-readable throughput line per benchmark so CI logs - /// are readable without parsing XCTest's measure output. + /// are readable without parsing XCTest's measure output. The same line is + /// appended to the file named by `BITCHAT_PERF_LOG` (if set): under + /// `swift test --parallel` the runner swallows stdout of passing tests, + /// so the CI floor gate (scripts/check-perf-floors.sh) reads the file. private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) { guard !samples.isEmpty else { return } let avg = samples.reduce(0, +) / Double(samples.count) let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity - print(String( + let line = String( format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)", name, opsPerSec, unit, avg * 1000, operations, samples.count - )) + ) + print(line) + Self.appendToPerfLog(line) + } + + private static var perfLogPath: String? { + let path = ProcessInfo.processInfo.environment["BITCHAT_PERF_LOG"] + return (path?.isEmpty ?? true) ? nil : path + } + + /// Appends with `O_APPEND` because `swift test --parallel` may split this + /// class across worker processes that write concurrently. The file is + /// append-only (CI workspaces start fresh); delete it between local runs + /// if you reuse a path. + private static func appendToPerfLog(_ line: String) { + guard let path = perfLogPath else { return } + let fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0o644) + guard fd >= 0 else { return } + defer { close(fd) } + let bytes = Array((line + "\n").utf8) + bytes.withUnsafeBufferPointer { buffer in + _ = write(fd, buffer.baseAddress, buffer.count) + } } // MARK: - 1a. Nostr inbound event handling (fresh events) @@ -471,6 +496,34 @@ final class PerformanceBaselineTests: XCTestCase { reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages") } + // MARK: - 8. ConversationStore invariant audit (field observability) + + /// `ConversationStore.auditInvariants()` over a realistic 5k-message + /// corpus (mesh + geohash + 75 private chats). The audit runs in the + /// field on the read-receipt cleanup cadence + /// (`ChatViewModel.auditConversationStore`), so this measures the + /// per-audit cost that piggybacks on peer-list updates — it must stay + /// trivially cheap relative to that cadence. + func testConversationStoreAudit() { + let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 75, messagesPerPeer: 40) + let store = context.store + XCTAssertEqual(store.totalMessageCount, 5000) + let repsPerPass = 20 + var samples: [TimeInterval] = [] + + measure { + let start = Date() + var violationCount = 0 + for _ in 0.. Bool) -> Bool { diff --git a/bitchatTests/Performance/perf-floors.json b/bitchatTests/Performance/perf-floors.json new file mode 100644 index 00000000..af349819 --- /dev/null +++ b/bitchatTests/Performance/perf-floors.json @@ -0,0 +1,43 @@ +{ + "_philosophy": [ + "Floor throughputs for the PERF[...] lines printed by PerformanceBaselineTests.", + "Floors catch algorithmic regressions (O(n) -> O(n^2), accidental sync I/O,", + "quadratic re-scans), NOT tuning noise: each floor is deliberately set at", + "~25% of the throughput measured on a local dev machine (2026-06, Apple", + "Silicon), leaving ~4x headroom for CI runner variance so the gate never", + "flakes on a slow runner while still failing loudly on order-of-magnitude", + "regressions.", + "Raise floors deliberately after intentional performance improvements;", + "lower them only with a written justification in the PR. If a benchmark is", + "renamed or removed, update this file in the same change - the gate fails", + "when a floored benchmark stops reporting.", + "Checked by scripts/check-perf-floors.sh against captured swift-test output." + ], + "_units": "operations per second, matching each benchmark's PERF line", + "_reference_local_numbers_2026_06": { + "nostrInbound.fresh": 2132, + "nostrInbound.duplicate": 2558907, + "bleInbound.roundTripAndDedup": 38063, + "gcs.buildAndDecode": 776, + "delivery.incrementalUpdate": 172615, + "delivery.storeUpdate": 158862, + "formatting.formatMessage": 12261, + "pipeline.privateIngest": 24848, + "pipeline.publicIngest": 13102, + "store.append": 213201, + "store.audit": 362 + }, + "floors": { + "nostrInbound.fresh": 530, + "nostrInbound.duplicate": 600000, + "bleInbound.roundTripAndDedup": 9500, + "gcs.buildAndDecode": 190, + "delivery.incrementalUpdate": 43000, + "delivery.storeUpdate": 39000, + "formatting.formatMessage": 3000, + "pipeline.privateIngest": 6000, + "pipeline.publicIngest": 3200, + "store.append": 53000, + "store.audit": 90 + } +} diff --git a/bitchatTests/ProtocolContractTests.swift b/bitchatTests/ProtocolContractTests.swift index 27541f47..61e2d717 100644 --- a/bitchatTests/ProtocolContractTests.swift +++ b/bitchatTests/ProtocolContractTests.swift @@ -21,7 +21,6 @@ private final class DefaultTransportProbe: Transport { let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([]) let myPeerID = PeerID(str: "0011223344556677") var myNickname = "Tester" - private let keychain = MockKeychain() private(set) var sentMessages: [(content: String, mentions: [String])] = [] var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { @@ -40,7 +39,6 @@ private final class DefaultTransportProbe: Transport { func getFingerprint(for peerID: PeerID) -> String? { nil } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func triggerHandshake(with peerID: PeerID) {} - func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: keychain) } func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) } func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {} func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index 01aea3ff..13f020a9 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -110,6 +110,98 @@ struct MessageRouterTests { #expect(transport.sentPrivateMessages.count == 8) } + // MARK: - Drop visibility (onMessageDropped) + + @Test @MainActor + func flushOutbox_attemptCapDropInvokesOnMessageDropped() async { + let peerID = PeerID(str: "0000000000000009") + let transport = MockTransport() + transport.reachablePeers.insert(peerID) + + let router = MessageRouter(transports: [transport]) + var dropped: [(messageID: String, peerID: PeerID)] = [] + router.onMessageDropped = { dropped.append(($0, $1)) } + + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m9") + for _ in 0..<10 { + router.flushOutbox(for: peerID) + } + + #expect(dropped.count == 1) + #expect(dropped.first?.messageID == "m9") + #expect(dropped.first?.peerID == peerID) + } + + @Test @MainActor + func flushOutbox_ttlExpiryInvokesOnMessageDroppedAndDoesNotResend() async { + let peerID = PeerID(str: "000000000000000a") + let transport = MockTransport() + let clock = MutableTestClock() + + let router = MessageRouter(transports: [transport], now: { clock.now }) + var dropped: [String] = [] + router.onMessageDropped = { messageID, _ in dropped.append(messageID) } + + // No reachable transport: the message is queued, never sent. + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m10") + #expect(transport.sentPrivateMessages.isEmpty) + + // Past the 24h TTL the flush must drop it (visibly), not send it. + clock.now = clock.now.addingTimeInterval(24 * 60 * 60 + 1) + transport.reachablePeers.insert(peerID) + router.flushOutbox(for: peerID) + + #expect(dropped == ["m10"]) + #expect(transport.sentPrivateMessages.isEmpty) + + // The drop is final: nothing is retained for later flushes. + router.flushOutbox(for: peerID) + #expect(dropped == ["m10"]) + } + + @Test @MainActor + func cleanupExpiredMessages_invokesOnMessageDroppedForExpiredOnly() async { + let peerID = PeerID(str: "000000000000000b") + let transport = MockTransport() + let clock = MutableTestClock() + + let router = MessageRouter(transports: [transport], now: { clock.now }) + var dropped: [String] = [] + router.onMessageDropped = { messageID, _ in dropped.append(messageID) } + + router.sendPrivate("Old", to: peerID, recipientNickname: "Peer", messageID: "m11-old") + clock.now = clock.now.addingTimeInterval(24 * 60 * 60 - 60) + router.sendPrivate("Fresh", to: peerID, recipientNickname: "Peer", messageID: "m11-fresh") + clock.now = clock.now.addingTimeInterval(120) + + router.cleanupExpiredMessages() + + #expect(dropped == ["m11-old"]) + + // The fresh message survived and still flushes once reachable. + transport.reachablePeers.insert(peerID) + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.map(\.messageID) == ["m11-fresh"]) + } + + @Test @MainActor + func enqueue_perPeerOverflowEvictionInvokesOnMessageDropped() async { + let peerID = PeerID(str: "000000000000000c") + let transport = MockTransport() + + let router = MessageRouter(transports: [transport]) + var dropped: [String] = [] + router.onMessageDropped = { messageID, _ in dropped.append(messageID) } + + // No reachable transport: everything queues. The cap is 100 per peer, + // so the 101st enqueue evicts the oldest. + for i in 0...100 { + router.sendPrivate("Hello \(i)", to: peerID, recipientNickname: "Peer", messageID: "q\(i)") + } + + #expect(dropped == ["q0"]) + } + @Test @MainActor func sendReadReceipt_usesReachableTransport() async { let peerID = PeerID(str: "0000000000000003") @@ -135,3 +227,9 @@ struct MessageRouterTests { #expect(transport.sentFavoriteNotifications.count == 1) } } + +/// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable +/// without real waiting. +private final class MutableTestClock { + var now = Date(timeIntervalSince1970: 1_700_000_000) +} diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index 7139b430..c6271973 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -1195,6 +1195,98 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(reconnected) } + func test_pendingSubscriptions_perRelayCapEvictsOldestByInsertionOrder() async { + let relayURL = "wss://pending-cap.example" + // Tor stalled: nothing flushes, so every REQ stays pending. + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + let cap = TransportConfig.nostrPendingSubscriptionsPerRelayCap + + for i in 0..<(cap + 3) { + context.manager.subscribe(filter: makeFilter(), id: "cap-sub-\(i)", relayUrls: [relayURL], handler: { _ in }) + } + + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), cap) + let pendingIDs = context.manager.debugPendingSubscriptionIDs(for: relayURL) + // The three oldest entries were evicted; the newest survive. + for i in 0..<3 { + XCTAssertFalse(pendingIDs.contains("cap-sub-\(i)"), "expected cap-sub-\(i) to be evicted") + } + for i in 3..<(cap + 3) { + XCTAssertTrue(pendingIDs.contains("cap-sub-\(i)"), "expected cap-sub-\(i) to be retained") + } + } + + func test_pendingSubscriptions_staleEntriesSweptOnConnectAttempt() async { + let relayURL = "wss://pending-sweep.example" + // Tor stalled: the REQ stays pending and no socket ever opens. + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + + context.manager.subscribe(filter: makeFilter(), id: "stale-pending-sub", relayUrls: [relayURL], handler: { _ in }) + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1) + + // Just under the TTL the entry survives a connect attempt. + context.clock.now = context.clock.now.addingTimeInterval(TransportConfig.nostrPendingSubscriptionTTLSeconds - 1) + context.manager.ensureConnections(to: [relayURL]) + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1) + + // Past the TTL the next connect attempt sweeps it. + context.clock.now = context.clock.now.addingTimeInterval(2) + context.manager.ensureConnections(to: [relayURL]) + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) + } + + func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async { + let relayURL = "wss://jitter-bounds.example" + // Pin the jitter source to the extremes and the midpoint of [0, 1). + let jitter = JitterSequence([0.0, 1.0.nextDown, 0.25]) + let context = makeContext(permission: .denied, jitterUnit: { jitter.next() }) + // Persistent ping failure: every connect attempt fails and schedules + // the next reconnect with an increasing attempt count. + context.sessionFactory.pingErrorByURL[relayURL] = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + + context.manager.ensureConnections(to: [relayURL]) + + var delays: [TimeInterval] = [] + for attempt in 1...3 { + let scheduled = await waitUntil { context.scheduler.scheduled.count == 1 } + XCTAssertTrue(scheduled, "reconnect for attempt \(attempt) was not scheduled") + delays.append(context.scheduler.scheduled[0].delay) + context.scheduler.runNext() + } + + // Bases: 1s, 2s, 4s. Jitter factors: 0.8, ~1.2, 0.9. + XCTAssertEqual(delays[0], 0.8 * TransportConfig.nostrRelayInitialBackoffSeconds, accuracy: 1e-9) + XCTAssertEqual(delays[1], 1.2 * TransportConfig.nostrRelayInitialBackoffSeconds * TransportConfig.nostrRelayBackoffMultiplier, accuracy: 1e-6) + XCTAssertEqual(delays[2], 0.9 * TransportConfig.nostrRelayInitialBackoffSeconds * pow(TransportConfig.nostrRelayBackoffMultiplier, 2), accuracy: 1e-9) + } + + func test_reconnectBackoff_realRandomJitterStaysInBoundsAndVaries() async { + let relayURL = "wss://jitter-random.example" + let context = makeContext(permission: .denied, jitterUnit: { Double.random(in: 0..<1) }) + context.sessionFactory.pingErrorByURL[relayURL] = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + + context.manager.ensureConnections(to: [relayURL]) + + var factors: [Double] = [] + for attempt in 1...5 { + let scheduled = await waitUntil { context.scheduler.scheduled.count == 1 } + XCTAssertTrue(scheduled, "reconnect for attempt \(attempt) was not scheduled") + let base = min( + TransportConfig.nostrRelayInitialBackoffSeconds * pow(TransportConfig.nostrRelayBackoffMultiplier, Double(attempt - 1)), + TransportConfig.nostrRelayMaxBackoffSeconds + ) + let factor = context.scheduler.scheduled[0].delay / base + XCTAssertGreaterThanOrEqual(factor, 1.0 - TransportConfig.nostrRelayBackoffJitterRatio) + XCTAssertLessThan(factor, 1.0 + TransportConfig.nostrRelayBackoffJitterRatio) + factors.append(factor) + context.scheduler.runNext() + } + + // A real RNG must not produce a constant delay across attempts + // (5 identical uniform doubles is probability ~0). + XCTAssertGreaterThan(Set(factors).count, 1) + } + private func makeContext( permission: LocationChannelManager.PermissionState, favorites: Set = [], @@ -1202,7 +1294,8 @@ final class NostrRelayManagerTests: XCTestCase { userTorEnabled: Bool = false, torEnforced: Bool = false, torIsReady: Bool = true, - torIsForeground: Bool = true + torIsForeground: Bool = true, + jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter) ) -> RelayManagerTestContext { let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) @@ -1228,7 +1321,8 @@ final class NostrRelayManagerTests: XCTestCase { scheduleAfter: { delay, action in scheduler.schedule(delay: delay, action: action) }, - now: { clock.now } + now: { clock.now }, + jitterUnit: jitterUnit ) ) return RelayManagerTestContext( @@ -1305,6 +1399,20 @@ private final class MutableClock { } } +/// Deterministic jitter source: returns the queued values in order, then a +/// neutral 0.5 (jitter factor 1.0) once exhausted. +private final class JitterSequence { + private var values: [Double] + + init(_ values: [Double]) { + self.values = values + } + + func next() -> Double { + values.isEmpty ? 0.5 : values.removeFirst() + } +} + private final class MutableBool { var value: Bool diff --git a/bitchatTests/Services/VerificationServiceTests.swift b/bitchatTests/Services/VerificationServiceTests.swift index 575b1781..b8f688d3 100644 --- a/bitchatTests/Services/VerificationServiceTests.swift +++ b/bitchatTests/Services/VerificationServiceTests.swift @@ -98,10 +98,13 @@ final class VerificationServiceTests: XCTestCase { } private func makeService() -> (VerificationService, NoiseEncryptionService) { - let noise = NoiseEncryptionService(keychain: MockKeychain()) + // The service consumes Noise identity operations through the + // Transport's narrow noise* wrappers; the mock transport's backing + // encryption service is returned for direct assertions. + let transport = MockTransport() let service = VerificationService() - service.configure(with: noise) - return (service, noise) + service.configure(with: transport) + return (service, transport.mockNoiseService) } private func makeSignedQR( diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 06d5b1df..0d6f99c6 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -629,6 +629,52 @@ struct ViewSmokeTests { #expect(playback.progress == 0) } + @Test + func messageRows_snapshotDeliveryStatusForSwiftUIDiffing() { + // Regression: `BitchatMessage` is a reference type mutated in place + // by `ConversationStore.applyDeliveryStatus`, and SwiftUI compares + // reference-typed view fields by identity — so a status-only change + // (delivered → read) on the SAME instance is invisible to the row's + // structural diff and its body gets skipped even when the list + // re-renders. The row views must therefore snapshot the status as a + // value-typed stored property at init, so a rebuilt row value + // compares different and re-renders. + func deliveryStatusSnapshot(of row: Any) -> DeliveryStatus? { + Mirror(reflecting: row).children + .first { $0.label == "deliveryStatus" }? + .value as? DeliveryStatus + } + + let delivered = DeliveryStatus.delivered(to: "builder", at: Date(timeIntervalSince1970: 50)) + let message = BitchatMessage( + id: "dm-status-1", + sender: "anon", + content: "hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "builder", + senderPeerID: PeerID(str: "abcdef1234567890"), + deliveryStatus: delivered + ) + + #expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == delivered) + + // In-place mutation of the shared instance (what the store does on a + // READ ack); a freshly built row must carry the new status value. + let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100)) + message.deliveryStatus = read + + #expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == read) + + let mediaRow = MediaMessageView( + message: message, + media: .image(URL(fileURLWithPath: "/tmp/never-loaded.jpg")), + imagePreviewURL: .constant(nil) + ) + #expect(deliveryStatusSnapshot(of: mediaRow) == read) + } + #if os(iOS) @Test func cameraScannerView_previewAndCoordinatorSmoke() { diff --git a/localPackages/BitLogger/Sources/SecureLogger.swift b/localPackages/BitLogger/Sources/SecureLogger.swift index 2599dfdf..5aaffd53 100644 --- a/localPackages/BitLogger/Sources/SecureLogger.swift +++ b/localPackages/BitLogger/Sources/SecureLogger.swift @@ -136,25 +136,32 @@ public extension SecureLogger { static func info(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { + #if DEBUG guard shouldLog(.info) else { return } log(message(), category: category, level: .info, file: file, line: line, function: function) + #endif } static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { + #if DEBUG guard shouldLog(.warning) else { return } log(message(), category: category, level: .warning, file: file, line: line, function: function) + #endif } static func error(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { + #if DEBUG guard shouldLog(.error) else { return } log(message(), category: category, level: .error, file: file, line: line, function: function) + #endif } /// Log errors with context static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { + #if DEBUG let location = formatLocation(file: file, line: line, function: function) let sanitized = context().sanitized() let errorDesc = error.localizedDescription.sanitized() @@ -164,6 +171,7 @@ public extension SecureLogger { #else os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) #endif + #endif } } @@ -242,32 +250,25 @@ private extension SecureLogger { /// Log general messages with automatic sensitive data filtering static func log(_ message: @autoclosure () -> String, category: OSLog, level: LogLevel, file: String, line: Int, function: String) { + // All public wrappers are compiled out of release builds; this core + // is gated too so no future call path can reintroduce production + // logging. bitchat is privacy-first: release builds emit nothing. + #if DEBUG guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) let sanitized = "\(location) \(message())".sanitized() - - #if DEBUG os_log("%{public}@", log: category, type: level.osLogType, sanitized) - #else - // In release builds, only log non-debug messages - if level != .debug { - os_log("%{private}@", log: category, type: level.osLogType, sanitized) - } #endif } /// Log a security event static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info, file: String, line: Int, function: String) { + #if DEBUG guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) let message = "\(location) \(event.message)" - - #if DEBUG os_log("%{public}@", log: .security, type: level.osLogType, message) - #else - // In release, use private logging to prevent sensitive data exposure - os_log("%{private}@", log: .security, type: level.osLogType, message) #endif } diff --git a/scripts/check-perf-floors.sh b/scripts/check-perf-floors.sh new file mode 100755 index 00000000..27d72a9e --- /dev/null +++ b/scripts/check-perf-floors.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# check-perf-floors.sh — order-of-magnitude performance regression gate. +# +# Parses the `PERF[name]: N unit/sec ...` lines that PerformanceBaselineTests +# prints into captured test output and fails if any benchmark's throughput is +# below its floor from bitchatTests/Performance/perf-floors.json. +# +# Floor philosophy (see the floors file): floors sit at ~25% of locally +# measured throughput, so they catch algorithmic regressions (O(n) -> O(n^2)), +# never runner variance. Raise floors deliberately after intentional +# improvements; never tune them to chase noise. +# +# Usage: scripts/check-perf-floors.sh [floors-file] +# +# Skips gracefully (exit 0) when: +# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or +# - the output contains no PERF lines (e.g. package-only matrix entries). +# +# Fails (exit 1) when: +# - any benchmark reports throughput below its floor, or +# - PERF lines are present but a floored benchmark is missing +# (a silently-dropped benchmark must be an explicit floors-file change). + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [floors-file]" >&2 + exit 2 +fi + +OUTPUT_FILE="$1" +FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}" + +if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then + echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate." + exit 0 +fi + +if [[ ! -f "$OUTPUT_FILE" ]]; then + echo "perf-floors: output file '$OUTPUT_FILE' not found — skipping gate." >&2 + exit 0 +fi + +if [[ ! -f "$FLOORS_FILE" ]]; then + echo "perf-floors: floors file '$FLOORS_FILE' not found." >&2 + exit 2 +fi + +if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then + echo "perf-floors: no PERF lines in '$OUTPUT_FILE' — skipping gate." + exit 0 +fi + +OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF' +import json +import os +import re +import sys + +output_file = os.environ["OUTPUT_FILE"] +floors_file = os.environ["FLOORS_FILE"] + +with open(floors_file) as f: + floors = json.load(f)["floors"] + +# PERF[delivery.storeUpdate]: 158862 updates/sec (avg 3.147 ms per pass of 500, 10 passes) +pattern = re.compile(r"PERF\[([^\]]+)\]:\s*([0-9]+(?:\.[0-9]+)?)\s*(\S+)/sec") + +measured = {} +with open(output_file, errors="replace") as f: + for line in f: + m = pattern.search(line) + if m: + # Keep the last reported value if a benchmark prints twice. + measured[m.group(1)] = (float(m.group(2)), m.group(3)) + +failures = [] +print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)") +for name in sorted(set(floors) | set(measured)): + floor = floors.get(name) + if name not in measured: + failures.append( + f" MISSING {name}: floored benchmark reported no PERF line " + f"(removed/renamed? update perf-floors.json in the same change)") + continue + value, unit = measured[name] + if floor is None: + print(f" NO-FLOOR {name}: {value:.0f} {unit}/sec (consider adding a floor)") + continue + status = "OK" if value >= floor else "BELOW" + line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})" + print(line) + if value < floor: + failures.append( + f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} " + f"({value / floor * 100:.0f}% of floor)") + +if failures: + print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:") + print("\n".join(failures)) + print("\nFloors are ~25% of healthy local throughput; falling below one means an") + print("algorithmic regression, not runner noise. If the change is intentional,") + print("update bitchatTests/Performance/perf-floors.json deliberately.") + sys.exit(1) + +print("perf-floors: all benchmarks at or above their floors.") +PYEOF