mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 11:45:21 +00:00
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.
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
//
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user