mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
* Consolidate duplicate BLE links after restore; suppress duplicate fragment streams; onChange coalescing Field evidence (two-phone test after BLE state-restoration relaunches): both phones held 2-3 simultaneous same-role connections to each other — one side received every packet from three distinct centrals all bound to the same peer — so every PTT voice frame arrived 3x, and a 41KB voice file went out as TWO complete independent 89-fragment streams (different assembly ids), both fully reassembled and the duplicate only dropped at the very end by messageID dedup. 2-3x airtime/battery on all traffic plus doubled reassembly memory. Root causes and fixes: - Duplicate links stayed unbound forever: announces (the only packet that binds a link to a peer) went through the per-peer duplicate-link collapse, so a peer's second/third link never received the announce it needed to become bound — and unbound links pass the collapse untouched, so every broadcast sprayed down all of them. Direct announces now bypass the collapse and reach every live link (relayed announces keep it); once bound, the existing collapse dedups all traffic. - Same-role link retirement: a verified direct announce now consolidates our central-role connections to that peer — keep the link the announce arrived on (or the most recently bound one), cancel other connected links bound to the same peer. One connection per role per peer is the normal dual-role topology; only same-role duplicates are touched, only links already announce-bound are retired (never pre-announce links), at most one retirement per peer per rebind-cooldown window, and the peer keeps a live link either way. Directness stays forgeable (TTL is unsigned), so a replay could nominate the survivor — bounded by the cooldown and by DM routing's existing canDeliverSecurely gate. A rotation rebind now also cancels other stale links still bound to the rotated-away ID, so the ghost identity retires promptly. - Deterministic preferred-link collapse: when several bound links to one peer are candidates, collapse now keeps the peer's most recently bound link (the reverse-mapped one) instead of dictionary order; links without a discovered characteristic are excluded from fanout (they cannot be written to, and could silently eat a peer's collapsed copy). links(to:) now reports all bound peripheral links, and removing one duplicate no longer clobbers the reverse map of the survivor. - Duplicate fragment streams: a transferId-less resend (gossip-sync replay, spool) of file content already being fragmented out to a covering audience is dropped at the outbound scheduler (broadcast covers everyone; directed covers its recipient). App-initiated sends carry an explicit transferId the progress UI tracks and always run. A peer that asks after the stream completes still gets a resend. - didUnsubscribeFrom no longer flaps a peer that is still live on other links (the far side retiring its duplicate arrives as an unsubscribe); didDisconnectPeripheral bookkeeping is skipped for self-retired links by removing the store entry before cancelling. Also: guard the DeliveryStatus onChange state write in TextMessageView/ MediaMessageView (unconditional per-row writes under a message storm tripped SwiftUI's "tried to update multiple times per frame" warning). The restore-path bgRemaining=∞ log was already fixed on main (#1425 review follow-up 07b5ac31: init-time seed + sampler-routed restore captures); verified, no change needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: multi-link disconnect guard, characteristic-aware retirement Adversarial review of the duplicate-link PR (merge-with-nits): - didDisconnectPeripheral gets the same multi-link guard as the unsubscribe path: when a duplicate link drops naturally while the peer stays live on another (dual-role central link, or a second bound link during the post-restore consolidation window), peer-disconnect bookkeeping (markDisconnected + disconnect notify) no longer runs — a UI blip until the next announce re-marked the peer connected. The reverse map was just repaired onto a connected survivor, so directLinkState is the accurate probe. Scan restart and connect-slot refill stay unguarded: they respond to the physical drop regardless of remaining logical links. (No unit test: driving the CBCentralManager delegate requires CBPeripheral instances, which cannot be constructed in tests; the policy pieces backing the guard are covered.) - Retirement is now characteristic-aware: the policy snapshot carries characteristic presence, and keptPeripheralUUID selects anchors only among writable links while any exist — consolidation must not keep a link mid-service-rediscovery (didModifyServices cleared its characteristic) and cancel the writable duplicate, stranding outbound traffic on the central link until rediscovery finishes. When neither anchor is writable but a writable duplicate exists, consolidation defers to a later announce instead of guessing. The reverse-map survivor repair in removePeripheral prefers writable links for the same reason. Three new policy tests cover charless-vs-writable. Deferred with code comments per review: central-side collapse keeps the oldest subscription (no recency signal; remote consolidates within its cooldown), and the extra preferred-bindings bleQueue hop in sendOnAllLinks (fold into a combined snapshot if profiling flags it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
279 lines
9.9 KiB
Swift
279 lines
9.9 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
struct BLEFanoutSelectorTests {
|
|
@Test
|
|
func directedSendUsesAllNonIngressLinks() {
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["p1", "p2"],
|
|
centralIDs: ["c1", "c2"],
|
|
ingressLink: .central("c1"),
|
|
directedPeerHint: PeerID(str: "1122334455667788"),
|
|
packetType: MessageType.noiseEncrypted.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["p1", "p2"]))
|
|
#expect(selection.centralIDs == Set(["c2"]))
|
|
}
|
|
|
|
@Test
|
|
func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() {
|
|
let target = PeerID(str: "1122334455667788")
|
|
let bystander = PeerID(str: "8877665544332211")
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["target-p", "bystander-p"],
|
|
centralIDs: ["target-c", "bystander-c"],
|
|
ingressLink: nil,
|
|
peripheralPeerBindings: [
|
|
"target-p": target,
|
|
"bystander-p": bystander
|
|
],
|
|
centralPeerBindings: [
|
|
"target-c": target,
|
|
"bystander-c": bystander
|
|
],
|
|
directedPeerHint: target,
|
|
packetType: MessageType.courierEnvelope.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["target-p"]))
|
|
#expect(selection.centralIDs.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() {
|
|
let target = PeerID(str: "1122334455667788")
|
|
let bystander = PeerID(str: "8877665544332211")
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["bystander-p"],
|
|
centralIDs: ["target-c", "bystander-c"],
|
|
ingressLink: nil,
|
|
peripheralPeerBindings: [
|
|
"bystander-p": bystander
|
|
],
|
|
centralPeerBindings: [
|
|
"target-c": target,
|
|
"bystander-c": bystander
|
|
],
|
|
directedPeerHint: target,
|
|
packetType: MessageType.courierEnvelope.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs.isEmpty)
|
|
#expect(selection.centralIDs == Set(["target-c"]))
|
|
}
|
|
|
|
@Test
|
|
func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() {
|
|
let target = PeerID(str: "1122334455667788")
|
|
let bystander = PeerID(str: "8877665544332211")
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["bystander-p"],
|
|
centralIDs: ["target-c", "bystander-c"],
|
|
ingressLink: .central("target-c"),
|
|
peripheralPeerBindings: [
|
|
"bystander-p": bystander
|
|
],
|
|
centralPeerBindings: [
|
|
"target-c": target,
|
|
"bystander-c": bystander
|
|
],
|
|
directedPeerHint: target,
|
|
packetType: MessageType.courierEnvelope.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs.isEmpty)
|
|
#expect(selection.centralIDs.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func directedSendExcludesAllLinksToIngressPeer() {
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["p1", "p2"],
|
|
centralIDs: ["c1", "c2"],
|
|
ingressLink: .central("c1"),
|
|
excludedLinks: [.peripheral("p2"), .central("c2")],
|
|
directedPeerHint: PeerID(str: "1122334455667788"),
|
|
packetType: MessageType.noiseEncrypted.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["p1"]))
|
|
#expect(selection.centralIDs.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func controlPacketsUseAllNonIngressLinks() {
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["p1", "p2", "p3"],
|
|
centralIDs: ["c1", "c2", "c3"],
|
|
ingressLink: .peripheral("p2"),
|
|
directedPeerHint: nil,
|
|
packetType: MessageType.requestSync.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["p1", "p3"]))
|
|
#expect(selection.centralIDs == Set(["c1", "c2", "c3"]))
|
|
}
|
|
|
|
@Test
|
|
func broadcastPacketsUseDeterministicSubsetAfterIngressExclusion() {
|
|
let peripherals = (1...8).map { "p\($0)" }
|
|
let centrals = (1...8).map { "c\($0)" }
|
|
|
|
let first = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: peripherals,
|
|
centralIDs: centrals,
|
|
ingressLink: .peripheral("p4"),
|
|
directedPeerHint: nil,
|
|
packetType: MessageType.message.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
let second = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: peripherals,
|
|
centralIDs: centrals,
|
|
ingressLink: .peripheral("p4"),
|
|
directedPeerHint: nil,
|
|
packetType: MessageType.message.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(first == second)
|
|
#expect(!first.peripheralIDs.contains("p4"))
|
|
#expect(first.peripheralIDs.count == 4)
|
|
#expect(first.centralIDs.count == 4)
|
|
}
|
|
|
|
@Test
|
|
func dualLinkPeerRelaysOnSingleLinkPreferringPeripheral() {
|
|
// A dual-role pair holds two live links to the same peer; relays must
|
|
// not transmit the same packet down both. The peripheral (write) link
|
|
// wins because it has per-link flow control.
|
|
let peer = PeerID(str: "1122334455667788")
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["p1"],
|
|
centralIDs: ["c1"],
|
|
ingressLink: nil,
|
|
peripheralPeerBindings: ["p1": peer],
|
|
centralPeerBindings: ["c1": peer],
|
|
directedPeerHint: nil,
|
|
packetType: MessageType.fragment.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["p1"]))
|
|
#expect(selection.centralIDs.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func unboundLinksSurviveDuplicatePeerCollapse() {
|
|
// Links whose peer is not yet known (pre-announce) must keep
|
|
// receiving broadcasts alongside a deduplicated bound pair.
|
|
let peer = PeerID(str: "1122334455667788")
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["p1"],
|
|
centralIDs: ["c-bound", "c-unbound"],
|
|
ingressLink: nil,
|
|
peripheralPeerBindings: ["p1": peer],
|
|
centralPeerBindings: ["c-bound": peer],
|
|
directedPeerHint: nil,
|
|
packetType: MessageType.fragment.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["p1"]))
|
|
#expect(selection.centralIDs == Set(["c-unbound"]))
|
|
}
|
|
|
|
@Test
|
|
func duplicateBoundLinksToOnePeerCollapseToItsPreferredLink() {
|
|
// After a restore the same phone can hold several live links bound to
|
|
// one peer; broadcasts must go down exactly one — the most recently
|
|
// bound (preferred) one, not dictionary order.
|
|
let peer = PeerID(str: "1122334455667788")
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["p-stale", "p-preferred", "p-stale-2"],
|
|
centralIDs: ["c-bound"],
|
|
ingressLink: nil,
|
|
peripheralPeerBindings: [
|
|
"p-stale": peer,
|
|
"p-preferred": peer,
|
|
"p-stale-2": peer
|
|
],
|
|
centralPeerBindings: ["c-bound": peer],
|
|
preferredPeripheralPerPeer: [peer: "p-preferred"],
|
|
directedPeerHint: nil,
|
|
packetType: MessageType.fragment.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["p-preferred"]))
|
|
#expect(selection.centralIDs.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func directedSendCollapsesDuplicateBoundLinksToPreferred() {
|
|
let peer = PeerID(str: "1122334455667788")
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["p-stale", "p-preferred"],
|
|
centralIDs: [],
|
|
ingressLink: nil,
|
|
peripheralPeerBindings: [
|
|
"p-stale": peer,
|
|
"p-preferred": peer
|
|
],
|
|
preferredPeripheralPerPeer: [peer: "p-preferred"],
|
|
directedPeerHint: peer,
|
|
packetType: MessageType.noiseEncrypted.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["p-preferred"]))
|
|
#expect(selection.centralIDs.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func uncollapsedSelectionReachesEveryLinkOfADuplicatelyLinkedPeer() {
|
|
// Announce fanout (collapse bypassed by the planner): the announce is
|
|
// the packet that binds links, so every live link must receive it.
|
|
let peer = PeerID(str: "1122334455667788")
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["p1", "p2"],
|
|
centralIDs: ["c1"],
|
|
ingressLink: nil,
|
|
peripheralPeerBindings: ["p1": peer, "p2": peer],
|
|
centralPeerBindings: ["c1": peer],
|
|
preferredPeripheralPerPeer: [peer: "p1"],
|
|
collapseDuplicatePeerLinks: false,
|
|
directedPeerHint: nil,
|
|
packetType: MessageType.announce.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["p1", "p2"]))
|
|
#expect(selection.centralIDs == Set(["c1"]))
|
|
}
|
|
|
|
@Test
|
|
func broadcastWithTwoLinksKeepsBothAfterIngressExclusion() {
|
|
let selection = BLEFanoutSelector.selectLinks(
|
|
peripheralIDs: ["p1", "p2"],
|
|
centralIDs: ["c1", "c2"],
|
|
ingressLink: nil,
|
|
directedPeerHint: nil,
|
|
packetType: MessageType.message.rawValue,
|
|
messageID: "message-1"
|
|
)
|
|
|
|
#expect(selection.peripheralIDs == Set(["p1", "p2"]))
|
|
#expect(selection.centralIDs == Set(["c1", "c2"]))
|
|
}
|
|
}
|