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:
jack
2026-06-11 20:10:28 +01:00
committed by GitHub
37 changed files with 1543 additions and 158 deletions
+14
View File
@@ -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
View File
@@ -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")
]
)
]
+1 -1
View File
@@ -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
+223
View File
@@ -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 {
+85 -9
View File
@@ -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,15 +1048,25 @@ 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
@@ -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
}
+58 -13
View File
@@ -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()
@@ -402,9 +406,16 @@ 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()
+25 -5
View File
@@ -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)
}
}
}
}
+2 -10
View File
@@ -143,16 +143,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
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 */ }
+39 -11
View File
@@ -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
}
+25
View File
@@ -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
}()
}
+34 -1
View File
@@ -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) {}
+19
View File
@@ -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
+14 -12
View File
@@ -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) {
+69 -18
View File
@@ -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)
+14 -1
View File
@@ -14,8 +14,21 @@ struct TextMessageView: View {
@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)
}
+17 -4
View File
@@ -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
+41
View File
@@ -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
+176
View File
@@ -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)
}
}
-6
View File
@@ -35,8 +35,6 @@ final class MockBLEService: NSObject {
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
}
+28 -2
View File
@@ -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
+25 -1
View File
@@ -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 {
+43
View File
@@ -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
}
}
-2
View File
@@ -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(
+46
View File
@@ -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
}
+108
View File
@@ -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