mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +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>
262 lines
11 KiB
Swift
262 lines
11 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
struct BLEOutboundFragmentTransferSchedulerTests {
|
|
@Test
|
|
func submitStartsPublicMessageWithoutTransferReservation() {
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let request = makeRequest(type: MessageType.message.rawValue, transferId: nil)
|
|
|
|
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
|
|
|
|
if case let .start(_, reservedTransferId) = result {
|
|
#expect(reservedTransferId == nil)
|
|
#expect(scheduler.activeCount == 0)
|
|
#expect(scheduler.pendingCount == 0)
|
|
} else {
|
|
Issue.record("Expected non-file fragments to start without reserving a transfer slot")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func submitQueuesFileTransferWhenSlotsAreFull() {
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "first")
|
|
let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "second")
|
|
|
|
guard case let .start(_, firstReservation?) = scheduler.submit(first, maxConcurrentTransfers: 1) else {
|
|
Issue.record("Expected first file transfer to reserve a slot")
|
|
return
|
|
}
|
|
#expect(firstReservation == "first")
|
|
|
|
let result = scheduler.submit(second, maxConcurrentTransfers: 1)
|
|
|
|
if case let .queued(_, transferId, position) = result {
|
|
#expect(transferId == "second")
|
|
#expect(position == .back)
|
|
#expect(scheduler.activeCount == 1)
|
|
#expect(scheduler.pendingCount == 1)
|
|
} else {
|
|
Issue.record("Expected second file transfer to queue while slots are full")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func submitQueuesDuplicateActiveTransferAtFront() {
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let request = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "same")
|
|
|
|
_ = scheduler.submit(request, maxConcurrentTransfers: 2)
|
|
let result = scheduler.submit(request, maxConcurrentTransfers: 2)
|
|
|
|
if case let .queued(_, transferId, position) = result {
|
|
#expect(transferId == "same")
|
|
#expect(position == .front)
|
|
#expect(scheduler.activeCount == 1)
|
|
#expect(scheduler.pendingCount == 1)
|
|
} else {
|
|
Issue.record("Expected duplicate active transfer to queue at the front")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func resendWithoutTransferIdOfActiveBroadcastContentIsDropped() {
|
|
// Field bug: a gossip-sync replay re-fragmented a 41KB voice file
|
|
// that was still being broadcast, sending two complete fragment
|
|
// streams. The resend path has no explicit transferId; drop it while
|
|
// a covering transfer of the same bytes is in flight.
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let original = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "app-id", payload: "voice-file")
|
|
let resend = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: nil, payload: "voice-file")
|
|
|
|
_ = scheduler.submit(original, maxConcurrentTransfers: 2)
|
|
let result = scheduler.submit(resend, maxConcurrentTransfers: 2)
|
|
|
|
if case let .droppedDuplicate(_, activeTransferId) = result {
|
|
#expect(activeTransferId == "app-id")
|
|
#expect(scheduler.activeCount == 1)
|
|
#expect(scheduler.pendingCount == 0)
|
|
} else {
|
|
Issue.record("Expected the transferId-less resend of in-flight broadcast content to be dropped")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func directedResendToAnUncoveredAudienceStillRuns() {
|
|
// The in-flight copy is directed to one peer; a resend of the same
|
|
// bytes to a different peer is not redundant.
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let toFirstPeer = makeRequest(
|
|
type: MessageType.fileTransfer.rawValue,
|
|
transferId: "app-id",
|
|
payload: "shared-file",
|
|
directedPeer: PeerID(str: "1122334455667788")
|
|
)
|
|
let toSecondPeer = makeRequest(
|
|
type: MessageType.fileTransfer.rawValue,
|
|
transferId: nil,
|
|
payload: "shared-file",
|
|
directedPeer: PeerID(str: "8877665544332211")
|
|
)
|
|
|
|
_ = scheduler.submit(toFirstPeer, maxConcurrentTransfers: 2)
|
|
let result = scheduler.submit(toSecondPeer, maxConcurrentTransfers: 2)
|
|
|
|
if case .start = result {
|
|
#expect(scheduler.activeCount == 2)
|
|
} else {
|
|
Issue.record("Expected a resend directed at an uncovered peer to start")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func explicitTransferIdSendIsNeverDroppedAsDuplicate() {
|
|
// App-initiated sends carry a transferId the progress UI tracks;
|
|
// only transferId-less resend paths are deduplicated.
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "send-1", payload: "same-bytes")
|
|
let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "send-2", payload: "same-bytes")
|
|
|
|
_ = scheduler.submit(first, maxConcurrentTransfers: 2)
|
|
let result = scheduler.submit(second, maxConcurrentTransfers: 2)
|
|
|
|
if case let .start(_, reservedTransferId?) = result {
|
|
#expect(reservedTransferId == "send-2")
|
|
} else {
|
|
Issue.record("Expected an explicit-transferId send to run despite identical content")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func duplicateOfPendingContentIsDroppedAtSubmit() {
|
|
// A duplicate must not queue behind a pending copy of the same
|
|
// content and resend the whole file when the slot frees.
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active", payload: "file-a")
|
|
let queuedContent = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "waiting", payload: "file-b")
|
|
let queuedDuplicate = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: nil, payload: "file-b")
|
|
|
|
_ = scheduler.submit(active, maxConcurrentTransfers: 1)
|
|
_ = scheduler.submit(queuedContent, maxConcurrentTransfers: 1)
|
|
|
|
if case .droppedDuplicate = scheduler.submit(queuedDuplicate, maxConcurrentTransfers: 1) {
|
|
// Dropped immediately: the pending "waiting" transfer covers it.
|
|
} else {
|
|
Issue.record("Expected the duplicate of pending content to be dropped at submit")
|
|
}
|
|
#expect(scheduler.pendingCount == 1)
|
|
}
|
|
|
|
@Test
|
|
func resendAfterCompletionIsAllowed() {
|
|
// Duplicate suppression only covers in-flight transfers: a peer that
|
|
// requests the file after the stream completed must get a resend.
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let original = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "app-id", payload: "voice-file")
|
|
let resend = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: nil, payload: "voice-file")
|
|
|
|
_ = scheduler.submit(original, maxConcurrentTransfers: 1)
|
|
let didActivate = scheduler.activateReservedTransfer(id: "app-id", totalFragments: 1, workItems: [])
|
|
#expect(didActivate)
|
|
#expect(scheduler.markFragmentSent(transferId: "app-id") == .complete(sentFragments: 1, totalFragments: 1))
|
|
|
|
if case .start = scheduler.submit(resend, maxConcurrentTransfers: 1) {
|
|
#expect(scheduler.activeCount == 1)
|
|
} else {
|
|
Issue.record("Expected a resend after completion to start")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func cancelActiveTransferReturnsScheduledWorkItems() {
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let request = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active")
|
|
_ = scheduler.submit(request, maxConcurrentTransfers: 1)
|
|
let workItem = DispatchWorkItem {}
|
|
|
|
let didActivate = scheduler.activateReservedTransfer(id: "active", totalFragments: 2, workItems: [workItem])
|
|
#expect(didActivate)
|
|
|
|
if case let .active(transferId, workItems) = scheduler.cancelTransfer("active") {
|
|
#expect(transferId == "active")
|
|
#expect(workItems.count == 1)
|
|
#expect(scheduler.activeCount == 0)
|
|
} else {
|
|
Issue.record("Expected active transfer cancellation to return its work items")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func completedTransferFreesSlotForPendingTransfer() {
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "first")
|
|
let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "second")
|
|
|
|
_ = scheduler.submit(first, maxConcurrentTransfers: 1)
|
|
let didActivate = scheduler.activateReservedTransfer(id: "first", totalFragments: 2, workItems: [])
|
|
#expect(didActivate)
|
|
_ = scheduler.submit(second, maxConcurrentTransfers: 1)
|
|
|
|
#expect(scheduler.markFragmentSent(transferId: "first") == .progress(sentFragments: 1, totalFragments: 2))
|
|
#expect(scheduler.markFragmentSent(transferId: "first") == .complete(sentFragments: 2, totalFragments: 2))
|
|
|
|
let starts = scheduler.reservePendingStarts(maxConcurrentTransfers: 1)
|
|
#expect(starts.count == 1)
|
|
|
|
if case let .start(_, reservedTransferId?) = starts.first {
|
|
#expect(reservedTransferId == "second")
|
|
#expect(scheduler.activeCount == 1)
|
|
#expect(scheduler.pendingCount == 0)
|
|
} else {
|
|
Issue.record("Expected pending transfer to reserve the freed slot")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func removeAllReturnsActiveWorkItemsAndDropsPendingTransfers() {
|
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
|
let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active")
|
|
let pending = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "pending")
|
|
let workItem = DispatchWorkItem {}
|
|
|
|
_ = scheduler.submit(active, maxConcurrentTransfers: 1)
|
|
let didActivate = scheduler.activateReservedTransfer(id: "active", totalFragments: 1, workItems: [workItem])
|
|
#expect(didActivate)
|
|
_ = scheduler.submit(pending, maxConcurrentTransfers: 1)
|
|
|
|
let removed = scheduler.removeAll()
|
|
|
|
#expect(removed.count == 1)
|
|
#expect(removed.first?.id == "active")
|
|
#expect(removed.first?.workItems.count == 1)
|
|
#expect(scheduler.activeCount == 0)
|
|
#expect(scheduler.pendingCount == 0)
|
|
}
|
|
|
|
private func makeRequest(
|
|
type: UInt8,
|
|
transferId: String?,
|
|
payload: String? = nil,
|
|
directedPeer: PeerID? = nil
|
|
) -> BLEOutboundFragmentTransferRequest {
|
|
BLEOutboundFragmentTransferRequest(
|
|
packet: BitchatPacket(
|
|
type: type,
|
|
senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
|
|
recipientID: nil,
|
|
timestamp: 0x0102030405,
|
|
payload: Data((payload ?? transferId ?? "payload").utf8),
|
|
signature: nil,
|
|
ttl: 3
|
|
),
|
|
pad: false,
|
|
maxChunk: nil,
|
|
directedPeer: directedPeer,
|
|
transferId: transferId
|
|
)
|
|
}
|
|
}
|