mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Close architecture re-grade gaps: visible failures, relay bounds, crypto encapsulation, perf gate, field diagnostics, resurrected Noise vectors (#1335)
Close architecture re-grade gaps: visible send failures, relay bounds, crypto encapsulation, perf gate, resurrected Noise vector tests
This commit is contained in:
@@ -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.
|
||||
|
||||
+8
-2
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ConversationID>] = [:]
|
||||
|
||||
/// 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<ConversationChange, Never>()
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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<String>] = [:] // 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<String>, 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<String> {
|
||||
guard let map = pendingSubscriptions[relayUrl] else { return [] }
|
||||
return Set(map.keys)
|
||||
}
|
||||
|
||||
var debugDuplicateInboundEventDropCount: Int {
|
||||
duplicateInboundEventDropCount
|
||||
}
|
||||
|
||||
@@ -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<CBCentral>()
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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<String> = [] // 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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// TestEnvironment.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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
|
||||
}()
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<String> = [] { // 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] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String> = []
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<URL?>) {
|
||||
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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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..<conversation.cap {
|
||||
store.append(makeMessage(id: "m\(index)", timestamp: TimeInterval(index)), to: .mesh)
|
||||
}
|
||||
#expect(store.auditInvariants().isEmpty)
|
||||
|
||||
store._testAppendBypassingCap(
|
||||
makeMessage(id: "over-cap", timestamp: TimeInterval(conversation.cap)),
|
||||
to: .mesh
|
||||
)
|
||||
|
||||
let violations = store.auditInvariants()
|
||||
#expect(violations.contains { $0.contains("exceeds cap") })
|
||||
// The hook keeps the map exact: only the cap invariant fires.
|
||||
#expect(violations.count == 1)
|
||||
}
|
||||
|
||||
@Test("audit flags unread entries for nonexistent conversations")
|
||||
@MainActor
|
||||
func auditFlagsStaleUnreadEntry() {
|
||||
let store = Self.makeExercisedStore()
|
||||
store._testInsertUnreadConversationID(makeDirectConversationID("gone"))
|
||||
|
||||
let violations = store.auditInvariants()
|
||||
#expect(violations.contains { $0.contains("unreadConversations contains unknown conversation") })
|
||||
}
|
||||
|
||||
@Test("audit flags a selection pointing at a nonexistent conversation")
|
||||
@MainActor
|
||||
func auditFlagsDanglingSelection() {
|
||||
let store = Self.makeExercisedStore()
|
||||
store._testSetSelectedConversationID(makeDirectConversationID("gone"))
|
||||
|
||||
let violations = store.auditInvariants()
|
||||
#expect(violations.contains { $0.contains("selectedConversationID") && $0.contains("has no conversation") })
|
||||
}
|
||||
|
||||
@Test("appendCount counts every insertion, not duplicates or updates")
|
||||
@MainActor
|
||||
func appendCountTracksInsertions() {
|
||||
let store = ConversationStore()
|
||||
let source = makeDirectConversationID("ephemeral")
|
||||
let destination = makeDirectConversationID("stable")
|
||||
|
||||
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)
|
||||
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh) // duplicate: not counted
|
||||
store.upsertByID(makeMessage(id: "m2", timestamp: 2), in: .mesh) // upsert-append: counted
|
||||
store.upsertByID(makeMessage(id: "m2", timestamp: 2, content: "edit"), in: .mesh) // in-place: not counted
|
||||
#expect(store.appendCount == 2)
|
||||
|
||||
store.append(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true), to: source)
|
||||
store.migrateConversation(from: source, to: destination) // migration insert: counted
|
||||
#expect(store.appendCount == 4)
|
||||
|
||||
// Removal does not decrement: the counter is a throughput odometer.
|
||||
store.removeMessage(withID: "dm-1", from: destination)
|
||||
#expect(store.appendCount == 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,7 @@ final class MockBLEService: NSObject {
|
||||
weak var delegate: BitchatDelegate?
|
||||
var myPeerID = PeerID(str: "MOCK1234")
|
||||
var myNickname: String = "MockUser"
|
||||
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
|
||||
// Test-specific properties
|
||||
var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = []
|
||||
var sentPackets: [BitchatPacket] = []
|
||||
@@ -240,10 +238,6 @@ final class MockBLEService: NSObject {
|
||||
delegate?.didUpdatePeerList([])
|
||||
}
|
||||
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
return NoiseEncryptionService(keychain: mockKeychain)
|
||||
}
|
||||
|
||||
func getFingerprint(for peerID: String) -> String? {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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..<repsPerPass {
|
||||
violationCount += store.auditInvariants().count
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertEqual(violationCount, 0, "healthy corpus must audit clean")
|
||||
}
|
||||
|
||||
reportThroughput("store.audit", samples: samples, operations: repsPerPass, unit: "audits")
|
||||
}
|
||||
|
||||
/// Spins the main run loop in small slices (draining main-queue tasks and
|
||||
/// timers) until `condition` holds or `timeout` elapses.
|
||||
private func spinMainRunLoop(timeout: TimeInterval, until condition: () -> Bool) -> Bool {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Data> = [],
|
||||
@@ -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<LocationChannelManager.PermissionState, Never>(permission)
|
||||
let favoritesSubject = CurrentValueSubject<Set<Data>, 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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Executable
+108
@@ -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 <test-output-file> [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 <test-output-file> [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
|
||||
Reference in New Issue
Block a user