mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:45:20 +00:00
Fix restore-path main↔bleQueue deadlock and courier drop amplification
A device froze permanently in a two-phone test. Debugger stacks showed an ABBA deadlock: the main actor was in bleQueue.sync (delivery-ack send → broadcastPacket → readLinkState) while bleQueue was in main.sync (captureBluetoothStatus reading backgroundTimeRemaining). The load that lined the two edges up came from a courier-drop amplification storm: drop dedup was in-memory only while the outbox driving 120s re-deposits is persisted, so every relaunch republished the same undelivered DM as a fresh 24h relay drop and every gateway relaunch re-fetched the whole backlog — ~20 copies of one DM delivered in 40ms, each triggering decrypt + delivery + ack + handshake work. Fixes, in rank order: - Edge B (P0): captureBluetoothStatus no longer main.syncs from bleQueue; backgroundTimeRemaining is sampled on main and cached behind a lock. Invariant documented: bleQueue must NEVER sync-dispatch to main. - Edge A (P0, defense in depth): sendDeliveryAck / sendReadReceipt / sendPrivateMessage / sendNoisePayload / triggerHandshake hop to messageQueue like sendMessage, so no main-actor call path reaches readLinkState's bleQueue.sync. - Drop dedup (P1): publishedDropKeys and seenDropEventIDs persist across relaunches (new BridgeDropDedupStore, entries expire with the 24h NIP-40 drop window; wiped on panic) — one drop per message ID per 24h regardless of relaunch count. - Receiver dedup (P1): openCourierEnvelope dedups on the inner private message ID before delivery, so a duplicate copy costs one decrypt and never re-delivers, re-acks, or re-triggers a handshake. - Handshake gating (P2): queued acks initiate a Noise handshake only for reachable peers; mail from absent/rotated identities no longer turns each copy into a mesh-wide handshake flood (the ack stays queued and flushes when a session eventually establishes). - Outbox (P3): re-enqueueing a queued message ID carries over its depositedCourierKeys so resends stop re-burning the same courier slots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,8 +35,8 @@ struct BridgeCourierServiceTests {
|
||||
|
||||
let service: BridgeCourierService
|
||||
|
||||
init() {
|
||||
service = BridgeCourierService()
|
||||
init(dedupStore: BridgeDropDedupStore? = nil) {
|
||||
service = BridgeCourierService(dedupStore: dedupStore)
|
||||
service.bridgeEnabled = { [weak self] in self?.bridgeOn ?? false }
|
||||
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
|
||||
service.publishEvent = { [weak self] event in self?.publishedEvents.append(event) }
|
||||
@@ -175,6 +175,53 @@ struct BridgeCourierServiceTests {
|
||||
#expect(fixture.sealRequests.count == 1)
|
||||
}
|
||||
|
||||
@Test func publishedDropDedupSurvivesRelaunch() throws {
|
||||
// Regression (field-verified amplification storm): the outbox that
|
||||
// drives re-deposits is persisted, but the sender-side drop dedup was
|
||||
// in-memory only — every relaunch republished the same undelivered
|
||||
// message as a fresh drop, and relays hold each for 24h.
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let recipientKey = Fixture.randomKey()
|
||||
let messageID = UUID().uuidString
|
||||
|
||||
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||
#expect(fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey))
|
||||
#expect(fixture.publishedEvents.count == 1)
|
||||
|
||||
// "Relaunch": a fresh service over the same store must refuse to
|
||||
// publish the same message ID again (before even re-sealing it).
|
||||
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||
#expect(!relaunched.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey))
|
||||
#expect(relaunched.publishedEvents.isEmpty)
|
||||
#expect(relaunched.sealRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test func seenDropEventDedupSurvivesRelaunch() throws {
|
||||
// Same storm, gateway side: relays redeliver the whole 24h drop
|
||||
// backlog on every launch; a relaunch must not re-open (and re-ack)
|
||||
// events it already handled.
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
|
||||
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||
let myKey = try #require(fixture.myKey)
|
||||
fixture.service.refresh()
|
||||
let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey))
|
||||
fixture.service.handleDropEvent(event)
|
||||
#expect(fixture.openedEnvelopes.count == 1)
|
||||
|
||||
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||
relaunched.myKey = myKey
|
||||
relaunched.service.refresh()
|
||||
relaunched.service.handleDropEvent(event)
|
||||
#expect(relaunched.openedEnvelopes.isEmpty)
|
||||
}
|
||||
|
||||
@Test func distinctDropsUseDistinctThrowawayKeys() {
|
||||
let fixture = Fixture()
|
||||
let keyA = Fixture.randomKey()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//
|
||||
// BridgeDropDedupStoreTests.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("Bridge drop dedup persistence")
|
||||
struct BridgeDropDedupStoreTests {
|
||||
|
||||
// MARK: - ExpiringIDSet
|
||||
|
||||
@Test func entriesExpireAfterLifetime() {
|
||||
let start = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var set = ExpiringIDSet(capacity: 8, lifetime: 60)
|
||||
|
||||
let inserted = set.insert("a", now: start)
|
||||
#expect(inserted)
|
||||
#expect(set.contains("a", now: start))
|
||||
let duplicate = set.insert("a", now: start.addingTimeInterval(30))
|
||||
#expect(!duplicate)
|
||||
|
||||
// Past the lifetime the slot is free again.
|
||||
let later = start.addingTimeInterval(61)
|
||||
#expect(!set.contains("a", now: later))
|
||||
let reinserted = set.insert("a", now: later)
|
||||
#expect(reinserted)
|
||||
}
|
||||
|
||||
@Test func capacityEvictsOldestFirst() {
|
||||
let start = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var set = ExpiringIDSet(capacity: 2, lifetime: 3600)
|
||||
|
||||
set.insert("oldest", now: start)
|
||||
set.insert("middle", now: start.addingTimeInterval(1))
|
||||
set.insert("newest", now: start.addingTimeInterval(2))
|
||||
|
||||
let check = start.addingTimeInterval(3)
|
||||
#expect(!set.contains("oldest", now: check))
|
||||
#expect(set.contains("middle", now: check))
|
||||
#expect(set.contains("newest", now: check))
|
||||
}
|
||||
|
||||
@Test func removeReleasesSlot() {
|
||||
let now = Date()
|
||||
var set = ExpiringIDSet(capacity: 8, lifetime: 3600)
|
||||
set.insert("a", now: now)
|
||||
set.remove("a")
|
||||
#expect(!set.contains("a", now: now))
|
||||
let reinserted = set.insert("a", now: now)
|
||||
#expect(reinserted)
|
||||
}
|
||||
|
||||
@Test func initPrunesExpiredPersistedEntries() {
|
||||
let now = Date()
|
||||
let set = ExpiringIDSet(
|
||||
capacity: 8,
|
||||
lifetime: 3600,
|
||||
entries: [
|
||||
"stale": now.addingTimeInterval(-7200),
|
||||
"fresh": now.addingTimeInterval(-60),
|
||||
],
|
||||
now: now
|
||||
)
|
||||
#expect(!set.contains("stale", now: now))
|
||||
#expect(set.contains("fresh", now: now))
|
||||
}
|
||||
|
||||
// MARK: - Store round trip
|
||||
|
||||
@Test func snapshotRoundTripsThroughDisk() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-store-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let recorded = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
let store = BridgeDropDedupStore(fileURL: fileURL)
|
||||
store.save(BridgeDropDedupStore.Snapshot(
|
||||
publishedDropKeys: ["msg-1": recorded],
|
||||
seenDropEventIDs: ["event-1": recorded]
|
||||
))
|
||||
|
||||
let reloaded = BridgeDropDedupStore(fileURL: fileURL).load()
|
||||
#expect(reloaded.publishedDropKeys["msg-1"] == recorded)
|
||||
#expect(reloaded.seenDropEventIDs["event-1"] == recorded)
|
||||
}
|
||||
|
||||
@Test func wipeRemovesTheRecord() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-store-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
|
||||
let store = BridgeDropDedupStore(fileURL: fileURL)
|
||||
store.save(BridgeDropDedupStore.Snapshot(
|
||||
publishedDropKeys: ["msg-1": Date()],
|
||||
seenDropEventIDs: [:]
|
||||
))
|
||||
store.wipe()
|
||||
|
||||
let reloaded = BridgeDropDedupStore(fileURL: fileURL).load()
|
||||
#expect(reloaded.publishedDropKeys.isEmpty)
|
||||
#expect(reloaded.seenDropEventIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test func nonPersistingStoreStaysEmpty() {
|
||||
let store = BridgeDropDedupStore(persistsToDisk: false)
|
||||
store.save(BridgeDropDedupStore.Snapshot(
|
||||
publishedDropKeys: ["msg-1": Date()],
|
||||
seenDropEventIDs: [:]
|
||||
))
|
||||
#expect(store.load().publishedDropKeys.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -446,6 +446,34 @@ struct MessageRouterTests {
|
||||
#expect(transport.sentCourierMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func enqueueReplacementCarriesOverDepositedCourierKeys() async {
|
||||
// Re-sending a queued message ID replaces the outbox entry; the
|
||||
// replacement must inherit which couriers already carry the message,
|
||||
// or the deposit retry re-burns the same courier slots (duplicate
|
||||
// sealed copies to the same peer).
|
||||
let recipient = PeerID(str: "00000000000000aa")
|
||||
let recipientKey = Data(repeating: 0xBB, count: 32)
|
||||
let courier = PeerID(str: "00000000000000cc")
|
||||
let courierKey = Data(repeating: 0xCC, count: 32)
|
||||
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(courier)
|
||||
transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)])
|
||||
|
||||
let router = MessageRouter(
|
||||
transports: [transport],
|
||||
courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
|
||||
)
|
||||
router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "ck1")
|
||||
#expect(transport.sentCourierMessages.count == 1)
|
||||
|
||||
// Same message ID re-sent (e.g. a resend while still queued): the
|
||||
// courier already carrying it must not receive a second copy.
|
||||
router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "ck1")
|
||||
#expect(transport.sentCourierMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func courierBecameAvailable_ignoresTheRecipientThemselves() async {
|
||||
let recipient = PeerID(str: "00000000000000aa")
|
||||
|
||||
Reference in New Issue
Block a user