test: remove concurrent suite timing races

This commit is contained in:
Dev 2026-07-28 11:26:59 +03:00
parent b7e11ff3f4
commit 255cc88d80
2 changed files with 58 additions and 32 deletions

View File

@ -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

View File

@ -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<Void, Never>] = []
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<Void, Never>] 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<Void, Never>] = []
@ -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()