Merge branch 'main' into fix/noise-dh-secret-clearing

This commit is contained in:
jack
2026-01-04 13:41:38 -10:00
committed by GitHub
4 changed files with 260 additions and 9 deletions
@@ -12,6 +12,8 @@ import Foundation
/// Generic LRU (Least Recently Used) cache for deduplication. /// Generic LRU (Least Recently Used) cache for deduplication.
/// Uses an efficient O(1) lookup with periodic compaction. /// Uses an efficient O(1) lookup with periodic compaction.
/// Thread-safe via @MainActor - all callers are already on main actor.
@MainActor
final class LRUDeduplicationCache<Value> { final class LRUDeduplicationCache<Value> {
private var map: [String: Value] = [:] private var map: [String: Value] = [:]
private var order: [String] = [] private var order: [String] = []
@@ -157,6 +159,8 @@ enum ContentNormalizer {
/// Service that manages message deduplication using LRU caches. /// Service that manages message deduplication using LRU caches.
/// Provides separate caches for content-based dedup and Nostr event ID dedup. /// Provides separate caches for content-based dedup and Nostr event ID dedup.
/// Thread-safe via @MainActor - all callers are already on main actor.
@MainActor
final class MessageDeduplicationService { final class MessageDeduplicationService {
/// Cache for content-based near-duplicate detection /// Cache for content-based near-duplicate detection
+14 -9
View File
@@ -149,8 +149,11 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits // Enqueue and process with throttling to avoid relay rate limits
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) // Use barrier to synchronize access to readQueue
processReadQueueIfNeeded() queue.async(flags: .barrier) { [weak self] in
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
self?.processReadQueueIfNeeded()
}
} }
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
@@ -260,16 +263,17 @@ extension NostrTransport {
// MARK: - Private Helpers // MARK: - Private Helpers
extension NostrTransport { extension NostrTransport {
/// Must be called within a barrier on `queue`
private func processReadQueueIfNeeded() { private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return } guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return } guard !readQueue.isEmpty else { return }
isSendingReadAcks = true isSendingReadAcks = true
sendNextReadAck() let item = readQueue.removeFirst()
sendReadAckItem(item)
} }
private func sendNextReadAck() { /// Sends a single read ack item (called after extraction from queue within barrier)
guard !readQueue.isEmpty else { isSendingReadAcks = false; return } private func sendReadAckItem(_ item: QueuedRead) {
let item = readQueue.removeFirst()
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return } guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
@@ -301,9 +305,10 @@ extension NostrTransport {
private func scheduleNextReadAck() { private func scheduleNextReadAck() {
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
guard let self = self else { return } self?.queue.async(flags: .barrier) { [weak self] in
self.isSendingReadAcks = false self?.isSendingReadAcks = false
self.processReadQueueIfNeeded() self?.processReadQueueIfNeeded()
}
} }
} }
@@ -12,6 +12,8 @@ import Foundation
// MARK: - LRU Deduplication Cache Tests // MARK: - LRU Deduplication Cache Tests
@Suite("LRU Deduplication Cache")
@MainActor
struct LRUDeduplicationCacheTests { struct LRUDeduplicationCacheTests {
// MARK: - Basic Operations // MARK: - Basic Operations
@@ -265,6 +267,8 @@ struct ContentNormalizerTests {
// MARK: - Message Deduplication Service Tests // MARK: - Message Deduplication Service Tests
@Suite("Message Deduplication Service")
@MainActor
struct MessageDeduplicationServiceTests { struct MessageDeduplicationServiceTests {
// MARK: - Content Deduplication // MARK: - Content Deduplication
@@ -467,4 +471,134 @@ struct MessageDeduplicationServiceTests {
#expect(service.contentTimestamp(for: "hello world") == now) #expect(service.contentTimestamp(for: "hello world") == now)
#expect(service.contentTimestamp(for: "Hello World") == now) #expect(service.contentTimestamp(for: "Hello World") == now)
} }
// MARK: - Thread Safety Tests (via @MainActor enforcement)
@Test("Concurrent content recording is safe via MainActor")
func concurrentContentRecording() async {
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
let iterations = 100
// All operations run on MainActor due to @MainActor annotation
// This test verifies the pattern works correctly
await withTaskGroup(of: Void.self) { group in
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordContent("Message \(i)", timestamp: Date())
}
}
}
// Verify some entries were recorded
#expect(service.contentTimestamp(for: "Message 0") != nil)
#expect(service.contentTimestamp(for: "Message 99") != nil)
}
@Test("Concurrent Nostr event recording is safe via MainActor")
func concurrentNostrEventRecording() async {
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
let iterations = 100
await withTaskGroup(of: Void.self) { group in
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordNostrEvent("event_\(i)")
}
}
}
// Verify events were recorded
#expect(service.hasProcessedNostrEvent("event_0"))
#expect(service.hasProcessedNostrEvent("event_99"))
}
@Test("Mixed concurrent operations are safe via MainActor")
func concurrentMixedOperations() async {
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
let iterations = 50
await withTaskGroup(of: Void.self) { group in
// Content recording tasks
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordContent("Content \(i)", timestamp: Date())
}
}
// Event recording tasks
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordNostrEvent("event_\(i)")
}
}
// ACK recording tasks
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordNostrAck("ack_\(i)")
}
}
// Read tasks
for i in 0..<iterations {
group.addTask { @MainActor in
_ = service.contentTimestamp(for: "Content \(i)")
_ = service.hasProcessedNostrEvent("event_\(i)")
_ = service.hasProcessedNostrAck("ack_\(i)")
}
}
}
// If we reach here without crashes, the test passes
}
}
// MARK: - LRU Cache Thread Safety Tests
@Suite("LRU Cache Thread Safety")
@MainActor
struct LRUCacheThreadSafetyTests {
@Test("Concurrent cache access is safe via MainActor")
func concurrentCacheAccess() async {
let cache = LRUDeduplicationCache<Int>(capacity: 500)
let iterations = 100
await withTaskGroup(of: Void.self) { group in
// Write tasks
for i in 0..<iterations {
group.addTask { @MainActor in
cache.record("key_\(i)", value: i)
}
}
// Read tasks
for i in 0..<iterations {
group.addTask { @MainActor in
_ = cache.contains("key_\(i)")
_ = cache.value(for: "key_\(i)")
}
}
}
// Verify cache is in consistent state
#expect(cache.count <= 500) // Respects capacity
}
@Test("Cache eviction under concurrent load is safe")
func cacheEvictionUnderLoad() async {
let cache = LRUDeduplicationCache<Int>(capacity: 10)
let iterations = 100
await withTaskGroup(of: Void.self) { group in
for i in 0..<iterations {
group.addTask { @MainActor in
cache.record("key_\(i)", value: i)
}
}
}
// Cache should maintain its capacity constraint
#expect(cache.count == 10)
}
} }
@@ -0,0 +1,108 @@
//
// NostrTransportTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
@Suite("NostrTransport Thread Safety Tests")
struct NostrTransportTests {
@Test("Concurrent read receipt enqueue does not crash")
@MainActor
func concurrentReadReceiptEnqueue() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
// Create 100 concurrent read receipt submissions
let iterations = 100
await withTaskGroup(of: Void.self) { group in
for i in 0..<iterations {
group.addTask {
let receipt = ReadReceipt(
originalMessageID: UUID().uuidString,
readerID: PeerID(str: String(format: "%016x", i)),
readerNickname: "Reader\(i)"
)
let peerID = PeerID(str: String(format: "%016x", i))
transport.sendReadReceipt(receipt, to: peerID)
}
}
}
// If we reach here without crashing, the test passes
// The concurrent enqueue operations completed without data races
}
@Test("Read queue processes under concurrent load")
@MainActor
func readQueueProcessingUnderLoad() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
// Rapidly enqueue many receipts from multiple concurrent sources
let iterations = 50
// First batch - rapid fire
for i in 0..<iterations {
let receipt = ReadReceipt(
originalMessageID: UUID().uuidString,
readerID: PeerID(str: String(format: "%016x", i)),
readerNickname: "Reader\(i)"
)
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
}
// Give some time for processing to start
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
// Second batch - while first might be processing
await withTaskGroup(of: Void.self) { group in
for i in iterations..<(iterations * 2) {
group.addTask {
let receipt = ReadReceipt(
originalMessageID: UUID().uuidString,
readerID: PeerID(str: String(format: "%016x", i)),
readerNickname: "Reader\(i)"
)
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
}
}
}
// If we reach here without crashing or deadlocking, test passes
}
@Test("isPeerReachable is thread safe")
@MainActor
func isPeerReachableThreadSafety() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
let iterations = 100
// Concurrent reads on isPeerReachable
await withTaskGroup(of: Bool.self) { group in
for i in 0..<iterations {
group.addTask {
let peerID = PeerID(str: String(format: "%016x", i))
return transport.isPeerReachable(peerID)
}
}
// Collect results (all should be false since no favorites configured)
for await result in group {
#expect(result == false)
}
}
}
}