mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:25:20 +00:00
Mesh robustness optimizations (#463)
* Mesh robustness: link-aware fragmentation, relay jitter, connection budget, adaptive scanning; fix BLE queue deadlock - Link-aware fragmentation using per-link write/notify limits - Directed fragments for one-to-one; exclude from relays - Add relay jitter with cancel-on-duplicate to reduce floods - Cap central links and rate-limit connect attempts with queue - Foreground duty-cycled scanning when connected; continuous when isolated - Fix deadlock by avoiding sync on BLE queue when already on it - Silence Keychain var->let warnings * Mesh: add relay jitter; dynamic RSSI threshold; candidate scoring/backoff; fix misplaced lines in BLEService extension * macOS UI: restore mesh peer list when geohash feature is iOS-only by moving mesh list outside iOS switch (ContentView) * ContentView: add macOS mesh peer list under #else to fix missing People section on macOS * Refactor People section: extract MeshPeerList and GeohashPeopleList into separate views to reduce SwiftUI type-checking complexity; integrate in ContentView * BLE: prevent re-fragmentation loops; UI: darker mesh blue + first-row spacing in peer list * Mesh flood/battery: probabilistic relays, adaptive TTL caps, fragment pacing + scan pause, assembly cap; enable relayed DMs with direct-first then flood fallback. * Refactor BLEService broadcast: split pad policy, encrypted/direct-first handler, and link-aware sender; simplify broadcastPacket to delegate by type. * Extract relay decision into RelayController with RelayDecision; simplify handleReceivedPacket to use it. * Move RelayController and RelayDecision into their own file under Services; keep BLEService leaner. * UI: adjust mesh blue to a darker, less purple tone; apply consistently for count and #mesh badge. * Project: include RelayController.swift in target and sync project file changes. --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
+479
-183
@@ -21,9 +21,13 @@ final class BLEService: NSObject {
|
||||
#endif
|
||||
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
||||
|
||||
private let maxFragmentSize = 469 // 512 MTU - headers
|
||||
// Default per-fragment chunk size when link limits are unknown
|
||||
private let defaultFragmentSize = 469 // ~512 MTU minus protocol overhead
|
||||
private let maxMessageLength = 10_000
|
||||
private let messageTTL: UInt8 = 7
|
||||
// Flood/battery controls
|
||||
private let maxInFlightAssemblies = 128 // cap concurrent fragment assemblies
|
||||
private let highDegreeThreshold = 6 // for adaptive TTL/probabilistic relays
|
||||
|
||||
// MARK: - Core State (5 Essential Collections)
|
||||
|
||||
@@ -82,7 +86,7 @@ final class BLEService: NSObject {
|
||||
// MARK: - Identity
|
||||
|
||||
var myPeerID: String = ""
|
||||
var myNickname: String = "Anonymous"
|
||||
var myNickname: String = "anon"
|
||||
private let noiseService = NoiseEncryptionService()
|
||||
|
||||
// MARK: - Advertising Privacy
|
||||
@@ -94,6 +98,7 @@ final class BLEService: NSObject {
|
||||
private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent)
|
||||
private let messageQueueKey = DispatchSpecificKey<Void>()
|
||||
private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated)
|
||||
private let bleQueueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
// Queue for messages pending handshake completion
|
||||
private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:]
|
||||
@@ -103,11 +108,54 @@ final class BLEService: NSObject {
|
||||
|
||||
// Accumulate long write chunks per central until a full frame decodes
|
||||
private var pendingWriteBuffers: [String: Data] = [:]
|
||||
// Relay jitter scheduling to reduce redundant floods
|
||||
private var scheduledRelays: [String: DispatchWorkItem] = [:]
|
||||
// Track short-lived traffic bursts to adapt announces/scanning under load
|
||||
private var recentPacketTimestamps: [Date] = []
|
||||
|
||||
// MARK: - Maintenance Timer
|
||||
|
||||
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
||||
private var maintenanceCounter = 0 // Track maintenance cycles
|
||||
|
||||
// MARK: - Connection budget & scheduling (central role)
|
||||
private let maxCentralLinks = 6
|
||||
private let connectRateLimitInterval: TimeInterval = 0.5
|
||||
private var lastGlobalConnectAttempt: Date = .distantPast
|
||||
private struct ConnectionCandidate {
|
||||
let peripheral: CBPeripheral
|
||||
let rssi: Int
|
||||
let name: String
|
||||
let isConnectable: Bool
|
||||
let discoveredAt: Date
|
||||
}
|
||||
private var connectionCandidates: [ConnectionCandidate] = []
|
||||
private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures
|
||||
private var lastIsolatedAt: Date? = nil
|
||||
private var dynamicRSSIThreshold: Int = -90
|
||||
|
||||
// MARK: - Adaptive scanning duty-cycle
|
||||
private var scanDutyTimer: DispatchSourceTimer?
|
||||
private var dutyEnabled: Bool = true
|
||||
private var dutyOnDuration: TimeInterval = 5
|
||||
private var dutyOffDuration: TimeInterval = 10
|
||||
private var dutyActive: Bool = false
|
||||
|
||||
// MARK: - Link capability snapshots (thread-safe via bleQueue)
|
||||
private func snapshotPeripheralStates() -> [PeripheralState] {
|
||||
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||
return Array(peripherals.values)
|
||||
} else {
|
||||
return bleQueue.sync { Array(peripherals.values) }
|
||||
}
|
||||
}
|
||||
private func snapshotSubscribedCentrals() -> ([CBCentral], [String: String]) {
|
||||
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||
return (self.subscribedCentrals, self.centralToPeerID)
|
||||
} else {
|
||||
return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer snapshots publisher (non-UI convenience)
|
||||
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
|
||||
@@ -201,6 +249,9 @@ final class BLEService: NSObject {
|
||||
)
|
||||
#endif
|
||||
|
||||
// Tag BLE queue for re-entrancy detection
|
||||
bleQueue.setSpecific(key: bleQueueKey, value: ())
|
||||
|
||||
// Initialize BLE on background queue to prevent main thread blocking
|
||||
// This prevents app freezes during BLE operations
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||
@@ -229,6 +280,8 @@ final class BLEService: NSObject {
|
||||
|
||||
deinit {
|
||||
maintenanceTimer?.cancel()
|
||||
scanDutyTimer?.cancel()
|
||||
scanDutyTimer = nil
|
||||
centralManager?.stopScan()
|
||||
peripheralManager?.stopAdvertising()
|
||||
#if os(iOS)
|
||||
@@ -337,6 +390,8 @@ final class BLEService: NSObject {
|
||||
// Stop timer
|
||||
maintenanceTimer?.cancel()
|
||||
maintenanceTimer = nil
|
||||
scanDutyTimer?.cancel()
|
||||
scanDutyTimer = nil
|
||||
|
||||
centralManager?.stopScan()
|
||||
peripheralManager?.stopAdvertising()
|
||||
@@ -722,168 +777,130 @@ final class BLEService: NSObject {
|
||||
// MARK: - Packet Broadcasting
|
||||
|
||||
private func broadcastPacket(_ packet: BitchatPacket) {
|
||||
// Balanced privacy: pad sensitive payloads (Noise handshake/encrypted),
|
||||
// keep public/announce/leave unpadded to reduce airtime.
|
||||
let padForBLE: Bool = {
|
||||
if let t = MessageType(rawValue: packet.type) {
|
||||
switch t {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
// Encode once using a small per-type padding policy, then delegate by type
|
||||
let padForBLE = padPolicy(for: packet.type)
|
||||
guard let data = packet.toBinaryData(padding: padForBLE) else {
|
||||
SecureLogger.log("❌ Failed to convert packet to binary data", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
// Only log broadcasts for non-announce packets
|
||||
// Log encrypted and relayed packets for debugging
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
SecureLogger.log("📡 Encrypted packet to \(packet.recipientID?.hexEncodedString() ?? "unknown")",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
} else if packet.ttl < messageTTL {
|
||||
// Relayed packet
|
||||
}
|
||||
|
||||
// Check if application-level fragmentation needed for large messages
|
||||
// (CoreBluetooth only handles ATT-level fragmentation for single writes)
|
||||
if data.count > 512 && packet.type != MessageType.fragment.rawValue {
|
||||
sendFragmentedPacket(packet, pad: padForBLE)
|
||||
sendEncrypted(packet, data: data, pad: padForBLE)
|
||||
return
|
||||
}
|
||||
|
||||
// For private encrypted messages (not handshakes), send to specific peer only
|
||||
// Handshakes need broader delivery to establish encryption
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let recipientID = packet.recipientID {
|
||||
let recipientPeerID = recipientID.hexEncodedString()
|
||||
var sentEncrypted = false
|
||||
|
||||
// Check routing availability (only log if there's an issue)
|
||||
let hasPeripheral = peerToPeripheralUUID[recipientPeerID] != nil
|
||||
let hasCentral = centralToPeerID.values.contains(recipientPeerID)
|
||||
|
||||
// Try to send directly to the specific peer as peripheral first
|
||||
if let peripheralUUID = peerToPeripheralUUID[recipientPeerID],
|
||||
let state = peripherals[peripheralUUID],
|
||||
state.isConnected,
|
||||
let characteristic = state.characteristic {
|
||||
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
// Successfully routed via peripheral
|
||||
sentEncrypted = true
|
||||
sendGenericBroadcast(packet, data: data, pad: padForBLE)
|
||||
}
|
||||
|
||||
// MARK: - Broadcast helpers (single responsibility)
|
||||
private func padPolicy(for type: UInt8) -> Bool {
|
||||
switch MessageType(rawValue: type) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
|
||||
guard let recipientID = packet.recipientID else { return }
|
||||
let recipientPeerID = recipientID.hexEncodedString()
|
||||
var sentEncrypted = false
|
||||
|
||||
// Per-link limits for the specific peer
|
||||
var peripheralMaxLen: Int?
|
||||
if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }) {
|
||||
if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) {
|
||||
peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse)
|
||||
}
|
||||
|
||||
// Also try notification if peer is connected as central (dual-role support)
|
||||
if let characteristic = characteristic {
|
||||
// Find the specific central for this peer
|
||||
for central in subscribedCentrals {
|
||||
if centralToPeerID[central.identifier.uuidString] == recipientPeerID {
|
||||
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false
|
||||
if success {
|
||||
// Successfully routed via central notification
|
||||
sentEncrypted = true
|
||||
} else {
|
||||
// Queue for retry when notification queue has space
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Limit queue size to prevent memory issues
|
||||
if self.pendingNotifications.count < 20 {
|
||||
self.pendingNotifications.append((data: data, centrals: [central]))
|
||||
SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
} else {
|
||||
SecureLogger.log("⚠️ Pending notification queue full, dropping packet",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do NOT broadcast encrypted messages to all centrals
|
||||
// Encrypted messages must only go to the intended recipient
|
||||
}
|
||||
var centralMaxLen: Int?
|
||||
do {
|
||||
let (centrals, mapping) = snapshotSubscribedCentrals()
|
||||
if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) {
|
||||
centralMaxLen = central.maximumUpdateValueLength
|
||||
}
|
||||
|
||||
if !sentEncrypted {
|
||||
// Log detailed routing failure for debugging
|
||||
SecureLogger.log("⚠️ Failed to route encrypted message to \(recipientPeerID) - peripheral=\(hasPeripheral) central=\(hasCentral)",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
|
||||
}
|
||||
if let pm = peripheralMaxLen, data.count > pm {
|
||||
let overhead = 13 + 8 + 8 + 13
|
||||
let chunk = max(64, pm - overhead)
|
||||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
|
||||
return
|
||||
}
|
||||
|
||||
// For broadcast messages, use the original simple routing
|
||||
// This ensures announces can be sent before peer ID mappings are established
|
||||
var sentToPeripherals = 0
|
||||
var sentToCentrals = 0
|
||||
|
||||
// 1. First try sending as central via writes to connected peripherals
|
||||
// This is the preferred path when we have direct peripheral connections
|
||||
for state in peripherals.values where state.isConnected {
|
||||
if let characteristic = state.characteristic {
|
||||
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
sentToPeripherals += 1
|
||||
}
|
||||
if let cm = centralMaxLen, data.count > cm {
|
||||
let overhead = 13 + 8 + 8 + 13
|
||||
let chunk = max(64, cm - overhead)
|
||||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Also send via notifications to subscribed centrals
|
||||
// This ensures all connected peers receive the message regardless of their connection role
|
||||
// Broadcast message types that should go to all peers
|
||||
// Include handshakes since they need to reach peers to establish encryption
|
||||
let isBroadcastType = packet.type == MessageType.announce.rawValue ||
|
||||
packet.type == MessageType.message.rawValue ||
|
||||
packet.type == MessageType.leave.rawValue ||
|
||||
packet.type == MessageType.noiseHandshake.rawValue
|
||||
if isBroadcastType, let characteristic = characteristic, !subscribedCentrals.isEmpty {
|
||||
// If value exceeds minimum allowed by connected centrals, handle per constraints
|
||||
let minAllowed = subscribedCentrals.map { $0.maximumUpdateValueLength }.min() ?? 20
|
||||
// Minimum BitChat frame = 13 (header) + 8 (senderID) = 21 bytes
|
||||
if minAllowed < 21 {
|
||||
// Cannot deliver any BitChat frame via notifications on this link; skip notify
|
||||
SecureLogger.log("⚠️ Skipping notify: central max update length (\(minAllowed)) < 21", category: SecureLogger.session, level: .debug)
|
||||
} else if data.count > minAllowed {
|
||||
// Fragment via protocol (preserve chosen padding for BLE)
|
||||
sendFragmentedPacket(packet, pad: padForBLE)
|
||||
return
|
||||
}
|
||||
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false
|
||||
if success {
|
||||
sentToCentrals = subscribedCentrals.count
|
||||
if packet.type == MessageType.message.rawValue {
|
||||
// Broadcast message sent
|
||||
} else if packet.type == MessageType.noiseHandshake.rawValue {
|
||||
// Handshake broadcast to centrals
|
||||
}
|
||||
} else {
|
||||
// Notification queue full - queue for retry on handshake and announce packets
|
||||
if packet.type == MessageType.noiseHandshake.rawValue || packet.type == MessageType.announce.rawValue {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.pendingNotifications.count < 20 {
|
||||
self.pendingNotifications.append((data: data, centrals: nil))
|
||||
let kind = packet.type == MessageType.announce.rawValue ? "announce" : "handshake"
|
||||
SecureLogger.log("📋 Queued \(kind) packet for retry (notification queue full)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
|
||||
// Direct write via peripheral link
|
||||
if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }),
|
||||
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
|
||||
state.isConnected,
|
||||
let characteristic = state.characteristic {
|
||||
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
sentEncrypted = true
|
||||
}
|
||||
|
||||
// Notify via central link (dual-role)
|
||||
if let characteristic = characteristic, !sentEncrypted {
|
||||
let (centrals, mapping) = snapshotSubscribedCentrals()
|
||||
for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID {
|
||||
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false
|
||||
if success { sentEncrypted = true; break }
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.pendingNotifications.count < 20 {
|
||||
self.pendingNotifications.append((data: data, centrals: [central]))
|
||||
SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
} else {
|
||||
SecureLogger.log("⚠️ Notification queue full for packet type \(packet.type)",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let totalSent = sentToPeripherals + sentToCentrals
|
||||
if totalSent == 0 {
|
||||
// No peers to send to - this is normal when isolated
|
||||
} else {
|
||||
// Broadcast sent
|
||||
|
||||
if !sentEncrypted {
|
||||
// Flood as last resort with recipient set; link aware
|
||||
sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendGenericBroadcast(_ packet: BitchatPacket, data: Data, pad: Bool) {
|
||||
sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: nil)
|
||||
}
|
||||
|
||||
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) {
|
||||
let states = snapshotPeripheralStates()
|
||||
var minCentralWriteLen: Int?
|
||||
for s in states where s.isConnected {
|
||||
let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse)
|
||||
minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m
|
||||
}
|
||||
var snapshotCentrals: [CBCentral] = []
|
||||
if let _ = characteristic {
|
||||
let (centrals, _) = snapshotSubscribedCentrals()
|
||||
snapshotCentrals = centrals
|
||||
}
|
||||
var minNotifyLen: Int?
|
||||
if !snapshotCentrals.isEmpty {
|
||||
minNotifyLen = snapshotCentrals.map { $0.maximumUpdateValueLength }.min()
|
||||
}
|
||||
// Avoid re-fragmenting fragment packets
|
||||
if packet.type != MessageType.fragment.rawValue,
|
||||
let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min(),
|
||||
data.count > minLen {
|
||||
let overhead = 13 + 8 + 8 + 13
|
||||
let chunk = max(64, minLen - overhead)
|
||||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
|
||||
return
|
||||
}
|
||||
// Writes to connected peripherals
|
||||
for s in states where s.isConnected {
|
||||
if let ch = s.characteristic {
|
||||
s.peripheral.writeValue(data, for: ch, type: .withoutResponse)
|
||||
}
|
||||
}
|
||||
// Notify all subscribed centrals
|
||||
if let ch = characteristic {
|
||||
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,15 +919,31 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: - Fragmentation (Required for messages > BLE MTU)
|
||||
|
||||
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool) {
|
||||
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: String? = nil) {
|
||||
guard let fullData = packet.toBinaryData(padding: pad) else { return }
|
||||
// Fragment the unpadded frame; each fragment will be encoded independently
|
||||
|
||||
let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||
let fragments = stride(from: 0, to: fullData.count, by: maxFragmentSize).map { offset in
|
||||
Data(fullData[offset..<min(offset + maxFragmentSize, fullData.count)])
|
||||
let chunk = maxChunk ?? defaultFragmentSize
|
||||
let safeChunk = max(64, chunk)
|
||||
let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in
|
||||
Data(fullData[offset..<min(offset + safeChunk, fullData.count)])
|
||||
}
|
||||
|
||||
// Lightweight pacing to reduce floods and allow BLE buffers to drain
|
||||
// Also briefly pause scanning during long fragment trains to save battery
|
||||
let totalFragments = fragments.count
|
||||
if totalFragments > 4 {
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
|
||||
if c.isScanning { c.stopScan() }
|
||||
// Resume scanning after we expect last fragment to be sent
|
||||
let expectedMs = min(2000, totalFragments * 8) // ~8ms per fragment
|
||||
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
|
||||
self?.startScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (index, fragment) in fragments.enumerated() {
|
||||
var payload = Data()
|
||||
payload.append(fragmentID)
|
||||
@@ -919,18 +952,26 @@ final class BLEService: NSObject {
|
||||
payload.append(packet.type)
|
||||
payload.append(fragment)
|
||||
|
||||
// Choose recipient for the fragment: directed override if provided
|
||||
let fragmentRecipient: Data? = {
|
||||
if let only = directedOnlyPeer { return Data(hexString: only) }
|
||||
return packet.recipientID
|
||||
}()
|
||||
|
||||
let fragmentPacket = BitchatPacket(
|
||||
type: MessageType.fragment.rawValue,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
recipientID: fragmentRecipient,
|
||||
timestamp: packet.timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
|
||||
// Send immediately (should be on messageQueue already)
|
||||
broadcastPacket(fragmentPacket)
|
||||
// Pace fragments with small jitter to avoid bursts
|
||||
let delayMs = index * 6 // ~6ms spacing per fragment
|
||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
||||
self?.broadcastPacket(fragmentPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,6 +1002,14 @@ final class BLEService: NSObject {
|
||||
// Store fragment
|
||||
let key = "\(senderHex):\(fragmentID)"
|
||||
if incomingFragments[key] == nil {
|
||||
// Cap in-flight assemblies to prevent memory/battery blowups
|
||||
if incomingFragments.count >= maxInFlightAssemblies {
|
||||
// Evict the oldest assembly by timestamp
|
||||
if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
|
||||
incomingFragments.removeValue(forKey: oldest)
|
||||
fragmentMetadata.removeValue(forKey: oldest)
|
||||
}
|
||||
}
|
||||
incomingFragments[key] = [:]
|
||||
fragmentMetadata[key] = (originalType, total, Date())
|
||||
}
|
||||
@@ -1014,12 +1063,31 @@ final class BLEService: NSObject {
|
||||
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
// Cancel any pending relay for this message (arrived via another neighbor)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
return // Duplicate ignored
|
||||
}
|
||||
|
||||
// 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
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let now = Date()
|
||||
self.recentPacketTimestamps.append(now)
|
||||
// keep last 100 timestamps within 30s window
|
||||
let cutoff = now.addingTimeInterval(-30)
|
||||
if self.recentPacketTimestamps.count > 100 {
|
||||
self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - 100)
|
||||
}
|
||||
self.recentPacketTimestamps.removeAll { $0 < cutoff }
|
||||
}
|
||||
|
||||
|
||||
// Process by type
|
||||
switch MessageType(rawValue: packet.type) {
|
||||
@@ -1047,19 +1115,34 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
// Relay if TTL > 1 and we're not the original sender
|
||||
// Do this asynchronously to avoid blocking and potential loops
|
||||
// BUT: Don't relay private encrypted messages (they have a specific recipient)
|
||||
let shouldRelay = packet.ttl > 1 &&
|
||||
senderID != myPeerID &&
|
||||
packet.type != MessageType.noiseEncrypted.rawValue
|
||||
|
||||
if shouldRelay {
|
||||
messageQueue.async { [weak self] in
|
||||
// Relay decision and scheduling (extracted via RelayController)
|
||||
do {
|
||||
let degree = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
|
||||
let decision = RelayController.decide(
|
||||
ttl: packet.ttl,
|
||||
senderIsSelf: senderID == myPeerID,
|
||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
degree: degree,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
)
|
||||
guard decision.shouldRelay else { return }
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Remove scheduled task before executing
|
||||
self.collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
_ = self?.scheduledRelays.removeValue(forKey: messageID)
|
||||
}
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl -= 1
|
||||
// Relaying packet
|
||||
self?.broadcastPacket(relayPacket)
|
||||
relayPacket.ttl = decision.newTTL
|
||||
self.broadcastPacket(relayPacket)
|
||||
}
|
||||
// Track the scheduled relay so duplicates can cancel it
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.scheduledRelays[messageID] = work
|
||||
}
|
||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1479,8 +1562,20 @@ final class BLEService: NSObject {
|
||||
private func performMaintenance() {
|
||||
maintenanceCounter += 1
|
||||
|
||||
// Always: Send keep-alive announce (every 10 seconds)
|
||||
sendAnnounce(forceSend: true)
|
||||
// Adaptive announce: reduce frequency when we have connected peers
|
||||
let now = Date()
|
||||
let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
|
||||
let elapsed = now.timeIntervalSince(lastAnnounceSent)
|
||||
if connectedCount == 0 {
|
||||
// Discovery mode: keep frequent announces (~10s)
|
||||
if elapsed >= 10.0 { sendAnnounce(forceSend: true) }
|
||||
} else {
|
||||
// Connected mode: announce less often; much less in dense networks
|
||||
let base = connectedCount >= 6 ? 90.0 : 45.0
|
||||
let jitter = connectedCount >= 6 ? 20.0 : 7.5
|
||||
let target = base + Double.random(in: -jitter...jitter)
|
||||
if elapsed >= target { sendAnnounce(forceSend: true) }
|
||||
}
|
||||
|
||||
// If we have no peers, ensure we're scanning and advertising
|
||||
if peers.isEmpty {
|
||||
@@ -1490,6 +1585,10 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Update scanning duty-cycle based on connectivity
|
||||
updateScanningDutyCycle(connectedCount: connectedCount)
|
||||
updateRSSIThreshold(connectedCount: connectedCount)
|
||||
|
||||
// Every 20 seconds (2 cycles): Check peer connectivity
|
||||
if maintenanceCounter % 2 == 0 {
|
||||
checkPeerConnectivity()
|
||||
@@ -1567,6 +1666,96 @@ final class BLEService: NSObject {
|
||||
// Clean old connection timeout backoff entries (> 2 minutes)
|
||||
let timeoutCutoff = now.addingTimeInterval(-120)
|
||||
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff }
|
||||
|
||||
// Clean up stale scheduled relays that somehow persisted (> 2s)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if !self.scheduledRelays.isEmpty {
|
||||
// Nothing to compare times to; just cap the size defensively
|
||||
if self.scheduledRelays.count > 512 {
|
||||
self.scheduledRelays.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateScanningDutyCycle(connectedCount: Int) {
|
||||
guard let central = centralManager, central.state == .poweredOn else { return }
|
||||
// Duty cycle only when app is active and at least one peer connected
|
||||
#if os(iOS)
|
||||
let active = isAppActive
|
||||
#else
|
||||
let active = true
|
||||
#endif
|
||||
let shouldDuty = dutyEnabled && active && connectedCount > 0
|
||||
if shouldDuty {
|
||||
if scanDutyTimer == nil {
|
||||
// Start timer to toggle scanning on/off
|
||||
let t = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||
// Start with scanning ON; we'll turn OFF after onDuration
|
||||
if !central.isScanning { startScanning() }
|
||||
dutyActive = true
|
||||
// Adjust duty cycle under dense networks to save battery
|
||||
if connectedCount >= 6 {
|
||||
dutyOnDuration = 3
|
||||
dutyOffDuration = 15
|
||||
} else {
|
||||
dutyOnDuration = 5
|
||||
dutyOffDuration = 10
|
||||
}
|
||||
t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
|
||||
t.setEventHandler { [weak self] in
|
||||
guard let self = self, let c = self.centralManager else { return }
|
||||
if self.dutyActive {
|
||||
// Turn OFF scanning for offDuration
|
||||
if c.isScanning { c.stopScan() }
|
||||
self.dutyActive = false
|
||||
// Schedule turning back ON after offDuration
|
||||
self.bleQueue.asyncAfter(deadline: .now() + self.dutyOffDuration) {
|
||||
if self.centralManager?.state == .poweredOn { self.startScanning() }
|
||||
self.dutyActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
t.resume()
|
||||
scanDutyTimer = t
|
||||
}
|
||||
} else {
|
||||
// Cancel duty cycle and ensure scanning is ON for discovery
|
||||
scanDutyTimer?.cancel()
|
||||
scanDutyTimer = nil
|
||||
if !central.isScanning { startScanning() }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRSSIThreshold(connectedCount: Int) {
|
||||
// Adjust RSSI threshold based on connectivity, candidate pressure, and failures
|
||||
if connectedCount == 0 {
|
||||
// Isolated: relax floor slowly to hunt for distant nodes
|
||||
if lastIsolatedAt == nil { lastIsolatedAt = Date() }
|
||||
let iso = lastIsolatedAt ?? Date()
|
||||
let elapsed = Date().timeIntervalSince(iso)
|
||||
if elapsed > 60 {
|
||||
dynamicRSSIThreshold = -92
|
||||
} else {
|
||||
dynamicRSSIThreshold = -90
|
||||
}
|
||||
return
|
||||
}
|
||||
lastIsolatedAt = nil
|
||||
// Base threshold when connected
|
||||
var threshold = -90
|
||||
// If we're at budget or queue is large, prefer closer peers
|
||||
let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||
if linkCount >= maxCentralLinks || connectionCandidates.count > 20 {
|
||||
threshold = -85
|
||||
}
|
||||
// If we have many recent timeouts, raise further
|
||||
let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < 60 }.count
|
||||
if recentTimeouts >= 3 {
|
||||
threshold = max(threshold, -80)
|
||||
}
|
||||
dynamicRSSIThreshold = threshold
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1610,26 +1799,62 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
// Skip if peripheral is not connectable (per advertisement data)
|
||||
guard isConnectable else { return }
|
||||
|
||||
// Skip if signal too weak - prevents connection attempts at extreme range
|
||||
guard rssiValue > -90 else {
|
||||
// Too far away, don't attempt connection
|
||||
// Skip immediate connect if signal too weak for current conditions; enqueue instead
|
||||
if rssiValue <= dynamicRSSIThreshold {
|
||||
connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))
|
||||
// Keep list tidy
|
||||
connectionCandidates.sort { (a, b) in
|
||||
if a.rssi != b.rssi { return a.rssi > b.rssi }
|
||||
return a.discoveredAt < b.discoveredAt
|
||||
}
|
||||
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) }
|
||||
return
|
||||
}
|
||||
|
||||
// Budget: limit simultaneous central links (connected + connecting)
|
||||
let currentCentralLinks = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||
if currentCentralLinks >= maxCentralLinks {
|
||||
// Enqueue as candidate; we'll attempt later as slots open
|
||||
connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))
|
||||
// Keep candidate list tidy: prefer stronger RSSI, then recency; cap list
|
||||
connectionCandidates.sort { (a, b) in
|
||||
if a.rssi != b.rssi { return a.rssi > b.rssi }
|
||||
return a.discoveredAt < b.discoveredAt
|
||||
}
|
||||
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) }
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit global connect attempts
|
||||
let sinceLast = Date().timeIntervalSince(lastGlobalConnectAttempt)
|
||||
if sinceLast < connectRateLimitInterval {
|
||||
connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))
|
||||
connectionCandidates.sort { (a, b) in
|
||||
if a.rssi != b.rssi { return a.rssi > b.rssi }
|
||||
return a.discoveredAt < b.discoveredAt
|
||||
}
|
||||
// Schedule a deferred attempt after rate-limit interval
|
||||
let delay = connectRateLimitInterval - sinceLast + 0.05
|
||||
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.tryConnectFromQueue()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we already have this peripheral
|
||||
if let state = peripherals[peripheralID] {
|
||||
if state.isConnected || state.isConnecting {
|
||||
return // Already connected or connecting
|
||||
}
|
||||
|
||||
// Add backoff for reconnection attempts
|
||||
if let lastAttempt = state.lastConnectionAttempt {
|
||||
let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt)
|
||||
if timeSinceLastAttempt < 2.0 {
|
||||
return // Wait at least 2 seconds between connection attempts
|
||||
}
|
||||
// Add backoff for reconnection attempts
|
||||
if let lastAttempt = state.lastConnectionAttempt {
|
||||
let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt)
|
||||
if timeSinceLastAttempt < 2.0 {
|
||||
return // Wait at least 2 seconds between connection attempts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backoff if this peripheral recently timed out connection within the last 15 seconds
|
||||
if let lastTimeout = recentConnectTimeouts[peripheralID], Date().timeIntervalSince(lastTimeout) < 15 {
|
||||
@@ -1669,6 +1894,7 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
CBConnectPeripheralOptionNotifyOnNotificationKey: true
|
||||
]
|
||||
central.connect(peripheral, options: options)
|
||||
lastGlobalConnectAttempt = Date()
|
||||
|
||||
// Set a timeout for the connection attempt (slightly longer for reliability)
|
||||
// Use BLE queue to mutate BLE-related state consistently
|
||||
@@ -1680,13 +1906,16 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
// Connection timed out - cancel it
|
||||
SecureLogger.log("⏱️ Timeout: \(advertisedName)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
self.peripherals[peripheralID] = nil
|
||||
self.recentConnectTimeouts[peripheralID] = Date()
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
self.peripherals[peripheralID] = nil
|
||||
self.recentConnectTimeouts[peripheralID] = Date()
|
||||
self.failureCounts[peripheralID, default: 0] += 1
|
||||
// Try next candidate if any
|
||||
self.tryConnectFromQueue()
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
|
||||
// Update state to connected
|
||||
@@ -1705,6 +1934,10 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
// Reset backoff state on success
|
||||
failureCounts[peripheralID] = 0
|
||||
recentConnectTimeouts.removeValue(forKey: peripheralID)
|
||||
|
||||
SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Discover services
|
||||
@@ -1741,6 +1974,8 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
self?.startScanning()
|
||||
}
|
||||
}
|
||||
// Attempt to fill freed slot from queue
|
||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||
|
||||
// Notify delegate about disconnection on main thread
|
||||
notifyUI { [weak self] in
|
||||
@@ -1764,6 +1999,67 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
peripherals.removeValue(forKey: peripheralID)
|
||||
|
||||
SecureLogger.log("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: SecureLogger.session, level: .error)
|
||||
failureCounts[peripheralID, default: 0] += 1
|
||||
// Try next candidate
|
||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection scheduling helpers
|
||||
extension BLEService {
|
||||
private func tryConnectFromQueue() {
|
||||
guard let central = centralManager, central.state == .poweredOn else { return }
|
||||
// Check budget and rate limit
|
||||
let current = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||
guard current < maxCentralLinks else { return }
|
||||
let delta = Date().timeIntervalSince(lastGlobalConnectAttempt)
|
||||
guard delta >= connectRateLimitInterval else {
|
||||
let delay = connectRateLimitInterval - delta + 0.05
|
||||
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
|
||||
return
|
||||
}
|
||||
// Pull best candidate by composite score
|
||||
guard !connectionCandidates.isEmpty else { return }
|
||||
// compute score: connectable> RSSI > recency, with backoff penalty
|
||||
func score(_ c: ConnectionCandidate) -> Int {
|
||||
let uuid = c.peripheral.identifier.uuidString
|
||||
// Penalty if recently timed out (exponential)
|
||||
let fails = failureCounts[uuid] ?? 0
|
||||
let penalty = min(20, (1 << min(4, fails))) // 1,2,4,8,16 cap 16-20
|
||||
let timeoutRecent = recentConnectTimeouts[uuid]
|
||||
let timeoutBias = (timeoutRecent != nil && Date().timeIntervalSince(timeoutRecent!) < 60) ? 10 : 0
|
||||
let base = (c.isConnectable ? 1000 : 0) + (c.rssi + 100) * 2
|
||||
let rec = -Int(Date().timeIntervalSince(c.discoveredAt) * 10)
|
||||
return base + rec - penalty - timeoutBias
|
||||
}
|
||||
connectionCandidates.sort { score($0) > score($1) }
|
||||
let candidate = connectionCandidates.removeFirst()
|
||||
guard candidate.isConnectable else { return }
|
||||
let peripheral = candidate.peripheral
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true {
|
||||
// Already in progress; skip
|
||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||
return
|
||||
}
|
||||
// Initiate connection
|
||||
peripherals[peripheralID] = PeripheralState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: Date()
|
||||
)
|
||||
peripheral.delegate = self
|
||||
let options: [String: Any] = [
|
||||
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
||||
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
||||
CBConnectPeripheralOptionNotifyOnNotificationKey: true
|
||||
]
|
||||
central.connect(peripheral, options: options)
|
||||
lastGlobalConnectAttempt = Date()
|
||||
SecureLogger.log("⏩ Queue connect: \(candidate.name) [RSSI:\(candidate.rssi)]", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ class KeychainManager {
|
||||
|
||||
private func retrieveData(forKey key: String) -> Data? {
|
||||
// Base query
|
||||
var base: [String: Any] = [
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecAttrService as String: service,
|
||||
@@ -158,7 +158,7 @@ class KeychainManager {
|
||||
|
||||
private func delete(forKey key: String) -> Bool {
|
||||
// Base delete query
|
||||
var base: [String: Any] = [
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecAttrService as String: service
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
// RelayDecision encapsulates a single relay scheduling choice.
|
||||
struct RelayDecision {
|
||||
let shouldRelay: Bool
|
||||
let newTTL: UInt8
|
||||
let delayMs: Int
|
||||
}
|
||||
|
||||
// RelayController centralizes flood control policy for relays.
|
||||
struct RelayController {
|
||||
static func decide(ttl: UInt8,
|
||||
senderIsSelf: Bool,
|
||||
isEncrypted: Bool,
|
||||
isDirectedFragment: Bool,
|
||||
isHandshake: Bool,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
// Suppress obvious non-relays
|
||||
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
||||
|
||||
// Degree-aware probability to reduce floods in dense graphs
|
||||
let baseProb: Double
|
||||
switch degree {
|
||||
case 0...2: baseProb = 1.0
|
||||
case 3...4: baseProb = 0.9
|
||||
case 5...6: baseProb = 0.7
|
||||
case 7...9: baseProb = 0.55
|
||||
default: baseProb = 0.45
|
||||
}
|
||||
var prob = baseProb
|
||||
if isHandshake { prob = max(0.3, baseProb - 0.2) }
|
||||
|
||||
// Sample a forwarding decision
|
||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||
|
||||
// TTL clamping in dense graphs
|
||||
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
||||
let clamped = max(1, min(ttl, ttlCap))
|
||||
let newTTL = clamped &- 1
|
||||
|
||||
// Short jitter to desynchronize rebroadcasts
|
||||
let delayMs = Int.random(in: 20...80)
|
||||
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user