Files
bitchat/bitchatTests/AudioSessionCoordinatorTests.swift
T
jackandGitHub 820c933958 Harden PTT audio and the 1.7.1 release (#1423)
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.

Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
2026-07-10 14:04:09 +02:00

511 lines
20 KiB
Swift

//
// AudioSessionCoordinatorTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
/// Thread-safe: the coordinator invokes it on its private serial queue (that
/// the calls happen off the main thread is itself under test) while the test
/// reads from the main actor.
private final class MockAudioSession: SessionApplying, @unchecked Sendable {
enum Call: Equatable {
case setCategory(AudioSessionCoordinator.Category)
case setActive(Bool, notifyOthers: Bool)
}
private let lock = NSLock()
private var _calls: [Call] = []
private var _callsOnMainThread: [Bool] = []
private var _nextError: Error?
private var _nextActivationError: Error?
var calls: [Call] { lock.withLock { _calls } }
/// Whether each recorded call ran on the main thread — the coordinator's
/// whole point is that none ever does (the real calls block on IPC to the
/// audio server).
var callsOnMainThread: [Bool] { lock.withLock { _callsOnMainThread } }
var nextError: Error? {
get { lock.withLock { _nextError } }
set { lock.withLock { _nextError = newValue } }
}
/// Fails only the next `setActive` (so `setCategory` can succeed first).
var nextActivationError: Error? {
get { lock.withLock { _nextActivationError } }
set { lock.withLock { _nextActivationError = newValue } }
}
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
try lock.withLock {
if let error = _nextError {
_nextError = nil
throw error
}
_calls.append(.setCategory(category))
_callsOnMainThread.append(Thread.isMainThread)
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
try lock.withLock {
if let error = _nextError {
_nextError = nil
throw error
}
if let error = _nextActivationError {
_nextActivationError = nil
throw error
}
_calls.append(.setActive(active, notifyOthers: notifyOthersOnDeactivation))
_callsOnMainThread.append(Thread.isMainThread)
}
}
var categoryCalls: [AudioSessionCoordinator.Category] {
calls.compactMap { if case .setCategory(let category) = $0 { category } else { nil } }
}
var activationCalls: [Bool] {
calls.compactMap { if case .setActive(let active, _) = $0 { active } else { nil } }
}
}
private struct MockSessionError: Error {}
/// Async suspension gate used to force lifecycle races without sleeps. The
/// production operation announces that it reached the gated boundary, then
/// stays suspended until the test opens it.
private actor AsyncGate {
private var isOpen = false
private var hasWaiter = false
private var arrivalWaiters: [CheckedContinuation<Void, Never>] = []
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
hasWaiter = true
let arrivals = arrivalWaiters
arrivalWaiters = []
for continuation in arrivals {
continuation.resume()
}
guard !isOpen else { return }
await withCheckedContinuation { continuation in
gateWaiters.append(continuation)
}
}
func waitUntilEntered() async {
guard !hasWaiter else { return }
await withCheckedContinuation { continuation in
arrivalWaiters.append(continuation)
}
}
func open() {
isOpen = true
let waiters = gateWaiters
gateWaiters = []
for continuation in waiters {
continuation.resume()
}
}
}
@MainActor
struct AudioSessionCoordinatorTests {
// MARK: - Reference-counted activation
@Test func activatesOnFirstAcquireAndDeactivatesOnLastRelease() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let first = try await coordinator.acquire(.playback) {}
let second = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true])
coordinator.release(first)
await coordinator.drain()
#expect(session.activationCalls == [true])
coordinator.release(second)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
#expect(session.calls.last == .setActive(false, notifyOthers: true))
}
@Test func releasingOneOfTwoClientsDoesNotDeactivate() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let playback = try await coordinator.acquire(.playback) {}
let capture = try await coordinator.acquire(.capture) {}
coordinator.release(capture)
await coordinator.drain()
#expect(session.activationCalls == [true])
coordinator.release(playback)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
@Test func doubleReleaseIsIdempotent() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let first = try await coordinator.acquire(.playback) {}
let second = try await coordinator.acquire(.playback) {}
coordinator.release(first)
coordinator.release(first)
await coordinator.drain()
// The stale second release must not tear the session out from under
// the remaining holder.
#expect(session.activationCalls == [true])
coordinator.release(second)
coordinator.release(second)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
// MARK: - Off-main session calls
@Test func sessionCallsNeverRunOnTheMainThread() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
// The real setCategory/setActive block on IPC to the audio server
// (>1 s observed under contention, tripping the system gesture gate
// on PTT press) — every call must land on the coordinator's queue.
let token = try await coordinator.acquire(.capture) {}
coordinator.release(token)
await coordinator.drain()
#expect(session.calls.count == 3) // setCategory + activate + deactivate
#expect(session.callsOnMainThread == [false, false, false])
}
@Test func failedActivationDoesNotRegisterAHolder() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
session.nextError = MockSessionError()
await #expect(throws: MockSessionError.self) {
try await coordinator.acquire(.playback) {}
}
// The failed acquire left no holder behind: the next one is 0->1
// again and activates.
let token = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true])
coordinator.release(token)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
@Test func failedActivationRollsBackEscalatedCategory() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
// setCategory(.playAndRecord) succeeds, setActive throws (e.g. a
// phone call owns the hardware).
session.nextActivationError = MockSessionError()
await #expect(throws: MockSessionError.self) {
try await coordinator.acquire(.capture) {}
}
// With no holder registered the escalated category must not stick:
// the next playback-only acquire runs under .playback, not the
// leftover .playAndRecord.
let token = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playAndRecord, .playback])
// And the failed acquire left no holder behind: this one was 0->1.
#expect(session.activationCalls == [true])
coordinator.release(token)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
// MARK: - Category escalation
@Test func captureWhilePlaybackEscalatesExactlyOnceAndNeverDowngrades() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let playback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playback])
let capture = try await coordinator.acquire(.capture) {}
#expect(session.categoryCalls == [.playback, .playAndRecord])
// More clients of either use don't touch the category again.
let secondCapture = try await coordinator.acquire(.capture) {}
let secondPlayback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playback, .playAndRecord])
// Capture ending must not downgrade the route under live playback.
coordinator.release(capture)
coordinator.release(secondCapture)
await coordinator.drain()
#expect(session.categoryCalls == [.playback, .playAndRecord])
// Even a fresh playback acquire stays on playAndRecord while held.
let thirdPlayback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playback, .playAndRecord])
coordinator.release(playback)
coordinator.release(secondPlayback)
coordinator.release(thirdPlayback)
await coordinator.drain()
}
@Test func categoryResetsAfterAllHoldersRelease() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let capture = try await coordinator.acquire(.capture) {}
coordinator.release(capture)
await coordinator.drain()
#expect(session.categoryCalls == [.playAndRecord])
// With no holders left the next playback-only session downgrades.
let playback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playAndRecord, .playback])
coordinator.release(playback)
await coordinator.drain()
}
@Test func escalationNotifiesExistingHoldersSoEnginesCanRestart() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var playbackInterruptions = 0
var captureInterruptions = 0
let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 }
let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 }
// The pre-existing playback holder was reconfigured underneath (the
// fan-out is delivered before acquire returns); the newly acquiring
// capture client was not.
#expect(playbackInterruptions == 1)
#expect(captureInterruptions == 0)
// A second capture doesn't change the category — nobody is notified.
let secondCapture = try await coordinator.acquire(.capture) {}
#expect(playbackInterruptions == 1)
#expect(captureInterruptions == 0)
coordinator.release(playback)
coordinator.release(capture)
coordinator.release(secondCapture)
await coordinator.drain()
}
@Test func escalationPrefersCategoryChangeCallbackOverInterruption() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var escalations = 0
var interruptions = 0
let playback = try await coordinator.acquire(
.playback,
onInterrupted: { interruptions += 1 },
onCategoryEscalated: { escalations += 1 }
)
// Escalation reaches the dedicated callback (the holder restarts and
// keeps playing) — not onInterrupted (which would stop it for good).
let capture = try await coordinator.acquire(.capture) {}
#expect(escalations == 1)
#expect(interruptions == 0)
// A real interruption still stops it.
await coordinator.handleInterruptionBegan()
#expect(escalations == 1)
#expect(interruptions == 1)
coordinator.release(playback)
coordinator.release(capture)
await coordinator.drain()
}
// MARK: - Interruptions and route changes
@Test func interruptionDuringAcquireHandoffCancelsAcquire() async throws {
let session = MockAudioSession()
let handoffGate = AsyncGate()
let coordinator = AudioSessionCoordinator(
session: session,
testingHooks: .init(beforeAcquireHandoff: {
await handoffGate.wait()
})
)
var interruptionCount = 0
let acquireTask = Task { @MainActor in
try await coordinator.acquire(.capture) {
interruptionCount += 1
}
}
// The session-queue registration is complete, but the caller has not
// received its token. An interruption here used to invoke the callback
// immediately, when capture clients could not release the token yet.
await handoffGate.waitUntilEntered()
await coordinator.handleInterruptionBegan()
#expect(interruptionCount == 0)
await handoffGate.open()
await #expect(throws: CancellationError.self) {
try await acquireTask.value
}
await coordinator.drain()
#expect(interruptionCount == 0)
// The OS already deactivated the interrupted session; removing the
// provisional token must not issue a redundant setActive(false).
#expect(session.activationCalls == [true])
// The canceled acquire left no holder behind and the now-open test gate
// does not affect a subsequent ownership handoff.
let replacement = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true, true])
coordinator.release(replacement)
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func releasedSnapshotCannotInterruptReacquiredToken() async throws {
let session = MockAudioSession()
let deliveryGate = AsyncGate()
let coordinator = AudioSessionCoordinator(
session: session,
testingHooks: .init(beforeCallbackDelivery: {
await deliveryGate.wait()
})
)
// Model a single client whose callback acts on whichever token it owns
// now. If the old snapshot is delivered after reacquisition, it would
// incorrectly release the new session.
var activeToken: AudioSessionCoordinator.Token?
var interruptionCount = 0
let onInterrupted: @MainActor () -> Void = {
interruptionCount += 1
activeToken.map(coordinator.release)
}
let first = try await coordinator.acquire(.playback, onInterrupted: onInterrupted)
activeToken = first
let interruptionTask = Task {
await coordinator.handleInterruptionBegan()
}
// The queue snapshot contains `first`, but main-actor delivery is held.
await deliveryGate.waitUntilEntered()
coordinator.release(first)
activeToken = nil
await coordinator.drain()
let second = try await coordinator.acquire(.playback, onInterrupted: onInterrupted)
activeToken = second
#expect(session.activationCalls == [true, true])
await deliveryGate.open()
await interruptionTask.value
await coordinator.drain()
#expect(interruptionCount == 0)
// A stale callback would have released `second` and appended false.
#expect(session.activationCalls == [true, true])
coordinator.release(second)
activeToken = nil
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func interruptionFansOutToAllHoldersAndResetsActiveState() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var playbackInterruptions = 0
var captureInterruptions = 0
// Capture first so no escalation fan-out muddies the counters.
let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 }
let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 }
#expect(session.activationCalls == [true])
await coordinator.handleInterruptionBegan()
#expect(playbackInterruptions == 1)
#expect(captureInterruptions == 1)
// The OS deactivated the session; the coordinator must not issue its
// own setActive(false) on top of it.
#expect(session.activationCalls == [true])
// The active state was reset: the next acquire re-activates even
// though holders never released.
let resumed = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true, true])
coordinator.release(playback)
coordinator.release(capture)
coordinator.release(resumed)
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func interruptedHoldersReleasingDuringFanOutStaySafe() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
// Real clients release from within onInterrupted (stop() paths);
// release is fire-and-forget onto the coordinator's queue, so it is
// safe from inside the main-actor fan-out.
var tokens: [AudioSessionCoordinator.Token] = []
for _ in 0..<2 {
var token: AudioSessionCoordinator.Token?
token = try await coordinator.acquire(.playback) {
token.map(coordinator.release)
}
tokens.append(token!)
}
await coordinator.handleInterruptionBegan()
await coordinator.drain()
// Every holder released mid-fan-out; the session was already
// deactivated by the OS, so no redundant setActive(false).
#expect(session.activationCalls == [true])
// All holders are gone: a fresh acquire is 0->1 again.
let token = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true, true])
coordinator.release(token)
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func routeDeviceUnavailableNotifiesHoldersButKeepsSessionActive() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var interruptions = 0
// Capture first so no escalation fan-out muddies the counter.
let capture = try await coordinator.acquire(.capture) { interruptions += 1 }
let playback = try await coordinator.acquire(.playback) { interruptions += 1 }
await coordinator.handleRouteDeviceUnavailable()
#expect(interruptions == 2)
// Unlike an interruption, the session itself is still active — the
// last holder's release performs the deactivation.
coordinator.release(playback)
coordinator.release(capture)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
}