Originate v2 source routes and wire fragmentIdFilter targeted resync

Part A — source-route origination policy:
- Gate route application (BLESourceRouteOriginationPolicy): only packets we
  author, directed at a single peer, with TTL headroom, whose recipient is
  not directly connected. Relays no longer attach routes to (and re-sign)
  packets they merely forward.
- Version-gate paths: MeshTopologyTracker records the highest protocol
  version observed per peer; BFS routes require every intermediate hop and
  the recipient to be v2-observed, capped at 4 intermediate hops.
- Degrade on failure: BLESourceRouteFailureCache marks a routed send that
  sees no inbound traffic from the recipient within 10s as failed and floods
  for 60s before retrying routes.

Part B — REQUEST_SYNC fragmentIdFilter (TLV 0x06):
- Requester: BLEFragmentAssemblyBuffer reports stalled broadcast
  reassemblies (no new fragment for 5s, retried at most every 10s); the
  maintenance pass sends a types=fragment REQUEST_SYNC naming the stalled
  8-byte fragment stream IDs to each connected peer.
- Responder: GossipSyncManager restricts the fragment diff to exactly the
  named streams, bypassing the since-cursor while the GCS filter still
  excludes pieces the requester holds; RSR/TTL-0/rate-limit semantics
  unchanged and REQUEST_SYNC stays link-local.
- Bounds: at most 60 IDs per request (60*17-1 = 1019 bytes <= the 1024-byte
  decoder cap); oversized 0x06 values are ignored, not fatal.

Docs: SOURCE_ROUTING.md gains the iOS origination policy (§8);
REQUEST_SYNC_MANAGER.md documents 0x05/0x06 as implemented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 20:02:12 +02:00
co-authored by Claude Fable 5
parent 7341696280
commit 18dcc747d7
17 changed files with 966 additions and 34 deletions
+94 -1
View File
@@ -469,6 +469,98 @@ struct GossipSyncManagerTests {
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
}
// MARK: - Fragment-ID filter (targeted resync)
private func makeFragmentPacket(sender: Data, fragmentID: Data, index: UInt16, timestamp: UInt64) -> BitchatPacket {
// Fragment payload: 8-byte stream ID + index + total + original type.
var payload = fragmentID
payload.append(contentsOf: withUnsafeBytes(of: index.bigEndian) { Data($0) })
payload.append(contentsOf: withUnsafeBytes(of: UInt16(4).bigEndian) { Data($0) })
payload.append(MessageType.fileTransfer.rawValue)
payload.append(Data([0xEE]))
return BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: sender,
recipientID: nil,
timestamp: timestamp,
payload: payload,
signature: nil,
ttl: 1
)
}
@Test func handleRequestSyncHonorsFragmentIdFilter() async throws {
var config = GossipSyncManager.Config()
config.fragmentCapacity = 10
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
let wantedID = try #require(Data(hexString: "0102030405060708"))
let otherID = try #require(Data(hexString: "1112131415161718"))
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let wanted = makeFragmentPacket(sender: sender, fragmentID: wantedID, index: 1, timestamp: nowMs - 60_000)
let other = makeFragmentPacket(sender: sender, fragmentID: otherID, index: 2, timestamp: nowMs)
manager.onPublicPacketSeen(wanted)
manager.onPublicPacketSeen(other)
// The since-cursor sits after both fragments; without the filter the
// responder would send nothing for `wanted`. The filter both bypasses
// the cursor and restricts the diff to exactly the named stream.
let request = RequestSyncPacket(
p: 7,
m: 1,
data: Data(),
types: .fragment,
sinceTimestamp: nowMs + 1,
fragmentIdFilter: RequestSyncPacket.encodeFragmentIdFilter([wantedID])
)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
// Barrier: flush the sync queue so a late second packet would be visible.
manager._performMaintenanceSynchronously(now: Date())
let sentPackets = delegate.packets
#expect(sentPackets.count == 1)
let sent = try #require(sentPackets.first)
#expect(sent.type == MessageType.fragment.rawValue)
#expect(sent.payload.prefix(8) == wantedID)
#expect(sent.ttl == 0)
#expect(sent.isRSR)
}
@Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws {
var config = GossipSyncManager.Config()
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")]
manager.delegate = delegate
let stalledID = try #require(Data(hexString: "0102030405060708"))
manager.requestMissingFragments(fragmentIDs: [stalledID])
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
let sent = try #require(delegate.packets.first)
#expect(sent.type == MessageType.requestSync.rawValue)
#expect(sent.ttl == 0)
let request = try #require(RequestSyncPacket.decode(from: sent.payload))
#expect(request.types == .fragment)
let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter))
#expect(ids == Set([stalledID]))
}
// MARK: - Archive persistence
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
@@ -545,6 +637,7 @@ struct GossipSyncManagerTests {
private final class RecordingDelegate: GossipSyncManager.Delegate {
var onSend: (() -> Void)?
var connectedPeers: [PeerID] = []
private(set) var lastPacket: BitchatPacket?
private(set) var packets: [BitchatPacket] = []
private let lock = NSLock()
@@ -566,6 +659,6 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
}
func getConnectedPeers() -> [PeerID] {
return []
return connectedPeers
}
}