From c6b7096b2ff5f0ba80d1c62511f16444700c7b56 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:03:20 +0100 Subject: [PATCH] BLE transport architecture V3: one engine domain, capability ports, feature-owned state (#1498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make peer registry and local announce state lock-backed The main actor answered isPeerConnected/peerNickname/currentPeerSnapshots and flipped runtime capability bits by blocking on collectionsQueue behind whatever transport work was in flight. Peer state now lives in a lock-backed BLEPeerRegistryStore (every registry mutation is a single whole-transition method, so readers never observe a torn state), and the runtime capability bits move into BLELocalIdentityStateStore next to the identity they ride announces with. No transport entry point called from the main actor blocks on a transport queue for peer state anymore. Co-Authored-By: Claude Fable 5 * Move BLE link egress/ingress buffers to bleQueue ownership pendingPeripheralWrites, pendingNotifications, and pendingWriteBuffers were collectionsQueue-guarded, but every producer and drain already runs on bleQueue next to the CoreBluetooth objects they feed — each access paid a cross-queue barrier for state that never leaves the radio thread, and the notification drain even invoked peripheralManager.updateValue from the collections queue. They are now bleQueue-confined like the link state store: CB delegate callbacks and drains touch them directly, and the few engine-side entry points hop to bleQueue (the direction the transport's sync-edge order already allows). This clears most bleQueue-to-collectionsQueue sync edges ahead of merging the collections queue into the message queue. Co-Authored-By: Claude Fable 5 * Stop bleQueue maintenance and status paths from blocking on collectionsQueue The traffic-burst tracker becomes a lock-backed monitor (written by the receive pipeline, read by scan-duty adaptation and announce pacing on bleQueue), the status-log peer summary and topology refresh read the already lock-backed registry directly, and the stalled-fragment reap moves to an async collections hop with the gossip resync request inside it. bleQueue no longer sync-waits on the collections queue anywhere. Co-Authored-By: Claude Fable 5 * Unify the message and collections queues into one serial engine queue The old model ran a concurrent message queue over a second concurrent collections queue whose barrier flags served as the real mutual exclusion — every field carried an ownership comment, and correctness lived in per-site discipline. The message queue is now a single serial engine queue that owns all mesh protocol state; the collections queue, its 98 sync/async hops, and every barrier flag are gone. Cross-thread callers go through onEngine, which documents and (in debug) enforces the transport's sync-edge order: main and test threads may block on the engine, the engine may block on bleQueue and the crypto/identity queues, and nothing may block the other way. The debug trap caught two latent inversions the leaf-lock structure had been masking: the verified-announce rebind path re-resolved the ingress link through the engine from inside its bleQueue critical section (it now receives the already-resolved link), and the noise session-generation closures sync-re-entered the engine from the noise manager's queue while their own engine slot was blocked on it (they now touch engine state directly, which the held slot makes exclusive). BLE throughput is orders of magnitude below what one serial queue sustains; the full suite runs at identical speed. Co-Authored-By: Claude Fable 5 * Wire gateway/bridge/panic features to capability ports, not BLEService App wiring discovered mesh-only features by casting the Transport to the concrete BLEService class in nine places. Those surfaces are now three capability protocols — BluetoothStateReporting, PanicResettingTransport, and MeshBridgingTransport — discovered with as? like any optional capability, so the bootstrapper, panic flow, and lifecycle coordinator no longer name the concrete transport at all. A future second mesh transport picks up gateway/bridge wiring and the panic lifecycle by conforming, and the remaining Transport god-protocol requirements can migrate to the same pattern. Co-Authored-By: Claude Fable 5 * Extract mesh-ping diagnostics state into a pure engine-confined tracker First slice of the feature-module direction: BLEMeshPingTracker owns the outstanding-probe map and the per-link inbound response budget as pure state (register/resolve/expire/reset), so the security invariants — a pong only resolves against the probed peer, the budget keys on the ingress link because claimed senders are forgeable, panic reset drops probes and budget together — are now unit-tested without queues or radios. The transport keeps only packet I/O, timers, and main-actor delivery around it. Co-Authored-By: Claude Fable 5 * Document the V3 transport architecture and remaining roadmap Co-Authored-By: Claude Fable 5 * Resolve the pass-6 review findings and the proof-timeout drain defect Periphery: the registry store's unused forwarders are gone (the struct method stays — it has direct tests). F1: refreshPeerIdentity, deliverBridgedEnvelope, and the three panic fences route through onEngine, so every sync entry onto the engine now carries the bleQueue trap. F2: the registry-store ownership comments state the real writer set (engine plus the two bleQueue link-drop paths). F7: BLEQueueContractTests pins the contract — only onEngine may sync-enter the engine, transport code never sync-dispatches to main, and the collections queue stays deleted — with a queue-contract-ok waiver for the two sanctioned lines. The real defect behind the timeoutRestoredSession CI flake: a timeout-restore parks the outbound queues until the convergence retry, but the capability-proof watchdog armed at the original authentication kept draining them when it fired — encrypting the parked traffic under restored keys the counterpart may have discarded, the exact silent loss the defer path exists to prevent. Deferred peers are now tracked and the watchdog drain respects the same rule; the test fires the watchdog deterministically inside the deferred window instead of losing that race only on stalled runners. Co-Authored-By: Claude Fable 5 * Extract private-media session state into a lock-backed store The six generation-keyed maps plus the convergence-deferral set move out of BLEService into BLEPrivateMediaSessionStore, each transition one whole method under a leaf lock with direct unit tests (generation rotation rejects mismatched waiters, stale proofs cannot classify a replacement session, expiry requires the live deadline identity, clears rebase waiters onto a nil-generation deadline, peer-state sends are once per generation per kind). Being a leaf lock also simplifies two contracts: the send policy is now answered entirely from locks (the main actor no longer sync-enters the engine for it), and the noise-manager critical sections call ordinary store methods instead of relying on the held-engine-slot direct-access subtlety. Co-Authored-By: Claude Fable 5 * Split the mesh-only Transport surface into capability protocols Transport kept ~50 requirements that only the BLE mesh implements — files/private media, voice, courier, groups, board, diagnostics, verification, archive — held together by an extension of inert defaults, so every call site compiled against a surface most transports faked. Those are now eight capability protocols (MeshFileTransferring, MeshVoiceStreaming, MeshCourierTransporting, MeshGroupMessaging, MeshBoardBroadcasting, MeshDiagnosing, MeshVerifying, MeshPublicArchiving) discovered with as?, joining the bridging/panic ports from the previous pass. Consumers resolve the capability they need; where the old defaults encoded a safe floor the caller keeps it explicitly (private-media policy degrades to blockedDowngrade). The inert-defaults extension is deleted, along with the never-implemented acceptPendingFile/declinePendingFile pair. NostrTransport is untouched — it only ever implemented the core. Co-Authored-By: Claude Fable 5 * Update the V3 doc for the completed feature-peeling and Transport split Co-Authored-By: Claude Fable 5 * Drop the dead three-argument sendFilePrivate overload Every production caller goes through the allowLegacyFallback variant; the short form only existed as a Transport-era forwarding default. Tests that used it on the concrete service now state the fallback decision explicitly, which is the point of the parameter. Co-Authored-By: Claude Fable 5 * Decide the link-auth boundary: bindings become engine-owned The atomicity that keeps link-auth on bleQueue exists to stop a binding from changing between a security check and its action; once every rebind is an engine operation, the engine's serial slot gives the same guarantee, the stolen-link residual is unchanged (directed payloads are Noise ciphertext), and the receive path lands in its sans-I/O shape — the link layer reports bytes-plus-linkID and the engine resolves the sender. Records the extraction order too: the binding-free radio half first (after #1521 lands — it collides in the scanPlan region), then bindings, then the delegates behind the port. Co-Authored-By: Claude Fable 5 * Fix two bleQueue-to-engine sync edges the queue merge created The collections-to-engine conversion turned two formerly leaf-lock sync calls into onEngine calls reachable from bleQueue, where the debug trap (correctly) aborts: flushDirectedSpool runs from bleQueue maintenance and now hops to the engine asynchronously, and ingress recording — which must answer the duplicate gate on bleQueue the moment a frame decodes — moves to a lock-backed BLEIngressLinkStore read by the engine's relay and routing decisions. Unit suites never hit either path (no CoreBluetooth managers means no maintenance timer and no live receive path); the iOS simulator job boots the real app as its test host, which is exactly where the maintenance trap fired. The ingress one would have trapped a real device on its first received packet — worth a device pass before release. Co-Authored-By: Claude Fable 5 * Route all deferred engine work through an injectable scheduler Relay jitter, announce delays, the ping and capability-proof deadlines, notification retry backoff, and fragment pacing all reached the engine through raw messageQueue.asyncAfter with product constants as deadlines — the hidden-elapsed-deadline flake class that the test-timing hygiene rules exist to contain, testable only by racing the wall clock. BLEEngineScheduling is now the transport's single source of engine delay: production is a thin veneer over the engine queue, tests inject a manually advanced clock whose advance() returns only after the released work has finished on the engine. The queue-contract test pins the seam (no raw messageQueue.asyncAfter), and the ping deadline gets the pattern's proof: the real 10s constant asserted in milliseconds — must not fire early, fires exactly once at the deadline, stays consumed after. Co-Authored-By: Claude Fable 5 * Assert the armed deadline count in the injected-clock ping test Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 3 +- bitchat/Services/BLE/BLEEngineScheduler.swift | 39 + .../Services/BLE/BLEIngressLinkRegistry.swift | 42 + .../BLE/BLELocalIdentityStateStore.swift | 68 +- bitchat/Services/BLE/BLEMeshPingTracker.swift | 62 + .../Services/BLE/BLEPeerRegistryStore.swift | 84 ++ .../BLE/BLEPrivateMediaSessionStore.swift | 363 ++++++ bitchat/Services/BLE/BLEReceivePipeline.swift | 20 + bitchat/Services/BLE/BLEService.swift | 1132 +++++++---------- bitchat/Services/Board/BoardManager.swift | 6 +- bitchat/Services/CommandProcessor.swift | 9 +- .../Services/MeshTransportCapabilities.swift | 152 +++ bitchat/Services/MessageRouter.swift | 6 +- bitchat/Services/Transport.swift | 156 +-- bitchat/Services/UnifiedPeerService.swift | 2 +- bitchat/ViewModels/ChatGroupCoordinator.swift | 9 +- .../ViewModels/ChatLifecycleCoordinator.swift | 4 +- .../ChatMediaTransferCoordinator.swift | 23 +- .../ChatVerificationCoordinator.swift | 7 +- bitchat/ViewModels/ChatViewModel.swift | 19 +- .../ChatViewModelBootstrapper.swift | 16 +- bitchat/ViewModels/ChatVouchCoordinator.swift | 2 +- .../ChatViewModel+PrivateChat.swift | 11 +- bitchatTests/BLEServiceCoreTests.swift | 69 +- .../EndToEnd/CourierEndToEndTests.swift | 2 +- .../EndToEnd/PrivateMediaEndToEndTests.swift | 9 +- .../Mocks/BLEEngineManualScheduler.swift | 49 + bitchatTests/Mocks/MockTransport.swift | 63 +- bitchatTests/ProtocolContractTests.swift | 18 +- .../Services/BLEMeshPingTrackerTests.swift | 84 ++ .../BLEPrivateMediaSessionStoreTests.swift | 192 +++ .../Services/BLEQueueContractTests.swift | 85 ++ docs/BLE-ARCHITECTURE-V3.md | 175 +++ 33 files changed, 2063 insertions(+), 918 deletions(-) create mode 100644 bitchat/Services/BLE/BLEEngineScheduler.swift create mode 100644 bitchat/Services/BLE/BLEMeshPingTracker.swift create mode 100644 bitchat/Services/BLE/BLEPeerRegistryStore.swift create mode 100644 bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift create mode 100644 bitchat/Services/MeshTransportCapabilities.swift create mode 100644 bitchatTests/Mocks/BLEEngineManualScheduler.swift create mode 100644 bitchatTests/Services/BLEMeshPingTrackerTests.swift create mode 100644 bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift create mode 100644 bitchatTests/Services/BLEQueueContractTests.swift create mode 100644 docs/BLE-ARCHITECTURE-V3.md diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 227536ca..70a6c28c 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -85,7 +85,8 @@ final class AppChromeModel: ObservableObject { /// neighbor claim but never announced to us) fall back to a short ID. func meshTopologyDisplayModel() -> MeshTopologyDisplayModel { let mesh = chatViewModel.meshService - guard let snapshot = mesh.currentMeshTopology() else { return .empty } + guard let diagnostics = mesh as? MeshDiagnosing, + let snapshot = diagnostics.currentMeshTopology() else { return .empty } let nicknames = mesh.getPeerNicknames() let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in diff --git a/bitchat/Services/BLE/BLEEngineScheduler.swift b/bitchat/Services/BLE/BLEEngineScheduler.swift new file mode 100644 index 00000000..ab9ebbe8 --- /dev/null +++ b/bitchat/Services/BLE/BLEEngineScheduler.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Schedules deferred engine work: relay jitter, announce delays, protocol +/// deadlines (ping, capability proof), notification retry backoff, and +/// fragment pacing. +/// +/// This is the transport's only source of engine-side delay. Production +/// wraps the engine queue's `asyncAfter`; tests inject a manually advanced +/// clock so timer-driven behavior is asserted deterministically instead of +/// racing the wall clock — product constants used as deadlines are exactly +/// the hidden-elapsed-deadline flake class the test-timing hygiene rules +/// exist to contain. +protocol BLEEngineScheduling: AnyObject { + /// Called once by the transport with its engine queue. Scheduled work + /// always executes there: deferred bodies touch engine-confined state. + func activate(engineQueue: DispatchQueue) + /// Runs `work` on the engine queue after `delay`, honoring + /// `DispatchWorkItem` cancellation. + func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) +} + +extension BLEEngineScheduling { + func schedule(after delay: TimeInterval, _ body: @escaping () -> Void) { + schedule(after: delay, execute: DispatchWorkItem(block: body)) + } +} + +/// Production scheduler: a thin veneer over the engine queue. +final class BLEEngineDispatchScheduler: BLEEngineScheduling { + private var queue: DispatchQueue? + + func activate(engineQueue: DispatchQueue) { + queue = engineQueue + } + + func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) { + queue?.asyncAfter(deadline: .now() + delay, execute: work) + } +} diff --git a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift index 970921b1..77a00f56 100644 --- a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift +++ b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift @@ -124,3 +124,45 @@ struct BLEIngressLinkRegistry { packet.isRSR && packet.ttl == 0 } } + +/// Lock-backed shared ownership of the ingress-link registry. Ingress is +/// recorded on bleQueue the moment a frame decodes (the link identity is +/// only known there, and the duplicate-ingress gate must answer before +/// the packet is handed to the engine), while relay and routing decisions +/// read it from the engine. Every registry mutation is a single +/// whole-transition method, so readers never observe a torn state. +final class BLEIngressLinkStore: @unchecked Sendable { + private let lock = NSLock() + private var registry = BLEIngressLinkRegistry() + + var isEmpty: Bool { + lock.withLock { registry.isEmpty } + } + + func removeAll() { + lock.withLock { registry.removeAll() } + } + + func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? { + lock.withLock { registry.record(for: packet) } + } + + func link(for packet: BitchatPacket) -> BLEIngressLinkID? { + lock.withLock { registry.link(for: packet) } + } + + func recordIfNew( + _ packet: BitchatPacket, + link: BLEIngressLinkID, + peerID: PeerID, + lifetime: TimeInterval + ) -> Bool { + lock.withLock { + registry.recordIfNew(packet, link: link, peerID: peerID, lifetime: lifetime) + } + } + + func prune(before cutoff: Date) { + lock.withLock { registry.prune(before: cutoff) } + } +} diff --git a/bitchat/Services/BLE/BLELocalIdentityStateStore.swift b/bitchat/Services/BLE/BLELocalIdentityStateStore.swift index b379b0cd..7b5be20b 100644 --- a/bitchat/Services/BLE/BLELocalIdentityStateStore.swift +++ b/bitchat/Services/BLE/BLELocalIdentityStateStore.swift @@ -5,6 +5,20 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable { let peerID: PeerID let peerIDData: Data let nickname: String + /// Runtime-toggled capability bits (e.g. the internet-gateway toggle) + /// ORed into `PeerCapabilities.localSupported` for every announce. + let runtimeCapabilities: PeerCapabilities + /// Rendezvous cell advertised while bridging; rides announces only + /// while the `.bridge` capability is enabled. + let bridgeGeohash: String? + + var advertisedCapabilities: PeerCapabilities { + PeerCapabilities.localSupported.union(runtimeCapabilities) + } + + var advertisedBridgeGeohash: String? { + runtimeCapabilities.contains(.bridge) ? bridgeGeohash : nil + } } /// Lock-backed local identity state shared by the transport's message, @@ -12,8 +26,8 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable { /// /// `peerID` and its binary wire representation must change as one unit during /// panic rotation. A snapshot also gives announce construction one consistent -/// view of the nickname and identity instead of reading three independently -/// mutable properties across queues. +/// view of the nickname, identity, and advertised capabilities instead of +/// reading independently mutable properties across queues. final class BLELocalIdentityStateStore: @unchecked Sendable { private let lock = NSLock() private var state: BLELocalIdentitySnapshot @@ -25,7 +39,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable { state = BLELocalIdentitySnapshot( peerID: peerID, peerIDData: Data(hexString: peerID.id) ?? Data(), - nickname: nickname + nickname: nickname, + runtimeCapabilities: [], + bridgeGeohash: nil ) } @@ -38,7 +54,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable { state = BLELocalIdentitySnapshot( peerID: state.peerID, peerIDData: state.peerIDData, - nickname: nickname + nickname: nickname, + runtimeCapabilities: state.runtimeCapabilities, + bridgeGeohash: state.bridgeGeohash ) } } @@ -48,8 +66,48 @@ final class BLELocalIdentityStateStore: @unchecked Sendable { state = BLELocalIdentitySnapshot( peerID: peerID, peerIDData: Data(hexString: peerID.id) ?? Data(), - nickname: state.nickname + nickname: state.nickname, + runtimeCapabilities: state.runtimeCapabilities, + bridgeGeohash: state.bridgeGeohash ) } } + + /// Flips a runtime capability bit. Returns whether anything changed. + @discardableResult + func setCapability(_ capability: PeerCapabilities, enabled: Bool) -> Bool { + lock.withLock { + var capabilities = state.runtimeCapabilities + if enabled { + capabilities.insert(capability) + } else { + capabilities.remove(capability) + } + guard capabilities != state.runtimeCapabilities else { return false } + state = BLELocalIdentitySnapshot( + peerID: state.peerID, + peerIDData: state.peerIDData, + nickname: state.nickname, + runtimeCapabilities: capabilities, + bridgeGeohash: state.bridgeGeohash + ) + return true + } + } + + /// Sets the bridged rendezvous cell. Returns whether anything changed. + @discardableResult + func setBridgeGeohash(_ cell: String?) -> Bool { + lock.withLock { + guard cell != state.bridgeGeohash else { return false } + state = BLELocalIdentitySnapshot( + peerID: state.peerID, + peerIDData: state.peerIDData, + nickname: state.nickname, + runtimeCapabilities: state.runtimeCapabilities, + bridgeGeohash: cell + ) + return true + } + } } diff --git a/bitchat/Services/BLE/BLEMeshPingTracker.swift b/bitchat/Services/BLE/BLEMeshPingTracker.swift new file mode 100644 index 00000000..3e1e6644 --- /dev/null +++ b/bitchat/Services/BLE/BLEMeshPingTracker.swift @@ -0,0 +1,62 @@ +import BitFoundation +import Foundation + +struct BLEMeshPingProbe { + let peerID: PeerID + let sentAt: Date + let lifecycleGeneration: UInt64 + let completion: @MainActor (MeshPingResult?) -> Void + let timeout: DispatchWorkItem +} + +/// Engine-confined /ping diagnostics state: outstanding probes keyed by +/// their unguessable nonce, plus the inbound response budget. +/// +/// The budget is keyed by the ingress link (the directly connected peer +/// that delivered the packet), never the packet-claimed sender: pings are +/// unsigned, so the claimed sender is attacker-controlled and rotating it +/// would reset the budget, turning a directed unencrypted probe into an +/// amplification primitive. +/// +/// Pure state — the transport owns packet I/O, timers, and main-actor +/// completion delivery around it. +struct BLEMeshPingTracker { + private var pendingProbes: [Data: BLEMeshPingProbe] = [:] + private var responseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) + + mutating func register(_ probe: BLEMeshPingProbe, nonce: Data) { + pendingProbes[nonce] = probe + } + + /// Resolves a pong against its outstanding probe. The echoed nonce plus + /// the sender check bind the reply to the probed peer. + mutating func resolve(nonce: Data, from peerID: PeerID) -> BLEMeshPingProbe? { + guard pendingProbes[nonce]?.peerID == peerID else { return nil } + return pendingProbes.removeValue(forKey: nonce) + } + + /// Removes a timed-out probe so its completion can fire once with nil. + mutating func expire(nonce: Data) -> BLEMeshPingProbe? { + pendingProbes.removeValue(forKey: nonce) + } + + /// Whether an inbound ping delivered by this link is within budget. + mutating func shouldRespond(toLink linkPeerID: PeerID, now: Date) -> Bool { + responseLimiter.shouldRespond(to: linkPeerID, now: now) + } + + /// Drops all probes and restores a fresh response budget (panic wipe). + /// Returns the orphaned timeout work items for the caller to cancel. + mutating func reset() -> [DispatchWorkItem] { + let timeouts = pendingProbes.values.map(\.timeout) + pendingProbes.removeAll() + responseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) + return timeouts + } +} diff --git a/bitchat/Services/BLE/BLEPeerRegistryStore.swift b/bitchat/Services/BLE/BLEPeerRegistryStore.swift new file mode 100644 index 00000000..68110aed --- /dev/null +++ b/bitchat/Services/BLE/BLEPeerRegistryStore.swift @@ -0,0 +1,84 @@ +import BitFoundation +import Foundation + +/// Lock-backed shared ownership of the peer registry, readable from any +/// queue or the main actor without hopping onto a transport queue. +/// +/// Mutations come only from the transport's own serial queues — the +/// engine, plus the bleQueue link-drop paths that mark a peer +/// disconnected — and the lock serializes them against each other and +/// against readers, so the main actor answers questions like +/// `isPeerConnected` without blocking behind in-flight transport work. +/// Every `BLEPeerRegistry` mutation is a single whole-transition method, +/// so a reader between two mutations always observes a valid pre- or +/// post-state, never a torn one. +/// +/// Closures passed to `read`/`mutate` run under the (non-recursive) lock +/// and must not call back into the store. +final class BLEPeerRegistryStore: @unchecked Sendable { + private let lock = NSLock() + private var registry = BLEPeerRegistry() + + /// One consistent view across multiple registry reads. + func read(_ body: (BLEPeerRegistry) -> T) -> T { + lock.withLock { body(registry) } + } + + func mutate(_ body: (inout BLEPeerRegistry) -> T) -> T { + lock.withLock { body(®istry) } + } + + // MARK: - Single-question reads + + var isEmpty: Bool { read { $0.isEmpty } } + var peerIDs: [PeerID] { read { $0.peerIDs } } + var connectedCount: Int { read { $0.connectedCount } } + var connectedPeerIDs: [PeerID] { read { $0.connectedPeerIDs } } + var connectedRoutingData: [Data] { read { $0.connectedRoutingData } } + var snapshotByID: [PeerID: BLEPeerInfo] { read { $0.snapshotByID } } + + func info(for peerID: PeerID) -> BLEPeerInfo? { + read { $0.info(for: peerID) } + } + + func isConnected(_ peerID: PeerID) -> Bool { + read { $0.isConnected(peerID) } + } + + func isReachable(_ peerID: PeerID, now: Date) -> Bool { + read { $0.isReachable(peerID, now: now) } + } + + func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? { + read { $0.nickname(for: peerID, connectedOnly: connectedOnly) } + } + + func fingerprint(for peerID: PeerID) -> String? { + read { $0.fingerprint(for: peerID) } + } + + func capabilities(for peerID: PeerID) -> PeerCapabilities { + read { $0.capabilities(for: peerID) } + } + + func advertisedBridgeGeohash() -> String? { + read { $0.advertisedBridgeGeohash() } + } + + func displayNicknames(selfNickname: String) -> [PeerID: String] { + read { $0.displayNicknames(selfNickname: selfNickname) } + } + + func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] { + read { $0.transportSnapshots(selfNickname: selfNickname) } + } + + /// Peers advertising `capability` that are reachable now, in one + /// consistent view. + func reachablePeers(advertising capability: PeerCapabilities, now: Date) -> [PeerID] { + read { registry in + registry.peers(advertising: capability) + .filter { registry.isReachable($0, now: now) } + } + } +} diff --git a/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift b/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift new file mode 100644 index 00000000..461d934c --- /dev/null +++ b/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift @@ -0,0 +1,363 @@ +import BitFoundation +import Foundation + +struct BLEAuthenticatedPeerStateObservation { + let fingerprint: String + let sessionGeneration: UUID + let capabilities: PeerCapabilities +} + +struct BLEPrivateMediaProofTimeoutMarker { + let fingerprint: String + let sessionGeneration: UUID? +} + +struct BLEPrivateMediaProofWatchdog { + let fingerprint: String + let sessionGeneration: UUID + let timeoutNonce: UUID +} + +struct BLEPendingPrivateMediaPolicyResolution { + let fingerprint: String + var sessionGeneration: UUID? + var timeoutNonce: UUID + var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void] +} + +struct BLEAuthenticatedPeerStateSendProgress { + let sessionGeneration: UUID + var sentInitial = false + var sentEcho = false +} + +/// Lock-backed private-media session state: which Noise generation each +/// peer's capability proof, peer-state exchange, and policy waiters are +/// bound to. A fresh Noise authentication rotates the generation UUID, so +/// stale proof timers and proof packets cannot classify a replacement +/// session. +/// +/// Lock-backed rather than engine-confined for two reasons: the send +/// policy is answered synchronously on the main actor, and several +/// transitions run inside noise-manager critical sections that the engine +/// is sync-waiting on (where re-entering the engine would self-deadlock, +/// but taking a leaf lock is safe). Every method is one whole transition +/// under the lock, so no caller can observe a torn intermediate state. +final class BLEPrivateMediaSessionStore: @unchecked Sendable { + private let lock = NSLock() + private var sessionGenerations: [PeerID: UUID] = [:] + private var authenticatedStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:] + private var proofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:] + private var proofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:] + private var pendingPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:] + private var stateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:] + /// Peers whose parked outbound queues must stay parked until the + /// convergence retry re-authenticates: a timeout-restore brings back + /// keys the counterpart may have already discarded, so nothing — not + /// even the capability-proof watchdog — may drain the queues under + /// them. Set on the deferred restore transition, cleared by any + /// transition that is allowed to drain. + private var outboundConvergenceDeferred: Set = [] + + // MARK: Reads + + func currentGeneration(for peerID: PeerID) -> UUID? { + lock.withLock { sessionGenerations[peerID] } + } + + /// The exact current generation iff it authenticated both encrypted + /// private media (bit 8) and durable receipts/retry (bit 9). + func receiptSessionGeneration(for peerID: PeerID, currentNoiseGeneration: UUID?) -> UUID? { + lock.withLock { + guard let generation = sessionGenerations[peerID], + generation == currentNoiseGeneration, + let authenticated = authenticatedStates[peerID], + authenticated.sessionGeneration == generation, + authenticated.capabilities.contains(.privateMedia), + authenticated.capabilities.contains(.privateMediaReceipts) else { + return nil + } + return generation + } + } + + /// One consistent view of the state the send-policy calculus needs. + func policyInputs(for peerID: PeerID) -> ( + sessionGeneration: UUID?, + authenticatedState: BLEAuthenticatedPeerStateObservation?, + timedOut: BLEPrivateMediaProofTimeoutMarker? + ) { + lock.withLock { + ( + sessionGenerations[peerID], + authenticatedStates[peerID], + proofTimeoutMarkers[peerID] + ) + } + } + + func hasPendingPolicyResolution(for peerID: PeerID) -> Bool { + lock.withLock { pendingPolicyResolutions[peerID] != nil } + } + + /// The live proof-timeout identity for a peer (watchdog first, then a + /// registered waiter) — what a forced/expired timeout must present. + func proofTimeoutTarget(for peerID: PeerID) -> (fingerprint: String, generation: UUID?, nonce: UUID)? { + lock.withLock { + if let watchdog = proofWatchdogs[peerID] { + return (watchdog.fingerprint, watchdog.sessionGeneration, watchdog.timeoutNonce) + } + if let pending = pendingPolicyResolutions[peerID] { + return (pending.fingerprint, pending.sessionGeneration, pending.timeoutNonce) + } + return nil + } + } + + // MARK: Generation transitions + + /// Installs a freshly authenticated generation: rotates the proof + /// watchdog, resets peer-state send progress, and re-binds any pending + /// policy waiters whose fingerprint still matches (mismatched waiters + /// are rejected and returned for completion). Returns nil when the + /// generation is already current — the same-generation reconciliation + /// path, which must not re-arm proof machinery. + func beginAuthenticatedGeneration( + for peerID: PeerID, + fingerprint: String, + generation: UUID + ) -> (watchdogNonce: UUID, rejected: [@MainActor (PrivateMediaSendPolicy) -> Void])? { + lock.withLock { + guard sessionGenerations[peerID] != generation else { return nil } + let watchdogNonce = UUID() + sessionGenerations[peerID] = generation + authenticatedStates.removeValue(forKey: peerID) + proofTimeoutMarkers.removeValue(forKey: peerID) + proofWatchdogs[peerID] = BLEPrivateMediaProofWatchdog( + fingerprint: fingerprint, + sessionGeneration: generation, + timeoutNonce: watchdogNonce + ) + stateSendProgress[peerID] = + BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) + + guard var pending = pendingPolicyResolutions[peerID] else { + return (watchdogNonce, []) + } + guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else { + pendingPolicyResolutions.removeValue(forKey: peerID) + return (watchdogNonce, Array(pending.completions.values)) + } + pending.sessionGeneration = generation + pending.timeoutNonce = watchdogNonce + pendingPolicyResolutions[peerID] = pending + return (watchdogNonce, []) + } + } + + /// Records a verified authenticated-peer-state packet for the current + /// generation: pins the observation, retires proof timers, and releases + /// matching policy waiters. Returns nil when the generation is no longer + /// current (the caller's lease raced a replacement). + func applyAuthenticatedPeerState( + for peerID: PeerID, + fingerprint: String, + generation: UUID, + capabilities: PeerCapabilities + ) -> [@MainActor (PrivateMediaSendPolicy) -> Void]? { + lock.withLock { + guard sessionGenerations[peerID] == generation else { return nil } + authenticatedStates[peerID] = BLEAuthenticatedPeerStateObservation( + fingerprint: fingerprint, + sessionGeneration: generation, + capabilities: capabilities + ) + proofTimeoutMarkers.removeValue(forKey: peerID) + proofWatchdogs.removeValue(forKey: peerID) + guard let pending = pendingPolicyResolutions.removeValue(forKey: peerID), + pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + pending.sessionGeneration == generation else { + return [] + } + return Array(pending.completions.values) + } + } + + /// Consumes one peer-state send slot (initial or echo) for the current + /// generation. Returns whether the packet should actually go out. + func markPeerStateSend(for peerID: PeerID, echo: Bool) -> Bool { + lock.withLock { + guard let generation = sessionGenerations[peerID], + var progress = stateSendProgress[peerID], + progress.sessionGeneration == generation else { return false } + if echo { + guard !progress.sentEcho else { return false } + progress.sentEcho = true + } else { + guard !progress.sentInitial else { return false } + progress.sentInitial = true + } + stateSendProgress[peerID] = progress + return true + } + } + + // MARK: Outbound convergence deferral + + func setOutboundDeferredUntilConvergence(_ peerID: PeerID) { + lock.withLock { _ = outboundConvergenceDeferred.insert(peerID) } + } + + func clearOutboundDeferredUntilConvergence(_ peerID: PeerID) { + lock.withLock { _ = outboundConvergenceDeferred.remove(peerID) } + } + + // MARK: Proof timeout + + /// Expires a proof deadline if its nonce/generation/fingerprint still + /// identify the live watchdog or waiter set. On expiry the timeout + /// marker is pinned and any waiters are returned for completion. + /// `deferredOutbound` reports whether the peer's parked queues must + /// stay parked (timeout-restore pending its convergence retry). + func expireProofDeadline( + for peerID: PeerID, + fingerprint: String, + sessionGeneration: UUID?, + nonce: UUID + ) -> (expired: Bool, deferredOutbound: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) { + lock.withLock { + let pending = pendingPolicyResolutions[peerID] + let pendingMatches = pending?.timeoutNonce == nonce + && pending?.sessionGeneration == sessionGeneration + && pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame + let watchdog = proofWatchdogs[peerID] + let watchdogMatches = sessionGeneration != nil + && watchdog?.timeoutNonce == nonce + && watchdog?.sessionGeneration == sessionGeneration + && watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame + guard pendingMatches || watchdogMatches else { + return (false, false, []) + } + var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = [] + if pendingMatches, let pending { + completions = Array(pending.completions.values) + } + if pendingMatches { + pendingPolicyResolutions.removeValue(forKey: peerID) + } + if watchdogMatches { + proofWatchdogs.removeValue(forKey: peerID) + } + proofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker( + fingerprint: fingerprint, + sessionGeneration: sessionGeneration + ) + return (true, outboundConvergenceDeferred.contains(peerID), completions) + } + } + + /// Registers a policy-resolution waiter for a peer still awaiting its + /// capability proof. Joins the existing waiter set when fingerprints + /// match (bounded), otherwise starts one, reusing the live watchdog's + /// deadline identity when it covers the same fingerprint/generation so + /// only one timeout is ever in flight. `shouldSchedule` tells the + /// caller to arm a fresh deadline. + func registerPolicyResolution( + for peerID: PeerID, + fingerprint: String, + requestID: UUID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) { + lock.withLock { + let generation = sessionGenerations[peerID] + if var pending = pendingPolicyResolutions[peerID] { + guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + pending.completions.count + < TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else { + return (false, false, UUID(), generation) + } + pending.completions[requestID] = completion + pendingPolicyResolutions[peerID] = pending + return (true, false, pending.timeoutNonce, pending.sessionGeneration) + } + + guard pendingPolicyResolutions.count + < TransportConfig.privateMediaCapabilityProofPendingPeerCap else { + return (false, false, UUID(), generation) + } + let currentWatchdog = proofWatchdogs[peerID] + let reusesWatchdog = currentWatchdog?.fingerprint + .caseInsensitiveCompare(fingerprint) == .orderedSame + && currentWatchdog?.sessionGeneration == generation + let nonce: UUID + if reusesWatchdog, let currentWatchdog { + nonce = currentWatchdog.timeoutNonce + } else { + nonce = UUID() + } + pendingPolicyResolutions[peerID] = + BLEPendingPrivateMediaPolicyResolution( + fingerprint: fingerprint, + sessionGeneration: generation, + timeoutNonce: nonce, + completions: [requestID: completion] + ) + return (true, !reusesWatchdog, nonce, generation) + } + } + + // MARK: Teardown + + /// A session clear retires every generation-bound record. Waiters are + /// kept but rebased onto a nil generation with a fresh deadline nonce, + /// returned so the caller re-arms their timeout. + func clearSession(for peerID: PeerID) -> (fingerprint: String, nonce: UUID)? { + lock.withLock { + sessionGenerations.removeValue(forKey: peerID) + authenticatedStates.removeValue(forKey: peerID) + proofTimeoutMarkers.removeValue(forKey: peerID) + proofWatchdogs.removeValue(forKey: peerID) + stateSendProgress.removeValue(forKey: peerID) + outboundConvergenceDeferred.remove(peerID) + guard var pending = pendingPolicyResolutions[peerID] else { + return nil + } + let nonce = UUID() + pending.sessionGeneration = nil + pending.timeoutNonce = nonce + pendingPolicyResolutions[peerID] = pending + return (pending.fingerprint, nonce) + } + } + + /// Panic wipe: these records belong to pre-panic transfer state, and + /// invoking their callbacks would let queued UI work recreate or resend + /// wiped media — drop everything. + func panicReset() { + lock.withLock { + sessionGenerations.removeAll() + authenticatedStates.removeAll() + proofTimeoutMarkers.removeAll() + proofWatchdogs.removeAll() + pendingPolicyResolutions.removeAll() + stateSendProgress.removeAll() + outboundConvergenceDeferred.removeAll() + } + } +} + +extension BLEPrivateMediaSessionStore { + /// The current generation iff its authenticated peer state proved the + /// private-media capability (and, when required, durable receipts). + func provenGeneration(for peerID: PeerID, requireReceipts: Bool) -> UUID? { + let inputs = policyInputs(for: peerID) + guard let generation = inputs.sessionGeneration, + let authenticated = inputs.authenticatedState, + authenticated.sessionGeneration == generation, + authenticated.capabilities.contains(.privateMedia) else { return nil } + if requireReceipts { + guard authenticated.capabilities.contains(.privateMediaReceipts) else { return nil } + } + return generation + } +} diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index e81fabd5..17af256b 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -77,6 +77,26 @@ struct BLEReceivePipeline { } } +/// Lock-backed traffic-level signal: the receive pipeline records packets, +/// and the radio layer (maintenance and scan-duty adaptation on bleQueue) +/// reads the level without crossing onto a transport queue. +final class BLERecentTrafficMonitor: @unchecked Sendable { + private let lock = NSLock() + private var tracker = BLERecentTrafficTracker() + + func recordPacket(at now: Date) { + lock.withLock { tracker.recordPacket(at: now) } + } + + func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool { + lock.withLock { tracker.hasTraffic(within: seconds, now: now) } + } + + func removeAll() { + lock.withLock { tracker.removeAll() } + } +} + struct BLERecentTrafficTracker: Equatable { private var packetTimestamps: [Date] = [] diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 919c3820..488fb098 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -176,36 +176,6 @@ private final class BLEPrivateMediaTransferAdmissionRegistry { } } -private struct BLEAuthenticatedPeerStateObservation { - let fingerprint: String - let sessionGeneration: UUID - let capabilities: PeerCapabilities -} - -private struct BLEPrivateMediaProofTimeoutMarker { - let fingerprint: String - let sessionGeneration: UUID? -} - -private struct BLEPrivateMediaProofWatchdog { - let fingerprint: String - let sessionGeneration: UUID - let timeoutNonce: UUID -} - -private struct BLEPendingPrivateMediaPolicyResolution { - let fingerprint: String - var sessionGeneration: UUID? - var timeoutNonce: UUID - var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void] -} - -private struct BLEAuthenticatedPeerStateSendProgress { - let sessionGeneration: UUID - var sentInitial = false - var sentEcho = false -} - /// BLEService — Bluetooth Mesh Transport /// - Emits events exclusively via `BitchatDelegate` for UI. /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). @@ -254,8 +224,13 @@ final class BLEService: NSObject { // BCH-01-004: Rate-limiting for subscription-triggered announces. private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() - // 3. Peer Information (single source of truth) - private var peerRegistry = BLEPeerRegistry() + // 3. Peer Information (single source of truth). Lock-backed so the main + // actor reads it directly instead of blocking on the engine queue. + // Mutations come only from the transport's own serial queues — the + // engine, plus the two bleQueue link-drop paths (didDisconnectPeripheral + // / didUnsubscribeFrom) that mark a peer disconnected the moment its + // last physical link goes; the store's lock serializes them. + private let peerRegistry = BLEPeerRegistryStore() // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() @@ -278,14 +253,14 @@ final class BLEService: NSObject { // Verified one-time prekey bundles gossiped by other peers, used to seal // courier mail forward-secretly. Injectable for tests. var prekeyBundleStore: PrekeyBundleStore = .shared - // Throttle for re-broadcasting our own (unchanged) bundle; guarded by - // collectionsQueue barriers. + // Throttle for re-broadcasting our own (unchanged) bundle + // (engine-confined). private var lastPrekeyBundleSentAt: Date? // Prekey bundles that arrived before their owner's verified announce bound - // a signing key. The receive queue is concurrent, so a bundle can race - // ahead of the announce it depends on; we retain the latest such bundle per - // owner (bounded) and re-attempt attribution when the announce lands. - // Guarded by collectionsQueue barriers. + // a signing key. Over the air a bundle can still arrive before the + // announce it depends on; we retain the latest such bundle per owner + // (bounded) and re-attempt attribution when the announce lands. + // Engine-confined. private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:] private static let pendingPrekeyBundleCap = 64 // Gateway mode: sink for received nostrCarrier packets (set by app @@ -297,8 +272,6 @@ final class BLEService: NSObject { /// Fired (off-main) when a signature-verified announce is processed — /// the bridge courier watch refreshes its tag set on new arrivals. var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? - private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue - private var localBridgeGeohash: String? // collectionsQueue #if DEBUG // Test-only tap on the outbound pipeline so multi-node tests can ferry @@ -322,27 +295,11 @@ final class BLEService: NSObject { #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() - // Route health for originated source routes; guarded by collectionsQueue. + // Route health for originated source routes (engine-confined). private var sourceRouteFailures = BLESourceRouteFailureCache() - // Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the - // inbound ping budget — keyed by the ingress link (the directly connected - // peer that delivered the packet), since the unsigned claimed sender is - // spoofable — so a directed unencrypted probe cannot be turned into an - // amplification primitive. Both are owned by collectionsQueue barriers - // like the other mutable collections. - private struct PendingMeshPing { - let peerID: PeerID - let sentAt: Date - let lifecycleGeneration: UInt64 - let completion: @MainActor (MeshPingResult?) -> Void - let timeout: DispatchWorkItem - } - private var pendingMeshPings: [Data: PendingMeshPing] = [:] - private var meshPingResponseLimiter = SyncResponseRateLimiter( - maxResponses: TransportConfig.meshPingInboundMaxPerLink, - window: TransportConfig.meshPingInboundWindowSeconds - ) + // Mesh diagnostics (/ping): engine-confined probe and budget state. + private var meshPings = BLEMeshPingTracker() // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() @@ -350,15 +307,10 @@ final class BLEService: NSObject { private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in self?.handlePrivateMediaAdmissionExpiry(transferId) } - // All six maps below are protected by `collectionsQueue`. A fresh Noise - // authentication rotates the generation UUID, so stale proof timers and - // proof packets cannot classify a replacement session. - private var privateMediaSessionGenerations: [PeerID: UUID] = [:] - private var authenticatedPeerStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:] - private var privateMediaProofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:] - private var privateMediaProofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:] - private var pendingPrivateMediaPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:] - private var authenticatedPeerStateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:] + // Generation-bound private-media session state (lock-backed store: the + // main actor answers the send policy from it synchronously, and noise + // critical sections mutate it without re-entering the engine). + private let privateMediaSessions = BLEPrivateMediaSessionStore() private let incomingFileStore: BLEIncomingFileStore // Simple announce throttling @@ -404,39 +356,74 @@ final class BLEService: NSObject { // MARK: - Queues - private let messageQueue = DispatchQueue(label: "mesh.message", attributes: .concurrent) - private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent) + /// The engine queue: one serial domain that owns every piece of mesh + /// protocol state (the former concurrent message queue and the separate + /// collections queue it guarded state with). BLE throughput is far below + /// what one queue serializes comfortably, and a single writer makes the + /// old per-field ownership comments and barrier discipline structural. + private let messageQueue = DispatchQueue(label: "mesh.message") private let messageQueueKey = DispatchSpecificKey() + /// The only source of deferred engine work (see BLEEngineScheduling); + /// injectable so tests drive protocol deadlines with a manual clock. + private let engineScheduler: BLEEngineScheduling private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated) private let bleQueueKey = DispatchSpecificKey() + + /// Runs `body` exclusively with respect to all engine-owned state. + /// Executes inline when already on the engine queue; otherwise blocks + /// until the engine drains the work ahead of it. + /// + /// Sync-edge order (deadlock freedom): main and test threads may + /// sync-wait on the engine; the engine sync-waits on bleQueue + /// (`readLinkState`) and on the crypto/identity services' internal + /// queues. None of those may ever sync-wait back on the engine — + /// bleQueue callers hop with `messageQueue.async` instead, and debug + /// builds trap any violation here. + private func onEngine(_ body: () -> T) -> T { + #if DEBUG + dispatchPrecondition(condition: .notOnQueue(bleQueue)) + #endif + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + return body() + } + // queue-contract-ok: this is the single sanctioned sync entry — the + // trap above is exactly what BLEQueueContractTests exists to protect. + return messageQueue.sync(execute: body) + } // Noise messages and typed payloads pending handshake completion. private var pendingNoiseSessionQueues = BLENoiseSessionQueues() - // Queue for notifications that failed due to full queue + // Queue for notifications that failed due to full queue (bleQueue-owned, + // like the link state store: every producer and drain runs there). private var pendingNotifications = BLEOutboundNotificationBuffer() // Backpressure logging fires per fragment during media transfers // (hundreds of lines per image); sampled via this counter, which is - // only touched inside collectionsQueue barriers (no sync needed). + // only touched on bleQueue (no sync needed). var notificationBackpressureLogCount = 0 // Accumulate long write chunks per central until a full frame decodes + // (bleQueue-owned) private var pendingWriteBuffers = BLEInboundWriteBuffer() // Relay jitter scheduling to reduce redundant floods private var scheduledRelays = BLEScheduledRelayStore() // Track short-lived traffic bursts to adapt announces/scanning under load - private var recentTrafficTracker = BLERecentTrafficTracker() + // (lock-backed: written by the receive pipeline, read on bleQueue) + private let recentTrafficTracker = BLERecentTrafficMonitor() // Ingress link tracking for duplicate and last-hop suppression - private var ingressLinks = BLEIngressLinkRegistry() + // (lock-backed: recorded on bleQueue the moment a frame decodes, read + // by engine relay/routing decisions) + private let ingressLinks = BLEIngressLinkStore() // Inner message IDs of recently opened courier envelopes. Redundant // copies of one message ride different envelopes (each seal uses a fresh // ephemeral key, and bridge drops multiply across relays/couriers), so // envelope-level dedup can't catch them; dedup on the inner ID before // delivery so a duplicate costs one decrypt instead of a delivery + ack - // + handshake each. Owned by collectionsQueue barriers. + // + handshake each. Engine-confined. private var openedCourierMessageIDs = BoundedIDSet(capacity: TransportConfig.courierOpenedMessageIDCap) private let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5) + // Per-peripheral write backpressure (bleQueue-owned) private var pendingPeripheralWrites = BLEOutboundWriteBuffer() // Debounce duplicate disconnect notifies private var disconnectNotifyDebouncer = BLEPeerEventDebouncer() @@ -492,7 +479,7 @@ final class BLEService: NSObject { case .publishNow: publishFullPeerData() case .schedule(let delay): - messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + engineScheduler.schedule(after: delay) { [weak self] in guard let self = self else { return } self.peerPublishCoalescer.scheduledPublishFired(now: Date()) self.publishFullPeerData() @@ -512,8 +499,10 @@ final class BLEService: NSObject { incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(), startSuspendedForPanicRecovery: Bool = false, noiseResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler() ) { + self.engineScheduler = engineScheduler self.keychain = keychain self.idBridge = idBridge self.incomingFileStore = incomingFileStore @@ -532,6 +521,7 @@ final class BLEService: NSObject { // Set queue key for identification messageQueue.setSpecific(key: messageQueueKey, value: ()) + engineScheduler.activate(engineQueue: messageQueue) // Set up application state tracking (iOS only) #if os(iOS) @@ -544,6 +534,10 @@ final class BLEService: NSObject { isAppActive = UIApplication.shared.applicationState == .active refreshCachedBackgroundTimeRemaining() } else { + // queue-contract-ok: init-time only — no engine or bleQueue work + // exists yet that main could be sync-waiting on, so this cannot + // pair into a cycle. Everything after init caches main-actor + // state instead (see scheduleBluetoothStatusSample). DispatchQueue.main.sync { isAppActive = UIApplication.shared.applicationState == .active refreshCachedBackgroundTimeRemaining() @@ -731,7 +725,7 @@ final class BLEService: NSObject { // generation-bound handoffs that raced this barrier reject themselves. // Clear the old identity's bounded early-ciphertext queue again after // those callbacks drain so none can repopulate it after the first wipe. - messageQueue.sync(flags: .barrier) { + onEngine { noisePacketHandler.resetForPanic() } clearEmergencySessionState() @@ -759,18 +753,14 @@ final class BLEService: NSObject { gossipSyncManager = nil // Discard deferred pre-panic ciphertext behind any in-flight receive // handlers so none can repopulate the handler's bounded queue. - messageQueue.sync(flags: .barrier) { + onEngine { noisePacketHandler.resetForPanic() } - // pendingNoiseSessionQueues is owned by collectionsQueue everywhere - // else, so clear it there too rather than on messageQueue. - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.removeAll() } - let panicReset = collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.removeAll() - pendingNotifications.removeAll() + let panicReset = onEngine { let transfers = outboundFragmentTransfers.removeAll() fragmentAssemblyBuffer.removeAll() pendingDirectedRelays.removeAll() @@ -779,12 +769,7 @@ final class BLEService: NSObject { scheduledRelays.cancelAll() // These callbacks belong to pre-panic transfer state. Invoking // them would let queued UI work recreate or resend wiped media. - pendingPrivateMediaPolicyResolutions.removeAll() - privateMediaSessionGenerations.removeAll() - authenticatedPeerStates.removeAll() - privateMediaProofTimeoutMarkers.removeAll() - privateMediaProofWatchdogs.removeAll() - authenticatedPeerStateSendProgress.removeAll() + privateMediaSessions.panicReset() // Let the post-panic identity publish its fresh bundle promptly. lastPrekeyBundleSentAt = nil return transfers @@ -796,6 +781,8 @@ final class BLEService: NSObject { } bleQueue.sync { + pendingPeripheralWrites.removeAll() + pendingNotifications.removeAll() pendingWriteBuffers.removeAll() noiseAuthenticatedLinkOwners.removeAll() noiseReconnectPolicy.removeAll() @@ -808,7 +795,7 @@ final class BLEService: NSObject { // must never observe the new Noise service alongside the old peer ID // (it would sign with the new identity while carrying the old sender). // refreshPeerIdentity() executes inline here via its re-entrancy check. - messageQueue.sync(flags: .barrier) { + onEngine { noiseService.clearEphemeralStateForPanic() noiseService.clearPersistentIdentity() @@ -826,7 +813,7 @@ final class BLEService: NSObject { // would force-send an announce and break that silence). localIdentityState.setNickname(currentNickname) messageDeduplicator.reset() - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.selfBroadcastTracker.removeAll() } requestPeerDataPublish() @@ -896,9 +883,7 @@ final class BLEService: NSObject { weak var peerEventsDelegate: TransportPeerEventsDelegate? func currentPeerSnapshots() -> [TransportPeerSnapshot] { - collectionsQueue.sync { - peerRegistry.transportSnapshots(selfNickname: myNickname) - } + peerRegistry.transportSnapshots(selfNickname: myNickname) } // MARK: Identity @@ -961,7 +946,7 @@ final class BLEService: NSObject { // Send initial announce after services are ready // Use longer delay to avoid conflicts with other announces - messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in + engineScheduler.schedule(after: TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in guard let self, self.isCurrentPanicLifecycleGeneration( lifecycleGeneration @@ -1016,7 +1001,7 @@ final class BLEService: NSObject { } // Clear pending notifications - collectionsQueue.sync(flags: .barrier) { + bleQueue.sync { pendingNotifications.removeAll() } @@ -1040,7 +1025,7 @@ final class BLEService: NSObject { /// pumping the main run loop. Close the radio and timers immediately; /// the identity/session cleanup follows synchronously. private func stopServicesImmediatelyForPanic() { - collectionsQueue.sync(flags: .barrier) { + bleQueue.sync { pendingNotifications.removeAll() } @@ -1067,17 +1052,12 @@ final class BLEService: NSObject { private func clearEmergencySessionState() { // Clear all sessions and peers - let cancelled = collectionsQueue.sync(flags: .barrier) { + let cancelled = onEngine { let entries = outboundFragmentTransfers.removeAll().map { (id: $0.id, items: $0.workItems) } - let pingTimeouts = pendingMeshPings.values.map(\.timeout) - pendingMeshPings.removeAll() - meshPingResponseLimiter = SyncResponseRateLimiter( - maxResponses: TransportConfig.meshPingInboundMaxPerLink, - window: TransportConfig.meshPingInboundWindowSeconds - ) - peerRegistry.removeAll() + let pingTimeouts = meshPings.reset() + peerRegistry.mutate { $0.removeAll() } fragmentAssemblyBuffer.removeAll() sourceRouteFailures = BLESourceRouteFailureCache() // Also clear pending message queues to avoid stale state across sessions @@ -1110,14 +1090,12 @@ final class BLEService: NSObject { func isPeerConnected(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys - return collectionsQueue.sync { peerRegistry.isConnected(peerID) } + return peerRegistry.isConnected(peerID) } func isPeerReachable(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys - return collectionsQueue.sync { - peerRegistry.isReachable(peerID, now: Date()) - } + peerRegistry.isReachable(peerID, now: Date()) } func canDeliverSecurely(to peerID: PeerID) -> Bool { @@ -1134,15 +1112,13 @@ final class BLEService: NSObject { } func peerNickname(peerID: PeerID) -> String? { - collectionsQueue.sync { - peerRegistry.nickname(for: peerID, connectedOnly: true) - } + peerRegistry.nickname(for: peerID, connectedOnly: true) } /// Capabilities the peer advertised in its last verified announce. /// Empty for peers that predate the capabilities TLV. func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { - collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } + peerRegistry.capabilities(for: peerID) } func authenticatedPrivateMediaReceiptSessionGeneration( @@ -1151,21 +1127,10 @@ final class BLEService: NSObject { let normalizedPeerID = peerID.toShort() let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID) - return collectionsQueue.sync { - guard let generation = - privateMediaSessionGenerations[normalizedPeerID], - generation == currentNoiseGeneration, - let authenticated = - authenticatedPeerStates[normalizedPeerID], - authenticated.sessionGeneration == generation, - authenticated.capabilities.contains(.privateMedia), - authenticated.capabilities.contains( - .privateMediaReceipts - ) else { - return nil - } - return generation - } + return privateMediaSessions.receiptSessionGeneration( + for: normalizedPeerID, + currentNoiseGeneration: currentNoiseGeneration + ) } private func privateMediaPolicyFingerprint( @@ -1183,11 +1148,9 @@ final class BLEService: NSObject { // registry entry populated by a public announce. return fingerprint } - return collectionsQueue.sync { - peerRegistry.info(for: normalizedPeerID)? - .noisePublicKey? - .sha256Fingerprint() - } + return peerRegistry.info(for: normalizedPeerID)? + .noisePublicKey? + .sha256Fingerprint() } func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { @@ -1198,16 +1161,17 @@ final class BLEService: NSObject { sessionGeneration: UUID?, authenticatedState: BLEAuthenticatedPeerStateObservation?, timedOut: BLEPrivateMediaProofTimeoutMarker? - ) = collectionsQueue.sync { + ) = { let info = peerRegistry.info(for: normalizedPeerID) + let session = privateMediaSessions.policyInputs(for: normalizedPeerID) return ( info?.capabilities ?? [], info?.noisePublicKey?.sha256Fingerprint(), - privateMediaSessionGenerations[normalizedPeerID], - authenticatedPeerStates[normalizedPeerID], - privateMediaProofTimeoutMarkers[normalizedPeerID] + session.sessionGeneration, + session.authenticatedState, + session.timedOut ) - } + }() let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID) // A session replacement can happen before its authentication callback @@ -1274,9 +1238,7 @@ final class BLEService: NSObject { return } - let generation = self.collectionsQueue.sync { - self.privateMediaSessionGenerations[normalizedPeerID] - } + let generation = self.privateMediaSessions.currentGeneration(for: normalizedPeerID) let fingerprint = self.privateMediaPolicyFingerprint( for: normalizedPeerID, expectedSessionGeneration: generation @@ -1287,43 +1249,12 @@ final class BLEService: NSObject { } let requestID = UUID() - let registration = self.collectionsQueue.sync(flags: .barrier) { - () -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) in - let generation = self.privateMediaSessionGenerations[normalizedPeerID] - if var pending = self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] { - guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, - pending.completions.count - < TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else { - return (false, false, UUID(), generation) - } - pending.completions[requestID] = completion - self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending - return (true, false, pending.timeoutNonce, pending.sessionGeneration) - } - - guard self.pendingPrivateMediaPolicyResolutions.count - < TransportConfig.privateMediaCapabilityProofPendingPeerCap else { - return (false, false, UUID(), generation) - } - let currentWatchdog = self.privateMediaProofWatchdogs[normalizedPeerID] - let reusesWatchdog = currentWatchdog?.fingerprint - .caseInsensitiveCompare(fingerprint) == .orderedSame - && currentWatchdog?.sessionGeneration == generation - let nonce: UUID - if reusesWatchdog, let currentWatchdog { - nonce = currentWatchdog.timeoutNonce - } else { - nonce = UUID() - } - self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = - BLEPendingPrivateMediaPolicyResolution( - fingerprint: fingerprint, - sessionGeneration: generation, - timeoutNonce: nonce, - completions: [requestID: completion] - ) - return (true, !reusesWatchdog, nonce, generation) - } + let registration = self.privateMediaSessions.registerPolicyResolution( + for: normalizedPeerID, + fingerprint: fingerprint, + requestID: requestID, + completion: completion + ) guard registration.registered else { self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) @@ -1360,9 +1291,7 @@ final class BLEService: NSObject { sessionGeneration: UUID?, nonce: UUID ) { - messageQueue.asyncAfter( - deadline: .now() + TransportConfig.privateMediaCapabilityProofTimeoutSeconds - ) { [weak self] in + engineScheduler.schedule(after: TransportConfig.privateMediaCapabilityProofTimeoutSeconds) { [weak self] in self?.handlePrivateMediaProofTimeout( for: peerID, fingerprint: fingerprint, @@ -1378,39 +1307,17 @@ final class BLEService: NSObject { sessionGeneration: UUID?, nonce: UUID ) { - let expiration = collectionsQueue.sync(flags: .barrier) { - () -> (expired: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in - let pending = pendingPrivateMediaPolicyResolutions[peerID] - let pendingMatches = pending?.timeoutNonce == nonce - && pending?.sessionGeneration == sessionGeneration - && pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame - let watchdog = privateMediaProofWatchdogs[peerID] - let watchdogMatches = sessionGeneration != nil - && watchdog?.timeoutNonce == nonce - && watchdog?.sessionGeneration == sessionGeneration - && watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame - guard pendingMatches || watchdogMatches else { - return (false, []) - } - var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = [] - if pendingMatches, let pending { - completions = Array(pending.completions.values) - } - if pendingMatches { - pendingPrivateMediaPolicyResolutions.removeValue(forKey: peerID) - } - if watchdogMatches { - privateMediaProofWatchdogs.removeValue(forKey: peerID) - } - privateMediaProofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker( - fingerprint: fingerprint, - sessionGeneration: sessionGeneration - ) - return (true, completions) - } + let expiration = privateMediaSessions.expireProofDeadline( + for: peerID, + fingerprint: fingerprint, + sessionGeneration: sessionGeneration, + nonce: nonce + ) guard expiration.expired else { return } let policy = privateMediaSendPolicy(to: peerID) - sendPendingNoisePayloadsAfterHandshake(for: peerID) + if !expiration.deferredOutbound { + sendPendingNoisePayloadsAfterHandshake(for: peerID) + } completePrivateMediaPolicyResolution(expiration.completions, with: policy) } @@ -1418,67 +1325,41 @@ final class BLEService: NSObject { /// internet-gateway toggle) and re-announces so peers learn promptly. /// Build-time bits stay in `PeerCapabilities.localSupported`. func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) { - let changed: Bool = collectionsQueue.sync(flags: .barrier) { - let before = runtimeCapabilities - if enabled { - runtimeCapabilities.insert(capability) - } else { - runtimeCapabilities.remove(capability) - } - return runtimeCapabilities != before - } - guard changed else { return } + guard localIdentityState.setCapability(capability, enabled: enabled) else { return } sendAnnounce(forceSend: true) } /// Reachable peers currently advertising the `.gateway` capability. func reachableGatewayPeers() -> [PeerID] { - let now = Date() - return collectionsQueue.sync { - peerRegistry.peers(advertising: .gateway) - .filter { peerRegistry.isReachable($0, now: now) } - } + peerRegistry.reachablePeers(advertising: .gateway, now: Date()) } /// Reachable peers currently advertising the `.bridge` capability. func reachableBridgePeers() -> [PeerID] { - let now = Date() - return collectionsQueue.sync { - peerRegistry.peers(advertising: .bridge) - .filter { peerRegistry.isReachable($0, now: now) } - } + peerRegistry.reachablePeers(advertising: .bridge, now: Date()) } /// A rendezvous cell advertised by a bridge-capable peer's announce. func advertisedBridgeGeohash() -> String? { - collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() } + peerRegistry.advertisedBridgeGeohash() } /// The rendezvous cell this device advertises in its own announces while /// bridging with the gateway toggle on. Set from the main actor; the /// value rides the next (forced) announce. func setLocalBridgeGeohash(_ cell: String?) { - let changed: Bool = collectionsQueue.sync(flags: .barrier) { - guard localBridgeGeohash != cell else { return false } - localBridgeGeohash = cell - return true - } - guard changed else { return } + guard localIdentityState.setBridgeGeohash(cell) else { return } sendAnnounce(forceSend: true) } func getPeerNicknames() -> [PeerID: String] { - return collectionsQueue.sync { - peerRegistry.displayNicknames(selfNickname: myNickname) - } + peerRegistry.displayNicknames(selfNickname: myNickname) } // MARK: Protocol utilities func getFingerprint(for peerID: PeerID) -> String? { - return collectionsQueue.sync { - peerRegistry.fingerprint(for: peerID) - } + peerRegistry.fingerprint(for: peerID) } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { @@ -1542,10 +1423,10 @@ final class BLEService: NSObject { // MARK: Messaging private func handlePrivateMediaAdmissionExpiry(_ transferId: String) { - // Expiry can be discovered from the BLE maintenance queue or while a - // caller already owns collectionsQueue. Cleanup is therefore - // fire-and-forget; never synchronously re-enter the collections lock. - collectionsQueue.async(flags: .barrier) { [weak self] in + // Expiry can be discovered from the BLE maintenance queue or from an + // engine slot. Cleanup is therefore fire-and-forget; never + // synchronously re-enter the engine. + messageQueue.async { [weak self] in _ = self?.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) } TransferProgressManager.shared.rejectBeforeStart( @@ -1563,7 +1444,7 @@ final class BLEService: NSObject { // Noise cleanup remains asynchronous, but deferred private-media work // cannot pass another admission boundary after this returns. privateMediaTransferAdmissions.cancel(transferId) - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } switch self.outboundFragmentTransfers.cancelTransfer(transferId) { @@ -1638,15 +1519,6 @@ final class BLEService: NSObject { } } - func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { - sendFilePrivate( - filePacket, - to: peerID, - transferId: transferId, - allowLegacyFallback: false - ) - } - func sendFilePrivate( _ filePacket: BitchatFilePacket, to peerID: PeerID, @@ -1838,7 +1710,7 @@ final class BLEService: NSObject { self.privateMediaTransferAdmissions.finish(transferId) return } - let queued = self.collectionsQueue.sync(flags: .barrier) { + let queued = onEngine { self.privateMediaTransferAdmissions.withActive(transferId) { self.pendingNoiseSessionQueues.appendTypedPayload( typedPayload, @@ -1854,7 +1726,7 @@ final class BLEService: NSObject { } SecureLogger.debug("📥 Queued private file for \(targetID.id.prefix(8))… pending handshake", category: .session) guard self.privateMediaTransferAdmissions.isActive(transferId) else { - self.collectionsQueue.sync(flags: .barrier) { + onEngine { _ = self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) } self.privateMediaTransferAdmissions.finish(transferId) @@ -1981,7 +1853,7 @@ final class BLEService: NSObject { // Queue for after handshake; initiate only while the peer is // around to answer (see sendDeliveryAck — absent senders must // not turn queued acks into handshake floods). - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID) } if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) { @@ -2065,14 +1937,12 @@ final class BLEService: NSObject { } private func recordIngressIfNew(_ packet: BitchatPacket, link: BLEIngressLinkID, peerID: PeerID) -> Bool { - return collectionsQueue.sync(flags: .barrier) { - ingressLinks.recordIfNew( - packet, - link: link, - peerID: peerID, - lifetime: TransportConfig.bleIngressRecordLifetimeSeconds - ) - } + ingressLinks.recordIfNew( + packet, + link: link, + peerID: peerID, + lifetime: TransportConfig.bleIngressRecordLifetimeSeconds + ) } // MARK: - Packet Broadcasting @@ -2277,7 +2147,7 @@ final class BLEService: NSObject { private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) { guard !isPanicSuspended else { return } - collectionsQueue.async(flags: .barrier) { [weak self] in + bleQueue.async { [weak self] in guard let self = self else { return } guard !self.isPanicSuspended else { return } let result = self.pendingNotifications.enqueue( @@ -2297,8 +2167,7 @@ final class BLEService: NSObject { } let backoff = TransportConfig.bleNotificationRetryDelayMs * max(1, attempt + 1) - let deadline = DispatchTime.now() + .milliseconds(backoff) - self.messageQueue.asyncAfter(deadline: deadline) { [weak self] in + self.engineScheduler.schedule(after: Double(backoff) / 1_000) { [weak self] in self?.enqueuePendingNotification(data: data, centrals: centrals, context: context, attempt: attempt + 1) } } @@ -2312,13 +2181,12 @@ final class BLEService: NSObject { centrals: [CBCentral], context: String ) -> Bool { - let result = collectionsQueue.sync(flags: .barrier) { - pendingNotifications.enqueue( - data: data, - targets: centrals, - capCount: TransportConfig.blePendingNotificationsCapCount - ) - } + dispatchPrecondition(condition: .onQueue(bleQueue)) + let result = pendingNotifications.enqueue( + data: data, + targets: centrals, + capCount: TransportConfig.blePendingNotificationsCapCount + ) switch result { case let .enqueued(count): SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session) @@ -2381,7 +2249,7 @@ final class BLEService: NSObject { requireNoiseAuthenticatedPeerLink: Bool = false ) -> Bool { guard !isPanicSuspended else { return false } - let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) } + let ingressRecord = ingressLinks.record(for: packet) var excludedPeerLinks = links(to: ingressRecord?.peerID) if requireNoiseAuthenticatedPeerLink { guard let directedOnlyPeer else { return false } @@ -2517,7 +2385,7 @@ final class BLEService: NSObject { // MARK: - Directed store-and-forward private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) { let msgID = BLEOutboundPacketPolicy.messageID(for: packet) - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } if self.pendingDirectedRelays.enqueue( packet: packet, @@ -2532,16 +2400,18 @@ final class BLEService: NSObject { private func flushDirectedSpool() { guard !isPanicSuspended else { return } - // Move items out and attempt broadcast; if still no links, they'll be re-spooled - let toSend = collectionsQueue.sync(flags: .barrier) { - pendingDirectedRelays.drainUnexpired( + // Runs from bleQueue maintenance: hop to the engine asynchronously + // (bleQueue must never sync-wait on the engine). Move items out and + // attempt broadcast; if still no links, they'll be re-spooled. + messageQueue.async { [weak self] in + guard let self, !self.isPanicSuspended else { return } + let toSend = self.pendingDirectedRelays.drainUnexpired( now: Date(), window: TransportConfig.bleDirectedSpoolWindowSeconds ) - } - guard !toSend.isEmpty else { return } - for entry in toSend { - messageQueue.async { [weak self] in self?.broadcastPacket(entry.packet) } + for entry in toSend { + self.broadcastPacket(entry.packet) + } } } @@ -2624,7 +2494,7 @@ final class BLEService: NSObject { let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty else { return nil } let senderPeerID = PeerID(hexData: packet.senderID) - let peers = collectionsQueue.sync { peerRegistry.snapshotByID } + let peers = peerRegistry.snapshotByID // Archived senders are usually long gone, so the signature-derived // identity is the best shot at a name; a live registry entry is // next; anonymous fallback matches the live path. @@ -2663,7 +2533,7 @@ final class BLEService: NSObject { }, peersSnapshot: { [weak self] in guard let self = self else { return [:] } - return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } + return self.peerRegistry.snapshotByID }, verifyPacketSignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false @@ -2709,7 +2579,7 @@ final class BLEService: NSObject { // queued barrier must still observe the path as pending. If // insertion wins first, the next MainActor snapshot sees the // new bubble and protects the path explicitly. - self?.messageQueue.async(flags: .barrier) { + self?.messageQueue.async { self?.incomingFileStore.finishIncomingFileDelivery( at: storedURL ) @@ -2718,7 +2588,7 @@ final class BLEService: NSObject { isPrivateMediaSenderBlocked: { [weak self] peerID in guard let self else { return false } let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID) - ?? self.collectionsQueue.sync { + ?? onEngine { self.peerRegistry.info(for: peerID)?.noisePublicKey } guard let senderStaticKey else { return false } @@ -2797,7 +2667,7 @@ final class BLEService: NSObject { // initiating a handshake broadcast turns one undeliverable ack // into a mesh-wide flood. The queued ack flushes whenever a // session eventually establishes. - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID) } if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) { @@ -2812,7 +2682,7 @@ final class BLEService: NSObject { /// keeps delayed/relayed leaves verifiable after the live registry entry /// has aged out. private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { - let registrySigningKey = collectionsQueue.sync { + let registrySigningKey = onEngine { peerRegistry.info(for: peerID)?.signingPublicKey } let verifiedViaRegistry = registrySigningKey.map { @@ -2846,10 +2716,8 @@ final class BLEService: NSObject { noiseReconnectPolicy.endLinkEpoch(link) } } - _ = collectionsQueue.sync(flags: .barrier) { - // Remove the peer when they leave - peerRegistry.remove(peerID) - } + // Remove the peer when they leave + peerRegistry.mutate { _ = $0.remove(peerID) } // Remove any stored announcement for sync purposes gossipSyncManager?.removeAnnouncementForPeer(peerID) // Send on main thread @@ -2857,7 +2725,7 @@ final class BLEService: NSObject { guard let self = self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs self.deliverTransportEvent(.peerDisconnected(peerID)) self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) @@ -2870,7 +2738,7 @@ final class BLEService: NSObject { // related state snapshots. Serialize the whole operation with identity // rotation instead of letting CoreBluetooth and maintenance callbacks // execute it directly on their own queues. - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.sendAnnounceNow(forceSend: forceSend) } } @@ -2890,15 +2758,10 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync { - ( - peerRegistry.connectedRoutingData, - PeerCapabilities.localSupported.union(runtimeCapabilities), - runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil - ) - } - + let connectedPeerIDs = peerRegistry.connectedRoutingData let localIdentity = localIdentityState.snapshot() + let advertisedCapabilities = localIdentity.advertisedCapabilities + let advertisedBridgeCell = localIdentity.advertisedBridgeGeohash let announcement = AnnouncementPacket( nickname: localIdentity.nickname, noisePublicKey: noisePub, @@ -3070,7 +2933,7 @@ extension BLEService: GossipSyncManager.Delegate { } func getConnectedPeers() -> [PeerID] { - return collectionsQueue.sync { + return onEngine { peerRegistry.connectedPeerIDs } } @@ -3171,9 +3034,7 @@ extension BLEService: CBCentralManagerDelegate { let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID) for state in peripheralStates { let peripheralID = state.peripheral.identifier.uuidString - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: peripheralID) - } + pendingPeripheralWrites.discardAll(for: peripheralID) noiseAuthenticatedLinkOwners.removeValue( forKey: .peripheral(peripheralID) ) @@ -3333,9 +3194,7 @@ extension BLEService: CBCentralManagerDelegate { #endif // Clean up references and peer mappings - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: peripheralID) - } + pendingPeripheralWrites.discardAll(for: peripheralID) noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = linkStateStore.removePeripheral(peripheralID) @@ -3351,9 +3210,7 @@ extension BLEService: CBCentralManagerDelegate { let peerStillLinked = (remainingLinks?.hasPeripheral ?? false) || (remainingLinks?.hasCentral ?? false) if let peerID, !peerStillLinked { // Do not remove peer; mark as not connected but retain for reachability - collectionsQueue.sync(flags: .barrier) { - peerRegistry.markDisconnected(peerID) - } + peerRegistry.mutate { $0.markDisconnected(peerID) } refreshLocalTopology() } @@ -3374,7 +3231,7 @@ extension BLEService: CBCentralManagerDelegate { guard let self = self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs if let peerID, !peerStillLinked { self.notifyPeerDisconnectedDebounced(peerID) @@ -3388,13 +3245,11 @@ extension BLEService: CBCentralManagerDelegate { let peripheralID = peripheral.identifier.uuidString // Clean up the references - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: peripheralID) - } + pendingPeripheralWrites.discardAll(for: peripheralID) noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = linkStateStore.removePeripheral(peripheralID) - + SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) connectionScheduler.recordConnectionFailure(peripheralID: peripheralID) // Try next candidate @@ -3496,9 +3351,7 @@ extension BLEService { SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) central.cancelPeripheralConnection(peripheral) - self.collectionsQueue.sync(flags: .barrier) { - self.pendingPeripheralWrites.discardAll(for: peripheralID) - } + self.pendingPeripheralWrites.discardAll(for: peripheralID) self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = self.linkStateStore.removePeripheral(peripheralID) @@ -3567,15 +3420,15 @@ extension BLEService { if preseedPeer { // Ensure the synthetic peer is known and marked verified for public-message tests let normalizedID = PeerID(hexData: packet.senderID) - collectionsQueue.sync(flags: .barrier) { - if var existing = peerRegistry.info(for: normalizedID) { + peerRegistry.mutate { registry in + if var existing = registry.info(for: normalizedID) { existing.isConnected = true existing.isVerifiedNickname = true if let signingPublicKey { existing.signingPublicKey = signingPublicKey } existing.lastSeen = Date() - peerRegistry.upsert(existing) + registry.upsert(existing) } else { - peerRegistry.upsert(BLEPeerInfo( + registry.upsert(BLEPeerInfo( peerID: normalizedID, nickname: "TestPeer_\(fromPeerID.id.prefix(4))", isConnected: true, @@ -3596,7 +3449,7 @@ extension BLEService { /// avoiding wall-clock sleeps that become flaky under a parallel suite. func _test_drainFragmentPipeline() async { await withCheckedContinuation { continuation in - messageQueue.async(flags: .barrier) { + messageQueue.async { // Reassembled packets are reinjected synchronously on // `messageQueue`; their UI delivery task is therefore already // enqueued before this later MainActor marker. @@ -3656,8 +3509,8 @@ extension BLEService { capabilities: PeerCapabilities? = nil, noisePublicKey: Data? = nil ) { - collectionsQueue.sync(flags: .barrier) { - peerRegistry.upsert(BLEPeerInfo( + peerRegistry.mutate { + $0.upsert(BLEPeerInfo( peerID: peerID, nickname: nickname, isConnected: true, @@ -3686,7 +3539,7 @@ extension BLEService { messageID: String, for peerID: PeerID ) { - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendPrivateMessage( content: content, messageID: messageID, @@ -3701,7 +3554,7 @@ extension BLEService { for peerID: PeerID ) { guard privateMediaTransferAdmissions.begin(transferId) == .admitted else { return } - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendTypedPayload( payload, transferId: transferId, @@ -3715,31 +3568,12 @@ extension BLEService { } func _test_hasPendingPrivateMediaPolicyResolution(for peerID: PeerID) -> Bool { - collectionsQueue.sync { - pendingPrivateMediaPolicyResolutions[peerID.toShort()] != nil - } + privateMediaSessions.hasPendingPolicyResolution(for: peerID.toShort()) } func _test_forcePrivateMediaProofTimeout(for peerID: PeerID) { let normalizedPeerID = peerID.toShort() - let target = collectionsQueue.sync { - () -> (fingerprint: String, generation: UUID?, nonce: UUID)? in - if let watchdog = privateMediaProofWatchdogs[normalizedPeerID] { - return ( - watchdog.fingerprint, - watchdog.sessionGeneration, - watchdog.timeoutNonce - ) - } - if let pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] { - return ( - pending.fingerprint, - pending.sessionGeneration, - pending.timeoutNonce - ) - } - return nil - } + let target = privateMediaSessions.proofTimeoutTarget(for: normalizedPeerID) guard let target else { return } handlePrivateMediaProofTimeout( for: normalizedPeerID, @@ -3752,7 +3586,7 @@ extension BLEService { func _test_privateMediaTransferState( transferId: String ) -> (admissionActive: Bool, pendingNoise: Bool, activeScheduler: Int, pendingScheduler: Int) { - let scheduler = collectionsQueue.sync { + let scheduler = onEngine { ( pendingNoiseSessionQueues.containsTypedPayload(transferId: transferId), outboundFragmentTransfers.activeCount, @@ -3785,10 +3619,9 @@ extension BLEService { } func _test_drainPrivateMediaSendPipeline() async { - let collectionsQueue = self.collectionsQueue await withCheckedContinuation { continuation in - messageQueue.async { - collectionsQueue.async(flags: .barrier) { + self.messageQueue.async { [weak self] in + self?.messageQueue.async { continuation.resume() } } @@ -3807,10 +3640,9 @@ extension BLEService { } func _test_drainNoiseMessagePipeline() async { - let collectionsQueue = self.collectionsQueue await withCheckedContinuation { continuation in - messageQueue.async(flags: .barrier) { - collectionsQueue.async(flags: .barrier) { + self.messageQueue.async { + self.messageQueue.async { continuation.resume() } } @@ -3821,7 +3653,7 @@ extension BLEService { /// this to prove same-generation reconciliation is idempotent. func _test_reconcileCurrentNoiseSession(for peerID: PeerID) { let normalizedPeerID = peerID.toShort() - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self, let generation = self.noiseService.sessionGeneration( for: normalizedPeerID @@ -3927,7 +3759,7 @@ extension BLEService: CBPeripheralDelegate { SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session) // Send announce after subscription is confirmed (force send for new connection) - messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in + engineScheduler.schedule(after: TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) // Try flushing any spooled directed packets now that we have a link self?.flushDirectedSpool() @@ -4147,10 +3979,8 @@ extension BLEService: CBPeripheralManagerDelegate { ) noiseReconnectPolicy.endLinkEpoch(.central(centralID)) } - collectionsQueue.sync(flags: .barrier) { - pendingNotifications.removeAll() - pendingWriteBuffers.removeAll() - } + pendingNotifications.removeAll() + pendingWriteBuffers.removeAll() let centralPeerIDs = linkStateStore.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil @@ -4255,14 +4085,14 @@ extension BLEService: CBPeripheralManagerDelegate { } // Still flush directed packets for legitimate mesh operation - messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in + engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in self?.flushDirectedSpool() } return } // Send announce to the newly subscribed central after a small delay - messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in + engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) // Flush any spooled directed packets now that we have a central subscribed self?.flushDirectedSpool() @@ -4272,9 +4102,7 @@ extension BLEService: CBPeripheralManagerDelegate { func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { let centralID = central.identifier.uuidString SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session) - collectionsQueue.sync(flags: .barrier) { - pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } - } + pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID)) noiseReconnectPolicy.endLinkEpoch(.central(centralID)) let removedPeerID = linkStateStore.removeSubscribedCentral(central) @@ -4295,9 +4123,7 @@ extension BLEService: CBPeripheralManagerDelegate { // the bookkeeping. guard linkStateStore.links(to: peerID).isEmpty else { return } // Mark peer as not connected; retain for reachability - collectionsQueue.sync(flags: .barrier) { - peerRegistry.markDisconnected(peerID) - } + peerRegistry.mutate { $0.markDisconnected(peerID) } refreshLocalTopology() @@ -4306,7 +4132,7 @@ extension BLEService: CBPeripheralManagerDelegate { guard let self = self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs self.notifyPeerDisconnectedDebounced(peerID) // Publish snapshots so UnifiedPeerService can refresh icons promptly @@ -4330,7 +4156,7 @@ extension BLEService: CBPeripheralManagerDelegate { } private func drainPendingNotifications(logPrefix: String) { - collectionsQueue.async(flags: .barrier) { [weak self] in + bleQueue.async { [weak self] in guard let self = self, let characteristic = self.characteristic, !self.pendingNotifications.isEmpty else { return } @@ -4493,7 +4319,7 @@ extension BLEService: PrivateMediaDeletionPersisting { completion: @escaping @MainActor (Bool) -> Void ) { let fileStore = incomingFileStore - messageQueue.async(flags: .barrier) { + messageQueue.async { guard let reservation = fileStore .reservePrivateMediaDeletion( messageIDs: messageIDs, @@ -4521,7 +4347,7 @@ extension BLEService: PrivateMediaDeletionPersisting { @MainActor func removeLegacyPrivateMediaPayload(relativePath: String) { let fileStore = incomingFileStore - messageQueue.async(flags: .barrier) { + messageQueue.async { fileStore.removeLegacyIncomingFile(relativePath: relativePath) } } @@ -4716,10 +4542,10 @@ extension BLEService { let peripheralState = peripheralManager?.state ?? .unknown let isAdvertising = peripheralManager?.isAdvertising ?? false - let peerSummary = collectionsQueue.sync { + let peerSummary = peerRegistry.read { ( - connected: peerRegistry.connectedCount, - known: peerRegistry.count, + connected: $0.connectedCount, + known: $0.count, candidates: connectionScheduler.candidateCount ) } @@ -4756,10 +4582,7 @@ extension BLEService { } private func refreshLocalTopology() { - let neighbors: [Data] = collectionsQueue.sync { - peerRegistry.connectedRoutingData - } - meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors) + meshTopology.updateNeighbors(for: myPeerIDData, neighbors: peerRegistry.connectedRoutingData) } private func computeRoute(to peerID: PeerID) -> [Data]? { @@ -4781,7 +4604,7 @@ extension BLEService { localPeerIDData: myPeerIDData, isRecipientConnected: { self.isPeerConnected($0) }, shouldAttemptRoute: { peer in - self.collectionsQueue.sync(flags: .barrier) { + onEngine { self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now) } }, @@ -4805,7 +4628,7 @@ 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) { + onEngine { sourceRouteFailures.noteRoutedSend(to: recipient, now: now) } return signedPacket @@ -4853,8 +4676,8 @@ extension BLEService { ) let timeout = DispatchWorkItem { [weak self] in guard let self else { return } - let expired = self.collectionsQueue.sync(flags: .barrier) { - self.pendingMeshPings.removeValue(forKey: nonce) + let expired = onEngine { + self.meshPings.expire(nonce: nonce) } guard let expired else { return } self.notifyUI { [weak self] in @@ -4867,17 +4690,20 @@ extension BLEService { expired.completion(nil) } } - self.collectionsQueue.sync(flags: .barrier) { - self.pendingMeshPings[nonce] = PendingMeshPing( - peerID: PeerID(hexData: recipientData), - sentAt: Date(), - lifecycleGeneration: generation, - completion: completion, - timeout: timeout + onEngine { + self.meshPings.register( + BLEMeshPingProbe( + peerID: PeerID(hexData: recipientData), + sentAt: Date(), + lifecycleGeneration: generation, + completion: completion, + timeout: timeout + ), + nonce: nonce ) } - self.messageQueue.asyncAfter( - deadline: .now() + TransportConfig.meshPingTimeoutSeconds, + self.engineScheduler.schedule( + after: TransportConfig.meshPingTimeoutSeconds, execute: timeout ) self.broadcastPacket(packet) @@ -4899,8 +4725,8 @@ extension BLEService { SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session) return } - let allowed = collectionsQueue.sync(flags: .barrier) { - meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date()) + let allowed = onEngine { + meshPings.shouldRespond(toLink: linkPeerID, now: Date()) } guard allowed else { if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") { @@ -4927,9 +4753,8 @@ extension BLEService { private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) { guard packet.recipientID == myPeerIDData else { return } guard let pong = MeshPingPayload.decode(packet.payload) else { return } - let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in - guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil } - return pendingMeshPings.removeValue(forKey: pong.nonce) + let pending = onEngine { + meshPings.resolve(nonce: pong.nonce, from: peerID) } guard let pending else { return } pending.timeout.cancel() @@ -5026,7 +4851,7 @@ extension BLEService { /// handshake. An old session keyed only by peer ID is insufficient: a /// replayed announce can rebind an attacker's link to that ID. private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) { - guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return } + guard let link = ingressLinks.link(for: packet) else { return } readLinkState { store in guard boundPeerID(for: link, in: store) == peerID else { return } noiseAuthenticatedLinkOwners[link] = peerID @@ -5034,7 +4859,7 @@ extension BLEService { } private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool { - guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false } + guard let link = ingressLinks.link(for: packet) else { return false } return readLinkState { store in noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID } @@ -5055,14 +4880,14 @@ extension BLEService { /// A peer-level session can outlive the physical link that established it. /// Revalidate a fresh direct link with an ordinary XX exchange, retiring /// cached sending keys atomically before message 1 can leave. + /// + /// Takes the already-resolved ingress link: both callers run inside the + /// rebind's bleQueue critical section, which must never sync-wait on the + /// engine (the engine sync-waits on bleQueue via `readLinkState`). private func refreshNoiseSessionForVerifiedDirectLink( - _ packet: BitchatPacket, + link: BLEIngressLinkID, peerID: PeerID ) { - guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { - return - } - let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID) let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID) let shouldRevalidate = readLinkState { store in @@ -5092,7 +4917,7 @@ extension BLEService { // Authentication can be reported while an initiator is still // returning XX message 3. Serialize generation-bound state and // every post-handshake drain behind the handshake packet handler. - self?.messageQueue.async(flags: .barrier) { [weak self] in + self?.messageQueue.async { [weak self] in self?.handleNoisePeerAuthenticated( peerID: peerID, fingerprint: fingerprint, @@ -5102,7 +4927,7 @@ extension BLEService { } service.onRekeyHandshakeReady = { [weak self, weak service] peerID, initiation in - self?.messageQueue.async(flags: .barrier) { + self?.messageQueue.async { [weak self, weak service] in guard let self, let service, @@ -5125,7 +4950,7 @@ extension BLEService { #if DEBUG self._test_beforeHandshakeRecoveryEnqueued?(request.peerID) #endif - self.messageQueue.async(flags: .barrier) { + self.messageQueue.async { [weak self, weak service] in guard let self, let service, @@ -5172,7 +4997,7 @@ extension BLEService { guard let self, let service else { return } // The manager makes restored keys visible atomically. Reconcile // transport state and queued sends as the next serialized phase. - self.messageQueue.async(flags: .barrier) { [weak self, weak service] in + self.messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service, @@ -5208,47 +5033,30 @@ extension BLEService { sessionGeneration generation: UUID, deferOutboundUntilConvergence: Bool = false ) { + // Engine-only: the store transition below runs inside the noise + // manager's critical section while this engine slot stays blocked. + // The store is a leaf lock, so that nesting is safe — but nothing in + // that closure may sync-re-enter the engine (self-deadlock). + #if DEBUG + dispatchPrecondition(condition: .onQueue(messageQueue)) + #endif let normalizedPeerID = peerID.toShort() - guard let transition = noiseService.withCurrentSessionGeneration( + // The generation lease serializes this transition against session + // replacement; nil-inside-nil distinguishes a lost lease (outer) + // from the same-generation reconciliation path (inner). + guard let leased = noiseService.withCurrentSessionGeneration( for: normalizedPeerID, expected: generation, { - collectionsQueue.sync(flags: .barrier) { - () -> ( - watchdog: (fingerprint: String, nonce: UUID)?, - rejected: [@MainActor (PrivateMediaSendPolicy) -> Void] - ) in - guard privateMediaSessionGenerations[normalizedPeerID] != generation else { - return (nil, []) - } - let watchdogNonce = UUID() - privateMediaSessionGenerations[normalizedPeerID] = generation - authenticatedPeerStates.removeValue(forKey: normalizedPeerID) - privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) - privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog( - fingerprint: fingerprint, - sessionGeneration: generation, - timeoutNonce: watchdogNonce - ) - authenticatedPeerStateSendProgress[normalizedPeerID] = - BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) - - guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { - return ((fingerprint, watchdogNonce), []) - } - guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else { - pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID) - return ((fingerprint, watchdogNonce), Array(pending.completions.values)) - } - pending.sessionGeneration = generation - pending.timeoutNonce = watchdogNonce - pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending - return ((pending.fingerprint, watchdogNonce), []) - } + privateMediaSessions.beginAuthenticatedGeneration( + for: normalizedPeerID, + fingerprint: fingerprint, + generation: generation + ) } ) else { return } - guard let watchdog = transition.watchdog else { + guard let fresh = leased else { // A quarantined transport restored the same cryptographic // generation. Its capability proof and announce state never // became stale; only work queued while outbound keys were paused @@ -5266,20 +5074,23 @@ extension BLEService { // The restore's mandatory convergence retry — or any later // handshake the reconnect policy initiates — re-enters this // transition with a fresh generation and drains them under - // keys both sides hold. + // keys both sides hold. The flag also holds the proof + // watchdog's drain to the same rule. + privateMediaSessions.setOutboundDeferredUntilConvergence(normalizedPeerID) return } + privateMediaSessions.clearOutboundDeferredUntilConvergence(normalizedPeerID) sendPendingMessagesAfterHandshake(for: normalizedPeerID) sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) return } - completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade) + completePrivateMediaPolicyResolution(fresh.rejected, with: .blockedDowngrade) schedulePrivateMediaProofTimeout( for: normalizedPeerID, - fingerprint: watchdog.fingerprint, + fingerprint: fingerprint, sessionGeneration: generation, - nonce: watchdog.nonce + nonce: fresh.watchdogNonce ) // Cross-link delivery can put ciphertext sent immediately after // message 3 ahead of message 3 itself. Retry the bounded queue only @@ -5295,11 +5106,15 @@ extension BLEService { // mandatory convergence retry — or any later handshake the // reconnect policy initiates — re-enters this transition with a // fresh generation and drains them under keys both sides hold. + // The flag also holds the proof watchdog's drain to the same + // rule — its timeout can fire while this restore is current. + privateMediaSessions.setOutboundDeferredUntilConvergence(normalizedPeerID) #if DEBUG _test_onPrivateMediaSessionReconciled?(normalizedPeerID) #endif return } + privateMediaSessions.clearOutboundDeferredUntilConvergence(normalizedPeerID) // `onPeerAuthenticated` can fire while the initiator is returning XX // message 3. This callback is queued behind the handshake handler, so @@ -5316,25 +5131,9 @@ extension BLEService { private func sendAuthenticatedPeerState(to peerID: PeerID, echo: Bool) { let normalizedPeerID = peerID.toShort() - let shouldSend = collectionsQueue.sync(flags: .barrier) { - guard let generation = privateMediaSessionGenerations[normalizedPeerID], - var progress = authenticatedPeerStateSendProgress[normalizedPeerID], - progress.sessionGeneration == generation else { return false } - if echo { - guard !progress.sentEcho else { return false } - progress.sentEcho = true - } else { - guard !progress.sentInitial else { return false } - progress.sentInitial = true - } - authenticatedPeerStateSendProgress[normalizedPeerID] = progress - return true - } - guard shouldSend else { return } + guard privateMediaSessions.markPeerStateSend(for: normalizedPeerID, echo: echo) else { return } - let capabilities = collectionsQueue.sync { - PeerCapabilities.localSupported.union(runtimeCapabilities) - } + let capabilities = localIdentityState.snapshot().advertisedCapabilities let state = AuthenticatedPeerStatePacket( capabilities: capabilities, signingPublicKey: noiseService.getSigningPublicKeyData() @@ -5351,6 +5150,13 @@ extension BLEService { from peerID: PeerID, sessionGeneration generation: UUID ) { + // Engine-only, like handleNoisePeerAuthenticated: the closure below + // runs on the noise manager's queue while this engine slot stays + // blocked, so it accesses engine-owned state directly instead of + // sync-re-entering the engine (self-deadlock). + #if DEBUG + dispatchPrecondition(condition: .onQueue(messageQueue)) + #endif let normalizedPeerID = peerID.toShort() guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else { SecureLogger.warning( @@ -5373,14 +5179,13 @@ extension BLEService { expected: generation, { () -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in - guard collectionsQueue.sync(execute: { - privateMediaSessionGenerations[normalizedPeerID] == generation - }) else { + guard privateMediaSessions.currentGeneration(for: normalizedPeerID) == generation else { return (false, []) } - // The generation lease prevents rekey/session promotion from - // interleaving between validation and these durable mutations. + // The generation lease (plus the engine slot this section + // holds) prevents rekey/session promotion from interleaving + // between validation and these durable mutations. identityManager.bindAuthenticatedSigningPublicKey( state.signingPublicKey, fingerprint: fingerprint @@ -5395,29 +5200,19 @@ extension BLEService { identityManager.markPrivateMediaCapable(fingerprint: fingerprint) } - let completions = collectionsQueue.sync(flags: .barrier) { - () -> [@MainActor (PrivateMediaSendPolicy) -> Void] in - guard privateMediaSessionGenerations[normalizedPeerID] == generation else { - return [] - } - peerRegistry.bindAuthenticatedSigningPublicKey( + peerRegistry.mutate { + $0.bindAuthenticatedSigningPublicKey( state.signingPublicKey, for: normalizedPeerID ) - authenticatedPeerStates[normalizedPeerID] = BLEAuthenticatedPeerStateObservation( - fingerprint: fingerprint, - sessionGeneration: generation, - capabilities: state.capabilities - ) - privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) - privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) - guard let pending = pendingPrivateMediaPolicyResolutions.removeValue( - forKey: normalizedPeerID - ), pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, - pending.sessionGeneration == generation else { - return [] - } - return Array(pending.completions.values) + } + guard let completions = privateMediaSessions.applyAuthenticatedPeerState( + for: normalizedPeerID, + fingerprint: fingerprint, + generation: generation, + capabilities: state.capabilities + ) else { + return (false, []) } return (true, completions) } @@ -5433,22 +5228,7 @@ extension BLEService { private func noteNoiseSessionCleared(for peerID: PeerID) { let normalizedPeerID = peerID.toShort() - let reset = collectionsQueue.sync(flags: .barrier) { - () -> (fingerprint: String, nonce: UUID)? in - privateMediaSessionGenerations.removeValue(forKey: normalizedPeerID) - authenticatedPeerStates.removeValue(forKey: normalizedPeerID) - privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) - privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) - authenticatedPeerStateSendProgress.removeValue(forKey: normalizedPeerID) - guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { - return nil - } - let nonce = UUID() - pending.sessionGeneration = nil - pending.timeoutNonce = nonce - pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending - return (pending.fingerprint, nonce) - } + let reset = privateMediaSessions.clearSession(for: normalizedPeerID) if let reset { schedulePrivateMediaProofTimeout( for: normalizedPeerID, @@ -5472,17 +5252,12 @@ extension BLEService { /// `messageQueue`; the re-entrancy check keeps any future on-queue caller /// from deadlocking. private func refreshPeerIdentity() { - let swap = { - let fingerprint = self.noiseService.getIdentityFingerprint() - self.localIdentityState.replacePeerIdentity( + onEngine { + let fingerprint = noiseService.getIdentityFingerprint() + localIdentityState.replacePeerIdentity( with: PeerID(str: fingerprint.prefix(16)) ) - self.meshTopology.reset() - } - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - swap() - } else { - messageQueue.sync(flags: .barrier, execute: swap) + meshTopology.reset() } } @@ -5502,7 +5277,7 @@ extension BLEService { // No established session yet - queue the payload synchronously // before initiating a handshake // to prevent race where fast handshake completion drains empty queue - collectionsQueue.sync(flags: .barrier) { + onEngine { self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID) SecureLogger.debug("📥 Queued noise payload for \(peerID.id.prefix(8))… pending handshake", category: .session) } @@ -5524,21 +5299,10 @@ extension BLEService { let encrypted: Data let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) if isPrivateFile { - let provenGeneration: UUID? = collectionsQueue.sync { - () -> UUID? in - guard let generation = privateMediaSessionGenerations[peerID], - let authenticated = authenticatedPeerStates[peerID], - authenticated.sessionGeneration == generation, - authenticated.capabilities.contains(.privateMedia) else { return nil } - if requiresAuthenticatedPrivateMediaReceipts { - guard authenticated.capabilities.contains( - .privateMediaReceipts - ) else { - return nil - } - } - return generation - } + let provenGeneration = privateMediaSessions.provenGeneration( + for: peerID, + requireReceipts: requiresAuthenticatedPrivateMediaReceipts + ) guard let provenGeneration else { throw NoiseEncryptionError.sessionNotEstablished } @@ -5681,18 +5445,14 @@ extension BLEService { guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false } guard let payload = envelope.encode() else { return false } let packet = makeCourierPacket(payload, to: peerID) - let send = { [weak self] in - self?.sendPacketDirected( + return onEngine { + sendPacketDirected( packet, to: peerID, requireDirectPeerLink: true, requireNoiseAuthenticatedPeerLink: true - ) ?? false + ) } - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - return send() - } - return messageQueue.sync(execute: send) } /// Our own Noise static public key (for computing our courier tags). @@ -5704,7 +5464,7 @@ extension BLEService { /// gateway watches courier drops for. func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] { let now = Date() - return collectionsQueue.sync { + return onEngine { peerRegistry.snapshotByID.values.compactMap { info in guard info.isVerifiedNickname, let key = info.noisePublicKey, @@ -5723,7 +5483,7 @@ extension BLEService { /// message consumes exactly one prekey ID regardless of courier count. private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { let shortID = PeerID(publicKey: recipientNoiseKey) - let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } + let knownOnMesh = peerRegistry.info(for: shortID) != nil if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) { return nil } @@ -5801,7 +5561,7 @@ extension BLEService { // so dedup here on the inner message ID — before delivery, ack, // and handshake work. A duplicate costs only the decrypt above // and at most one ack ever goes out per message ID. - let firstOpen = collectionsQueue.sync(flags: .barrier) { + let firstOpen = onEngine { openedCourierMessageIDs.insert(innerMessageID) } guard firstOpen else { @@ -5821,7 +5581,7 @@ extension BLEService { // favorite conversation instead of an unresolvable short-ID // thread labeled "Unknown". let shortID = PeerID(publicKey: senderStaticKey) - let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } + let isKnownOnMesh = peerRegistry.info(for: shortID) != nil let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey) SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session) sfMetrics?.record(.courierOpened) @@ -5851,7 +5611,7 @@ extension BLEService { SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security) return } - let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) } + let depositorInfo = peerRegistry.info(for: peerID) guard let depositorKey = depositorInfo?.noisePublicKey else { SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session) return @@ -5961,7 +5721,7 @@ extension BLEService { /// Forced sends (bundle changed after consumption) go immediately. private func sendPrekeyBundle(force: Bool = false) { let now = Date() - let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) { + let shouldSend: Bool = onEngine { if !force, let last = lastPrekeyBundleSentAt, now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds { @@ -6029,7 +5789,7 @@ extension BLEService { // ahead of the announce that binds the key. Reading the live registry // and stashing atomically closes the check-then-act gap against // handleAnnounce's drain (see drainPendingPrekeyBundles). - let signingKey: Data? = collectionsQueue.sync(flags: .barrier) { + let signingKey: Data? = onEngine { if let info = peerRegistry.info(for: owner), info.noisePublicKey == bundle.noiseStaticPublicKey, let key = info.signingPublicKey { @@ -6074,7 +5834,7 @@ extension BLEService { /// announce, in a barrier ordered after the registry write, so a bundle /// stashed before the write is always observed here. private func drainPendingPrekeyBundles(for owner: PeerID) { - let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) { + let pending: BitchatPacket? = onEngine { pendingPrekeyBundles.removeValue(forKey: owner) } guard let packet = pending, @@ -6088,7 +5848,7 @@ extension BLEService { /// from identities persisted for offline verification. private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? { let shortID = PeerID(publicKey: noiseKey) - if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }), + if let info = peerRegistry.info(for: shortID), info.noisePublicKey == noiseKey, let signingKey = info.signingPublicKey { return signingKey @@ -6161,7 +5921,7 @@ extension BLEService { // packet signature must verify against the sender's announced // signing key. Unlike courier deposits the depositor may be // multi-hop away, so ingress-link identity is not required. - let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey } + let signingKey = peerRegistry.info(for: senderID)?.signingPublicKey guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security) @@ -6209,22 +5969,20 @@ extension BLEService { if peripheral.canSendWriteWithoutResponse { peripheral.writeValue(data, for: characteristic, type: .withoutResponse) } else { - self.collectionsQueue.async(flags: .barrier) { - let result = self.pendingPeripheralWrites.enqueue( - data: data, - for: uuid, - priority: priority, - capBytes: TransportConfig.blePendingWriteBufferCapBytes - ) + let result = self.pendingPeripheralWrites.enqueue( + data: data, + for: uuid, + priority: priority, + capBytes: TransportConfig.blePendingWriteBufferCapBytes + ) - switch result { - case .oversized(let bytes): - SecureLogger.warning("⚠️ Dropping oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session) - case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0: - SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session) - case .enqueued: - break - } + switch result { + case .oversized(let bytes): + SecureLogger.warning("⚠️ Dropping oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session) + case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0: + SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session) + case .enqueued: + break } } } @@ -6261,14 +6019,12 @@ extension BLEService { return true } - let attempt = collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.enqueueReportingAcceptance( - data: data, - for: uuid, - priority: priority, - capBytes: TransportConfig.blePendingWriteBufferCapBytes - ) - } + let attempt = pendingPeripheralWrites.enqueueReportingAcceptance( + data: data, + for: uuid, + priority: priority, + capBytes: TransportConfig.blePendingWriteBufferCapBytes + ) switch attempt.result { case .oversized(let bytes): SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session) @@ -6293,11 +6049,7 @@ extension BLEService { guard !self.isPanicSuspended else { return } guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return } - // Atomically take all pending items from the queue to avoid race conditions - // where new items could be enqueued between read and update - let itemsToSend: [BLEPendingWrite] = self.collectionsQueue.sync(flags: .barrier) { - self.pendingPeripheralWrites.takeAll(for: uuid) - } + let itemsToSend = self.pendingPeripheralWrites.takeAll(for: uuid) guard !itemsToSend.isEmpty else { return } // Send as many as possible @@ -6314,9 +6066,7 @@ extension BLEService { // Re-enqueue any items that couldn't be sent (maintaining order) let unsent = Array(itemsToSend.dropFirst(sent)) if !unsent.isEmpty { - self.collectionsQueue.async(flags: .barrier) { - self.pendingPeripheralWrites.prepend(unsent, for: uuid) - } + self.pendingPeripheralWrites.prepend(unsent, for: uuid) } } } @@ -6328,7 +6078,7 @@ extension BLEService { /// Periodically try to drain pending writes for all connected peripherals private func drainAllPendingWrites() { - let uuids = collectionsQueue.sync { pendingPeripheralWrites.peripheralIDs } + let uuids = pendingPeripheralWrites.peripheralIDs for uuid in uuids { guard let state = linkStateStore.state(forPeripheralID: uuid), state.isConnected else { continue } drainPendingWrites(for: state.peripheral) @@ -6437,9 +6187,7 @@ extension BLEService { guard age > TransportConfig.bleConnectTimeoutSeconds else { continue } let peripheralID = state.peripheral.identifier.uuidString central.cancelPeripheralConnection(state.peripheral) - self.collectionsQueue.sync(flags: .barrier) { - self.pendingPeripheralWrites.discardAll(for: peripheralID) - } + self.pendingPeripheralWrites.discardAll(for: peripheralID) self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = self.linkStateStore.removePeripheral(peripheralID) @@ -6493,7 +6241,7 @@ extension BLEService { SecureLogger.debug("🤝 No session with \(recipientID.id.prefix(8))…, initiating handshake and queueing message", category: .session) // Queue the message (especially important for favorite notifications) - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendPrivateMessage(content: content, messageID: messageID, for: recipientID) } @@ -6515,7 +6263,7 @@ extension BLEService { ) else { return } - messageQueue.async(flags: .barrier) { + messageQueue.async { [weak self, weak service] in guard let self, let service, @@ -6557,7 +6305,7 @@ extension BLEService { with: peerID, retryOnTimeout: true ) - messageQueue.async(flags: .barrier) { [weak self, weak service] in + messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service else { @@ -6584,7 +6332,7 @@ extension BLEService { private func sendPendingMessagesAfterHandshake(for peerID: PeerID) { // Atomically take all pending messages to process (prevents concurrent modification) - let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingPrivateMessage] in + let pendingMessages = onEngine { () -> [BLEPendingPrivateMessage] in pendingNoiseSessionQueues.takePrivateMessages(for: peerID) } @@ -6627,7 +6375,7 @@ extension BLEService { // Re-queue any failed messages for retry on next handshake if !failedMessages.isEmpty { - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } // Prepend failed messages to maintain order self.pendingNoiseSessionQueues.prependPrivateMessages(failedMessages, for: peerID) @@ -6659,12 +6407,12 @@ extension BLEService { requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink ) - let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = collectionsQueue.sync(flags: .barrier) { + let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = onEngine { if requiresPrivateMediaAdmission { guard let transferId else { return nil } - // This lock is taken while the scheduler is already protected - // by collectionsQueue. Cancellation takes the admission lock - // synchronously but never waits on collectionsQueue, avoiding + // This lock is taken while the scheduler is already + // engine-confined. Cancellation takes the admission lock + // synchronously but never waits on the engine, avoiding // lock inversion while giving submit/cancel one linear order. return privateMediaTransferAdmissions.withActive(transferId) { outboundFragmentTransfers.submit( @@ -6729,7 +6477,7 @@ extension BLEService { let releaseReservedSlot: (String) -> Void = { [weak self] id in guard let self = self else { return } TransferProgressManager.shared.cancel(id: id) - self.collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in _ = self?.outboundFragmentTransfers.releaseReservation(id) } self.messageQueue.async { [weak self] in @@ -6764,7 +6512,7 @@ extension BLEService { let transferIdentifier: String? if let id = reservedTransferId { - let activated = collectionsQueue.sync(flags: .barrier) { + let activated = onEngine { self.outboundFragmentTransfers.activateReservedTransfer( id: id, totalFragments: plan.totalFragments, @@ -6823,7 +6571,7 @@ extension BLEService { let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } if let transferId = transferIdentifier { - let isActive = self.collectionsQueue.sync { self.outboundFragmentTransfers.isActive(transferId) } + let isActive = onEngine { self.outboundFragmentTransfers.isActive(transferId) } guard isActive else { return } } if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true { @@ -6840,14 +6588,14 @@ extension BLEService { if let transferId = transferIdentifier { let workItems = scheduledItems.map { $0.item } - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in _ = self?.outboundFragmentTransfers.updateWorkItems(workItems, for: transferId) } } for (workItem, index) in scheduledItems { let delayMs = index * plan.spacingMs - messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem) + engineScheduler.schedule(after: Double(delayMs) / 1_000, execute: workItem) } return true } @@ -6855,7 +6603,7 @@ extension BLEService { // MARK: - Fragmentation (Required for messages > BLE MTU) private func markFragmentSent(transferId: String) { - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } switch self.outboundFragmentTransfers.markFragmentSent(transferId: transferId) { @@ -6875,7 +6623,7 @@ extension BLEService { } private func startNextPendingTransferIfNeeded() { - let results = collectionsQueue.sync(flags: .barrier) { + let results = onEngine { outboundFragmentTransfers.reservePendingStarts(maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers) } @@ -6890,7 +6638,7 @@ extension BLEService { if DispatchQueue.getSpecific(key: messageQueueKey) != nil { fragmentHandler.handle(packet, from: peerID) } else { - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.fragmentHandler.handle(packet, from: peerID) } } @@ -6910,7 +6658,7 @@ extension BLEService { guard let self = self else { return .stored(header: header, started: false) } - return self.collectionsQueue.sync(flags: .barrier) { + return onEngine { self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies) } }, @@ -6961,7 +6709,7 @@ extension BLEService { capturePanicLifecycleGeneration() else { return } - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self, self.isCurrentPanicLifecycleGeneration( lifecycleGeneration @@ -6996,7 +6744,7 @@ extension BLEService { // 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 + messageQueue.async { [weak self] in guard let self = self else { return } self.recentTrafficTracker.recordPacket(at: Date()) self.sourceRouteFailures.noteInboundActivity(from: senderID) @@ -7101,9 +6849,9 @@ extension BLEService { SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID.prefix(24))…", category: .session) } - let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } + let connectedCount = peerRegistry.connectedCount if BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: connectedCount) { - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.scheduledRelays.cancel(messageID: messageID) } } @@ -7112,7 +6860,7 @@ extension BLEService { } private func scheduleRelayIfNeeded(_ packet: BitchatPacket, senderID: PeerID, messageID: String) { - let degree = collectionsQueue.sync { peerRegistry.connectedCount } + let degree = peerRegistry.connectedCount let decision = BLEReceivePipeline.relayDecision( for: packet, senderID: senderID, @@ -7124,7 +6872,7 @@ extension BLEService { let work = DispatchWorkItem { [weak self] in guard let self = self else { return } - self.collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.scheduledRelays.remove(messageID: messageID) } var relayPacket = packet @@ -7132,10 +6880,10 @@ extension BLEService { self.broadcastPacket(relayPacket) } - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.scheduledRelays.schedule(work, messageID: messageID) } - messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work) + engineScheduler.schedule(after: Double(decision.delayMs) / 1_000, execute: work) } private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { @@ -7219,7 +6967,7 @@ extension BLEService { /// sender owns the link it arrived on, so rebind the link to the new ID /// and retire the old identity. private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) { - guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return } + guard let link = ingressLinks.link(for: packet) else { return } bleQueue.async { [weak self] in guard let self else { return } let linkUUID: String @@ -7235,7 +6983,7 @@ extension BLEService { guard let previousPeerID else { return } guard previousPeerID != peerID else { self.refreshNoiseSessionForVerifiedDirectLink( - packet, + link: link, peerID: peerID ) return @@ -7277,7 +7025,7 @@ extension BLEService { // section. No observer may see the new binding while a cached // peer-level sender is still considered established. self.refreshNoiseSessionForVerifiedDirectLink( - packet, + link: link, peerID: peerID ) SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session) @@ -7325,7 +7073,7 @@ extension BLEService { /// retirement per peer per cooldown window, and the peer keeps a live /// link either way. private func retireRedundantPeripheralLinks(_ packet: BitchatPacket, to peerID: PeerID) { - let ingressLink = collectionsQueue.sync { ingressLinks.link(for: packet) } + let ingressLink = ingressLinks.link(for: packet) bleQueue.async { [weak self] in guard let self else { return } let now = Date() @@ -7367,9 +7115,7 @@ extension BLEService { ) for uuid in retiring { guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue } - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: uuid) - } + pendingPeripheralWrites.discardAll(for: uuid) noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid)) noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid)) _ = linkStateStore.removePeripheral(uuid) @@ -7400,15 +7146,13 @@ extension BLEService { /// link. The `.peerConnected` UI event already fired from the announce /// path (new/reconnected + direct), so only list state needs refreshing. private func promoteReboundPeerToConnected(_ peerID: PeerID) { - let promoted = collectionsQueue.sync(flags: .barrier) { - peerRegistry.markConnected(peerID) - } + let promoted = peerRegistry.mutate { $0.markConnected(peerID) } guard promoted else { return } refreshLocalTopology() publishFullPeerData() notifyUI { [weak self] in guard let self else { return } - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } } @@ -7417,15 +7161,13 @@ extension BLEService { /// instead of letting a ghost duplicate linger for the reachability /// retention window. private func retireRotatedPeer(_ peerID: PeerID) { - let removed = collectionsQueue.sync(flags: .barrier) { - peerRegistry.remove(peerID) != nil - } + let removed = peerRegistry.mutate { $0.remove(peerID) != nil } guard removed else { return } gossipSyncManager?.removeAnnouncementForPeer(peerID) refreshLocalTopology() notifyUI { [weak self] in guard let self else { return } - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs self.deliverTransportEvent(.peerDisconnected(peerID)) self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } @@ -7442,7 +7184,7 @@ extension BLEService { now: { Date() }, existingPeerKeys: { [weak self] peerID in guard let self = self else { return (nil, nil) } - return self.collectionsQueue.sync { + return onEngine { let info = self.peerRegistry.info(for: peerID) return (info?.noisePublicKey, info?.signingPublicKey) } @@ -7474,7 +7216,7 @@ extension BLEService { // connected. See the caller in BLEAnnounceHandler for why the // residual forged-presence window this leaves is accepted. guard let self else { return false } - guard let link = (self.collectionsQueue.sync { self.ingressLinks.link(for: packet) }) else { return false } + guard let link = self.ingressLinks.link(for: packet) else { return false } let boundPeerID: PeerID? = self.readLinkState { store in switch link { case .peripheral(let peripheralUUID): @@ -7487,28 +7229,30 @@ extension BLEService { return boundPeerID != peerID }, withRegistryBarrier: { [weak self] body in - self?.collectionsQueue.sync(flags: .barrier) { body() } + self?.onEngine { body() } }, upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in // Called from inside withRegistryBarrier; access registry directly. guard let self = self else { return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) } - return self.peerRegistry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: announcement.nickname, - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - isConnected: isConnected, - // Propagate `nil` (registry refused the announce because it - // carries a signing key different from the pinned one) so - // the handler's guard rejects it instead of overwriting the - // pinned identity. Main's capabilities/bridgeGeohash are - // preserved. - now: now, - capabilities: announcement.capabilities, - bridgeGeohash: announcement.bridgeGeohash - ) + return self.peerRegistry.mutate { + $0.upsertVerifiedAnnounce( + peerID: peerID, + nickname: announcement.nickname, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isConnected: isConnected, + // Propagate `nil` (registry refused the announce because it + // carries a signing key different from the pinned one) so + // the handler's guard rejects it instead of overwriting the + // pinned identity. Main's capabilities/bridgeGeohash are + // preserved. + now: now, + capabilities: announcement.capabilities, + bridgeGeohash: announcement.bridgeGeohash + ) + } }, shouldEmitReconnectLog: { [weak self] peerID, now in // Called from inside withRegistryBarrier; access debouncer directly. @@ -7547,7 +7291,7 @@ extension BLEService { self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) } // Get current peer list (after addition) - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs self.requestPeerDataPublish() self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } @@ -7559,7 +7303,7 @@ extension BLEService { self?.sendAnnounce(forceSend: true) }, scheduleAfterglow: { [weak self] delay in - self?.messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.engineScheduler.schedule(after: delay) { [weak self] in self?.sendAnnounce(forceSend: true) } } @@ -7638,7 +7382,7 @@ extension BLEService { // A response can replay the entire gossip store, so require proof the // requester owns the claimed sender ID: the request must verify // against the signing key from that peer's announce. - let signingKey = collectionsQueue.sync { peerRegistry.info(for: peerID)?.signingPublicKey } + let signingKey = peerRegistry.info(for: peerID)?.signingPublicKey guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") { SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security) @@ -7672,7 +7416,7 @@ extension BLEService { now: { Date() }, peersSnapshot: { [weak self] in guard let self = self else { return [:] } - return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } + return self.peerRegistry.snapshotByID }, verifyPacketSignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false @@ -7742,7 +7486,7 @@ extension BLEService { maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds ) else { return false } - let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID } + let peersSnapshot = peerRegistry.snapshotByID let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID) @@ -7837,15 +7581,16 @@ extension BLEService { }, decrypt: { [weak self] payload, peerID in guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } + // Decrypt runs on the engine queue; the readiness callback + // fires on the noise manager's queue; the session store is + // a leaf lock, so the read is safe from there. let result = try self.noiseService.decryptWithSessionGeneration( payload, from: peerID, establishedGenerationIsReady: { generation in - self.collectionsQueue.sync { - self.privateMediaSessionGenerations[ - peerID.toShort() - ] == generation - } + self.privateMediaSessions.currentGeneration( + for: peerID.toShort() + ) == generation } ) return BLENoiseDecryptionResult( @@ -7888,7 +7633,7 @@ extension BLEService { // MARK: Helper Functions private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { - let payloads = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingTypedPayload] in + let payloads = onEngine { () -> [BLEPendingTypedPayload] in pendingNoiseSessionQueues.takeTypedPayloads(for: peerID) } guard !payloads.isEmpty else { return } @@ -7906,7 +7651,7 @@ extension BLEService { // Handshake completion alone is insufficient. Put the // exact payload back until authenticated 0x21 state // arrives; that handler calls this drain again. - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendTypedPayload( pending.payload, transferId: pending.transferId, @@ -7970,10 +7715,7 @@ extension BLEService { } private func updatePeerLastSeen(_ peerID: PeerID) { - // Use async to avoid deadlock - we don't need immediate consistency for last seen updates - collectionsQueue.async(flags: .barrier) { - self.peerRegistry.updateLastSeen(peerID, at: Date()) - } + peerRegistry.mutate { $0.updateLastSeen(peerID, at: Date()) } } // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window @@ -7990,9 +7732,7 @@ extension BLEService { // NEW: Publish peer snapshots to subscribers and notify Transport delegates private func publishFullPeerData() { - let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { - peerRegistry.transportSnapshots(selfNickname: myNickname) - } + let transportPeers = peerRegistry.transportSnapshots(selfNickname: myNickname) notifyUI { [weak self] in self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers) } @@ -8006,12 +7746,10 @@ extension BLEService { lastMaintenanceAt = Date() let now = Date() - let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } + let connectedCount = peerRegistry.connectedCount let elapsed = announceThrottle.elapsed(since: now) - let recentSeen = collectionsQueue.sync { () -> Bool in - recentTrafficTracker.hasTraffic(within: 5.0, now: now) - } - let hasNoPeers = collectionsQueue.sync { peerRegistry.isEmpty } + let recentSeen = recentTrafficTracker.hasTraffic(within: 5.0, now: now) + let hasNoPeers = peerRegistry.isEmpty let plan = BLEMaintenancePolicy.plan( cycle: maintenanceCounter, connectedCount: connectedCount, @@ -8082,7 +7820,7 @@ extension BLEService { private func checkPeerConnectivity() { let now = Date() - let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs } + let peerIDsForLinkState: [PeerID] = peerRegistry.peerIDs var cachedLinkStates: [PeerID: BLEPeerLinkPresence] = [:] for peerID in peerIDsForLinkState { let state = linkState(for: peerID) @@ -8092,8 +7830,8 @@ extension BLEService { ) } - let changes = collectionsQueue.sync(flags: .barrier) { - peerRegistry.reconcileConnectivity(now: now, linkStates: cachedLinkStates) + let changes = peerRegistry.mutate { + $0.reconcileConnectivity(now: now, linkStates: cachedLinkStates) } for removedPeer in changes.removedPeers { SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(removedPeer.peerID.id.prefix(8))… (\(removedPeer.nickname))", category: .session) @@ -8106,7 +7844,7 @@ extension BLEService { guard let self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs for peerID in changes.disconnectedPeerIDs { self.deliverTransportEvent(.peerDisconnected(peerID)) @@ -8137,18 +7875,20 @@ extension BLEService { // 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 + messageQueue.async { [weak self] in + guard let self else { return } let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) - fragmentAssemblyBuffer.removeExpired(before: cutoff) - sourceRouteFailures.prune(now: now) - return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs( + self.fragmentAssemblyBuffer.removeExpired(before: cutoff) + self.sourceRouteFailures.prune(now: now) + let stalledFragmentIDs = self.fragmentAssemblyBuffer.stalledBroadcastFragmentIDs( stalledAfter: TransportConfig.bleFragmentResyncStallSeconds, retryAfter: TransportConfig.bleFragmentResyncRetrySeconds, now: now ) - } - if !stalledFragmentIDs.isEmpty { - gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs) + if !stalledFragmentIDs.isEmpty { + // GossipSyncManager serializes on its own internal queue. + self.gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs) + } } // Clean old connection timeout backoff entries (> window) @@ -8156,14 +7896,14 @@ extension BLEService { connectionScheduler.pruneConnectionTimeouts(before: timeoutCutoff) // Clean up stale scheduled relays that somehow persisted (> 2s) - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } // Nothing to compare times to; just cap the size defensively self.scheduledRelays.removeAllIfOverCapacity(512) } // Clean ingress link records older than configured seconds - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds) if !self.ingressLinks.isEmpty { @@ -8176,7 +7916,7 @@ extension BLEService { ) } - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } guard !self.selfBroadcastTracker.isEmpty else { return } let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds) @@ -8193,12 +7933,10 @@ extension BLEService { let active = true #endif // Force full-time scanning if we have very few neighbors or very recent traffic - let hasRecentTraffic: Bool = collectionsQueue.sync { - recentTrafficTracker.hasTraffic( - within: TransportConfig.bleRecentTrafficForceScanSeconds, - now: Date() - ) - } + let hasRecentTraffic = recentTrafficTracker.hasTraffic( + within: TransportConfig.bleRecentTrafficForceScanSeconds, + now: Date() + ) let scanPlan = BLEScanDutyPolicy.plan( dutyEnabled: dutyEnabled, appIsActive: active, diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift index 60c6b827..82f78a8f 100644 --- a/bitchat/Services/Board/BoardManager.swift +++ b/bitchat/Services/Board/BoardManager.swift @@ -19,6 +19,8 @@ final class BoardManager: ObservableObject { @Published private(set) var posts: [BoardPostPacket] = [] private let transport: Transport + /// Board broadcast rides the mesh only; absent on other transports. + private var boardTransport: MeshBoardBroadcasting? { transport as? MeshBoardBroadcasting } /// Publishes a bridged kind-1 note (expiring with the board post via /// NIP-40) and returns its Nostr event id, or nil when bridging failed or /// was skipped. @@ -122,7 +124,7 @@ final class BoardManager: ObservableObject { flags: flags, signature: signature ) - transport.sendBoardPayload(BoardWire.post(post).encode()) + boardTransport?.sendBoardPayload(BoardWire.post(post).encode()) // Nostr bridge: geohash posts also go out as kind-1 location notes so // online users see them. Remember the event id for merged deletes. @@ -148,7 +150,7 @@ final class BoardManager: ObservableObject { deletedAt: deletedAt, signature: signature ) - transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) + boardTransport?.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) // Merged delete: also retract the bridged Nostr copy when we still // know its event id. diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index a68bf65b..f86f2eca 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -90,6 +90,9 @@ protocol CommandContextProvider: AnyObject { final class CommandProcessor { weak var contextProvider: CommandContextProvider? weak var meshService: Transport? + /// Mesh-only command surfaces, absent when the transport lacks them. + private var meshDiagnostics: MeshDiagnosing? { meshService as? MeshDiagnosing } + private var meshArchive: MeshPublicArchiving? { meshService as? MeshPublicArchiving } private let identityManager: SecureIdentityStateManagerProtocol init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) { @@ -371,7 +374,7 @@ final class CommandProcessor { } // Scrub their carried public messages now, while the peerID is // resolvable, so they can't resurface as archived echoes. - meshService?.purgeArchivedPublicMessages(from: peerID) + meshArchive?.purgeArchivedPublicMessages(from: peerID) return .success(message: "blocked \(nickname). you will no longer receive messages from them") } // Mesh lookup failed; try geohash (Nostr) participant by display name @@ -474,7 +477,7 @@ final class CommandProcessor { // meshPingTimeoutSeconds later, and reading the selected chat at // callback time would misroute the result after a chat switch. let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline - meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in + meshDiagnostics?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in let provider = currentProvider guard let result else { provider?.addCommandOutput("no reply from \(nickname)", to: destination) @@ -496,7 +499,7 @@ final class CommandProcessor { } guard let mesh = meshService, - let intermediates = mesh.computeMeshPath(to: target.peerID) else { + let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else { return .success(message: "no known path to \(target.nickname)") } // Graph-derived from gossiped neighbor claims, not route-recorded — diff --git a/bitchat/Services/MeshTransportCapabilities.swift b/bitchat/Services/MeshTransportCapabilities.swift new file mode 100644 index 00000000..fd2203ea --- /dev/null +++ b/bitchat/Services/MeshTransportCapabilities.swift @@ -0,0 +1,152 @@ +import BitFoundation +import CoreBluetooth +import Foundation + +/// Optional transport capabilities, discovered with `as?` instead of casting +/// to a concrete transport class. `Transport` stays the contract every +/// transport genuinely implements; a capability protocol here is the +/// contract for one mesh-only feature surface, so app wiring depends on the +/// feature it needs rather than on `BLEService` itself. + +/// Radio-state reporting for transports backed by a local radio. +protocol BluetoothStateReporting: AnyObject { + func getCurrentBluetoothState() -> CBManagerState +} + +/// Panic-mode lifecycle for transports that own durable identity state. +/// A transport implementing this owns its own restart sequencing: +/// `completePanicReset` decides whether services come back, so generic +/// `startServices()` calls after a panic belong only to transports that +/// don't implement it. +protocol PanicResettingTransport: AnyObject { + /// Quiesces the radio and drains in-flight work ahead of a panic wipe. + func suspendForPanicReset() + /// Finishes a panic wipe, optionally restarting services. + func completePanicReset(restartServices: Bool) + /// Rotates the transport identity as part of a panic reset. + func resetIdentityForPanic(currentNickname: String, restartServices: Bool) +} + +/// File and private-media transfer over a mesh transport, including the +/// capability-proof policy that gates encrypted private media. +protocol MeshFileTransferring: AnyObject { + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) + /// Automatic whole-file retry is admitted only while this exact Noise + /// generation authenticates bit 9. It must never queue across a session + /// replacement or enter the signed raw legacy path. + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) + func cancelTransfer(_ transferId: String) + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy + /// The exact current Noise generation that authenticated both encrypted + /// private media (bit 8) and durable receipts/retry (bit 9). + func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID? + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) +} + +/// Live voice / push-to-talk: one encoded `VoiceBurstPacket`, +/// fire-and-forget inside the Noise session (private) or as a signed +/// ephemeral broadcast (public). Frames are only useful now — the +/// transport drops them (never queues) without an established session. +protocol MeshVoiceStreaming: AnyObject { + func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) + func sendVoiceFrameBroadcast(_ burstContent: Data) +} + +/// Courier store-and-forward: seal a message to the recipient's static +/// key and hand it to connected couriers for physical delivery while the +/// recipient is offline. Returns false when the transport cannot courier. +protocol MeshCourierTransporting: AnyObject { + @discardableResult + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool +} + +/// Private groups: creator-signed state travels 1:1 over Noise sessions; +/// group messages flood like public broadcasts. +protocol MeshGroupMessaging: AnyObject { + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) + func broadcastGroupMessage(_ envelope: Data) +} + +/// Bulletin board: broadcast a pre-signed board payload (post or +/// tombstone) so it spreads over relay and gossip sync. +protocol MeshBoardBroadcasting: AnyObject { + func sendBoardPayload(_ payload: Data) +} + +/// Mesh diagnostics (/ping, /trace, topology map). +protocol MeshDiagnosing: AnyObject { + /// Sends a directed ping probe; the completion fires exactly once on + /// the main actor with the measured result, or nil on timeout. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) + /// Estimated intermediate hops toward `peerID` from gossiped topology + /// ([] = direct link, nil = no known path). + func computeMeshPath(to peerID: PeerID) -> [PeerID]? + /// Current mesh graph for the topology map. + func currentMeshTopology() -> MeshTopologySnapshot? +} + +/// QR verification and transitive vouching over the Noise session. +protocol MeshVerifying: AnyObject { + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + /// Sends an encoded vouch-attestation batch inside the Noise session. + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) +} + +/// Store-and-forward archive: the public messages this device is carrying +/// for gossip sync, decoded for display as "heard here earlier" echoes. +protocol MeshPublicArchiving: AnyObject { + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) + /// Drops any carried public messages from a (newly blocked) sender so + /// they can't resurface as archived echoes on a later launch. + func purgeArchivedPublicMessages(from peerID: PeerID) + /// Erases the whole carried public-message archive, on disk included. + func purgeAllArchivedPublicMessages() +} + +/// Internet-gateway and geohash-bridge wiring surface (BLE mesh today). +/// Everything the gateway/bridge/courier services need from the mesh +/// transport, so their bootstrap wiring never touches the concrete class. +protocol MeshBridgingTransport: AnyObject { + // Runtime-advertised capability bits + func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) + func setLocalBridgeGeohash(_ cell: String?) + func advertisedBridgeGeohash() -> String? + + // Peers currently advertising bridging roles + func reachableGatewayPeers() -> [PeerID] + func reachableBridgePeers() -> [PeerID] + + // Gateway carrier packets (mesh <-> Nostr uplink/downlink) + @discardableResult + func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool + func broadcastNostrCarrier(_ payload: Data) + /// Sink for received carrier packets (set once by app wiring; called on + /// the main actor after transport-level checks). + var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? { get set } + + // Bridge courier drops (sealed envelopes carried across the bridge) + func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? + @discardableResult + func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool + @discardableResult + func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool + func myNoiseStaticPublicKey() -> Data + func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] + /// Fired (off-main) when a signature-verified announce is processed. + var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? { get set } +} diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index a070c7bf..4d8286ce 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -281,6 +281,7 @@ final class MessageRouter { guard remainingSlots > 0 else { return } for transport in transports { + guard let courierTransport = transport as? MeshCourierTransporting else { continue } let couriers = eligibleCouriers( on: transport, recipientKey: recipientKey, @@ -288,7 +289,7 @@ final class MessageRouter { limit: remainingSlots ) guard !couriers.isEmpty else { continue } - if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) { + if courierTransport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) { SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session) recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey)) onMessageCarried?(messageID, peerID) @@ -304,6 +305,7 @@ final class MessageRouter { /// `maxCouriersPerMessage` distinct couriers or expires. func courierBecameAvailable(_ peerID: PeerID) { for transport in transports { + guard let courierTransport = transport as? MeshCourierTransporting else { continue } guard transport.isPeerConnected(peerID), let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }), let courierKey = snapshot.noisePublicKey, @@ -319,7 +321,7 @@ final class MessageRouter { guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage, !message.depositedCourierKeys.contains(courierKey), currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue } - if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) { + if courierTransport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) { SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session) recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey]) onMessageCarried?(message.messageID, recipient) diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 76e6406f..3bb6db11 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -203,99 +203,14 @@ protocol Transport: AnyObject { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendBroadcastAnnounce() func sendDeliveryAck(for messageID: String, to peerID: PeerID) - func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) - func sendFilePrivate( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) - /// Automatic whole-file retry is admitted only while this exact Noise - /// generation authenticates bit 9. It must never queue across a session - /// replacement or enter the signed raw legacy path. - func sendFilePrivateReceiptRetry( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) - func cancelTransfer(_ transferId: String) - - // Live voice / push-to-talk (mesh transports only): one encoded - // `VoiceBurstPacket`, fire-and-forget inside the Noise session. Frames are - // only useful now — transports drop them (never queue) when no - // established session exists. - func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) - // Public-mesh counterpart: signed ephemeral broadcast, never synced. - func sendVoiceFrameBroadcast(_ burstContent: Data) - - // Courier store-and-forward (mesh transports only): seal a message to the - // recipient's static key and hand it to connected couriers for physical - // delivery while the recipient is offline. Returns false when the - // transport cannot courier (no connected courier, or unsupported). - func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool - - // Private groups (mesh transports only): creator-signed state travels - // 1:1 over Noise sessions; group messages flood like public broadcasts. - func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) - func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) - func broadcastGroupMessage(_ envelope: Data) - - // Bulletin board (mesh transports only): broadcast a pre-signed board - // payload (post or tombstone) so it spreads over relay and gossip sync. - func sendBoardPayload(_ payload: Data) - - // Mesh diagnostics (optional for transports). Defaults are inert so - // queue-backed transports (e.g. NostrTransport) stay untouched. - /// Sends a directed ping probe; the completion fires exactly once on the - /// main actor with the measured result, or nil on timeout/unsupported. - func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) - /// Estimated intermediate hops toward `peerID` from gossiped topology - /// ([] = direct link, nil = no known path). - func computeMeshPath(to peerID: PeerID) -> [PeerID]? - /// Current mesh graph for the topology map; nil when unsupported. - func currentMeshTopology() -> MeshTopologySnapshot? - - // QR verification (optional for transports) - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) - func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) - - // Vouching / transitive verification (optional for transports) /// Capabilities the peer advertised in its last verified announce; /// empty for peers that predate the capabilities TLV. func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy - /// The exact current Noise generation that authenticated both encrypted - /// private media (bit 8) and durable receipts/retry (bit 9). - func authenticatedPrivateMediaReceiptSessionGeneration( - to peerID: PeerID - ) -> UUID? - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) - /// Sends an encoded vouch-attestation batch inside the Noise session. - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) /// Appends a peer-authenticated observer. Unlike /// `installNoiseSessionCallbacks` this never touches the (single-slot) /// handshake-required callback, so secondary features can observe /// session establishment without disturbing the primary registration. func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) - - // Pending file management (BCH-01-002: files held in memory until user accepts) - func acceptPendingFile(id: String) -> URL? - func declinePendingFile(id: String) - - // Store-and-forward archive (mesh transports only): the public messages - // this device is carrying for gossip sync, decoded for display as - // "heard here earlier" timeline echoes. - func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) - /// Drops any carried public messages from a (newly blocked) sender so - /// they can't resurface as archived echoes on a later launch. - func purgeArchivedPublicMessages(from peerID: PeerID) - /// Erases the whole carried public-message archive, on disk included, so - /// clearing the mesh timeline deletes that history rather than hiding it. - func purgeAllArchivedPublicMessages() } /// A carried public mesh message from the store-and-forward window, decoded @@ -341,72 +256,12 @@ extension Transport { onHandshakeRequired: @escaping (PeerID) -> Void ) {} - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} - func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} - func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {} - func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} - func broadcastGroupMessage(_ envelope: Data) {} func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade } - func authenticatedPrivateMediaReceiptSessionGeneration( - to peerID: PeerID - ) -> UUID? { - nil - } - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) { - let policy = privateMediaSendPolicy(to: peerID) - Task { @MainActor in - completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy) - } - } - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} - func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } - func sendBoardPayload(_ payload: Data) {} - func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {} - func sendVoiceFrameBroadcast(_ burstContent: Data) {} - - // Mesh diagnostics are mesh-transport-only; other transports report - // "no reply"/"no path" rather than pretending to measure anything. - func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { - Task { @MainActor in completion(nil) } - } - func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil } - func currentMeshTopology() -> MeshTopologySnapshot? { nil } - func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} - func sendFilePrivate( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) { - guard !allowLegacyFallback else { return } - sendFilePrivate(packet, to: peerID, transferId: transferId) - } - func sendFilePrivateReceiptRetry( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) {} - func cancelTransfer(_ transferId: String) {} func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { sendMessage(content, mentions: mentions) } - - func acceptPendingFile(id: String) -> URL? { nil } - func declinePendingFile(id: String) {} - - func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { - Task { @MainActor in completion([]) } - } - - func purgeArchivedPublicMessages(from peerID: PeerID) {} - func purgeAllArchivedPublicMessages() {} } protocol TransportPeerEventsDelegate: AnyObject { @@ -450,3 +305,14 @@ extension BitchatDelegate { } extension BLEService: Transport {} +extension BLEService: MeshFileTransferring {} +extension BLEService: MeshVoiceStreaming {} +extension BLEService: MeshCourierTransporting {} +extension BLEService: MeshGroupMessaging {} +extension BLEService: MeshBoardBroadcasting {} +extension BLEService: MeshDiagnosing {} +extension BLEService: MeshVerifying {} +extension BLEService: MeshPublicArchiving {} +extension BLEService: BluetoothStateReporting {} +extension BLEService: PanicResettingTransport {} +extension BLEService: MeshBridgingTransport {} diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index f54523ec..879dc866 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -279,7 +279,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { // Purge while the fingerprint↔peerID mapping is still known: the // archived-echo seed filter can't resolve offline strangers, so // scrub their carried messages now rather than at relaunch. - meshService.purgeArchivedPublicMessages(from: peerID) + (meshService as? MeshPublicArchiving)?.purgeArchivedPublicMessages(from: peerID) } updatePeers() return fingerprint diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift index 6e146fa5..b8a2b2a6 100644 --- a/bitchat/ViewModels/ChatGroupCoordinator.swift +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -105,16 +105,19 @@ extension ChatViewModel: ChatGroupContext { identityManager.isBlocked(fingerprint: fingerprint) } + /// Group state rides the mesh's Noise sessions only. + private var groupTransport: MeshGroupMessaging? { meshService as? MeshGroupMessaging } + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) { - meshService.sendGroupInvite(payload, to: peerID) + groupTransport?.sendGroupInvite(payload, to: peerID) } func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) { - meshService.sendGroupKeyUpdate(payload, to: peerID) + groupTransport?.sendGroupKeyUpdate(payload, to: peerID) } func broadcastGroupMessagePayload(_ payload: Data) { - meshService.broadcastGroupMessage(payload) + groupTransport?.broadcastGroupMessage(payload) } // MARK: CommandContextProvider group commands (parsed by CommandProcessor) diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 9b0fab0d..1ea87a41 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -106,8 +106,8 @@ extension ChatViewModel: ChatLifecycleContext { } func refreshBluetoothState() { - if let bleService = meshService as? BLEService { - updateBluetoothState(bleService.getCurrentBluetoothState()) + if let radio = meshService as? BluetoothStateReporting { + updateBluetoothState(radio.getCurrentBluetoothState()) } } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index bad3a7fb..1d663044 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -151,14 +151,19 @@ extension ChatViewModel: ChatMediaTransferContext { // other contexts or satisfied by existing `ChatViewModel` members. The // members below flatten mesh service accesses. + /// File transfer rides the mesh only. Without that capability the + /// policy degrades to the safe floor (blocked), matching the old + /// inert protocol defaults. + private var fileTransport: MeshFileTransferring? { meshService as? MeshFileTransferring } + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { - meshService.privateMediaSendPolicy(to: peerID) + fileTransport?.privateMediaSendPolicy(to: peerID) ?? .blockedDowngrade } func authenticatedPrivateMediaReceiptSessionGeneration( to peerID: PeerID ) -> UUID? { - meshService.authenticatedPrivateMediaReceiptSessionGeneration( + fileTransport?.authenticatedPrivateMediaReceiptSessionGeneration( to: peerID ) } @@ -167,7 +172,11 @@ extension ChatViewModel: ChatMediaTransferContext { to peerID: PeerID, completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void ) { - meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion) + guard let fileTransport else { + Task { @MainActor in completion(.blockedDowngrade) } + return + } + fileTransport.resolvePrivateMediaSendPolicy(to: peerID, completion: completion) } func requestLegacyPrivateMediaConsent( @@ -197,7 +206,7 @@ extension ChatViewModel: ChatMediaTransferContext { transferId: String, allowLegacyFallback: Bool ) { - meshService.sendFilePrivate( + fileTransport?.sendFilePrivate( packet, to: peerID, transferId: transferId, @@ -210,7 +219,7 @@ extension ChatViewModel: ChatMediaTransferContext { to peerID: PeerID, transferId: String ) { - meshService.sendFilePrivateReceiptRetry( + fileTransport?.sendFilePrivateReceiptRetry( packet, to: peerID, transferId: transferId @@ -218,11 +227,11 @@ extension ChatViewModel: ChatMediaTransferContext { } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { - meshService.sendFileBroadcast(packet, transferId: transferId) + fileTransport?.sendFileBroadcast(packet, transferId: transferId) } func cancelTransfer(_ transferId: String) { - meshService.cancelTransfer(transferId) + fileTransport?.cancelTransfer(transferId) } func removeUntombstonedMediaMessage(withID messageID: String) { diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index 2de291ff..f6499988 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -129,12 +129,15 @@ extension ChatViewModel: ChatVerificationContext { messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases) } + /// QR verification rides the mesh's Noise sessions only. + private var verifyTransport: MeshVerifying? { meshService as? MeshVerifying } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { - meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) + verifyTransport?.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) } func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { - meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) + verifyTransport?.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) } func postLocalNotification(title: String, body: String, identifier: String) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 197616e7..d794ea3f 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1069,7 +1069,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage } func purgeArchivedPublicMessages() { - meshService.purgeAllArchivedPublicMessages() + (meshService as? MeshPublicArchiving)?.purgeAllArchivedPublicMessages() } /// Queues a system message for the next geohash channel visit. (Tiny @@ -1564,8 +1564,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // Quiesce the mesh before clearing stores. Identity replacement below // deliberately stays stopped until media deletion and marker commit. - if let bleService = meshService as? BLEService { - bleService.suspendForPanicReset() + if let panicTransport = meshService as? PanicResettingTransport { + panicTransport.suspendForPanicReset() } else { meshService.emergencyDisconnectAll() } @@ -1700,8 +1700,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // Replace the BLE identity while keeping the radio stopped. It may // reopen only after the durable panic transaction commits. - if let bleService = meshService as? BLEService { - bleService.resetIdentityForPanic( + if let panicTransport = meshService as? PanicResettingTransport { + panicTransport.resetIdentityForPanic( currentNickname: nickname, restartServices: false ) @@ -1746,18 +1746,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage guard panicCompleted else { return false } - if let bleService = meshService as? BLEService { + if let panicTransport = meshService as? PanicResettingTransport { // Startup recovery reopens admission but leaves actual service // start to the bootstrapper immediately after this method. - bleService.completePanicReset( + panicTransport.completePanicReset( restartServices: restartServices ) } if restartServices { // All persistent state and media are gone. Bring each service back - // only now, under the new identity. - if !(meshService is BLEService) { + // only now, under the new identity — a panic-resetting transport + // owns its own restart sequencing above. + if !(meshService is PanicResettingTransport) { meshService.startServices() } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 99ca2886..3ccb7d12 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -195,9 +195,8 @@ private extension ChatViewModelBootstrapper { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in guard let viewModel, - let bleService = viewModel.meshService as? BLEService else { return } - let state = bleService.getCurrentBluetoothState() - viewModel.updateBluetoothState(state) + let radio = viewModel.meshService as? BluetoothStateReporting else { return } + viewModel.updateBluetoothState(radio.getCurrentBluetoothState()) } viewModel.nostrRelayManager = NostrRelayManager.shared @@ -219,8 +218,9 @@ private extension ChatViewModelBootstrapper { /// right after transport start, so give it a beat before asking. private func loadArchivedEchoes() { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in - guard let viewModel else { return } - viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in + guard let viewModel, + let archive = viewModel.meshService as? MeshPublicArchiving else { return } + archive.collectArchivedPublicMessages { [weak viewModel] allArchived in guard let viewModel else { return } // A previous /clear dismissed everything heard up to its // watermark; only newer archive entries come back. Blocking a @@ -331,7 +331,7 @@ private extension ChatViewModelBootstrapper { func configureGateway() { // Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests) // has no carrier packets to bridge. - guard let bleService = viewModel.meshService as? BLEService else { return } + guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return } let gateway = GatewayService.shared gateway.publishToRelays = { event, geohash in @@ -410,7 +410,7 @@ private extension ChatViewModelBootstrapper { /// transport, the relay manager, location, and the public timeline. Same /// closure-injection style as `configureGateway`. func configureBridge() { - guard let bleService = viewModel.meshService as? BLEService else { return } + guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return } let bridge = BridgeService.shared let idBridge = viewModel.idBridge @@ -545,7 +545,7 @@ private extension ChatViewModelBootstrapper { /// manager, the mesh transport's sealing/opening primitives, the courier /// store, and the message router's deposit path. func configureBridgeCourier() { - guard let bleService = viewModel.meshService as? BLEService else { return } + guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return } let courier = BridgeCourierService.shared courier.bridgeEnabled = { BridgeService.shared.isEnabled } diff --git a/bitchat/ViewModels/ChatVouchCoordinator.swift b/bitchat/ViewModels/ChatVouchCoordinator.swift index 26cb8158..29c82440 100644 --- a/bitchat/ViewModels/ChatVouchCoordinator.swift +++ b/bitchat/ViewModels/ChatVouchCoordinator.swift @@ -90,7 +90,7 @@ extension ChatViewModel: ChatVouchContext { } func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { - meshService.sendVouchAttestations(payload, to: peerID) + (meshService as? MeshVerifying)?.sendVouchAttestations(payload, to: peerID) } func notifyPeerTrustChanged() { diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index d77a4221..9dad8030 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -97,14 +97,17 @@ extension ChatViewModel { /// `sendVoiceNote(at:)`, which live receivers absorb into the live bubble. @MainActor func makeVoiceCaptureSession() -> VoiceCaptureSession { + // Live voice rides the mesh only; frames are useful now or never, + // so a transport without the capability just drops them. + let voiceTransport = meshService as? MeshVoiceStreaming switch liveVoiceTarget() { case .peer(let peerID): - return PTTLiveVoiceSession(sendPacket: { [meshService] packet in - meshService.sendVoiceFrame(packet, to: peerID) + return PTTLiveVoiceSession(sendPacket: { packet in + voiceTransport?.sendVoiceFrame(packet, to: peerID) }) case .publicMesh: - return PTTLiveVoiceSession(sendPacket: { [meshService] packet in - meshService.sendVoiceFrameBroadcast(packet) + return PTTLiveVoiceSession(sendPacket: { packet in + voiceTransport?.sendVoiceFrameBroadcast(packet) }) case nil: SecureLogger.info("PTT: hold uses classic voice note (liveVoiceEnabled=\(PTTSettings.liveVoiceEnabled), dmSelected=\(selectedPrivateChatPeer != nil))", category: .session) diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 3c856e19..f0a573cd 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -13,6 +13,58 @@ import BitFoundation struct BLEServiceCoreTests { + /// Records ping completions (delivered on the main actor) so the + /// injected-clock test can assert from its own thread. + private final class MeshPingResultCollector: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [MeshPingResult?] = [] + var results: [MeshPingResult?] { lock.withLock { recorded } } + func record(_ result: MeshPingResult?) { + lock.withLock { recorded.append(result) } + } + } + + /// The ping deadline asserted on an injected clock: the real 10s + /// product constant, no wall-clock in the loop. This is the pattern + /// for every engine deadline — the timeout must not fire early, must + /// fire exactly once at the deadline, and must stay consumed after. + @Test + func meshPingTimesOutOnTheInjectedClockExactlyOnce() async throws { + let scheduler = BLEEngineManualScheduler() + let ble = makeService(engineScheduler: scheduler) + let peer = PeerID(str: "aabbccdd00112233") + ble._test_seedConnectedPeer(peer, nickname: "Alice") + + let collector = MeshPingResultCollector() + ble.sendMeshPing(to: peer) { result in + collector.record(result) + } + // The probe registers and its deadline schedules on the engine; + // fence that submission before touching the clock. + await ble._test_drainNoiseMessagePipeline() + #expect(scheduler.pendingCount == 1) + + // A hair before the deadline nothing may fire. + scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds - 0.01) + await ble._test_drainNoiseMessagePipeline() + #expect(collector.results.isEmpty) + + // Crossing the deadline expires the probe: nil, exactly once, on + // the main actor. + scheduler.advance(by: 0.02) + let completed = await TestHelpers.waitUntil( + { collector.results.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(completed) + #expect(collector.results == [nil]) + + // The deadline is consumed — more time cannot re-fire it. + scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds * 2) + await ble._test_drainNoiseMessagePipeline() + #expect(collector.results.count == 1) + } + @Test func duplicatePacket_isDeduped() async throws { let ble = makeService() @@ -908,6 +960,17 @@ struct BLEServiceCoreTests { // old generation the remote may no longer be able to read. #expect(outbound.count(ofType: .noiseEncrypted) == 0) + // The capability-proof watchdog armed at the original authentication + // is still live and can genuinely reach its real 5s deadline here on + // a stalled CI runner. Fire it deterministically: its drain must + // respect the deferred-until-convergence state instead of encrypting + // the parked queues under the restored keys (the exact silent loss + // the defer path exists to prevent). The retry below then still + // finds the queues parked. + ble._test_forcePrivateMediaProofTimeout(for: alicePeerID) + await ble._test_drainNoiseMessagePipeline() + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + // Release the mandatory convergence retry: it retires the restored // session and starts a fresh XX exchange with the live peer. recoveryGate.release() @@ -1499,7 +1562,8 @@ private final class PanicIngressObserver: @unchecked Sendable { private func makeService( noiseResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler() ) -> BLEService { let keychain = MockKeychain() let identityManager = MockIdentityManager(keychain) @@ -1509,7 +1573,8 @@ private func makeService( idBridge: idBridge, identityManager: identityManager, initializeBluetoothManagers: false, - noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout + noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout, + engineScheduler: engineScheduler ) } diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index 53de683b..6ce23355 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -669,7 +669,7 @@ struct CourierEndToEndTests { /// Minimal transport stub for exercising MessageRouter's courier deposit /// logic without BLE plumbing. -private final class CourierCaptureTransport: Transport { +private final class CourierCaptureTransport: Transport, MeshCourierTransporting { weak var delegate: BitchatDelegate? weak var eventDelegate: TransportEventDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index 44d6f51e..30f071f5 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -204,7 +204,8 @@ struct PrivateMediaEndToEndTests { alice.sendFilePrivate( file, to: bob.myPeerID, - transferId: deniedID + transferId: deniedID, + allowLegacyFallback: false ) let denied = await TestHelpers.waitUntil( { cancellations.contains(deniedID) }, @@ -254,7 +255,7 @@ struct PrivateMediaEndToEndTests { // Consent is invocation-scoped, not a sticky peer preference. let retryID = "legacy-retry-without-consent-\(UUID().uuidString)" - alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID) + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID, allowLegacyFallback: false) let retryDenied = await TestHelpers.waitUntil( { cancellations.contains(retryID) }, timeout: TestConstants.longTimeout @@ -998,7 +999,7 @@ struct PrivateMediaEndToEndTests { let encryptedID = "encrypted-over-256-\(UUID().uuidString)" let legacyID = "legacy-over-256-\(UUID().uuidString)" - alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID) + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID, allowLegacyFallback: false) alice.sendFilePrivate( file, to: oldCarol.myPeerID, @@ -1318,7 +1319,7 @@ struct PrivateMediaEndToEndTests { mimeType: mimeType, content: content ) - alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)") + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)", allowLegacyFallback: false) let fragmented = await TestHelpers.waitUntil( { tap.hasCompleteFragmentTrain }, diff --git a/bitchatTests/Mocks/BLEEngineManualScheduler.swift b/bitchatTests/Mocks/BLEEngineManualScheduler.swift new file mode 100644 index 00000000..cfe4d712 --- /dev/null +++ b/bitchatTests/Mocks/BLEEngineManualScheduler.swift @@ -0,0 +1,49 @@ +import Foundation +@testable import bitchat + +/// Manually advanced engine scheduler: deferred work runs when the test +/// advances the clock past its deadline, on the real engine queue (deferred +/// bodies touch engine-confined state), and `advance` returns only after +/// the released work has finished — so assertions that follow observe its +/// engine-side effects without polling. +final class BLEEngineManualScheduler: BLEEngineScheduling, @unchecked Sendable { + private let lock = NSLock() + private var engineQueue: DispatchQueue? + private var now: TimeInterval = 0 + private var pending: [(deadline: TimeInterval, work: DispatchWorkItem)] = [] + + func activate(engineQueue: DispatchQueue) { + lock.withLock { self.engineQueue = engineQueue } + } + + func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) { + lock.withLock { pending.append((now + delay, work)) } + } + + var pendingCount: Int { + lock.withLock { pending.count } + } + + /// Advances the clock, releasing due work in deadline order. + /// Cancellation keeps its production semantics: dispatch skips a + /// cancelled `DispatchWorkItem` at execution. + func advance(by interval: TimeInterval) { + let (due, queue): ([DispatchWorkItem], DispatchQueue?) = lock.withLock { + now += interval + let cutoff = now + let released = pending + .filter { $0.deadline <= cutoff } + .sorted { $0.deadline < $1.deadline } + .map(\.work) + pending.removeAll { $0.deadline <= cutoff } + return (released, engineQueue) + } + guard let queue else { return } + for work in due { + queue.async(execute: work) + } + // Fence: released work (and anything it enqueued) has run before + // the test's next assertion. + queue.sync {} + } +} diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 4620408f..9587e86f 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -14,7 +14,10 @@ import BitFoundation /// Mock Transport implementation for testing ChatViewModel in isolation. /// Records all method calls and allows test code to verify interactions. -final class MockTransport: Transport, PrivateMediaDeletionPersisting { +final class MockTransport: Transport, PrivateMediaDeletionPersisting, + MeshFileTransferring, MeshVerifying, MeshCourierTransporting, + MeshDiagnosing, MeshPublicArchiving, MeshVoiceStreaming, + MeshGroupMessaging, MeshBoardBroadcasting { // MARK: - Protocol Properties @@ -205,11 +208,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { sentBroadcastFiles.append((packet, transferId)) } - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { - sentPrivateFiles.append((packet, peerID, transferId)) - sentPrivateFileLegacyAllowances.append(false) - } - func sendFilePrivate( _ packet: BitchatFilePacket, to peerID: PeerID, @@ -242,6 +240,15 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { cancelledTransfers.append(transferId) } + private(set) var sentFileReceiptRetries: [(BitchatFilePacket, PeerID, String)] = [] + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) { + sentFileReceiptRetries.append((packet, peerID, transferId)) + } + @MainActor func persistDeletedPrivateMedia( messageIDs: [String], @@ -317,6 +324,50 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { meshTopologySnapshot } + // MARK: - Remaining mesh capabilities (recording stubs) + + private(set) var sentVouchAttestations: [(Data, PeerID)] = [] + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + sentVouchAttestations.append((payload, peerID)) + } + + var archivedPublicMessages: [ArchivedPublicMessage] = [] + private(set) var purgedAllArchived = false + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { + let archived = archivedPublicMessages + Task { @MainActor in completion(archived) } + } + func purgeAllArchivedPublicMessages() { + purgedAllArchived = true + } + + private(set) var sentVoiceFrames: [(Data, PeerID)] = [] + private(set) var sentVoiceBroadcasts: [Data] = [] + func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) { + sentVoiceFrames.append((burstContent, peerID)) + } + func sendVoiceFrameBroadcast(_ burstContent: Data) { + sentVoiceBroadcasts.append(burstContent) + } + + private(set) var sentGroupInvites: [(Data, PeerID)] = [] + private(set) var sentGroupKeyUpdates: [(Data, PeerID)] = [] + private(set) var broadcastGroupMessages: [Data] = [] + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) { + sentGroupInvites.append((statePayload, peerID)) + } + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) { + sentGroupKeyUpdates.append((statePayload, peerID)) + } + func broadcastGroupMessage(_ envelope: Data) { + broadcastGroupMessages.append(envelope) + } + + private(set) var sentBoardPayloads: [Data] = [] + func sendBoardPayload(_ payload: Data) { + sentBoardPayloads.append(payload) + } + // MARK: - Test Helpers /// Clears all recorded method calls for fresh assertions diff --git a/bitchatTests/ProtocolContractTests.swift b/bitchatTests/ProtocolContractTests.swift index d295ff4e..881dc3aa 100644 --- a/bitchatTests/ProtocolContractTests.swift +++ b/bitchatTests/ProtocolContractTests.swift @@ -85,24 +85,16 @@ struct ProtocolContractTests { func transportDefaults_forwardOrNoOp() { let probe = DefaultTransportProbe() let peerID = PeerID(str: "0123456789abcdef") - let filePacket = BitchatFilePacket( - fileName: "voice.m4a", - fileSize: 4, - mimeType: "audio/mp4", - content: Data([1, 2, 3, 4]) - ) probe.sendMessage("hello", mentions: ["@alice"], messageID: "msg-1", timestamp: Date()) - probe.sendVerifyChallenge(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x01])) - probe.sendVerifyResponse(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x02])) - probe.sendFileBroadcast(filePacket, transferId: "tx-1") - probe.sendFilePrivate(filePacket, to: peerID, transferId: "tx-2") - probe.cancelTransfer("tx-3") - probe.declinePendingFile(id: "pending") #expect(probe.sentMessages.count == 1) #expect(probe.sentMessages.first?.content == "hello") - #expect(probe.acceptPendingFile(id: "pending") == nil) + // Mesh-only features are capability protocols now, not inert + // defaults: a core-only transport simply doesn't have them. + #expect(!(probe as AnyObject is MeshFileTransferring)) + #expect(!(probe as AnyObject is MeshDiagnosing)) + #expect(probe.peerCapabilities(peerID).isEmpty) // Secure delivery defaults to prompt delivery (itself defaulting to // reachability) for transports without a forgeable link layer. #expect(probe.canDeliverSecurely(to: peerID) == false) diff --git a/bitchatTests/Services/BLEMeshPingTrackerTests.swift b/bitchatTests/Services/BLEMeshPingTrackerTests.swift new file mode 100644 index 00000000..a3a81008 --- /dev/null +++ b/bitchatTests/Services/BLEMeshPingTrackerTests.swift @@ -0,0 +1,84 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEMeshPingTrackerTests { + private func makeProbe(peerID: PeerID) -> BLEMeshPingProbe { + BLEMeshPingProbe( + peerID: peerID, + sentAt: Date(timeIntervalSince1970: 1_000), + lifecycleGeneration: 1, + completion: { _ in }, + timeout: DispatchWorkItem {} + ) + } + + @Test func resolveReturnsProbeOnlyForTheProbedPeer() { + var tracker = BLEMeshPingTracker() + let nonce = Data([1, 2, 3, 4, 5, 6, 7, 8]) + let probed = PeerID(str: "aaaaaaaaaaaaaaaa") + tracker.register(makeProbe(peerID: probed), nonce: nonce) + + // A pong claiming the right nonce from the wrong peer must not + // consume the probe. + let wrongPeer = tracker.resolve(nonce: nonce, from: PeerID(str: "bbbbbbbbbbbbbbbb")) + #expect(wrongPeer == nil) + let rightPeer = tracker.resolve(nonce: nonce, from: probed) + #expect(rightPeer != nil) + // Consumed exactly once. + let secondResolve = tracker.resolve(nonce: nonce, from: probed) + #expect(secondResolve == nil) + } + + @Test func expireConsumesTheProbeSoResolveCannotFireTwice() { + var tracker = BLEMeshPingTracker() + let nonce = Data([9, 9, 9, 9, 9, 9, 9, 9]) + let probed = PeerID(str: "aaaaaaaaaaaaaaaa") + tracker.register(makeProbe(peerID: probed), nonce: nonce) + + let firstExpire = tracker.expire(nonce: nonce) + #expect(firstExpire != nil) + let secondExpire = tracker.expire(nonce: nonce) + #expect(secondExpire == nil) + let resolveAfterExpire = tracker.resolve(nonce: nonce, from: probed) + #expect(resolveAfterExpire == nil) + } + + @Test func inboundBudgetIsPerLinkAndBounded() { + var tracker = BLEMeshPingTracker() + let now = Date(timeIntervalSince1970: 2_000) + let linkA = PeerID(str: "aaaaaaaaaaaaaaaa") + let linkB = PeerID(str: "bbbbbbbbbbbbbbbb") + + var allowedOnA = 0 + for _ in 0..<(TransportConfig.meshPingInboundMaxPerLink + 5) { + if tracker.shouldRespond(toLink: linkA, now: now) { allowedOnA += 1 } + } + #expect(allowedOnA == TransportConfig.meshPingInboundMaxPerLink) + // One saturated link must not consume another link's budget. + let allowedOnB = tracker.shouldRespond(toLink: linkB, now: now) + #expect(allowedOnB) + } + + @Test func resetDropsProbesRestoresBudgetAndHandsBackTimeouts() { + var tracker = BLEMeshPingTracker() + let now = Date(timeIntervalSince1970: 3_000) + let link = PeerID(str: "aaaaaaaaaaaaaaaa") + let nonce = Data([4, 4, 4, 4, 4, 4, 4, 4]) + tracker.register(makeProbe(peerID: link), nonce: nonce) + for _ in 0.. [Line] { + let enumerator = FileManager.default.enumerator( + at: bleRoot, + includingPropertiesForKeys: nil + ) + var out: [Line] = [] + while let url = enumerator?.nextObject() as? URL { + guard url.pathExtension == "swift" else { continue } + let name = url.lastPathComponent + let texts = try String(contentsOf: url, encoding: .utf8) + .components(separatedBy: .newlines) + for (index, text) in texts.enumerated() { + var waived = text.contains(waiver) + var back = index - 1 + while !waived, back >= 0 { + let previous = texts[back].trimmingCharacters(in: .whitespaces) + guard previous.hasPrefix("//") else { break } + waived = previous.contains(waiver) + back -= 1 + } + out.append(Line(file: name, number: index + 1, text: text, waived: waived)) + } + } + return out + } + + private func offenders(matching pattern: String) throws -> [String] { + try Self.bleLines() + .filter { !$0.waived && $0.text.contains(pattern) } + .map { "\($0.file):\($0.number): \($0.text.trimmingCharacters(in: .whitespaces))" } + } + + @Test func onlyOnEngineSyncEntersTheEngine() throws { + let hits = try offenders(matching: "messageQueue.sync") + #expect(hits.isEmpty, "Raw messageQueue.sync bypasses onEngine's bleQueue trap; route through onEngine (or waive with a reason): \(hits)") + } + + @Test func transportCodeNeverSyncDispatchesToMain() throws { + let hits = try offenders(matching: "DispatchQueue.main.sync") + #expect(hits.isEmpty, "A main.sync from transport code can complete an ABBA cycle with the main actor's sync reads: \(hits)") + } + + @Test func theCollectionsQueueStaysDeleted() throws { + let hits = try offenders(matching: "collectionsQueue") + #expect(hits.isEmpty, "Engine state has exactly one serial domain; do not reintroduce a side queue: \(hits)") + } + + @Test func deferredEngineWorkGoesThroughTheScheduler() throws { + let hits = try offenders(matching: "messageQueue.asyncAfter") + #expect(hits.isEmpty, "Engine delays must use BLEEngineScheduling so tests can drive protocol deadlines with a manual clock: \(hits)") + } +} diff --git a/docs/BLE-ARCHITECTURE-V3.md b/docs/BLE-ARCHITECTURE-V3.md new file mode 100644 index 00000000..60400ceb --- /dev/null +++ b/docs/BLE-ARCHITECTURE-V3.md @@ -0,0 +1,175 @@ +# BLE Transport Architecture V3 + +The plan of record for restructuring `BLEService` from an 8.3k-line god +object into a layered mesh stack. ARCHITECTURE_V2 rebuilt the app layer +above the transport and deliberately deferred the transport itself; this +document covers that remainder: what already landed, the target shape, and +the order for the rest. + +## Why the satellite strategy stalled + +V2's transport approach was to peel pure policies and closure-driven +handlers out of `BLEService` while the class kept coordinating. The ~30 +pure policy structs were a clear win. The five big handler extractions +were not: each needed an "environment" of 20–30 closures that weakly +capture the service and hop queues back into its state. Logic left, but +state ownership and synchronization never moved, so extraction paid a +plumbing tax that grew as fast as the logic shrank — the five +`make*HandlerEnvironment()` factories alone were ~1.5k lines. The file +held ~60 mutable fields across four concurrency domains whose ownership +lived in comments, and every new feature added Transport requirements, +state maps, and switch cases to the same class. + +Two chronic costs came straight from that structure: queue-order +deadlocks (the July 9 main↔bleQueue ABBA freeze), and timing-dependent +tests (correctness only observable through real queues and real time). + +## Target shape + +A packet-radio stack with one rule per layer about state and threads: + +1. **`BLELinkLayer`** — the only CoreBluetooth import. Owns both managers, + scanning/advertising, duty cycle, connection scheduling, MTU, write + and notification backpressure buffers, state restoration. Speaks + `LinkEvent` up (link up/down, bytes in, writable) and `LinkCommand` + down (send bytes on link, scan/advertise policy). Knows nothing about + packets, peers, or Noise. bleQueue-confined. A `SimulatedLinkLayer` + implementing the same port gives multi-node tests real topologies with + no radios and no wall-clock waits. +2. **Mesh engine** — one serial queue owning all protocol state: wire + codec, fragmentation, dedup, relay policy, peer registry, topology, + gossip sync, Noise orchestration. Synchronous single-writer logic; the + pure policy satellites slot in unchanged. Endgame: the engine core + becomes `handle(event, now) -> [Effect]` (sans-I/O), which makes the + whole mesh property-testable and fuzzable in simulation. +3. **Feature modules** — courier, board, prekeys, private media, file + transfer, voice, diagnostics, groups, verify/vouch each own their + state and register for their message types. A new feature is a new + module, not edits to the engine. +4. **App boundary** — a small `Transport` core both transports genuinely + implement, plus capability protocols discovered with `as?` + (`MeshBridgingTransport` etc.), replacing the ~90-requirement + god-protocol and its inert defaults. + +### Concurrency contract + +State is owned one of three ways: + +- **Engine-confined** — mutated only on the serial engine queue + (`mesh.message`). Cross-thread callers use `onEngine`. +- **bleQueue-confined** — link-layer state next to CoreBluetooth objects + (link store, write/notification buffers, link-auth maps). +- **Lock-backed store** — state with legitimate cross-domain readers + (peer registry, local identity/capabilities, traffic monitor). Writes + still come from one domain; the lock exists so readers never block on + a queue. Every mutation is a single whole-transition method, so + readers never observe torn state. + +Sync-edge order (deadlock freedom by construction, debug-enforced in +`onEngine`): + +``` +main / test threads ──sync──▶ engine ──sync──▶ bleQueue + └──sync──▶ noise / identity queues (leaves) +``` + +Nothing may sync-wait in the reverse direction: bleQueue and the crypto +queues reach the engine only via `async`, and nothing sync-dispatches to +main. Two subtleties worth knowing: + +- A closure executed inside a noise-manager critical section entered + *from* an engine slot may touch engine state directly (the blocked + slot makes it exclusive) but must never sync-re-enter the engine — + that is a self-deadlock. +- bleQueue critical sections (e.g. the verified-announce link rebind) + must receive engine-derived values as arguments rather than fetching + them through `onEngine`. + +## What landed in this pass + +- **Lock-backed peer state** (`BLEPeerRegistryStore`): every main-actor + Transport read (`isPeerConnected`, nicknames, snapshots, capability + queries) reads a lock, not a queue. Runtime capability bits moved into + `BLELocalIdentityStateStore` beside the identity they ride announces + with. +- **bleQueue owns the link buffers**: `pendingPeripheralWrites`, + `pendingNotifications`, `pendingWriteBuffers` are bleQueue-confined + (their producers and drains already ran there); the notification drain + no longer invokes CoreBluetooth from a transport queue. +- **One serial engine queue**: the concurrent message queue and the + collections queue it guarded state with are one serial domain; every + barrier flag and per-field ownership comment deleted; ~98 cross-queue + hops removed. `onEngine` documents and debug-enforces the sync-edge + order — and its trap caught two latent inversions during migration + (the announce-rebind path and the noise session-generation closures). +- **Capability ports**: gateway/bridge/courier wiring, the panic + lifecycle, and radio-state reads go through `MeshBridgingTransport`, + `PanicResettingTransport`, and `BluetoothStateReporting`; no app code + casts to `BLEService` anymore. +- **Feature-owned state**: `BLEMeshPingTracker` (the /ping probe map and + per-link response budget) and `BLEPrivateMediaSessionStore` (the six + generation-keyed private-media maps plus the convergence-deferral set, + as whole-transition methods under a leaf lock), both with direct unit + tests. The private-media store also took the last routine main-actor + sync reads off the engine and turned the noise-critical-section + transitions into ordinary leaf-lock calls. Remaining feature state + (courier, board, prekeys) already lives in injected stores. +- **Transport split**: the mesh-only surface left the god-protocol. + `Transport` is core only (lifecycle, identity, snapshots, basic + messaging, noise wrappers); files/private media, voice, courier, + groups, board, diagnostics, verification, and the public archive are + eight capability protocols discovered with `as?`, alongside the + bridging/panic/radio-state ports. The inert-defaults extension is + gone; consumers that relied on a default keep its safe floor + explicitly at the call site. +- **Contract pinning**: `BLEQueueContractTests` greps the transport + sources — only `onEngine` may sync-enter the engine, transport code + never sync-dispatches to main, and the collections queue stays + deleted (waivable per line with `queue-contract-ok:` plus a reason). + +Full suite green throughout (1,964 tests), identical wall-clock — BLE +throughput is nowhere near what one serial queue sustains. + +## Remaining roadmap (in order) + +1. **Link-layer extraction.** Move the CB delegates, scheduling, duty + cycle, and buffers behind `LinkEvent`/`LinkCommand` ports. + + **Link-auth boundary (decided): bindings become engine-owned.** + Today `noiseAuthenticatedLinkOwners`, the rebind containment rules, + and the peer↔link binding maps live on bleQueue so that "check + binding + auth, then act" is one critical section (the rebind path + and the authenticated-send commit point in + `notifyOrEnqueueIfAccepted`). That atomicity exists to stop a + binding from changing between a security check and its action — and + the engine's serial slot provides exactly the same guarantee once + every rebind is an engine operation. The residual stolen-link risk + is unchanged: directed payloads are Noise ciphertext, useless on a + link that changed hands after the decision. Making bindings engine + state also puts the receive path in its sans-I/O shape: the link + layer reports `received(bytes, linkID)` and the engine resolves the + sender binding, instead of the CB delegate resolving peers before + handoff. The link layer keeps only physical link state (CB objects, + connect/subscribe lifecycles, backpressure buffers) keyed by opaque + link IDs. + + Extraction order: (a) the binding-free radio half — scanning, + advertising, duty cycle, connection budget/scheduling — moves first + (it makes no peer decisions); (b) bindings + link-auth migrate to + the engine, converting `readLinkState` callers; (c) the delegates + shrink to event emission and move behind the port. +2. **Sans-I/O engine core + simulator.** Make the engine formally + `handle(event) -> [Effect]`, feed it from a `SimulatedLinkLayer`, and + move the multi-node E2E suite onto deterministic simulation (no + `waitUntil`, no timing hygiene battles). Property tests become + possible: relay-storm bounds, partition-heal convergence, dedup + soundness under duplicate floods. The remaining feature *code* moves + (courier, board, prekey, voice, file, group handlers out of the + packet switch) ride this seam as handler-registered modules instead + of getting closure-environment extractions now. + +## What this is not + +No wire changes: packet formats, signing (padding is signed), the +peerID identity binding, and courier tag construction are untouched — +see the wire-landmines notes before assuming any of that is local.