mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 04:25:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8da6dba905 |
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
@Published private(set) var messages: [BitchatMessage] = []
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
@Published private(set) var isUnread: Bool = false
|
@Published private(set) var isUnread: Bool = false
|
||||||
|
|
||||||
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||||
/// dedup and delivery-status lookup. Logical indexes are physical array
|
/// delivery-status lookup. Kept in sync on every mutation:
|
||||||
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
/// - tail append: single insert
|
||||||
/// instead of rewriting every surviving dictionary entry. This matters
|
/// - out-of-order insert: suffix reindex from the insertion point
|
||||||
/// after the 1337-message cap is reached, when every steady-state tail
|
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||||
/// append evicts one old row.
|
/// rebuild does not change the asymptotics, and trim only happens once
|
||||||
///
|
/// the cap (1337) is reached. Simple and correct beats the
|
||||||
/// Out-of-order inserts and middle removals still reindex only the
|
/// offset-tracking alternative here.
|
||||||
/// affected suffix. Full filtering resets the offset while rebuilding.
|
|
||||||
private var indexByMessageID: [String: Int] = [:]
|
private var indexByMessageID: [String: Int] = [:]
|
||||||
private var indexOffset = 0
|
|
||||||
|
|
||||||
fileprivate init(id: ConversationID, cap: Int) {
|
fileprivate init(id: ConversationID, cap: Int) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func message(withID messageID: String) -> BitchatMessage? {
|
func message(withID messageID: String) -> BitchatMessage? {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
return messages[index]
|
return messages[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
reindex(from: index)
|
reindex(from: index)
|
||||||
} else {
|
} else {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||||
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// timeline position (in-place updates like media progress reuse the
|
/// timeline position (in-place updates like media progress reuse the
|
||||||
/// original timestamp); a new message goes through ordered insertion.
|
/// original timestamp); a new message goes through ordered insertion.
|
||||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||||
if let index = physicalIndex(forMessageID: message.id) {
|
if let index = indexByMessageID[message.id] {
|
||||||
messages[index] = message
|
messages[index] = message
|
||||||
return .updated
|
return .updated
|
||||||
}
|
}
|
||||||
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||||
/// Returns `true` when the status was applied.
|
/// Returns `true` when the status was applied.
|
||||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
let message = messages[index]
|
let message = messages[index]
|
||||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||||
|
|
||||||
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// observers still need an @Published emission to re-render.
|
/// observers still need an @Published emission to re-render.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
messages[index] = messages[index]
|
messages[index] = messages[index]
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// Removes a single message by ID. Returns the removed message, or
|
/// Removes a single message by ID. Returns the removed message, or
|
||||||
/// `nil` when no message with that ID exists.
|
/// `nil` when no message with that ID exists.
|
||||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
let removed = messages.remove(at: index)
|
let removed = messages.remove(at: index)
|
||||||
indexByMessageID.removeValue(forKey: messageID)
|
indexByMessageID.removeValue(forKey: messageID)
|
||||||
if index == 0 {
|
reindex(from: index)
|
||||||
indexOffset += 1
|
|
||||||
} else {
|
|
||||||
reindex(from: index)
|
|
||||||
}
|
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
for id in removedIDs {
|
for id in removedIDs {
|
||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
indexOffset = 0
|
|
||||||
reindex(from: 0)
|
reindex(from: 0)
|
||||||
return removedIDs
|
return removedIDs
|
||||||
}
|
}
|
||||||
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
fileprivate func clearMessages() {
|
fileprivate func clearMessages() {
|
||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
indexByMessageID.removeAll()
|
indexByMessageID.removeAll()
|
||||||
indexOffset = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Diagnostics
|
// MARK: Diagnostics
|
||||||
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
let message = messages[position]
|
let message = messages[position]
|
||||||
// Count equality + every message resolving to its own position
|
// Count equality + every message resolving to its own position
|
||||||
// proves the index is exactly the inverse map (no stale extras).
|
// proves the index is exactly the inverse map (no stale extras).
|
||||||
if let logicalIndex = indexByMessageID[message.id] {
|
if let index = indexByMessageID[message.id] {
|
||||||
let expectedIndex = indexOffset + position
|
if index != position {
|
||||||
if logicalIndex != expectedIndex {
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||||
@@ -278,17 +269,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
private func reindex(from start: Int) {
|
private func reindex(from start: Int) {
|
||||||
for index in start..<messages.count {
|
for index in start..<messages.count {
|
||||||
indexByMessageID[messages[index].id] = indexOffset + index
|
indexByMessageID[messages[index].id] = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
|
||||||
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
|
||||||
let index = logicalIndex - indexOffset
|
|
||||||
guard messages.indices.contains(index) else { return nil }
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||||
private func trimIfNeeded() -> [String] {
|
private func trimIfNeeded() -> [String] {
|
||||||
guard messages.count > cap else { return [] }
|
guard messages.count > cap else { return [] }
|
||||||
@@ -298,7 +282,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
messages.removeFirst(overflow)
|
messages.removeFirst(overflow)
|
||||||
indexOffset += overflow
|
reindex(from: 0)
|
||||||
return trimmedIDs
|
return trimmedIDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,8 +844,8 @@ extension Conversation {
|
|||||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||||
func _testCorruptIndexEntries() {
|
func _testCorruptIndexEntries() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
indexByMessageID[messages[0].id] = indexOffset + 1
|
indexByMessageID[messages[0].id] = 1
|
||||||
indexByMessageID[messages[1].id] = indexOffset
|
indexByMessageID[messages[1].id] = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||||
@@ -875,8 +859,8 @@ extension Conversation {
|
|||||||
func _testCorruptOrderingPreservingIndex() {
|
func _testCorruptOrderingPreservingIndex() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
messages.swapAt(0, messages.count - 1)
|
messages.swapAt(0, messages.count - 1)
|
||||||
indexByMessageID[messages[0].id] = indexOffset
|
indexByMessageID[messages[0].id] = 0
|
||||||
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,7 +900,7 @@ extension ConversationStore {
|
|||||||
extension Conversation {
|
extension Conversation {
|
||||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct BLEAnnounceThrottle {
|
/// Thread-safe announce admission state.
|
||||||
|
///
|
||||||
|
/// Announce requests originate from the Bluetooth delegate queue, the
|
||||||
|
/// concurrent message queue, and the maintenance timer. Keeping the timestamp
|
||||||
|
/// behind a lock makes admission and maintenance snapshots atomic when those
|
||||||
|
/// request sources race.
|
||||||
|
final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
private var lastSent: Date
|
private var lastSent: Date
|
||||||
private let normalMinimumInterval: TimeInterval
|
private let normalMinimumInterval: TimeInterval
|
||||||
private let forcedMinimumInterval: TimeInterval
|
private let forcedMinimumInterval: TimeInterval
|
||||||
@@ -16,16 +23,18 @@ struct BLEAnnounceThrottle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func elapsed(since now: Date) -> TimeInterval {
|
func elapsed(since now: Date) -> TimeInterval {
|
||||||
now.timeIntervalSince(lastSent)
|
lock.withLock { now.timeIntervalSince(lastSent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
func shouldSend(force: Bool, now: Date) -> Bool {
|
||||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
lock.withLock {
|
||||||
guard elapsed(since: now) >= minimumInterval else {
|
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||||
return false
|
guard now.timeIntervalSince(lastSent) >= minimumInterval else {
|
||||||
}
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
lastSent = now
|
lastSent = now
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let peerIDData: Data
|
||||||
|
let nickname: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock-backed local identity state shared by the transport's message,
|
||||||
|
/// Bluetooth, maintenance, and main-actor entry points.
|
||||||
|
///
|
||||||
|
/// `peerID` and its binary wire representation must change as one unit during
|
||||||
|
/// panic rotation. A snapshot also gives announce construction one consistent
|
||||||
|
/// view of the nickname and identity instead of reading three independently
|
||||||
|
/// mutable properties across queues.
|
||||||
|
final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var state: BLELocalIdentitySnapshot
|
||||||
|
|
||||||
|
init(
|
||||||
|
peerID: PeerID = PeerID(str: ""),
|
||||||
|
nickname: String = "anon"
|
||||||
|
) {
|
||||||
|
state = BLELocalIdentitySnapshot(
|
||||||
|
peerID: peerID,
|
||||||
|
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> BLELocalIdentitySnapshot {
|
||||||
|
lock.withLock { state }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNickname(_ nickname: String) {
|
||||||
|
lock.withLock {
|
||||||
|
state = BLELocalIdentitySnapshot(
|
||||||
|
peerID: state.peerID,
|
||||||
|
peerIDData: state.peerIDData,
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePeerIdentity(with peerID: PeerID) {
|
||||||
|
lock.withLock {
|
||||||
|
state = BLELocalIdentitySnapshot(
|
||||||
|
peerID: peerID,
|
||||||
|
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||||
|
nickname: state.nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ final class BLEService: NSObject {
|
|||||||
private let incomingFileStore = BLEIncomingFileStore()
|
private let incomingFileStore = BLEIncomingFileStore()
|
||||||
|
|
||||||
// Simple announce throttling
|
// Simple announce throttling
|
||||||
private var announceThrottle = BLEAnnounceThrottle()
|
private let announceThrottle = BLEAnnounceThrottle()
|
||||||
|
|
||||||
// Application state tracking (thread-safe)
|
// Application state tracking (thread-safe)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -162,9 +162,7 @@ final class BLEService: NSObject {
|
|||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
/// Binary form of `myPeerID`; same contract — mutated only inside a
|
private let localIdentityState = BLELocalIdentityStateStore()
|
||||||
/// `messageQueue` barrier via `refreshPeerIdentity()`.
|
|
||||||
private var myPeerIDData: Data = Data()
|
|
||||||
|
|
||||||
// MARK: - Advertising Privacy
|
// MARK: - Advertising Privacy
|
||||||
// No Local Name by default for maximum privacy. No rotating alias.
|
// No Local Name by default for maximum privacy. No rotating alias.
|
||||||
@@ -417,7 +415,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resetIdentityForPanic(currentNickname: String) {
|
func resetIdentityForPanic(currentNickname: String) {
|
||||||
messageQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
pendingNoiseSessionQueues.removeAll()
|
pendingNoiseSessionQueues.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,20 +535,17 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: Identity
|
// MARK: Identity
|
||||||
|
|
||||||
/// Derived from the Noise identity fingerprint; rotated only via
|
/// Derived from the Noise identity fingerprint. Reads can originate from
|
||||||
/// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap
|
/// the main actor, message queue, Bluetooth queue, and maintenance timer,
|
||||||
/// inside a `messageQueue` barrier so concurrent queue work never sees a
|
/// so all three local identity fields live in one lock-backed snapshot.
|
||||||
/// half-updated identity. Externally read-only — no out-of-band mutation
|
var myPeerID: PeerID { localIdentityState.snapshot().peerID }
|
||||||
/// may bypass that derivation.
|
var myNickname: String { localIdentityState.snapshot().nickname }
|
||||||
private(set) var myPeerID = PeerID(str: "")
|
private var myPeerIDData: Data { localIdentityState.snapshot().peerIDData }
|
||||||
/// 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
|
/// Sole mutator for `myNickname`: updates the stored value and force-sends
|
||||||
/// an announce so peers learn the new name.
|
/// an announce so peers learn the new name.
|
||||||
func setNickname(_ nickname: String) {
|
func setNickname(_ nickname: String) {
|
||||||
self.myNickname = nickname
|
localIdentityState.setNickname(nickname)
|
||||||
// Send announce to notify peers of nickname change (force send)
|
// Send announce to notify peers of nickname change (force send)
|
||||||
sendAnnounce(forceSend: true)
|
sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
@@ -596,10 +591,11 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopServices() {
|
func stopServices() {
|
||||||
|
let localIdentity = localIdentityState.snapshot()
|
||||||
// Send leave message synchronously to ensure delivery
|
// Send leave message synchronously to ensure delivery
|
||||||
var leavePacket = BitchatPacket(
|
var leavePacket = BitchatPacket(
|
||||||
type: MessageType.leave.rawValue,
|
type: MessageType.leave.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: localIdentity.peerIDData,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: Data(),
|
payload: Data(),
|
||||||
@@ -1637,6 +1633,16 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
|
// Announce construction reads the replaceable Noise service and several
|
||||||
|
// related state snapshots. Serialize the whole operation with identity
|
||||||
|
// rotation instead of letting CoreBluetooth and maintenance callbacks
|
||||||
|
// execute it directly on their own queues.
|
||||||
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.sendAnnounceNow(forceSend: forceSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendAnnounceNow(forceSend: Bool) {
|
||||||
// Throttle announces to prevent flooding
|
// Throttle announces to prevent flooding
|
||||||
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
||||||
return
|
return
|
||||||
@@ -1656,8 +1662,9 @@ final class BLEService: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let localIdentity = localIdentityState.snapshot()
|
||||||
let announcement = AnnouncementPacket(
|
let announcement = AnnouncementPacket(
|
||||||
nickname: myNickname,
|
nickname: localIdentity.nickname,
|
||||||
noisePublicKey: noisePub,
|
noisePublicKey: noisePub,
|
||||||
signingPublicKey: signingPub,
|
signingPublicKey: signingPub,
|
||||||
directNeighbors: connectedPeerIDs,
|
directNeighbors: connectedPeerIDs,
|
||||||
@@ -1673,7 +1680,7 @@ final class BLEService: NSObject {
|
|||||||
// Create packet with signature using the noise private key
|
// Create packet with signature using the noise private key
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.announce.rawValue,
|
type: MessageType.announce.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: localIdentity.peerIDData,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -1687,14 +1694,7 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call directly if on messageQueue, otherwise dispatch
|
broadcastPacket(signedPacket)
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(signedPacket)
|
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in
|
|
||||||
self?.broadcastPacket(signedPacket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ensure our own announce is included in sync state
|
// Ensure our own announce is included in sync state
|
||||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
|
|
||||||
@@ -3358,8 +3358,9 @@ extension BLEService {
|
|||||||
private func refreshPeerIdentity() {
|
private func refreshPeerIdentity() {
|
||||||
let swap = {
|
let swap = {
|
||||||
let fingerprint = self.noiseService.getIdentityFingerprint()
|
let fingerprint = self.noiseService.getIdentityFingerprint()
|
||||||
self.myPeerID = PeerID(str: fingerprint.prefix(16))
|
self.localIdentityState.replacePeerIdentity(
|
||||||
self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data()
|
with: PeerID(str: fingerprint.prefix(16))
|
||||||
|
)
|
||||||
self.meshTopology.reset()
|
self.meshTopology.reset()
|
||||||
}
|
}
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
|||||||
@@ -45,154 +45,6 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deliberately simple O(n) model used to differentially test the store's
|
|
||||||
/// optimized logical-index bookkeeping. It models observable behavior only;
|
|
||||||
/// it has no offset or ID index and therefore cannot reproduce the same bug.
|
|
||||||
private struct ReferenceConversationTimeline {
|
|
||||||
struct Message: Equatable {
|
|
||||||
let id: String
|
|
||||||
let timestamp: Date
|
|
||||||
let content: String
|
|
||||||
var deliveryStatus: DeliveryStatus?
|
|
||||||
|
|
||||||
init(_ message: BitchatMessage) {
|
|
||||||
id = message.id
|
|
||||||
timestamp = message.timestamp
|
|
||||||
content = message.content
|
|
||||||
deliveryStatus = message.deliveryStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppendResult {
|
|
||||||
let inserted: Bool
|
|
||||||
let trimmedCount: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
let cap: Int
|
|
||||||
private(set) var messages: [Message] = []
|
|
||||||
|
|
||||||
func contains(_ id: String) -> Bool {
|
|
||||||
messages.contains { $0.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(_ message: BitchatMessage) -> AppendResult {
|
|
||||||
guard !contains(message.id) else {
|
|
||||||
return AppendResult(inserted: false, trimmedCount: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = Message(message)
|
|
||||||
var low = 0
|
|
||||||
var high = messages.count
|
|
||||||
while low < high {
|
|
||||||
let mid = (low + high) / 2
|
|
||||||
if messages[mid].timestamp <= snapshot.timestamp {
|
|
||||||
low = mid + 1
|
|
||||||
} else {
|
|
||||||
high = mid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages.insert(snapshot, at: low)
|
|
||||||
|
|
||||||
let overflow = max(0, messages.count - cap)
|
|
||||||
if overflow > 0 {
|
|
||||||
messages.removeFirst(overflow)
|
|
||||||
}
|
|
||||||
return AppendResult(inserted: true, trimmedCount: overflow)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func upsert(_ message: BitchatMessage) -> Int {
|
|
||||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
|
||||||
messages[index] = Message(message)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return append(message).trimmedCount
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
|
|
||||||
guard let index = messages.firstIndex(where: { $0.id == id }),
|
|
||||||
messages[index].deliveryStatus != status else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// The differential stream uses only unique `.delivered` values (or
|
|
||||||
// an exact repeat), so no-downgrade policy is intentionally outside
|
|
||||||
// this index-focused reference model.
|
|
||||||
messages[index].deliveryStatus = status
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func remove(at index: Int) -> Message {
|
|
||||||
messages.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll(where predicate: (Message) -> Bool) {
|
|
||||||
messages.removeAll(where: predicate)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func clear() {
|
|
||||||
messages.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ConversationStoreDifferentialRNG {
|
|
||||||
private var state: UInt64
|
|
||||||
|
|
||||||
init(seed: UInt64) {
|
|
||||||
state = seed
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func next() -> UInt64 {
|
|
||||||
state &+= 0x9E37_79B9_7F4A_7C15
|
|
||||||
var value = state
|
|
||||||
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
|
||||||
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
|
||||||
return value ^ (value >> 31)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func index(upperBound: Int) -> Int {
|
|
||||||
precondition(upperBound > 0)
|
|
||||||
return Int(next() % UInt64(upperBound))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func expectStore(
|
|
||||||
_ store: ConversationStore,
|
|
||||||
matches reference: ReferenceConversationTimeline,
|
|
||||||
issuedIDs: [String],
|
|
||||||
checkpoint: String
|
|
||||||
) {
|
|
||||||
let conversation = store.conversation(for: .mesh)
|
|
||||||
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
|
|
||||||
|
|
||||||
let lookupSnapshot = reference.messages.compactMap { expected in
|
|
||||||
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
|
|
||||||
}
|
|
||||||
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
|
|
||||||
#expect(
|
|
||||||
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
|
|
||||||
"per-conversation ID set mismatch at \(checkpoint)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
|
|
||||||
let id = reference.messages[index].id
|
|
||||||
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeIDs = Set(reference.messages.map(\.id))
|
|
||||||
var checkedStaleIDs = 0
|
|
||||||
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
|
|
||||||
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
|
|
||||||
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
|
|
||||||
checkedStaleIDs += 1
|
|
||||||
if checkedStaleIDs == 16 { break }
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite("ConversationStore")
|
@Suite("ConversationStore")
|
||||||
struct ConversationStoreTests {
|
struct ConversationStoreTests {
|
||||||
|
|
||||||
@@ -288,282 +140,6 @@ struct ConversationStoreTests {
|
|||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
|
|
||||||
@MainActor
|
|
||||||
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let conversation = store.conversation(for: .mesh)
|
|
||||||
let overflow = 64
|
|
||||||
|
|
||||||
for i in 0..<(conversation.cap + overflow) {
|
|
||||||
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(conversation.messages.first?.id == "m\(overflow)")
|
|
||||||
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
|
|
||||||
|
|
||||||
// Exercise a suffix reindex after the head offset has advanced, then
|
|
||||||
// trim the old head. The late row becomes the new first element.
|
|
||||||
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
|
|
||||||
#expect(store.append(late, to: .mesh))
|
|
||||||
#expect(conversation.messages.first?.id == "late")
|
|
||||||
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
|
|
||||||
|
|
||||||
// Head and middle removals, an in-place upsert, and a status update
|
|
||||||
// must all resolve through the same logical index representation.
|
|
||||||
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
|
|
||||||
let middleID = "m\(overflow + conversation.cap / 2)"
|
|
||||||
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
|
|
||||||
|
|
||||||
let probeID = "m\(overflow + 10)"
|
|
||||||
store.upsertByID(
|
|
||||||
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
|
|
||||||
in: .mesh
|
|
||||||
)
|
|
||||||
#expect(conversation.message(withID: probeID)?.content == "edited")
|
|
||||||
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
|
||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
|
||||||
#expect(store.auditInvariants().isEmpty)
|
|
||||||
|
|
||||||
// Clearing resets the logical offset as well as the maps.
|
|
||||||
store.clear(.mesh)
|
|
||||||
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
|
|
||||||
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
|
|
||||||
#expect(store.auditInvariants().isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("logical index offset matches a reference model under adversarial mutations")
|
|
||||||
@MainActor
|
|
||||||
func logicalIndexOffsetDifferentialStress() async {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let cap = store.conversation(for: .mesh).cap
|
|
||||||
var reference = ReferenceConversationTimeline(cap: cap)
|
|
||||||
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
|
|
||||||
var issuedIDs: [String] = []
|
|
||||||
var nextID = 0
|
|
||||||
var nextTailTimestamp: TimeInterval = 1_700_000_000
|
|
||||||
var trimmedCount = 0
|
|
||||||
|
|
||||||
var tailAppendCount = 0
|
|
||||||
var outOfOrderCount = 0
|
|
||||||
var duplicateOrReuseCount = 0
|
|
||||||
var headRemovalCount = 0
|
|
||||||
var middleRemovalCount = 0
|
|
||||||
var upsertCount = 0
|
|
||||||
var deliveryUpdateCount = 0
|
|
||||||
var filterCount = 0
|
|
||||||
var clearCount = 0
|
|
||||||
|
|
||||||
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
|
|
||||||
let number = nextID
|
|
||||||
nextID += 1
|
|
||||||
let id = "diff-\(number)"
|
|
||||||
issuedIDs.append(id)
|
|
||||||
let resolvedTimestamp: TimeInterval
|
|
||||||
if let timestamp {
|
|
||||||
resolvedTimestamp = timestamp
|
|
||||||
} else {
|
|
||||||
resolvedTimestamp = nextTailTimestamp
|
|
||||||
nextTailTimestamp += 1
|
|
||||||
}
|
|
||||||
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
|
|
||||||
return makeMessage(
|
|
||||||
id: id,
|
|
||||||
timestamp: resolvedTimestamp,
|
|
||||||
content: "\(tag) \(number)\(dropMarker)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
|
|
||||||
let expected = reference.append(message)
|
|
||||||
let actual = store.append(message, to: .mesh)
|
|
||||||
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
|
|
||||||
trimmedCount += expected.trimmedCount
|
|
||||||
return expected
|
|
||||||
}
|
|
||||||
|
|
||||||
func refill(extra: Int, checkpoint: String) async {
|
|
||||||
let appendCount = max(0, cap - reference.messages.count) + extra
|
|
||||||
for index in 0..<appendCount {
|
|
||||||
appendAndCompare(
|
|
||||||
issueMessage(tag: "refill"),
|
|
||||||
checkpoint: "\(checkpoint)-\(index)"
|
|
||||||
)
|
|
||||||
if index.isMultiple(of: 64) {
|
|
||||||
await Task.yield()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start well into steady state so the offset is already non-zero
|
|
||||||
// before any mixed operations begin.
|
|
||||||
await refill(extra: 384, checkpoint: "initial steady-state fill")
|
|
||||||
|
|
||||||
for step in 0..<1_200 {
|
|
||||||
if step == 300 || step == 900 {
|
|
||||||
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
|
|
||||||
reference.removeAll { $0.content.contains("[drop]") }
|
|
||||||
filterCount += 1
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "filter at step \(step)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if step == 600 {
|
|
||||||
store.clear(.mesh)
|
|
||||||
reference.clear()
|
|
||||||
clearCount += 1
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "clear at step \(step)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch rng.index(upperBound: 100) {
|
|
||||||
case 0..<35:
|
|
||||||
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
|
|
||||||
tailAppendCount += 1
|
|
||||||
|
|
||||||
case 35..<55:
|
|
||||||
if reference.messages.isEmpty {
|
|
||||||
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
|
|
||||||
} else {
|
|
||||||
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
|
|
||||||
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
|
|
||||||
appendAndCompare(
|
|
||||||
issueMessage(timestamp: timestamp, tag: "out-of-order"),
|
|
||||||
checkpoint: "out-of-order append \(step)"
|
|
||||||
)
|
|
||||||
outOfOrderCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 55..<65:
|
|
||||||
if issuedIDs.isEmpty {
|
|
||||||
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
|
|
||||||
} else {
|
|
||||||
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
|
|
||||||
let message = makeMessage(
|
|
||||||
id: reusedID,
|
|
||||||
timestamp: nextTailTimestamp,
|
|
||||||
content: "duplicate-or-trimmed-reuse \(step)"
|
|
||||||
)
|
|
||||||
nextTailTimestamp += 1
|
|
||||||
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
|
|
||||||
duplicateOrReuseCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 65..<73:
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
let expected = reference.remove(at: 0)
|
|
||||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
|
||||||
.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == expected, "head removal mismatch at step \(step)")
|
|
||||||
headRemovalCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 73..<81:
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
let middleStart = reference.messages.count / 4
|
|
||||||
let middleWidth = max(1, reference.messages.count / 2)
|
|
||||||
let index = min(
|
|
||||||
reference.messages.count - 1,
|
|
||||||
middleStart + rng.index(upperBound: middleWidth)
|
|
||||||
)
|
|
||||||
let expected = reference.remove(at: index)
|
|
||||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
|
||||||
.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == expected, "middle removal mismatch at step \(step)")
|
|
||||||
middleRemovalCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 81..<90:
|
|
||||||
let message: BitchatMessage
|
|
||||||
if step.isMultiple(of: 4) || reference.messages.isEmpty {
|
|
||||||
let timestamp = reference.messages.isEmpty
|
|
||||||
? nil
|
|
||||||
: reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
.timestamp.timeIntervalSince1970
|
|
||||||
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
|
|
||||||
} else {
|
|
||||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
message = makeMessage(
|
|
||||||
id: current.id,
|
|
||||||
timestamp: current.timestamp.timeIntervalSince1970,
|
|
||||||
content: "upsert-existing \(step)",
|
|
||||||
deliveryStatus: current.deliveryStatus
|
|
||||||
)
|
|
||||||
}
|
|
||||||
trimmedCount += reference.upsert(message)
|
|
||||||
store.upsertByID(message, in: .mesh)
|
|
||||||
upsertCount += 1
|
|
||||||
|
|
||||||
default:
|
|
||||||
let id: String
|
|
||||||
let repeatedStatus: DeliveryStatus?
|
|
||||||
if step.isMultiple(of: 6) || reference.messages.isEmpty {
|
|
||||||
id = "missing-\(step)"
|
|
||||||
repeatedStatus = nil
|
|
||||||
} else {
|
|
||||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
id = current.id
|
|
||||||
repeatedStatus = current.deliveryStatus
|
|
||||||
}
|
|
||||||
let status: DeliveryStatus
|
|
||||||
if step.isMultiple(of: 4), let repeatedStatus {
|
|
||||||
status = repeatedStatus
|
|
||||||
} else {
|
|
||||||
status = .delivered(
|
|
||||||
to: "peer",
|
|
||||||
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let expected = reference.applyDeliveryStatus(status, to: id)
|
|
||||||
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
|
|
||||||
#expect(actual == expected, "delivery update mismatch at step \(step)")
|
|
||||||
deliveryUpdateCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "mixed operation \(step)"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This intentionally expensive MainActor stress test runs beside
|
|
||||||
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
|
|
||||||
// release the actor so their bounded waits can make progress.
|
|
||||||
await Task.yield()
|
|
||||||
|
|
||||||
if (step + 1).isMultiple(of: 100) {
|
|
||||||
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guarantee another long run of one-row evictions after every other
|
|
||||||
// mutation family has perturbed and rebuilt the offset/index state.
|
|
||||||
await refill(extra: 512, checkpoint: "final steady-state trim run")
|
|
||||||
|
|
||||||
#expect(trimmedCount > 1_200)
|
|
||||||
#expect(tailAppendCount > 300)
|
|
||||||
#expect(outOfOrderCount > 150)
|
|
||||||
#expect(duplicateOrReuseCount > 75)
|
|
||||||
#expect(headRemovalCount > 50)
|
|
||||||
#expect(middleRemovalCount > 50)
|
|
||||||
#expect(upsertCount > 75)
|
|
||||||
#expect(deliveryUpdateCount > 75)
|
|
||||||
#expect(filterCount == 2)
|
|
||||||
#expect(clearCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Upsert
|
// MARK: - Upsert
|
||||||
|
|
||||||
@Test("upsertByID replaces in place and appends when absent")
|
@Test("upsertByID replaces in place and appends when absent")
|
||||||
|
|||||||
@@ -501,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 7b. ConversationStore append at the retention cap
|
|
||||||
|
|
||||||
/// Steady-state public timeline traffic after the 1337-message retention
|
|
||||||
/// cap has been reached. Every tail append evicts the oldest row, which is
|
|
||||||
/// the long-lived workload the cold `store.append` benchmark does not
|
|
||||||
/// exercise.
|
|
||||||
func testConversationStoreSteadyStateAppend() {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let cap = TransportConfig.meshTimelineCap
|
|
||||||
let messagesPerPass = 500
|
|
||||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
|
|
||||||
for i in 0..<cap {
|
|
||||||
store.append(
|
|
||||||
BitchatMessage(
|
|
||||||
id: "perf-steady-seed-\(i)",
|
|
||||||
sender: "perfsender",
|
|
||||||
content: "steady-state seed \(i)",
|
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
|
||||||
isRelay: false
|
|
||||||
),
|
|
||||||
to: .mesh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pass = 0
|
|
||||||
var samples: [TimeInterval] = []
|
|
||||||
measure {
|
|
||||||
let startIndex = cap + pass * messagesPerPass
|
|
||||||
let start = Date()
|
|
||||||
for offset in 0..<messagesPerPass {
|
|
||||||
let i = startIndex + offset
|
|
||||||
store.append(
|
|
||||||
BitchatMessage(
|
|
||||||
id: "perf-steady-\(i)",
|
|
||||||
sender: "perfsender",
|
|
||||||
content: "steady-state message \(i)",
|
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
|
||||||
isRelay: false
|
|
||||||
),
|
|
||||||
to: .mesh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
samples.append(Date().timeIntervalSince(start))
|
|
||||||
pass += 1
|
|
||||||
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
|
|
||||||
}
|
|
||||||
|
|
||||||
reportThroughput(
|
|
||||||
"store.steadyStateAppend",
|
|
||||||
samples: samples,
|
|
||||||
operations: messagesPerPass,
|
|
||||||
unit: "messages"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 8. ConversationStore invariant audit (field observability)
|
// MARK: - 8. ConversationStore invariant audit (field observability)
|
||||||
|
|
||||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||||
|
|||||||
@@ -30,10 +30,6 @@
|
|||||||
"store.append": 213201,
|
"store.append": 213201,
|
||||||
"store.audit": 362
|
"store.audit": 362
|
||||||
},
|
},
|
||||||
"_reference_local_numbers_2026_07": {
|
|
||||||
"store.steadyStateAppend_before": 2315,
|
|
||||||
"store.steadyStateAppend": 53976
|
|
||||||
},
|
|
||||||
"floors": {
|
"floors": {
|
||||||
"nostrInbound.fresh": 450,
|
"nostrInbound.fresh": 450,
|
||||||
"nostrInbound.duplicate": 250000,
|
"nostrInbound.duplicate": 250000,
|
||||||
@@ -45,7 +41,6 @@
|
|||||||
"pipeline.privateIngest": 3000,
|
"pipeline.privateIngest": 3000,
|
||||||
"pipeline.publicIngest": 2400,
|
"pipeline.publicIngest": 2400,
|
||||||
"store.append": 48000,
|
"store.append": 48000,
|
||||||
"store.steadyStateAppend": 10000,
|
|
||||||
"store.audit": 70
|
"store.audit": 70
|
||||||
},
|
},
|
||||||
"_slowest_observed_ci_numbers_2026_06": {
|
"_slowest_observed_ci_numbers_2026_06": {
|
||||||
@@ -61,4 +56,4 @@
|
|||||||
"store.append": 97423,
|
"store.append": 97423,
|
||||||
"store.audit": 140
|
"store.audit": 140
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import Testing
|
|||||||
struct BLEAnnounceThrottleTests {
|
struct BLEAnnounceThrottleTests {
|
||||||
@Test
|
@Test
|
||||||
func firstAnnounceIsAllowed() {
|
func firstAnnounceIsAllowed() {
|
||||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ struct BLEAnnounceThrottleTests {
|
|||||||
@Test
|
@Test
|
||||||
func regularAnnounceUsesNormalMinimumInterval() {
|
func regularAnnounceUsesNormalMinimumInterval() {
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
let first = throttle.shouldSend(force: false, now: now)
|
let first = throttle.shouldSend(force: false, now: now)
|
||||||
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
||||||
@@ -29,7 +29,7 @@ struct BLEAnnounceThrottleTests {
|
|||||||
@Test
|
@Test
|
||||||
func forcedAnnounceUsesShorterMinimumInterval() {
|
func forcedAnnounceUsesShorterMinimumInterval() {
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
let first = throttle.shouldSend(force: false, now: now)
|
let first = throttle.shouldSend(force: false, now: now)
|
||||||
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
||||||
@@ -43,10 +43,40 @@ struct BLEAnnounceThrottleTests {
|
|||||||
@Test
|
@Test
|
||||||
func elapsedReportsTimeSinceAcceptedSend() {
|
func elapsedReportsTimeSinceAcceptedSend() {
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
_ = throttle.shouldSend(force: false, now: now)
|
_ = throttle.shouldSend(force: false, now: now)
|
||||||
|
|
||||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func concurrentRequestsAdmitOnlyOneAnnounce() {
|
||||||
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
|
let throttle = BLEAnnounceThrottle(
|
||||||
|
normalMinimumInterval: 10,
|
||||||
|
forcedMinimumInterval: 2
|
||||||
|
)
|
||||||
|
let accepted = LockedCounter()
|
||||||
|
|
||||||
|
DispatchQueue.concurrentPerform(iterations: 1_000) { _ in
|
||||||
|
if throttle.shouldSend(force: false, now: now) {
|
||||||
|
accepted.increment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(accepted.value == 1)
|
||||||
|
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class LockedCounter: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
|
var value: Int { lock.withLock { count } }
|
||||||
|
|
||||||
|
func increment() {
|
||||||
|
lock.withLock { count += 1 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct BLELocalIdentityStateStoreTests {
|
||||||
|
@Test
|
||||||
|
func identityReplacementUpdatesWireBytesAtomically() throws {
|
||||||
|
let initial = PeerID(str: "0011223344556677")
|
||||||
|
let replacement = PeerID(str: "8899aabbccddeeff")
|
||||||
|
let store = BLELocalIdentityStateStore(peerID: initial, nickname: "alice")
|
||||||
|
|
||||||
|
store.replacePeerIdentity(with: replacement)
|
||||||
|
|
||||||
|
let snapshot = store.snapshot()
|
||||||
|
#expect(snapshot.peerID == replacement)
|
||||||
|
#expect(snapshot.peerIDData == Data(hexString: replacement.id))
|
||||||
|
#expect(snapshot.nickname == "alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func concurrentReadsNeverObserveSplitIdentityState() {
|
||||||
|
let peerIDs = [
|
||||||
|
PeerID(str: "0011223344556677"),
|
||||||
|
PeerID(str: "8899aabbccddeeff")
|
||||||
|
]
|
||||||
|
let store = BLELocalIdentityStateStore(peerID: peerIDs[0], nickname: "alice")
|
||||||
|
let failures = LockedFailureRecorder()
|
||||||
|
|
||||||
|
DispatchQueue.concurrentPerform(iterations: 2_000) { index in
|
||||||
|
if index.isMultiple(of: 2) {
|
||||||
|
store.replacePeerIdentity(with: peerIDs[index % peerIDs.count])
|
||||||
|
} else {
|
||||||
|
store.setNickname(index.isMultiple(of: 3) ? "alice" : "bob")
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = store.snapshot()
|
||||||
|
let expectedWireID = Data(hexString: snapshot.peerID.id) ?? Data()
|
||||||
|
if snapshot.peerIDData != expectedWireID {
|
||||||
|
failures.record()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!failures.hasFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class LockedFailureRecorder: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var failed = false
|
||||||
|
|
||||||
|
var hasFailure: Bool { lock.withLock { failed } }
|
||||||
|
|
||||||
|
func record() {
|
||||||
|
lock.withLock { failed = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user