mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Add performance baselines; check dedup before verifying Nostr signatures
New PerformanceBaselineTests measure the hot paths (Nostr inbound, BLE packet pipeline, GCS filters, delivery index, message formatting) with deterministic fixtures and logged PERF metrics - baselines, not assertions, so they cannot flake CI. The suite immediately exposed that every inbound handler ran Schnorr verification before the dedup lookup, so duplicate events - which dominate real multi-relay traffic - each paid ~0.5ms of main-actor crypto for nothing. All five handlers now do cheap rejects (kind, dedup lookup) first and only record an event as processed AFTER its signature verifies, so a forged-signature copy can never poison the dedup set and suppress the genuine event. Gift-wrap verification also moves entirely off the main actor with an atomic main-actor check-and-record. Measured: duplicate-event handling 2.2k -> 1.39M events/sec (~640x); fresh events unchanged (crypto-bound). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -246,13 +246,17 @@ final class ChatNostrCoordinator {
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
guard event.isValidSignature() else { return }
|
||||
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
||||
// duplicates dominate real traffic and must not pay for crypto.
|
||||
// Only verified events are recorded, so a forged-signature copy can
|
||||
// never poison the dedup set and suppress the genuine event.
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!context.hasProcessedNostrEvent(event.id)
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else { return }
|
||||
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
@@ -327,8 +331,9 @@ final class ChatNostrCoordinator {
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
@@ -441,14 +446,15 @@ final class ChatNostrCoordinator {
|
||||
@MainActor
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
guard event.isValidSignature() else { return }
|
||||
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
||||
// duplicates dominate real traffic and must not pay for crypto.
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if context.hasProcessedNostrEvent(event.id) { return }
|
||||
guard event.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||
@@ -559,10 +565,11 @@ final class ChatNostrCoordinator {
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
@@ -822,9 +829,12 @@ final class ChatNostrCoordinator {
|
||||
@MainActor
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
guard let context else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
||||
// processNostrMessage, which then does the authoritative
|
||||
// check-and-record. Recording stays after verification so a
|
||||
// forged-signature copy can never poison the dedup set and suppress
|
||||
// the genuine event.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processNostrMessage(giftWrap)
|
||||
@@ -834,6 +844,14 @@ final class ChatNostrCoordinator {
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard let context else { return }
|
||||
// Authoritative check-and-record, atomic on the main actor so two
|
||||
// concurrent detached tasks can't both process the same event.
|
||||
let alreadyProcessed: Bool = await MainActor.run {
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
return false
|
||||
}
|
||||
if alreadyProcessed { return }
|
||||
let currentIdentity: NostrIdentity? = await MainActor.run {
|
||||
context.currentNostrIdentity()
|
||||
}
|
||||
|
||||
@@ -411,11 +411,23 @@ struct ChatViewModelNostrExtensionTests {
|
||||
var invalidGiftWrap = giftWrap
|
||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
||||
|
||||
// Verification and recording now happen on a detached task; give the
|
||||
// invalid copy time to be verified-and-rejected, then assert it never
|
||||
// entered the dedup set.
|
||||
viewModel.handleNostrMessage(invalidGiftWrap)
|
||||
try await Task.sleep(nanoseconds: 150_000_000)
|
||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id))
|
||||
|
||||
viewModel.handleNostrMessage(giftWrap)
|
||||
#expect(viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id))
|
||||
var recorded = false
|
||||
for _ in 0..<200 {
|
||||
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||
recorded = true
|
||||
break
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
#expect(recorded)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
//
|
||||
// PerformanceBaselineTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Performance baselines for hot paths so regressions are measured, not
|
||||
// guessed. Uses XCTest `measure {}` (swift-testing has no equivalent) with
|
||||
// NO recorded baselines — these tests record timing in CI logs but never
|
||||
// fail on timing, so they cannot flake.
|
||||
//
|
||||
// Each benchmark builds its fixtures OUTSIDE the measure block with fixed
|
||||
// seeds/content so every iteration does deterministic work: no network,
|
||||
// no Tor, no sleeps.
|
||||
//
|
||||
// Skippable via the BITCHAT_SKIP_PERF_BASELINES=1 environment variable;
|
||||
// runs by default.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
final class PerformanceBaselineTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try XCTSkipIf(
|
||||
ProcessInfo.processInfo.environment["BITCHAT_SKIP_PERF_BASELINES"] == "1",
|
||||
"Performance baselines skipped via BITCHAT_SKIP_PERF_BASELINES"
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Drain main-queue tasks spawned by coordinators (handlePublicMessage
|
||||
// hops etc.) so they don't bleed into the next test's measure block.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// Reports one human-readable throughput line per benchmark so CI logs
|
||||
/// are readable without parsing XCTest's measure output.
|
||||
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(
|
||||
format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)",
|
||||
name, opsPerSec, unit, avg * 1000, operations, samples.count
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
||||
|
||||
/// `ChatNostrCoordinator.handleNostrEvent` for never-seen geo events
|
||||
/// (kind 20000): signature verification, dedup record, presence/nickname
|
||||
/// bookkeeping, and public-message ingest scheduling.
|
||||
func testNostrInboundEventHandling_freshEvents() throws {
|
||||
let events = try Self.makeSignedGeohashEvents(count: 500)
|
||||
// A fresh context per measure pass so every event takes the
|
||||
// first-seen path on every iteration. Kept alive so the weakly
|
||||
// captured Task hops stay valid.
|
||||
var keepAlive: [(PerfNostrContext, ChatNostrCoordinator)] = []
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
let context = PerfNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
keepAlive.append((context, coordinator))
|
||||
let start = Date()
|
||||
for event in events {
|
||||
coordinator.handleNostrEvent(event)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertEqual(context.processedEventCount, events.count)
|
||||
}
|
||||
|
||||
reportThroughput("nostrInbound.fresh", samples: samples, operations: events.count, unit: "events")
|
||||
}
|
||||
|
||||
// MARK: - 1b. Nostr inbound event handling (duplicate events)
|
||||
|
||||
/// The dedup-hit path: identical events replayed. Duplicates dominate
|
||||
/// real relay traffic (the same event arrives from several relays), so
|
||||
/// this path runs hundreds of times a minute in busy geohashes. Note it
|
||||
/// still pays full Schnorr signature verification before the dedup check.
|
||||
func testNostrInboundEventHandling_duplicateEvents() throws {
|
||||
let events = try Self.makeSignedGeohashEvents(count: 500)
|
||||
let context = PerfNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
// Pre-warm: every event is now recorded as processed.
|
||||
for event in events {
|
||||
coordinator.handleNostrEvent(event)
|
||||
}
|
||||
XCTAssertEqual(context.processedEventCount, events.count)
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
let start = Date()
|
||||
for event in events {
|
||||
coordinator.handleNostrEvent(event)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
}
|
||||
|
||||
// Dedup held: nothing was re-processed.
|
||||
XCTAssertEqual(context.processedEventCount, events.count)
|
||||
reportThroughput("nostrInbound.duplicate", samples: samples, operations: events.count, unit: "events")
|
||||
}
|
||||
|
||||
// MARK: - 2. BLE inbound packet pipeline (decode + dedup)
|
||||
|
||||
/// Binary encode/decode round trip plus `MessageDeduplicator` at
|
||||
/// realistic mesh sizes: 1000 packets with 100-300 byte payloads, each
|
||||
/// dedup-checked twice (first-seen insert, then duplicate hit) the way
|
||||
/// relayed packets arrive on multiple links.
|
||||
func testBLEInboundPacketPipeline() throws {
|
||||
var rng = SeededGenerator(seed: 0xB17C4A7)
|
||||
let baseTimestamp = UInt64(1_700_000_000_000)
|
||||
var packets: [BitchatPacket] = []
|
||||
var dedupIDs: [String] = []
|
||||
packets.reserveCapacity(1000)
|
||||
dedupIDs.reserveCapacity(1000)
|
||||
for index in 0..<1000 {
|
||||
let senderID = Data((0..<8).map { _ in UInt8.random(in: 0...255, using: &rng) })
|
||||
let payloadSize = Int.random(in: 100...300, using: &rng)
|
||||
let payload = Data((0..<payloadSize).map { _ in UInt8.random(in: 0...255, using: &rng) })
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: senderID,
|
||||
recipientID: nil,
|
||||
timestamp: baseTimestamp + UInt64(index),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
packets.append(packet)
|
||||
// Same dedup key shape BLEService uses: sender-timestamp-type.
|
||||
dedupIDs.append("\(senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)")
|
||||
}
|
||||
let deduplicator = MessageDeduplicator()
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
deduplicator.reset() // identical work every pass
|
||||
let start = Date()
|
||||
var decodedCount = 0
|
||||
var duplicateCount = 0
|
||||
for (index, packet) in packets.enumerated() {
|
||||
guard let data = packet.toBinaryData(),
|
||||
let decoded = BitchatPacket.from(data) else { continue }
|
||||
decodedCount += 1
|
||||
_ = decoded.payload.count
|
||||
if deduplicator.isDuplicate(dedupIDs[index]) { duplicateCount += 1 }
|
||||
}
|
||||
for id in dedupIDs where deduplicator.isDuplicate(id) {
|
||||
duplicateCount += 1
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertEqual(decodedCount, packets.count)
|
||||
XCTAssertEqual(duplicateCount, packets.count)
|
||||
}
|
||||
|
||||
reportThroughput("bleInbound.roundTripAndDedup", samples: samples, operations: packets.count, unit: "packets")
|
||||
}
|
||||
|
||||
// MARK: - 3. GCS sync filters
|
||||
|
||||
/// `GCSFilter.buildFilter` + `decodeToSortedSet` at the production gossip
|
||||
/// config (`TransportConfig.syncGCSMaxBytes` / `syncGCSTargetFpr`) with
|
||||
/// 1000 candidate packet IDs (the filter caps to its byte budget).
|
||||
func testGCSFilterBuildAndDecode() {
|
||||
var rng = SeededGenerator(seed: 0x6C5F11)
|
||||
let ids: [Data] = (0..<1000).map { _ in
|
||||
Data((0..<16).map { _ in UInt8.random(in: 0...255, using: &rng) })
|
||||
}
|
||||
let maxBytes = TransportConfig.syncGCSMaxBytes
|
||||
let targetFpr = TransportConfig.syncGCSTargetFpr
|
||||
let repsPerPass = 20
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
let start = Date()
|
||||
var decodedTotal = 0
|
||||
for _ in 0..<repsPerPass {
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: maxBytes, targetFpr: targetFpr)
|
||||
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
||||
decodedTotal += decoded.count
|
||||
XCTAssertLessThanOrEqual(params.data.count, maxBytes)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertGreaterThan(decodedTotal, 0)
|
||||
}
|
||||
|
||||
reportThroughput("gcs.buildAndDecode", samples: samples, operations: repsPerPass, unit: "filters")
|
||||
}
|
||||
|
||||
// MARK: - 4a. Delivery location index (incremental path)
|
||||
|
||||
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` against a warm
|
||||
/// location index: 2000 public + 50x40 private messages, 500 status
|
||||
/// updates per pass. Statuses alternate sent <-> delivered so every call
|
||||
/// performs a real update (never the skip path).
|
||||
func testDeliveryStatusIncrementalUpdates() {
|
||||
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
// Warm the index so the measure block exercises the incremental path.
|
||||
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
|
||||
|
||||
// 250 public + 250 private IDs spread across the corpus.
|
||||
var targetIDs: [String] = []
|
||||
for i in stride(from: 0, to: 2000, by: 8) { targetIDs.append(context.messages[i].id) }
|
||||
let peerIDs = context.privateChats.keys.sorted { $0.id < $1.id }
|
||||
for (offset, peer) in peerIDs.enumerated() where offset < 25 {
|
||||
guard let chat = context.privateChats[peer] else { continue }
|
||||
for i in stride(from: 0, to: chat.count, by: 4) { targetIDs.append(chat[i].id) }
|
||||
}
|
||||
XCTAssertEqual(targetIDs.count, 500)
|
||||
|
||||
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var toggle = false
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
toggle.toggle()
|
||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
||||
let start = Date()
|
||||
var updated = 0
|
||||
for id in targetIDs where coordinator.updateMessageDeliveryStatus(id, status: status) {
|
||||
updated += 1
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertEqual(updated, targetIDs.count)
|
||||
}
|
||||
|
||||
reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
|
||||
}
|
||||
|
||||
// MARK: - 4b. Delivery location index (rebuild path)
|
||||
|
||||
/// The expensive path: a non-append insertion (out-of-order arrival at
|
||||
/// the head of the timeline) invalidates the index, so the next status
|
||||
/// update triggers a full rebuild over ~4000 message locations.
|
||||
func testDeliveryStatusIndexRebuild() {
|
||||
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
// Warm the index before the first insertion invalidates it.
|
||||
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
|
||||
|
||||
// Pre-built head insertions, one consumed per measure pass.
|
||||
let insertions: [BitchatMessage] = (0..<32).map { i in
|
||||
BitchatMessage(
|
||||
id: "insert-\(i)",
|
||||
sender: "alice",
|
||||
content: "out-of-order arrival \(i)",
|
||||
timestamp: Date(timeIntervalSince1970: 1_690_000_000),
|
||||
isRelay: false
|
||||
)
|
||||
}
|
||||
let probeID = context.messages[1000].id
|
||||
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var pass = 0
|
||||
var toggle = false
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
precondition(pass < insertions.count, "add more pre-built insertions")
|
||||
context.messages.insert(insertions[pass], at: 0)
|
||||
pass += 1
|
||||
toggle.toggle()
|
||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
||||
let start = Date()
|
||||
let updated = coordinator.updateMessageDeliveryStatus(probeID, status: status)
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertTrue(updated)
|
||||
}
|
||||
|
||||
reportThroughput("delivery.indexRebuild", samples: samples, operations: 1, unit: "rebuilds")
|
||||
}
|
||||
|
||||
// MARK: - 5. Message formatting
|
||||
|
||||
/// `MessageFormattingEngine.formatMessage` over 200 messages with
|
||||
/// mentions, hashtags, and URLs. Formatting caches per message, so each
|
||||
/// measure pass consumes a fresh pre-built batch (cache-miss path, which
|
||||
/// is the cost paid when messages first render).
|
||||
func testMessageFormatting() {
|
||||
let context = PerfFormattingContext(nickname: "carol")
|
||||
let batchCount = 16 // > XCTest's default 10 measure iterations
|
||||
let batchSize = 200
|
||||
let contents: [(String, [String]?)] = [
|
||||
("hello mesh, anyone around tonight?", nil),
|
||||
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
|
||||
("checking in from the harbor #bitchat #mesh", nil),
|
||||
("@bob#0042 ping me when you get this", ["bob#0042"]),
|
||||
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil),
|
||||
]
|
||||
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
|
||||
(0..<batchSize).map { i in
|
||||
let (content, mentions) = contents[i % contents.count]
|
||||
return BitchatMessage(
|
||||
id: "fmt-\(batch)-\(i)",
|
||||
sender: "alice#a1b\(i % 10)",
|
||||
content: content,
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(i)),
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(str: "abcdef123456789\(i % 10)"),
|
||||
mentions: mentions
|
||||
)
|
||||
}
|
||||
}
|
||||
var pass = 0
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
precondition(pass < batches.count, "add more pre-built batches")
|
||||
let batch = batches[pass]
|
||||
pass += 1
|
||||
let start = Date()
|
||||
var formattedCharacters = 0
|
||||
for message in batch {
|
||||
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||
formattedCharacters += formatted.characters.count
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertGreaterThan(formattedCharacters, 0)
|
||||
}
|
||||
|
||||
reportThroughput("formatting.formatMessage", samples: samples, operations: batchSize, unit: "messages")
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// Builds deterministic signed kind-20000 geohash events. Content cycles
|
||||
/// through realistic chat lines; a handful of sender identities mimics a
|
||||
/// busy channel.
|
||||
private static func makeSignedGeohashEvents(count: Int) throws -> [NostrEvent] {
|
||||
let senders = try (0..<8).map { _ in try NostrIdentity.generate() }
|
||||
let lines = [
|
||||
"hello from the geohash",
|
||||
"anyone near the station?",
|
||||
"@bob#0042 are you on mesh too?",
|
||||
"check this out https://example.com/p/123 #bitchat",
|
||||
"teleport check, who's local?",
|
||||
]
|
||||
return try (0..<count).map { i in
|
||||
try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: "\(lines[i % lines.count]) [\(i)]",
|
||||
geohash: "u4pruyd",
|
||||
senderIdentity: senders[i % senders.count],
|
||||
nickname: "peer\(i % senders.count)",
|
||||
teleported: i % 25 == 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deterministic RNG
|
||||
|
||||
private struct SeededGenerator: RandomNumberGenerator {
|
||||
private var state: UInt64
|
||||
init(seed: UInt64) { state = seed }
|
||||
mutating func next() -> UInt64 {
|
||||
state &+= 0x9E37_79B9_7F4A_7C15
|
||||
var z = state
|
||||
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||
return z ^ (z >> 31)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock ChatNostrContext
|
||||
|
||||
/// Minimal `ChatNostrContext` for benchmarking `ChatNostrCoordinator`
|
||||
/// without a `ChatViewModel` (mirrors `ChatNostrCoordinatorContextTests`).
|
||||
/// Callbacks are cheap dictionary/array operations so the measured cost is
|
||||
/// the coordinator's own pipeline.
|
||||
@MainActor
|
||||
private final class PerfNostrContext: ChatNostrContext {
|
||||
var activeChannel: ChannelID = .location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd"))
|
||||
var currentGeohash: String? = "u4pruyd"
|
||||
var geoSubscriptionID: String?
|
||||
var geoDmSubscriptionID: String?
|
||||
var geoSamplingSubs: [String: String] = [:]
|
||||
var lastGeoNotificationAt: [String: Date] = [:]
|
||||
var nostrRelayManager: NostrRelayManager? { nil }
|
||||
|
||||
var messages: [BitchatMessage] = []
|
||||
func resetPublicMessagePipeline() {}
|
||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {}
|
||||
func refreshVisibleMessages(from channel: ChannelID?) {}
|
||||
func addPublicSystemMessage(_ content: String) {}
|
||||
func drainPendingGeohashSystemMessages() -> [String] { [] }
|
||||
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true }
|
||||
func synchronizePublicConversationStore(forGeohash geohash: String) {}
|
||||
|
||||
private(set) var handledPublicMessageCount = 0
|
||||
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
|
||||
func checkForMentions(_ message: BitchatMessage) {}
|
||||
func sendHapticFeedback(for message: BitchatMessage) {}
|
||||
func parseMentions(from content: String) -> [String] {
|
||||
MessageFormattingEngine.extractMentions(from: content)
|
||||
}
|
||||
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var nostrKeyMapping: [PeerID: String] = [:]
|
||||
func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {}
|
||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
||||
func startPrivateChat(with peerID: PeerID) {}
|
||||
|
||||
private struct NoIdentity: Error {}
|
||||
var geohashIdentities: [String: NostrIdentity] = [:]
|
||||
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
guard let identity = geohashIdentities[geohash] else { throw NoIdentity() }
|
||||
return identity
|
||||
}
|
||||
func currentNostrIdentity() -> NostrIdentity? { nil }
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { false }
|
||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { "anon#\(pubkeyHex.prefix(4))" }
|
||||
|
||||
private var processedNostrEventIDs: Set<String> = []
|
||||
var processedEventCount: Int { processedNostrEventIDs.count }
|
||||
func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) }
|
||||
func recordProcessedNostrEvent(_ eventID: String) { processedNostrEventIDs.insert(eventID) }
|
||||
func clearProcessedNostrEvents() { processedNostrEventIDs.removeAll() }
|
||||
|
||||
var geoNicknames: [String: String] = [:]
|
||||
private var teleportedKeys: Set<String> = []
|
||||
var teleportedGeoCount: Int { teleportedKeys.count }
|
||||
func startGeoParticipantRefreshTimer() {}
|
||||
func stopGeoParticipantRefreshTimer() {}
|
||||
func setActiveParticipantGeohash(_ geohash: String?) {}
|
||||
private(set) var participantRecords = 0
|
||||
func recordGeoParticipant(pubkeyHex: String) { participantRecords += 1 }
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String) { participantRecords += 1 }
|
||||
func geoParticipantCount(for geohash: String) -> Int { 0 }
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname }
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) }
|
||||
func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) }
|
||||
func clearTeleportedGeo() { teleportedKeys.removeAll() }
|
||||
func clearGeoNicknames() { geoNicknames.removeAll() }
|
||||
func visibleGeohashPeople() -> [GeoPerson] { [] }
|
||||
|
||||
var isTeleported = false
|
||||
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { false }
|
||||
|
||||
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {}
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {}
|
||||
}
|
||||
|
||||
// MARK: - Mock ChatDeliveryContext
|
||||
|
||||
@MainActor
|
||||
private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||
var messages: [BitchatMessage] = []
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
var sentReadReceipts: Set<String> = []
|
||||
var isStartupPhase: Bool { false }
|
||||
func notifyUIChanged() {}
|
||||
func markMessageDelivered(_ messageID: String) {}
|
||||
|
||||
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
|
||||
/// deterministic IDs and timestamps.
|
||||
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
||||
let context = PerfDeliveryContext()
|
||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
context.messages = (0..<publicCount).map { i in
|
||||
BitchatMessage(
|
||||
id: "pub-\(i)",
|
||||
sender: "peer\(i % 20)",
|
||||
content: "public message number \(i)",
|
||||
timestamp: base.addingTimeInterval(Double(i)),
|
||||
isRelay: false
|
||||
)
|
||||
}
|
||||
for p in 0..<peerCount {
|
||||
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
|
||||
context.privateChats[peerID] = (0..<messagesPerPeer).map { i in
|
||||
BitchatMessage(
|
||||
id: "dm-\(p)-\(i)",
|
||||
sender: "peer\(p)",
|
||||
content: "private message \(i) for peer \(p)",
|
||||
timestamp: base.addingTimeInterval(Double(i)),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock MessageFormattingContext
|
||||
|
||||
@MainActor
|
||||
private final class PerfFormattingContext: MessageFormattingContext {
|
||||
let nickname: String
|
||||
init(nickname: String) { self.nickname = nickname }
|
||||
func isSelfMessage(_ message: BitchatMessage) -> Bool { false }
|
||||
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { .blue }
|
||||
func peerURL(for peerID: PeerID) -> URL? { URL(string: "bitchat://peer/\(peerID.id)") }
|
||||
}
|
||||
Reference in New Issue
Block a user