Merge remote-tracking branch 'origin/feat/source-route-send' into feat/integration-all

# Conflicts:
#	bitchat/Models/RequestSyncPacket.swift
#	bitchat/Services/BLE/BLEService.swift
#	bitchatTests/GossipSyncManagerTests.swift
This commit is contained in:
jack
2026-07-06 22:25:19 +02:00
17 changed files with 1051 additions and 43 deletions
@@ -135,6 +135,140 @@ struct BLEFragmentAssemblyBufferTests {
}
}
@Test
func stalledBroadcastAssemblyReportsFragmentIDOnceUntilRetryLapses() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragmentID = Data((1...8).map { UInt8($0) })
let packet = makePacket(payload: makePayload(count: 256))
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
let first = try #require(BLEFragmentHeader(packet: fragments[0]))
let t0 = Date(timeIntervalSince1970: 100)
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0)
// Not yet stalled.
let early = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(4))
#expect(early.isEmpty)
// Stalled: reported once, big-endian stream ID.
let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
#expect(stalled == [fragmentID])
// Within the retry window: not re-reported.
let repeated = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(8))
#expect(repeated.isEmpty)
// After the retry window it is requested again.
let retried = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(17))
#expect(retried == [fragmentID])
}
@Test
func newFragmentResetsStallClockAndCompletionStopsRequests() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragmentID = Data((10...17).map { UInt8($0) })
let packet = makePacket(payload: makePayload(count: 384))
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
let headers = try fragments.map { try #require(BLEFragmentHeader(packet: $0)) }
#expect(headers.count >= 3)
let t0 = Date(timeIntervalSince1970: 100)
_ = buffer.append(headers[0], maxInFlightAssemblies: 8, now: t0)
// A fragment arriving at t0+4 resets the stall clock.
_ = buffer.append(headers[1], maxInFlightAssemblies: 8, now: t0.addingTimeInterval(4))
let afterProgress = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
#expect(afterProgress.isEmpty)
// Completion removes the assembly entirely.
var result: BLEFragmentAssemblyBuffer.AppendResult?
for header in headers.dropFirst(2) {
result = buffer.append(header, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5))
}
guard case .complete = result else {
Issue.record("Expected assembly to complete")
return
}
let afterCompletion = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60))
#expect(afterCompletion.isEmpty)
}
@Test
func duplicateFragmentsDoNotResetStallClock() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragmentID = Data((20...27).map { UInt8($0) })
let packet = makePacket(payload: makePayload(count: 256))
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
let first = try #require(BLEFragmentHeader(packet: fragments[0]))
let t0 = Date(timeIntervalSince1970: 100)
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0)
// Relay duplicates of the same index arrive every few seconds; they
// bring no new data, so they must not keep the stream "fresh".
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(3))
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5))
let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
#expect(stalled == [fragmentID])
}
@Test
func overflowStalledStreamsRotateAcrossPasses() throws {
var buffer = BLEFragmentAssemblyBuffer()
let cap = RequestSyncPacket.maxFragmentIdFilterCount
let streamCount = cap + 10
let t0 = Date(timeIntervalSince1970: 100)
// Incomplete broadcast assemblies with staggered last-fragment times
// (stream 0 is the oldest stall).
var ids: [Data] = []
for i in 0..<streamCount {
let fragmentID = Data([0xAB, 0, 0, 0, 0, 0, UInt8(i >> 8), UInt8(i & 0xFF)])
ids.append(fragmentID)
let header = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
fragmentID: fragmentID,
index: 0,
total: 2,
originalType: MessageType.message.rawValue,
fragmentData: Data([0x01])
)))
_ = buffer.append(header, maxInFlightAssemblies: streamCount, now: t0.addingTimeInterval(Double(i)))
}
// All streams are stalled; only the cap's worth (oldest first) is
// requested and rate-limited, the overflow stays eligible.
let firstPassAt = t0.addingTimeInterval(Double(streamCount) + 5)
let firstPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt)
#expect(firstPass == Array(ids.prefix(cap)))
// Next pass picks up exactly the overflow streams.
let secondPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(1))
#expect(secondPass == Array(ids.suffix(streamCount - cap)))
// Nothing left until a retry window lapses.
let thirdPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(2))
#expect(thirdPass.isEmpty)
}
@Test
func directedAssembliesAreNeverReportedAsStalled() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragment = makeFragmentPacket(
fragmentID: Data(repeating: 0x0A, count: 8),
index: 0,
total: 2,
originalType: MessageType.message.rawValue,
fragmentData: Data([0x01]),
recipientID: Data(hexString: "0102030405060708")
)
let header = try #require(BLEFragmentHeader(packet: fragment))
let t0 = Date(timeIntervalSince1970: 100)
_ = buffer.append(header, maxInFlightAssemblies: 8, now: t0)
let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60))
#expect(stalled.isEmpty)
}
private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
@@ -0,0 +1,98 @@
//
// BLESourceRouteFailureCacheTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
struct BLESourceRouteFailureCacheTests {
private let recipient = PeerID(str: "0102030405060708")
private let config = BLESourceRouteFailureCache.Config(
confirmationWindowSeconds: 10,
suppressionSeconds: 60
)
private func attempts(_ cache: inout BLESourceRouteFailureCache, at date: Date) -> Bool {
cache.shouldAttemptRoute(to: recipient, now: date)
}
@Test func allowsRoutingByDefault() {
var cache = BLESourceRouteFailureCache(config: config)
#expect(attempts(&cache, at: Date()))
}
@Test func unconfirmedRoutedSendSuppressesRouting() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
// Inside the confirmation window: keep routing.
#expect(attempts(&cache, at: t0.addingTimeInterval(5)))
// Past the window with no inbound traffic: route failed, flood.
#expect(!attempts(&cache, at: t0.addingTimeInterval(11)))
// Still suppressed for the suppression TTL.
#expect(!attempts(&cache, at: t0.addingTimeInterval(40)))
// Suppression lapses: routing may be attempted again.
#expect(attempts(&cache, at: t0.addingTimeInterval(11 + 61)))
}
@Test func inboundActivityConfirmsPendingSend() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
cache.noteInboundActivity(from: recipient)
// Confirmed: no suppression even long after the window.
#expect(attempts(&cache, at: t0.addingTimeInterval(30)))
}
@Test func inboundActivityDoesNotLiftActiveSuppression() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
// Trip the failure suppression starts at t0+15.
#expect(!attempts(&cache, at: t0.addingTimeInterval(15)))
// Inbound traffic may have arrived via flood; suppression holds.
cache.noteInboundActivity(from: recipient)
#expect(!attempts(&cache, at: t0.addingTimeInterval(20)))
#expect(attempts(&cache, at: t0.addingTimeInterval(15 + 61)))
}
@Test func backToBackSendsShareOneDeadline() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
cache.noteRoutedSend(to: recipient, now: t0.addingTimeInterval(8))
// Deadline runs from the first unconfirmed send.
#expect(!attempts(&cache, at: t0.addingTimeInterval(11)))
}
@Test func pruneDropsExpiredEntries() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
// Past confirmation + suppression: the entry can no longer matter.
cache.prune(now: t0.addingTimeInterval(75))
#expect(attempts(&cache, at: t0.addingTimeInterval(76)))
}
@Test func pruneKeepsEntriesThatStillMatter() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
cache.prune(now: t0.addingTimeInterval(30))
// The unconverted pending entry survives pruning and still converts
// into a suppression on the next routing decision.
#expect(!attempts(&cache, at: t0.addingTimeInterval(31)))
}
}
@@ -0,0 +1,98 @@
//
// BLESourceRouteOriginationPolicyTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
struct BLESourceRouteOriginationPolicyTests {
private let localPeerIDData = Data(hexString: "0102030405060708")!
private let recipient = PeerID(str: "1112131415161718")
private let hop = Data(hexString: "2122232425262728")!
private func makePacket(
senderID: Data? = nil,
recipientID: Data? = Data(hexString: "1112131415161718"),
ttl: UInt8 = 7
) -> BitchatPacket {
BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: senderID ?? localPeerIDData,
recipientID: recipientID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data([0x01]),
signature: nil,
ttl: ttl
)
}
private func route(
packet: BitchatPacket,
isRecipientConnected: Bool = false,
shouldAttemptRoute: Bool = true,
computedRoute: [Data]? = nil
) -> [Data]? {
BLESourceRouteOriginationPolicy.route(
for: packet,
to: recipient,
localPeerIDData: localPeerIDData,
isRecipientConnected: { _ in isRecipientConnected },
shouldAttemptRoute: { _ in shouldAttemptRoute },
computeRoute: { _ in computedRoute ?? [self.hop] }
)
}
@Test func routesWhenAllGatesPass() {
#expect(route(packet: makePacket()) == [hop])
}
@Test func relayedPacketNeverGetsRoute() {
let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
#expect(route(packet: relayed) == nil)
}
@Test func broadcastRecipientNeverGetsRoute() {
let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
#expect(route(packet: broadcast) == nil)
let noRecipient = makePacket(recipientID: nil)
#expect(route(packet: noRecipient) == nil)
}
@Test func linkLocalTTLNeverGetsRoute() {
// TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
#expect(route(packet: makePacket(ttl: 0)) == nil)
#expect(route(packet: makePacket(ttl: 1)) == nil)
}
@Test func directlyConnectedRecipientNeverGetsRoute() {
#expect(route(packet: makePacket(), isRecipientConnected: true) == nil)
}
@Test func suppressedRecipientFallsBackToFlood() {
#expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil)
}
@Test func missingOrEmptyRouteFallsBackToFlood() {
var sawComputeRoute = false
let result = BLESourceRouteOriginationPolicy.route(
for: makePacket(),
to: recipient,
localPeerIDData: localPeerIDData,
isRecipientConnected: { _ in false },
shouldAttemptRoute: { _ in true },
computeRoute: { _ in
sawComputeRoute = true
return nil
}
)
#expect(result == nil)
#expect(sawComputeRoute)
#expect(route(packet: makePacket(), computedRoute: []) == nil)
}
}
@@ -94,10 +94,132 @@ struct MeshTopologyTrackerTests {
tracker.updateNeighbors(for: a, neighbors: [b])
tracker.updateNeighbors(for: b, neighbors: [a])
// When start == end, route should be empty (no intermediate hops needed)
let route = try #require(tracker.computeRoute(from: a, to: a))
#expect(route == [])
}
@Test func noPathReturnsNil() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0101010101010101")
let b = try hex("0202020202020202")
let c = try hex("0303030303030303")
let d = try hex("0404040404040404")
// Two disconnected islands: A-B and C-D
tracker.updateNeighbors(for: a, neighbors: [b])
tracker.updateNeighbors(for: b, neighbors: [a])
tracker.updateNeighbors(for: c, neighbors: [d])
tracker.updateNeighbors(for: d, neighbors: [c])
#expect(tracker.computeRoute(from: a, to: d) == nil)
}
/// Build a confirmed line topology n0 - n1 - ... - n(count-1).
private func makeLine(_ tracker: MeshTopologyTracker, count: Int) throws -> [Data] {
let nodes = try (0..<count).map { try hex(String(format: "%016x", $0 + 1)) }
for i in 0..<count {
var neighbors: [Data] = []
if i > 0 { neighbors.append(nodes[i - 1]) }
if i < count - 1 { neighbors.append(nodes[i + 1]) }
tracker.updateNeighbors(for: nodes[i], neighbors: neighbors)
}
return nodes
}
@Test func maxHopsCapsIntermediateHopCount() throws {
let tracker = MeshTopologyTracker()
// 7 nodes: source + 5 intermediates + target
let nodes = try makeLine(tracker, count: 7)
// 5 intermediates exceed a 4-hop cap
#expect(tracker.computeRoute(from: nodes[0], to: nodes[6], maxHops: 4) == nil)
// 4 intermediates fit exactly
let route = try #require(tracker.computeRoute(from: nodes[0], to: nodes[5], maxHops: 4))
#expect(route == Array(nodes[1...4]))
}
@Test func staleNeighborBlocksRoute() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0101010101010101")
let b = try hex("0202020202020202")
let c = try hex("0303030303030303")
let staleDate = Date().addingTimeInterval(-120) // past 60s freshness
tracker.updateNeighbors(for: a, neighbors: [b])
tracker.updateNeighbors(for: b, neighbors: [a, c], at: staleDate)
tracker.updateNeighbors(for: c, neighbors: [b])
#expect(tracker.computeRoute(from: a, to: c) == nil)
// Refreshing B restores the route.
tracker.updateNeighbors(for: b, neighbors: [a, c])
let route = try #require(tracker.computeRoute(from: a, to: c))
#expect(route == [b])
}
@Test func versionGateBlocksV1AndUnknownHops() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0101010101010101")
let b = try hex("0202020202020202")
let c = try hex("0303030303030303")
tracker.updateNeighbors(for: a, neighbors: [b])
tracker.updateNeighbors(for: b, neighbors: [a, c])
tracker.updateNeighbors(for: c, neighbors: [b])
// Without the gate the route exists.
#expect(tracker.computeRoute(from: a, to: c) == [b])
// Version-unknown hops are assumed v1-only and block gated routes.
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
// A v1 observation does not unlock the gate.
tracker.recordObservedVersion(1, for: b)
tracker.recordObservedVersion(2, for: c)
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
// Once the hop is observed speaking v2 the route opens.
tracker.recordObservedVersion(2, for: b)
let route = try #require(tracker.computeRoute(from: a, to: c, requiringVersion: 2))
#expect(route == [b])
}
@Test func versionGateRequiresV2Target() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0101010101010101")
let b = try hex("0202020202020202")
let c = try hex("0303030303030303")
tracker.updateNeighbors(for: a, neighbors: [b])
tracker.updateNeighbors(for: b, neighbors: [a, c])
tracker.updateNeighbors(for: c, neighbors: [b])
tracker.recordObservedVersion(2, for: b)
// The recipient must decode the v2 frame too.
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
tracker.recordObservedVersion(2, for: c)
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b])
}
@Test func pruneDropsStaleObservedVersions() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0101010101010101")
let b = try hex("0202020202020202")
let c = try hex("0303030303030303")
tracker.updateNeighbors(for: a, neighbors: [b])
tracker.updateNeighbors(for: b, neighbors: [a, c])
tracker.updateNeighbors(for: c, neighbors: [b])
let old = Date().addingTimeInterval(-120)
tracker.recordObservedVersion(2, for: b, at: old)
tracker.recordObservedVersion(2, for: c, at: old)
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b])
tracker.prune(olderThan: 60)
// Claims are fresh but the version observations aged out.
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
}
}