mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 22:25:19 +00:00
Originate v2 source routes and wire fragmentIdFilter targeted resync (#1378)
* 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> * Fix stall-clock refresh on duplicates and overflow suppression in fragment resync Two fixes to stalledBroadcastFragmentIDs bookkeeping in BLEFragmentAssemblyBuffer: - Duplicate fragments no longer reset the stall clock. Fragment packets bypass the packet deduplicator, so relayed duplicates of an already-held index arriving every few seconds kept lastFragmentAt fresh and suppressed the targeted REQUEST_SYNC indefinitely. Now lastFragmentAt only updates when the index is new (actual progress). - Only the streams that will actually be encoded on the wire are rate-limited. Previously every stalled candidate got lastResyncRequestAt set, but encodeFragmentIdFilter serializes at most RequestSyncPacket.maxFragmentIdFilterCount (60) IDs, so overflow streams were suppressed for retryAfter without ever being requested. Selection now caps at that shared constant, oldest stall first, so overflow stays eligible and rotates fairly on the next pass. Tests: duplicates arriving periodically still trigger the stall report; 70 stalled streams yield the 60 oldest on the first pass and the remaining 10 on the next. 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>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
cee2bcd535
commit
70229f0be1
@@ -1,19 +1,21 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||
// - 0x04: types (bitfield) — SyncTypeFlags of covered message types
|
||||
// - 0x05: sinceTimestamp (uint64, big-endian) — oldest ts the filter covers
|
||||
// - 0x06: fragmentIdFilter (utf8) — reserved
|
||||
//
|
||||
// TODO(v2): fragmentIdFilter (0x06) is parsed and re-serialized but never
|
||||
// populated or honored — it's the reserved surface for incremental fragment
|
||||
// sync (request the missing fragments of one file by ID instead of diffing the
|
||||
// whole fragment set). Either wire it into buildGcsPayload/_handleRequestSync
|
||||
// or drop the field; don't leave it as silent dead protocol surface.
|
||||
// - 0x04: types (SyncTypeFlags) — packet types the filter covers
|
||||
// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor
|
||||
// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte)
|
||||
// fragment stream IDs; restricts the fragment diff to exactly those
|
||||
// streams (targeted resync for stalled reassemblies)
|
||||
struct RequestSyncPacket {
|
||||
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
|
||||
/// 16 hex chars plus a comma separator, so the largest encoded value is
|
||||
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
|
||||
static let maxFragmentIdFilterCount = 60
|
||||
|
||||
let p: Int
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
@@ -21,6 +23,29 @@ struct RequestSyncPacket {
|
||||
let sinceTimestamp: UInt64?
|
||||
let fragmentIdFilter: String?
|
||||
|
||||
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
|
||||
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
|
||||
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
|
||||
let tokens = fragmentIDs
|
||||
.filter { $0.count == 8 }
|
||||
.prefix(maxFragmentIdFilterCount)
|
||||
.map { $0.hexEncodedString() }
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
return tokens.joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
|
||||
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
|
||||
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
|
||||
guard let filter else { return nil }
|
||||
var ids: Set<Data> = []
|
||||
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
|
||||
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
|
||||
ids.insert(id)
|
||||
}
|
||||
return ids.isEmpty ? nil : ids
|
||||
}
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
@@ -97,7 +122,9 @@ struct RequestSyncPacket {
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
if let fid = String(data: v, encoding: .utf8) {
|
||||
// Same acceptance cap as the GCS payload; an oversized filter
|
||||
// is ignored rather than failing the whole request.
|
||||
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -64,6 +64,9 @@ struct BLEFragmentAssemblyBuffer {
|
||||
let type: UInt8
|
||||
let total: Int
|
||||
let timestamp: Date
|
||||
let isBroadcast: Bool
|
||||
var lastFragmentAt: Date
|
||||
var lastResyncRequestAt: Date?
|
||||
}
|
||||
|
||||
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||
@@ -105,7 +108,15 @@ struct BLEFragmentAssemblyBuffer {
|
||||
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
||||
}
|
||||
|
||||
// Only actual progress resets the stall clock: fragment packets
|
||||
// bypass the packet deduplicator, so relayed duplicates of an
|
||||
// already-held index must not keep suppressing the targeted
|
||||
// REQUEST_SYNC for a stalled stream.
|
||||
let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil
|
||||
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||
if isNewIndex {
|
||||
metadataByKey[header.key]?.lastFragmentAt = now
|
||||
}
|
||||
|
||||
guard let fragments = fragmentsByKey[header.key],
|
||||
fragments.count == header.total else {
|
||||
@@ -138,10 +149,59 @@ struct BLEFragmentAssemblyBuffer {
|
||||
}
|
||||
|
||||
fragmentsByKey[header.key] = [:]
|
||||
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
||||
metadataByKey[header.key] = Metadata(
|
||||
type: header.originalType,
|
||||
total: header.total,
|
||||
timestamp: now,
|
||||
isBroadcast: header.isBroadcastFragment,
|
||||
lastFragmentAt: now
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast
|
||||
/// reassemblies that have not seen a new fragment for `stalledAfter`
|
||||
/// seconds — candidates for a targeted REQUEST_SYNC. Each returned
|
||||
/// stream is marked so it is not re-requested within `retryAfter`.
|
||||
/// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are
|
||||
/// returned per pass — the wire filter cannot carry more — selected
|
||||
/// oldest-stall first; overflow streams stay unmarked and eligible for
|
||||
/// the next pass. Directed reassemblies are excluded: peers only archive
|
||||
/// broadcast fragments for gossip sync, so a targeted request cannot
|
||||
/// recover them.
|
||||
mutating func stalledBroadcastFragmentIDs(
|
||||
stalledAfter: TimeInterval,
|
||||
retryAfter: TimeInterval,
|
||||
now: Date = Date()
|
||||
) -> [Data] {
|
||||
var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = []
|
||||
for (key, metadata) in metadataByKey {
|
||||
guard metadata.isBroadcast,
|
||||
let fragments = fragmentsByKey[key],
|
||||
fragments.count < metadata.total,
|
||||
now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue }
|
||||
if let lastRequest = metadata.lastResyncRequestAt,
|
||||
now.timeIntervalSince(lastRequest) < retryAfter { continue }
|
||||
candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt))
|
||||
}
|
||||
|
||||
// Mark only the streams that will actually go on the wire, so the
|
||||
// overflow is not silently suppressed for `retryAfter`.
|
||||
let selected = candidates
|
||||
.sorted {
|
||||
if $0.lastFragmentAt != $1.lastFragmentAt {
|
||||
return $0.lastFragmentAt < $1.lastFragmentAt
|
||||
}
|
||||
return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id)
|
||||
}
|
||||
.prefix(RequestSyncPacket.maxFragmentIdFilterCount)
|
||||
|
||||
return selected.map { candidate in
|
||||
metadataByKey[candidate.key]?.lastResyncRequestAt = now
|
||||
return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||
if originalType == MessageType.fileTransfer.rawValue {
|
||||
// Allow headroom for TLV metadata and binary framing overhead.
|
||||
|
||||
@@ -69,7 +69,9 @@ final class BLEService: NSObject {
|
||||
#endif
|
||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||
private let meshTopology = MeshTopologyTracker()
|
||||
|
||||
// Route health for originated source routes; guarded by collectionsQueue.
|
||||
private var sourceRouteFailures = BLESourceRouteFailureCache()
|
||||
|
||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||||
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
||||
@@ -586,6 +588,7 @@ final class BLEService: NSObject {
|
||||
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
|
||||
peerRegistry.removeAll()
|
||||
fragmentAssemblyBuffer.removeAll()
|
||||
sourceRouteFailures = BLESourceRouteFailureCache()
|
||||
// Also clear pending message queues to avoid stale state across sessions
|
||||
pendingNoiseSessionQueues.removeAll()
|
||||
pendingDirectedRelays.removeAll()
|
||||
@@ -2392,13 +2395,31 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func computeRoute(to peerID: PeerID) -> [Data]? {
|
||||
meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID))
|
||||
// Version-gated: every hop and the recipient must have been observed
|
||||
// speaking v2, since a v1-only node drops v2 frames on decode.
|
||||
meshTopology.computeRoute(
|
||||
from: myPeerIDData,
|
||||
to: routingData(for: peerID),
|
||||
maxHops: TransportConfig.bleSourceRouteMaxIntermediateHops,
|
||||
requiringVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
|
||||
guard let route = computeRoute(to: recipient), route.count >= 1 else {
|
||||
return packet
|
||||
}
|
||||
let now = Date()
|
||||
let route = BLESourceRouteOriginationPolicy.route(
|
||||
for: packet,
|
||||
to: recipient,
|
||||
localPeerIDData: myPeerIDData,
|
||||
isRecipientConnected: { self.isPeerConnected($0) },
|
||||
shouldAttemptRoute: { peer in
|
||||
self.collectionsQueue.sync(flags: .barrier) {
|
||||
self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now)
|
||||
}
|
||||
},
|
||||
computeRoute: { self.computeRoute(to: $0) }
|
||||
)
|
||||
guard let route else { return packet }
|
||||
// Create new packet with route applied and version upgraded to 2
|
||||
let routedPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
@@ -2416,6 +2437,9 @@ extension BLEService {
|
||||
SecureLogger.error("❌ Failed to re-sign packet with route", category: .security)
|
||||
return packet // Return original packet if signing fails
|
||||
}
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
sourceRouteFailures.noteRoutedSend(to: recipient, now: now)
|
||||
}
|
||||
return signedPacket
|
||||
}
|
||||
|
||||
@@ -3187,13 +3211,23 @@ extension BLEService {
|
||||
// Update peer info without verbose logging - update the peer we received from, not the original sender
|
||||
updatePeerLastSeen(peerID)
|
||||
|
||||
// Track recent traffic timestamps for adaptive behavior
|
||||
// Track recent traffic timestamps for adaptive behavior; the same
|
||||
// barrier hop confirms route health for the packet's originator.
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.recentTrafficTracker.recordPacket(at: Date())
|
||||
self.sourceRouteFailures.noteInboundActivity(from: senderID)
|
||||
}
|
||||
|
||||
// Per-peer protocol version: originated source routes only use hops
|
||||
// observed speaking v2 (a v1-only node cannot decode v2 frames).
|
||||
if packet.version >= 2 {
|
||||
meshTopology.recordObservedVersion(packet.version, for: packet.senderID)
|
||||
if peerID != senderID {
|
||||
meshTopology.recordObservedVersion(packet.version, for: routingData(for: peerID))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Process by type
|
||||
switch context.messageType {
|
||||
case .announce:
|
||||
@@ -3760,10 +3794,21 @@ extension BLEService {
|
||||
// Clean old processed messages efficiently
|
||||
messageDeduplicator.cleanup()
|
||||
|
||||
// Clean old fragments (> configured seconds old)
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
// Clean old fragments (> configured seconds old), then ask peers for
|
||||
// the specific fragment streams whose reassembly has stalled instead
|
||||
// of waiting for the next periodic GCS fragment round.
|
||||
let stalledFragmentIDs = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
|
||||
let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)
|
||||
fragmentAssemblyBuffer.removeExpired(before: cutoff)
|
||||
sourceRouteFailures.prune(now: now)
|
||||
return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs(
|
||||
stalledAfter: TransportConfig.bleFragmentResyncStallSeconds,
|
||||
retryAfter: TransportConfig.bleFragmentResyncRetrySeconds,
|
||||
now: now
|
||||
)
|
||||
}
|
||||
if !stalledFragmentIDs.isEmpty {
|
||||
gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs)
|
||||
}
|
||||
|
||||
// Clean old connection timeout backoff entries (> window)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Tracks whether source-routed sends to a recipient appear to be working.
|
||||
///
|
||||
/// A routed unicast rides exactly one path, so a broken hop silently loses the
|
||||
/// packet where a flood would have healed around it. Rather than building a
|
||||
/// retransmission machine (MessageRouter already retries at a higher layer),
|
||||
/// this cache degrades: a routed send that sees no inbound traffic from the
|
||||
/// recipient within the confirmation window marks the route as failed, and
|
||||
/// subsequent sends fall back to flooding until the suppression TTL lapses.
|
||||
struct BLESourceRouteFailureCache {
|
||||
struct Config {
|
||||
/// How long a routed send may go unconfirmed before it counts as a
|
||||
/// route failure.
|
||||
var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds
|
||||
/// How long to flood instead of routing after a failure.
|
||||
var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var pendingSince: Date?
|
||||
var suppressedUntil: Date?
|
||||
}
|
||||
|
||||
private let config: Config
|
||||
private var states: [PeerID: State] = [:]
|
||||
|
||||
init(config: Config = Config()) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
/// Whether the next directed send to `recipient` may carry a source
|
||||
/// route. Flips the recipient into suppression when the last routed send
|
||||
/// went unconfirmed past the confirmation window.
|
||||
mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool {
|
||||
guard var state = states[recipient] else { return true }
|
||||
|
||||
if let until = state.suppressedUntil {
|
||||
guard now >= until else { return false }
|
||||
state.suppressedUntil = nil
|
||||
}
|
||||
|
||||
if let pending = state.pendingSince,
|
||||
now.timeIntervalSince(pending) > config.confirmationWindowSeconds {
|
||||
// The routed send was never confirmed: treat the route as broken
|
||||
// and flood until the suppression window lapses.
|
||||
state.pendingSince = nil
|
||||
state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds)
|
||||
states[recipient] = state
|
||||
return false
|
||||
}
|
||||
|
||||
states[recipient] = state
|
||||
return true
|
||||
}
|
||||
|
||||
/// Records that a source-routed packet was sent to `recipient`. Keeps the
|
||||
/// earliest unconfirmed send so back-to-back packets share one deadline.
|
||||
mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) {
|
||||
var state = states[recipient] ?? State()
|
||||
if state.pendingSince == nil {
|
||||
state.pendingSince = now
|
||||
}
|
||||
states[recipient] = state
|
||||
}
|
||||
|
||||
/// Any inbound packet authored by `peer` confirms the pending routed send
|
||||
/// (delivery acks and replies arrive this way). Deliberately does not
|
||||
/// lift an active suppression: that traffic may have arrived via flood.
|
||||
mutating func noteInboundActivity(from peer: PeerID) {
|
||||
guard var state = states[peer] else { return }
|
||||
state.pendingSince = nil
|
||||
if state.suppressedUntil == nil {
|
||||
states.removeValue(forKey: peer)
|
||||
} else {
|
||||
states[peer] = state
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops entries that can no longer influence a routing decision. An
|
||||
/// expired-but-unconverted pending entry is kept for as long as the
|
||||
/// suppression it would trigger could still be active.
|
||||
mutating func prune(now: Date = Date()) {
|
||||
let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds
|
||||
states = states.filter { _, state in
|
||||
if let until = state.suppressedUntil, now < until { return true }
|
||||
if let pending = state.pendingSince,
|
||||
now.timeIntervalSince(pending) <= pendingRetention {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Decides whether an outbound directed packet should carry a v2 source
|
||||
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
||||
enum BLESourceRouteOriginationPolicy {
|
||||
/// Returns the intermediate-hop route to attach, or nil to keep the
|
||||
/// current flood/direct-write behavior unchanged.
|
||||
///
|
||||
/// Routes are only originated when every gate passes:
|
||||
/// - we authored the packet (relays must not rewrite and re-sign someone
|
||||
/// else's packet; route-following for in-flight routed packets lives in
|
||||
/// `BLERouteForwardingPolicy`),
|
||||
/// - the packet is directed at a single peer (not broadcast),
|
||||
/// - the packet has TTL headroom to traverse hops (link-local TTL-0
|
||||
/// packets like REQUEST_SYNC never route),
|
||||
/// - the recipient is not directly connected (a direct write already
|
||||
/// delivers in one hop),
|
||||
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
||||
/// routed send, and
|
||||
/// - the topology yields a complete path.
|
||||
static func route(
|
||||
for packet: BitchatPacket,
|
||||
to recipient: PeerID,
|
||||
localPeerIDData: Data,
|
||||
isRecipientConnected: (PeerID) -> Bool,
|
||||
shouldAttemptRoute: (PeerID) -> Bool,
|
||||
computeRoute: (PeerID) -> [Data]?
|
||||
) -> [Data]? {
|
||||
guard packet.senderID == localPeerIDData else { return nil }
|
||||
guard let recipientData = packet.recipientID,
|
||||
recipientData.count == 8,
|
||||
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
|
||||
guard packet.ttl > 1 else { return nil }
|
||||
guard !isRecipientConnected(recipient) else { return nil }
|
||||
guard shouldAttemptRoute(recipient) else { return nil }
|
||||
guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
|
||||
return route
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ final class MeshTopologyTracker {
|
||||
private var claims: [RoutingID: Set<RoutingID>] = [:]
|
||||
// Last time we received an update from a node
|
||||
private var lastSeen: [RoutingID: Date] = [:]
|
||||
// Highest protocol version observed from each node's decoded packets.
|
||||
// Nodes absent from this map are assumed v1-only and are never used as
|
||||
// hops (or targets) for version-gated routes.
|
||||
private var observedVersions: [RoutingID: (version: UInt8, seenAt: Date)] = [:]
|
||||
|
||||
// Maximum age for topology claims to be considered fresh for routing
|
||||
// Routes computed using stale topology can fail when the network has changed
|
||||
@@ -19,18 +23,29 @@ final class MeshTopologyTracker {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.claims.removeAll()
|
||||
self.lastSeen.removeAll()
|
||||
self.observedVersions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the topology with a node's self-reported neighbor list
|
||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
|
||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data], at now: Date = Date()) {
|
||||
guard let source = sanitize(sourceData) else { return }
|
||||
// Sanitize neighbors and exclude self-loops
|
||||
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
|
||||
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
self.claims[source] = validNeighbors
|
||||
self.lastSeen[source] = Date()
|
||||
self.lastSeen[source] = now
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the protocol version observed on a decoded packet from a node.
|
||||
/// Only versions above the v1 baseline are stored; the highest wins.
|
||||
func recordObservedVersion(_ version: UInt8, for peerData: Data?, at now: Date = Date()) {
|
||||
guard version > 1, let peer = sanitize(peerData) else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
let current = self.observedVersions[peer]?.version ?? 1
|
||||
self.observedVersions[peer] = (version: max(version, current), seenAt: now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,28 +54,37 @@ final class MeshTopologyTracker {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.claims.removeValue(forKey: peer)
|
||||
self.lastSeen.removeValue(forKey: peer)
|
||||
self.observedVersions.removeValue(forKey: peer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Prune nodes that haven't updated their topology in `age` seconds
|
||||
func prune(olderThan age: TimeInterval) {
|
||||
let deadline = Date().addingTimeInterval(-age)
|
||||
func prune(olderThan age: TimeInterval, now: Date = Date()) {
|
||||
let deadline = now.addingTimeInterval(-age)
|
||||
queue.sync(flags: .barrier) {
|
||||
let stale = self.lastSeen.filter { $0.value < deadline }
|
||||
for (peer, _) in stale {
|
||||
self.claims.removeValue(forKey: peer)
|
||||
self.lastSeen.removeValue(forKey: peer)
|
||||
}
|
||||
self.observedVersions = self.observedVersions.filter { $0.value.seenAt >= deadline }
|
||||
}
|
||||
}
|
||||
|
||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
|
||||
/// BFS over confirmed, fresh edges. When `requiringVersion` is set, every
|
||||
/// node on the path except the source (i.e. all intermediate hops and the
|
||||
/// target) must have been observed speaking at least that protocol
|
||||
/// version — a v1-only hop cannot decode a v2 routed packet.
|
||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10, requiringVersion: UInt8? = nil, now: Date = Date()) -> [Data]? {
|
||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
||||
if source == target { return [] } // Direct connection, no intermediate hops
|
||||
|
||||
return queue.sync {
|
||||
let now = Date()
|
||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
||||
func meetsRequiredVersion(_ peer: RoutingID) -> Bool {
|
||||
guard let requiringVersion else { return true }
|
||||
return (observedVersions[peer]?.version ?? 1) >= requiringVersion
|
||||
}
|
||||
|
||||
// BFS
|
||||
var visited: Set<RoutingID> = [source]
|
||||
@@ -86,6 +110,10 @@ final class MeshTopologyTracker {
|
||||
for neighbor in neighbors {
|
||||
if visited.contains(neighbor) { continue }
|
||||
|
||||
// Version gate: skip nodes not known to speak the
|
||||
// required protocol version.
|
||||
guard meetsRequiredVersion(neighbor) else { continue }
|
||||
|
||||
// CONFIRMED EDGE CHECK:
|
||||
// 'last' claims 'neighbor' (checked above)
|
||||
// Does 'neighbor' claim 'last'?
|
||||
|
||||
@@ -208,6 +208,25 @@ enum TransportConfig {
|
||||
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
||||
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
||||
|
||||
// Source routing (v2 directed packets)
|
||||
// Longest path we will originate, in intermediate hops between us and the
|
||||
// recipient. Keep small: every hop must be a fresh, confirmed, v2-capable
|
||||
// node, and long stale paths fail more often than floods.
|
||||
static let bleSourceRouteMaxIntermediateHops: Int = 4
|
||||
// A routed send with no inbound traffic from the recipient within this
|
||||
// window counts as a route failure.
|
||||
static let bleSourceRouteConfirmationWindowSeconds: TimeInterval = 10.0
|
||||
// After a route failure, directed sends to that recipient flood instead
|
||||
// of routing until this lapses.
|
||||
static let bleSourceRouteSuppressionSeconds: TimeInterval = 60.0
|
||||
|
||||
// Targeted fragment resync (REQUEST_SYNC fragmentIdFilter)
|
||||
// A broadcast reassembly with no new fragment for this long is stalled
|
||||
// and triggers a targeted REQUEST_SYNC naming its fragment stream.
|
||||
static let bleFragmentResyncStallSeconds: TimeInterval = 5.0
|
||||
// Minimum spacing between targeted resync requests for the same stream.
|
||||
static let bleFragmentResyncRetrySeconds: TimeInterval = 10.0
|
||||
|
||||
// Store-and-forward for directed packets at relays. Spooled packets retry
|
||||
// on each maintenance flush until the window lapses; a longer window lets
|
||||
// brief link gaps (walking between rooms, reconnect churn) heal themselves.
|
||||
|
||||
@@ -274,11 +274,29 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||
/// Targeted fragment recovery: ask connected peers for the specific
|
||||
/// fragment streams whose reassembly has stalled, instead of waiting on
|
||||
/// the next periodic GCS fragment round to cover them.
|
||||
func requestMissingFragments(fragmentIDs: [Data]) {
|
||||
queue.async { [weak self] in
|
||||
self?._requestMissingFragments(fragmentIDs)
|
||||
}
|
||||
}
|
||||
|
||||
private func _requestMissingFragments(_ fragmentIDs: [Data]) {
|
||||
guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
|
||||
guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
|
||||
SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync)
|
||||
for peerID in connectedPeers {
|
||||
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags, fragmentIdFilter: String? = nil) {
|
||||
// Register the request for RSR validation
|
||||
requestSyncManager.registerRequest(to: peerID)
|
||||
|
||||
let payload = buildGcsPayload(for: types)
|
||||
|
||||
let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter)
|
||||
var recipient = Data()
|
||||
var temp = peerID.id
|
||||
while temp.count >= 2 && recipient.count < 8 {
|
||||
@@ -355,9 +373,19 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.fragment) {
|
||||
// A fragment-ID filter narrows the diff to exactly the named
|
||||
// fragment streams (targeted resync for stalled reassemblies)
|
||||
// and bypasses the since-cursor for them; the GCS filter still
|
||||
// excludes the pieces the requester already holds. Fragment
|
||||
// payloads start with the 8-byte stream ID.
|
||||
let fragmentIdFilter = RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter)
|
||||
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in frags {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
if let fragmentIdFilter {
|
||||
guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue }
|
||||
} else if let since, pkt.timestamp < since {
|
||||
continue
|
||||
}
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
@@ -400,7 +428,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
||||
private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data {
|
||||
var candidates: [BitchatPacket] = []
|
||||
if types.contains(.announce) {
|
||||
for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) {
|
||||
@@ -421,7 +449,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
@@ -442,7 +470,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
let takeN = min(candidates.count, min(nMax, cap))
|
||||
if takeN <= 0 {
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
|
||||
return req.encode()
|
||||
}
|
||||
let included = Array(candidates.prefix(takeN))
|
||||
@@ -460,7 +488,7 @@ final class GossipSyncManager {
|
||||
let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0)
|
||||
? included[covered - 1].timestamp
|
||||
: nil
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp)
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user