diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 4a3ecdb4..70279702 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -10,6 +10,9 @@ jobs: test: name: Run Swift Tests (${{ matrix.name }}) runs-on: macos-latest + # A hung test must fail fast, not hold a runner for GitHub's 360-minute + # default (observed: intermittent app-suite hangs starving the queue). + timeout-minutes: 15 strategy: fail-fast: false # Don't cancel other matrix jobs when one fails @@ -39,17 +42,25 @@ jobs: ${{ runner.os }}-${{ matrix.name }}- - name: Run Tests - # BITCHAT_PERF_LOG captures the PERF[...] lines that - # PerformanceBaselineTests reports (swift test --parallel swallows - # stdout of passing tests, so the floor gate reads this file instead). + # Perf benchmarks are excluded here and run in their own serial step + # below: measuring while parallel test processes contend for cores + # produces noisy numbers, and the XCTest measure machinery has hung + # intermittently under parallel workers on loaded runners. env: - BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log + BITCHAT_SKIP_PERF_BASELINES: "1" run: swift test --parallel --quiet --enable-code-coverage --package-path ${{ matrix.path }} - # Order-of-magnitude performance regression gate (app tests only — the - # package matrix entries write no PERF lines and the gate would skip - # anyway). Floors are deliberately generous (~25% of healthy local - # throughput, see bitchatTests/Performance/perf-floors.json) so this + # Benchmarks run serially on an otherwise idle runner for stable + # numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate. + - name: Run performance benchmarks (serial) + if: matrix.name == 'app' + timeout-minutes: 6 + env: + BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log + run: swift test --quiet --filter PerformanceBaselineTests + + # Order-of-magnitude performance regression gate. Floors are deliberately + # generous (see bitchatTests/Performance/perf-floors.json) so this # catches algorithmic regressions, never runner variance. - name: Performance floor gate if: matrix.name == 'app' @@ -76,6 +87,7 @@ jobs: ios-build: name: Build iOS app (simulator) runs-on: macos-latest + timeout-minutes: 15 steps: - name: Checkout code diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index aa9562c4..91cfaec4 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -198,6 +198,9 @@ final class NostrRelayManager: ObservableObject { } private var messageQueue: [PendingSend] = [] private let messageQueueLock = NSLock() + // Total pending sends dropped at the queue cap; drives the sampled + // overflow warning (first + every Nth drop). + private var pendingSendDropCount = 0 private let encoder = JSONEncoder() private var shouldUseTor: Bool { dependencies.userTorEnabled() } @@ -352,6 +355,18 @@ final class NostrRelayManager: ObservableObject { messageQueue.removeFirst(overflow) } messageQueueLock.unlock() + guard overflow > 0 else { return } + // Dropped events are ephemeral (presence/geo), so no status surfacing + // is needed — but the drops should be visible. Sampled so a sustained + // relay stall can't flood the log. + pendingSendDropCount += overflow + if pendingSendDropCount == 1 || + pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) { + SecureLogger.warning( + "📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)", + category: .session + ) + } } /// Try to flush any queued messages for relays that are now connected. @@ -452,7 +467,7 @@ final class NostrRelayManager: ObservableObject { if urls.isEmpty { onEOSE() } else if shouldWaitForTorBeforeConnecting { - pendingEOSECallbacks[id] = onEOSE + parkEOSECallbackUntilTorReady(id: id, callback: onEOSE) } else { startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE) } @@ -623,6 +638,31 @@ final class NostrRelayManager: ObservableObject { } } + /// Park an EOSE callback while Tor is not yet ready, and schedule the same + /// fallback timeout `startEOSETracking` uses. Without it, a parked callback + /// would only be unblocked by Tor-readiness retry exhaustion (several + /// awaitReady timeouts, i.e. minutes), leaving callers hanging far past the + /// normal EOSE fallback. If Tor recovers first the callback is promoted to + /// a real EOSE tracker (`startPendingEOSETrackingIfNeeded`), and if retry + /// exhaustion fires first it is drained by `unblockPendingEOSECallbacks`; + /// either way it leaves `pendingEOSECallbacks` and this timer is a no-op. + private func parkEOSECallbackUntilTorReady(id: String, callback: @escaping () -> Void) { + pendingEOSECallbacks[id] = callback + let generation = connectionGeneration + dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + // Stale timers from a previous connection generation are void. + guard generation == self.connectionGeneration else { return } + // Already fired (unsubscribe, retry-exhaustion unblock) or + // promoted to a real EOSE tracker: nothing to do. + guard let callback = self.pendingEOSECallbacks.removeValue(forKey: id) else { return } + SecureLogger.warning("Unblocking Tor-parked EOSE callback for \(id) after fallback timeout", category: .session) + callback() + } + } + } + /// Fire and clear all EOSE callbacks that are parked waiting for Tor. /// Callers treat EOSE as "initial fetch finished"; firing with no data is /// safe and prevents indefinite hangs when Tor cannot bootstrap. diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 596415f4..9584c5ae 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -74,6 +74,8 @@ final class BLEService: NSObject { private let identityManager: SecureIdentityStateManagerProtocol private let keychain: KeychainManagerProtocol private let idBridge: NostrIdentityBridge + /// Binary form of `myPeerID`; same contract — mutated only inside a + /// `messageQueue` barrier via `refreshPeerIdentity()`. private var myPeerIDData: Data = Data() // MARK: - Advertising Privacy @@ -315,13 +317,20 @@ final class BLEService: NSObject { } disconnectNotifyDebouncer.removeAll() - noiseService.clearEphemeralStateForPanic() - noiseService.clearPersistentIdentity() + // The crypto-service replacement and the derived identity swap must be + // one atomic unit with respect to messageQueue senders: a queued send + // 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) { + noiseService.clearEphemeralStateForPanic() + noiseService.clearPersistentIdentity() - let newNoise = NoiseEncryptionService(keychain: keychain) - noiseService = newNoise - configureNoiseServiceCallbacks(for: newNoise) - refreshPeerIdentity() + let newNoise = NoiseEncryptionService(keychain: keychain) + noiseService = newNoise + configureNoiseServiceCallbacks(for: newNoise) + refreshPeerIdentity() + } restartGossipManager() setNickname(currentNickname) @@ -407,8 +416,10 @@ final class BLEService: NSObject { // MARK: Identity /// Derived from the Noise identity fingerprint; rotated only via - /// `refreshPeerIdentity()` (e.g. panic reset). Externally read-only — - /// no out-of-band mutation may bypass that derivation. + /// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap + /// inside a `messageQueue` barrier so concurrent queue work never sees a + /// half-updated identity. Externally read-only — no out-of-band mutation + /// may bypass that derivation. private(set) var myPeerID = PeerID(str: "") /// Externally read-only; mutate via `setNickname(_:)`, which also /// broadcasts the change to peers. @@ -2370,11 +2381,25 @@ extension BLEService { } } + /// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity. + /// The swap runs as a `messageQueue` barrier so in-flight work items that + /// read the identity (e.g. `sendMessage` building packets) complete + /// against the old value and everything after sees the new one atomically. + /// Callers (init, panic reset on the main thread) are never on + /// `messageQueue`; the re-entrancy check keeps any future on-queue caller + /// from deadlocking. private func refreshPeerIdentity() { - let fingerprint = noiseService.getIdentityFingerprint() - myPeerID = PeerID(str: fingerprint.prefix(16)) - myPeerIDData = Data(hexString: myPeerID.id) ?? Data() - meshTopology.reset() + let swap = { + let fingerprint = self.noiseService.getIdentityFingerprint() + self.myPeerID = PeerID(str: fingerprint.prefix(16)) + self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data() + self.meshTopology.reset() + } + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + swap() + } else { + messageQueue.sync(flags: .barrier, execute: swap) + } } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 02ccfe4e..606185bd 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -167,6 +167,9 @@ enum TransportConfig { // bootstrap deadline) to attempt before unblocking pending EOSE callers. static let nostrTorReadyMaxWaitAttempts: Int = 3 static let nostrPendingSendQueueCap: Int = 200 + // Sample interval for the send-queue overflow warning (first + every Nth + // dropped event). Drops are ephemeral presence/geo traffic — log-only. + static let nostrPendingSendDropLogInterval: Int = 10 // Pending (not-yet-flushed) REQs are bounded per relay: oldest-by-insertion // eviction at the cap, plus an age sweep on connect attempts. Durable // subscription intent survives in subscriptionRequestState either way. diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 9b5155b2..992eb647 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -164,6 +164,23 @@ struct BLEServiceCoreTests { #expect(!ble._test_recordIngressIfNew(packet: packet, linkID: "central-b")) } + @Test + func panicReset_rotatesPeerIDDerivedFromNewNoiseFingerprint() async throws { + let ble = makeService() + let originalPeerID = ble.myPeerID + let originalFingerprint = ble.noiseIdentityFingerprint() + #expect(originalPeerID == PeerID(str: originalFingerprint.prefix(16))) + + ble.resetIdentityForPanic(currentNickname: "anon") + + // The Noise identity is regenerated and the peer ID swaps with it + // (atomically, behind a messageQueue barrier). + let newFingerprint = ble.noiseIdentityFingerprint() + #expect(newFingerprint != originalFingerprint) + #expect(ble.myPeerID != originalPeerID) + #expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16))) + } + @Test func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws { let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") diff --git a/bitchatTests/Performance/perf-floors.json b/bitchatTests/Performance/perf-floors.json index af349819..93abf125 100644 --- a/bitchatTests/Performance/perf-floors.json +++ b/bitchatTests/Performance/perf-floors.json @@ -2,11 +2,14 @@ "_philosophy": [ "Floor throughputs for the PERF[...] lines printed by PerformanceBaselineTests.", "Floors catch algorithmic regressions (O(n) -> O(n^2), accidental sync I/O,", - "quadratic re-scans), NOT tuning noise: each floor is deliberately set at", - "~25% of the throughput measured on a local dev machine (2026-06, Apple", - "Silicon), leaving ~4x headroom for CI runner variance so the gate never", - "flakes on a slow runner while still failing loudly on order-of-magnitude", - "regressions.", + "quadratic re-scans), NOT tuning noise. Basis: ~50% of the SLOWEST observed", + "CI run (GitHub macos-latest), not local numbers - CI slowdown is", + "benchmark-dependent (sub-millisecond passes amplify runner overhead, e.g.", + "nostrInbound.duplicate runs at ~20% of local speed on CI while most", + "benchmarks run at ~40-60%). Every floor remains 10-200x above known", + "regression values (the pre-optimization duplicate path measured 2.2k/sec", + "against a 250k floor), so the gate still fails loudly on order-of-", + "magnitude regressions while never flaking on a slow runner.", "Raise floors deliberately after intentional performance improvements;", "lower them only with a written justification in the PR. If a benchmark is", "renamed or removed, update this file in the same change - the gate fails", @@ -28,16 +31,29 @@ "store.audit": 362 }, "floors": { - "nostrInbound.fresh": 530, - "nostrInbound.duplicate": 600000, + "nostrInbound.fresh": 450, + "nostrInbound.duplicate": 250000, "bleInbound.roundTripAndDedup": 9500, "gcs.buildAndDecode": 190, "delivery.incrementalUpdate": 43000, "delivery.storeUpdate": 39000, - "formatting.formatMessage": 3000, - "pipeline.privateIngest": 6000, - "pipeline.publicIngest": 3200, - "store.append": 53000, - "store.audit": 90 + "formatting.formatMessage": 2200, + "pipeline.privateIngest": 3000, + "pipeline.publicIngest": 2400, + "store.append": 48000, + "store.audit": 70 + }, + "_slowest_observed_ci_numbers_2026_06": { + "nostrInbound.fresh": 918, + "nostrInbound.duplicate": 524924, + "bleInbound.roundTripAndDedup": 20489, + "gcs.buildAndDecode": 504, + "delivery.incrementalUpdate": 110892, + "delivery.storeUpdate": 96531, + "formatting.formatMessage": 4575, + "pipeline.privateIngest": 6388, + "pipeline.publicIngest": 5006, + "store.append": 97423, + "store.audit": 140 } -} +} \ No newline at end of file diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index c6271973..8184564a 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -133,6 +133,99 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) } + func test_subscribe_parkedEOSEFiresAfterFallbackTimeoutWhenTorNeverBecomesReady() async { + let relayURL = "wss://tor-eose-parked-fallback.example" + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + var eoseCount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "tor-parked-fallback", + relayUrls: [relayURL], + handler: { _ in }, + onEOSE: { eoseCount += 1 } + ) + + // Parking the callback schedules the normal EOSE fallback, not just + // the Tor retry-exhaustion unblock (~minutes later). + XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrSubscriptionEOSEFallbackSeconds) + XCTAssertEqual(eoseCount, 0) + + context.scheduler.runNext() + let unblocked = await waitUntil { eoseCount == 1 } + XCTAssertTrue(unblocked) + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + // A later retry-exhaustion unblock must not fire the callback again. + for _ in 0..