diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 74aa74d3..1f5ad904 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -816,6 +816,8 @@ struct BLEServiceCoreTests { let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let reconciled = SessionReconcileCounter() ble._test_onPrivateMediaSessionReconciled = reconciled.record + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record // Establish BLE as responder so the inbound reconnect below is not // coalesced by the initiator-completion grace path. @@ -838,12 +840,23 @@ struct BLEServiceCoreTests { ) await ble._test_drainNoiseMessagePipeline() #expect(ble.canDeliverSecurely(to: alicePeerID)) + let initialEncryptedFrameSent = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseEncrypted) >= 1 }, + timeout: TestConstants.longTimeout + ) + try #require(initialEncryptedFrameSent) + #expect(outbound.count(ofType: .noiseEncrypted) == 1) let initialReconcileRan = await TestHelpers.waitUntil( { reconciled.count(for: alicePeerID) == 1 }, timeout: TestConstants.longTimeout ) try #require(initialReconcileRan) await ble._test_drainNoiseMessagePipeline() + // Keep one tap installed for the service's whole lifetime. After the + // explicit initial-frame observation and queue fence, start a fresh, + // lock-protected capture epoch so every assertion below measures only + // rollback/convergence output under a saturated concurrent suite. + outbound.removeAll() // The convergence retry only prepares for reachable peers. ble._test_seedConnectedPeer(alicePeerID, nickname: "Alice") @@ -854,8 +867,6 @@ struct BLEServiceCoreTests { let recoveryGate = HandshakeRecoveryEnqueueGate() defer { recoveryGate.release() } ble._test_beforeHandshakeRecoveryEnqueued = { _ in recoveryGate.pause() } - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = outbound.record // Park the traffic the race would lose directly in the pending // queues — the same place live sends land during quarantine — so no @@ -1371,6 +1382,10 @@ private final class OutboundPacketTap { lock.lock(); defer { lock.unlock() } return packets } + + func removeAll() { + lock.lock(); packets.removeAll(); lock.unlock() + } } /// Blocks the convergence-recovery callback on its global-queue thread so a diff --git a/bitchatTests/VoiceRecorderTests.swift b/bitchatTests/VoiceRecorderTests.swift index c9b6839e..68069f11 100644 --- a/bitchatTests/VoiceRecorderTests.swift +++ b/bitchatTests/VoiceRecorderTests.swift @@ -10,10 +10,41 @@ import Foundation import Testing @testable import bitchat +/// One-shot event that bridges synchronous production seams to async tests +/// without blocking a shared dispatch worker while waiting for the seam. +private final class VoiceRecorderAsyncEvent: @unchecked Sendable { + private let lock = NSLock() + private var isSignaled = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + let resumeImmediately = lock.withLock { () -> Bool in + guard !isSignaled else { return true } + waiters.append(continuation) + return false + } + if resumeImmediately { + continuation.resume() + } + } + } + + func signal() { + let continuations = lock.withLock { () -> [CheckedContinuation] in + guard !isSignaled else { return [] } + isSignaled = true + defer { waiters.removeAll() } + return waiters + } + continuations.forEach { $0.resume() } + } +} + private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable { private let lock = NSLock() private let activationGate = DispatchSemaphore(value: 0) - private let activationBeganGate = DispatchSemaphore(value: 0) + private let activationBegan = VoiceRecorderAsyncEvent() private let shouldGateFirstActivation: Bool private var gatedFirstActivation = false private var _activationCalls: [Bool] = [] @@ -34,23 +65,13 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab return true } if shouldWait { - activationBeganGate.signal() + activationBegan.signal() activationGate.wait() } } - func waitUntilActivationBegan( - timeout: DispatchTimeInterval = .seconds(5) - ) async -> Bool { - await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - continuation.resume( - returning: self.activationBeganGate.wait( - timeout: DispatchTime.now() + timeout - ) == .success - ) - } - } + func waitUntilActivationBegan() async { + await activationBegan.wait() } func resumeActivation() { @@ -155,7 +176,7 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating { /// this remains deterministic when the full test suite saturates the executor. private final class VoiceRecorderPaddingGate: @unchecked Sendable { private let lock = NSLock() - private let enteredGate = DispatchSemaphore(value: 0) + private let entered = VoiceRecorderAsyncEvent() private var isOpen = false private var openWaiters: [CheckedContinuation] = [] @@ -166,25 +187,15 @@ private final class VoiceRecorderPaddingGate: @unchecked Sendable { openWaiters.append(continuation) return false } - enteredGate.signal() + entered.signal() if resumeImmediately { continuation.resume() } } } - func waitUntilEntered( - timeout: DispatchTimeInterval = .seconds(5) - ) async -> Bool { - await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - continuation.resume( - returning: self.enteredGate.wait( - timeout: DispatchTime.now() + timeout - ) == .success - ) - } - } + func waitUntilEntered() async { + await entered.wait() } func open() { @@ -223,7 +234,7 @@ struct VoiceRecorderTests { let owner = VoiceRecorder.RecordingOwner() let startTask = Task { try await voiceRecorder.startRecording(owner: owner) } - #expect(await session.waitUntilActivationBegan()) + await session.waitUntilActivationBegan() await voiceRecorder.cancelRecording(owner: owner) session.resumeActivation() @@ -321,7 +332,7 @@ struct VoiceRecorderTests { try await finishingHold.start() let firstURL = try #require(factory.urls.first) let finishTask = Task { await finishingHold.finish() } - #expect(await paddingGate.waitUntilEntered()) + await paddingGate.waitUntilEntered() await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) { try await rejectedHold.start()