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.