mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-08 06:56:10 +00:00
Deflake VoiceRecorderTests: replace timed semaphores with async events (#1572)
waitUntilActivationBegan hardcoded a 5-second DispatchSemaphore timeout — below the 10s house floor and invisible to TestTimingHygieneTests (it's a semaphore wait, not a helper-timeout parameter). On a starved runner the window expired before the recorder's session-acquire task was scheduled, failing cancelWhileSessionAcquireIsInFlightNeverCreates- ARecorder — 7 sightings, including three in the last two days (#1506 and #1528 merge runs, #1550's PR run). The fix is extracted verbatim from #1107 (mmalmi), which carries it but is blocked on a V3 rebase: both test gates (activation and padding) drop their DispatchSemaphore + timeout for an untimed async-event wait (VoiceRecorderAsyncEvent), so there is no timing constant left to starve — the test framework's own timeout is the backstop. Extracting it unblocks CI now; #1107's rebase will see this file already matching its branch. Verified (count-checked via xcresulttool): 7/7 VoiceRecorderTests on the iOS simulator, and 7/7 x 5 consecutive runs under 16x CPU oversubscription. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
59a9f628df
commit
6f32363774
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user