diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index e0738afc..d9239d66 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -94,7 +94,6 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, - bitchatShareExtension.entitlements, ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; }; @@ -379,6 +378,11 @@ E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */, ); }; + 7E9B64F63F93443FB7BA12DF /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; C5E027A42ECCDFD700BD6012 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( @@ -395,13 +399,6 @@ E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */, ); }; - 7E9B64F63F93443FB7BA12DF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/bitchat/Services/BLE/BLEAnnounceThrottle.swift b/bitchat/Services/BLE/BLEAnnounceThrottle.swift index d6bf5490..4b064136 100644 --- a/bitchat/Services/BLE/BLEAnnounceThrottle.swift +++ b/bitchat/Services/BLE/BLEAnnounceThrottle.swift @@ -37,4 +37,13 @@ final class BLEAnnounceThrottle: @unchecked Sendable { return true } } + + /// Forgets the last-sent timestamp. A panic rotation calls this so the + /// new identity's first announce cannot be swallowed by the old + /// identity's throttle debt — otherwise a panic within the forced + /// minimum interval of the last announce leaves the rotated identity + /// invisible until the next maintenance cycle. + func reset() { + lock.withLock { lastSent = .distantPast } + } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index cd366608..75775e51 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -782,6 +782,11 @@ final class BLEService: NSObject { // rebind/retirement cooldowns deliberately survive (see // BLELinkAuthState.removeAll). linkAuth.removeAll() + // The new identity owes no announce-throttle debt: without this, + // a panic within the forced minimum interval of the last + // announce swallows the rotation announce and the new identity + // stays invisible until the next maintenance cycle. + announceThrottle.reset() // These callbacks belong to pre-panic transfer state. Invoking // them would let queued UI work recreate or resend wiped media. privateMediaSessions.panicReset() @@ -3353,6 +3358,27 @@ extension BLEService { } } + /// Simulated-link ingress: the full production attribution path — + /// binding lookup, spoof rejection, raw-announce binding, ingress + /// recording — for a frame arriving on a synthetic link. The + /// SimulatedMesh harness feeds every node through this, so multi-node + /// tests exercise the same engine code as CoreBluetooth ingress. + func _test_ingestFrame(_ packet: BitchatPacket, link: BLEIngressLinkID) { + ingestDecodedPacket(packet, link: link, linkDescription: "Simulated \(link)") + } + + /// Sends an unthrottled announce, exactly like the maintenance forced + /// path. SimulatedMesh uses this as the deterministic discovery step. + func _test_forceAnnounce() { + onEngine { sendAnnounceNow(forceSend: true) } + } + + /// Blocks until every engine slot enqueued so far has run — the + /// deterministic settling fence for simulated-mesh pumping. + func _test_fenceEngine() { + onEngine {} + } + func _test_emitTransportEvent( _ event: TransportEvent, completion: @escaping () -> Void, diff --git a/bitchatTests/Services/BLEAnnounceThrottleTests.swift b/bitchatTests/Services/BLEAnnounceThrottleTests.swift index 0ce23814..0e135a3b 100644 --- a/bitchatTests/Services/BLEAnnounceThrottleTests.swift +++ b/bitchatTests/Services/BLEAnnounceThrottleTests.swift @@ -68,6 +68,21 @@ struct BLEAnnounceThrottleTests { #expect(accepted.value == 1) #expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3) } + + @Test + func resetForgetsThrottleDebtSoARotationAnnounceIsNeverSwallowed() { + let throttle = BLEAnnounceThrottle( + normalMinimumInterval: 1, + forcedMinimumInterval: 1 + ) + let now = Date() + #expect(throttle.shouldSend(force: true, now: now)) + // A panic inside the forced window would be throttled... + #expect(!throttle.shouldSend(force: true, now: now.addingTimeInterval(0.2))) + // ...so the rotation resets the debt and announces immediately. + throttle.reset() + #expect(throttle.shouldSend(force: true, now: now.addingTimeInterval(0.3))) + } } private final class LockedCounter: @unchecked Sendable { diff --git a/bitchatTests/Simulation/SimulatedMesh.swift b/bitchatTests/Simulation/SimulatedMesh.swift new file mode 100644 index 00000000..47491d34 --- /dev/null +++ b/bitchatTests/Simulation/SimulatedMesh.swift @@ -0,0 +1,129 @@ +import BitFoundation +import Foundation +@testable import bitchat + +/// A deterministic multi-node mesh over real `BLEService` engines and no +/// CoreBluetooth: nodes are wired edge-to-edge through the outbound packet +/// tap and the production ingress-attribution path (`_test_ingestFrame`), +/// so announces bind links, signatures verify, Noise handshakes complete, +/// and rotation rebinds run exactly the engine code a radio would drive. +/// +/// Determinism model: outbound packets are buffered under a lock (the tap +/// fires on each sender's engine); the test thread pumps deliveries and +/// fences every engine between rounds. Timer-driven work (relay jitter, +/// deferred flushes) is released explicitly through each node's +/// `BLEEngineManualScheduler` via `advanceTime`. +/// +/// Fidelity boundary: there are no physical links, so per-link fanout +/// planning always reports failure to the sender (directed packets spool) +/// — every capture happens at the pre-planning tap. Protocol-level +/// behavior (attribution, binding, dedup, TTL, relay decisions, sessions) +/// is faithful; link-selection and backpressure behavior is not exercised. +final class SimulatedMesh { + struct Node { + let service: BLEService + let scheduler: BLEEngineManualScheduler + } + + private let lock = NSLock() + private var pendingDeliveries: [(from: Int, packet: BitchatPacket)] = [] + /// Total (packet, receiving-node) deliveries pumped — the storm bound. + private(set) var deliveredFrameCount = 0 + + private(set) var nodes: [Node] = [] + private var neighbors: [Set] = [] + + @discardableResult + func addNode(nickname: String) -> Node { + let keychain = MockKeychain() + let identityManager = MockIdentityManager(keychain) + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let scheduler = BLEEngineManualScheduler() + let service = BLEService( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + initializeBluetoothManagers: false, + engineScheduler: scheduler + ) + let index = nodes.count + let node = Node(service: service, scheduler: scheduler) + nodes.append(node) + neighbors.append([]) + service.setNickname(nickname) + service._test_onOutboundPacket = { [weak self] packet in + // Runs on the sender's engine; only buffer here — delivering + // inline would nest one engine inside another. + guard let self else { return } + self.lock.lock() + self.pendingDeliveries.append((from: index, packet: packet)) + self.lock.unlock() + } + return node + } + + func connect(_ a: Int, _ b: Int) { + neighbors[a].insert(b) + neighbors[b].insert(a) + } + + /// The synthetic link a frame from `sender` arrives on at `receiver`. + /// Stable per directed edge, like a CoreBluetooth central UUID. + func linkUUID(from sender: Int, at receiver: Int) -> String { + "SIM-\(sender)-TO-\(receiver)" + } + + func forceAnnounce(from index: Int) { + nodes[index].service._test_forceAnnounce() + pump() + } + + /// Pumps buffered deliveries until the mesh is quiescent: no pending + /// frames and every engine drained. Timer-deferred work stays pending + /// until `advanceTime`. + func pump(maxRounds: Int = 64) { + for _ in 0.. Int { + lock.lock() + defer { lock.unlock() } + return publicMessages.filter { $0 == content }.count + } + + func drainedPublicMessageCount(content: String, drains: Int = 50) async -> Int { + for _ in 0.. 0 { break } + await MainActor.run {} + } + return count(content: content) + } +} diff --git a/docs/BLE-ARCHITECTURE-V3.md b/docs/BLE-ARCHITECTURE-V3.md index d72df1bf..5dd2fbd8 100644 --- a/docs/BLE-ARCHITECTURE-V3.md +++ b/docs/BLE-ARCHITECTURE-V3.md @@ -201,6 +201,27 @@ throughput is nowhere near what one serial queue sustains. packet switch) ride this seam as handler-registered modules instead of getting closure-environment extractions now. + **The simulator half is done — simulator-first.** Because the B2 + receive path already hands `(packet, linkID)` up through one choke + point, `SimulatedMesh` (bitchatTests/Simulation/) wires real + CB-free `BLEService` engines edge-to-edge through the outbound tap + and `_test_ingestFrame` (the production attribution path), with + per-edge synthetic link IDs and manual-scheduler time. Five + deterministic multi-node tests run in ~40ms: announce/bind + convergence, end-to-end Noise establishment, line-topology relay + within a TTL/frame budget, duplicate-flood dedup, and the panic + rotation single-slot rebind + containment — the scenario that + previously required two phones. Fidelity boundary: no physical + links, so fanout planning/backpressure is not exercised; protocol + behavior is. On its first day the simulator found a real bug: the + forced-announce throttle survived panic, so a rotation within + `bleForceAnnounceMinIntervalSeconds` of the last announce left the + new identity invisible until the next maintenance cycle + (`BLEAnnounceThrottle.reset()` now runs in the panic slot). + Remaining from the original slice-C scope: the mechanical delegate + extraction behind explicit LinkEvent/LinkCommand types, and the + formal `handle(event) -> [Effect]` engine shape. + ## What this is not No wire changes: packet formats, signing (padding is signed), the