Files
bitchat/bitchatTests/PTTBurstPlayerTests.swift
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

471 lines
18 KiB
Swift

//
// PTTBurstPlayerTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import AVFoundation
import Foundation
@testable import bitchat
/// Thread-safe: the coordinator invokes it on its private serial queue.
private final class StubAudioSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock()
private var _setCategoryError: Error?
var setCategoryError: Error? {
get { lock.withLock { _setCategoryError } }
set { lock.withLock { _setCategoryError = newValue } }
}
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
try lock.withLock {
if let error = _setCategoryError {
_setCategoryError = nil
throw error
}
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {}
}
private struct StubSessionError: Error {}
private final class StubExclusivePlayback: ExclusivePlayback {
private(set) var pauseCount = 0
func pauseForExclusivity() {
pauseCount += 1
}
}
/// Blocks activation until released, so a test can land events inside the
/// window where the (off-main) session acquire is still in flight.
private final class GatedAudioSession: SessionApplying, @unchecked Sendable {
private let gate = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var _categoryCallCount = 0
private var _activationCalls: [Bool] = []
/// Non-zero once the acquire has reached the session queue (setCategory
/// runs just before the gated setActive).
var categoryCallCount: Int { lock.withLock { _categoryCallCount } }
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
func open() { gate.signal() }
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
lock.withLock { _categoryCallCount += 1 }
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
if active { gate.wait() }
lock.withLock { _activationCalls.append(active) }
}
}
@MainActor
private final class MockPlaybackEngine: PTTPlaybackEngine {
private(set) var startCount = 0
private(set) var stopCount = 0
private(set) var scheduledBuffers: [AVAudioPCMBuffer] = []
private struct HeldCompletion {
let type: PTTPlaybackCompletionType
let callback: @Sendable (PTTPlaybackCompletionEvent) -> Void
}
private var heldCompletions: [HeldCompletion] = []
private(set) var requestedCompletionTypes: [PTTPlaybackCompletionType] = []
var startError: Error?
// No object -> the player registers no configuration-change observer.
var configChangeObject: AnyObject? { nil }
func start() throws {
if let error = startError { throw error }
startCount += 1
}
func play() {}
func stop() {
stopCount += 1
}
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
) {
// Completions are held, not fired automatically: most tests exercise
// lifecycle, not drain-out. The mock models the important distinction
// between the node consuming bytes and audio actually playing out.
scheduledBuffers.append(buffer)
requestedCompletionTypes.append(completionType)
heldCompletions.append(HeldCompletion(type: completionType, callback: completionHandler))
}
/// Advances the node only to the point where it has consumed each
/// buffer. A `.dataPlayedBack` request must remain pending here.
func fireDataConsumedCallbacks() {
for completion in heldCompletions {
completion.callback(.dataConsumed)
}
}
/// Advances all scheduled audio through audible playback.
func fireDataPlayedBackCallbacks() {
let completions = heldCompletions
heldCompletions = []
for completion in completions {
completion.callback(.dataPlayedBack)
}
}
/// Models AVAudioPlayerNode flushing its callbacks because an external
/// engine reconfiguration stopped the node before MainActor rebuilt it.
func firePlaybackStoppedCallbacks() {
let completions = heldCompletions
heldCompletions = []
for completion in completions {
completion.callback(.playbackStopped)
}
}
/// Plays only the oldest scheduled buffer, leaving the rest as an
/// audible tail that an engine rebuild must preserve.
func fireNextDataPlayedBackCallback() {
guard let index = heldCompletions.firstIndex(where: { $0.type == .dataPlayedBack }) else { return }
let completion = heldCompletions.remove(at: index)
completion.callback(.dataPlayedBack)
}
}
@MainActor
struct PTTBurstPlayerTests {
private func makePlayer(
coordinator: AudioSessionCoordinator
) throws -> (player: PTTBurstPlayer, engines: () -> [MockPlaybackEngine]) {
final class EngineBox { var engines: [MockPlaybackEngine] = [] }
let box = EngineBox()
// Fresh exclusivity slot: parallel tests must not steal this player's
// app-wide playback slot mid-test (the async session acquire opens
// suspension windows the old synchronous start never had).
let player = try #require(PTTBurstPlayer(
coordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator(),
makeEngine: {
let engine = MockPlaybackEngine()
box.engines.append(engine)
return engine
}
))
return (player, { box.engines })
}
/// Enough encoded audio to cross `TransportConfig.pttJitterBufferSeconds`
/// so playback starts without waiting for the deadline task.
private func encodeSineFrames(seconds: Double = 1.0) throws -> [Data] {
let encoder = try #require(PTTFrameEncoder())
let format = try #require(PTTAudioFormat.pcmFormat)
let totalFrames = AVAudioFrameCount(seconds * PTTAudioFormat.sampleRate)
let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: totalFrames))
buffer.frameLength = totalFrames
let channel = try #require(buffer.floatChannelData?[0])
for i in 0..<Int(totalFrames) {
channel[i] = sinf(2 * .pi * 440 * Float(i) / Float(PTTAudioFormat.sampleRate)) * 0.5
}
return encoder.encode(buffer)
}
/// The jitter-buffered start now acquires the session asynchronously
/// (its blocking IPC runs off the main actor), so tests await the
/// condition instead of asserting right after `enqueue`.
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(condition(), sourceLocation: sourceLocation)
}
// MARK: - Talk-over (bidirectional)
@Test func burstDrainWaitsForAudiblePlaybackNotDataConsumption() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
player.enqueue(try encodeSineFrames())
await waitUntil { player.isPlaying }
let engine = try #require(engines().first)
#expect(engine.requestedCompletionTypes.allSatisfy { $0 == .dataPlayedBack })
player.finishAfterDrain()
engine.fireDataConsumedCallbacks()
await Task.yield()
#expect(!player.stopped)
#expect(player.isPlaying)
engine.fireDataPlayedBackCallbacks()
await waitUntil { player.stopped }
#expect(!player.isPlaying)
}
@Test func olderPTTSessionAcquireCannotStealNewerPlaybackReservation() async throws {
let session = GatedAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let exclusivity = VoiceNotePlaybackCoordinator()
final class EngineBox { var engines: [MockPlaybackEngine] = [] }
let box = EngineBox()
let player = try #require(PTTBurstPlayer(
coordinator: coordinator,
exclusivity: exclusivity,
makeEngine: {
let engine = MockPlaybackEngine()
box.engines.append(engine)
return engine
}
))
player.enqueue(try encodeSineFrames())
await waitUntil { session.categoryCallCount == 1 }
// A user taps a voice note while the older inbound burst is blocked
// in audio-session activation. Its immediate play intent is newer.
let voiceNote = StubExclusivePlayback()
exclusivity.activate(voiceNote)
session.open()
await waitUntil { player.stopped }
#expect(box.engines.count == 1)
#expect(box.engines[0].startCount == 0)
#expect(voiceNote.pauseCount == 0)
#expect(!player.isPlaying)
}
@Test func categoryEscalationRestartsEngineAndKeepsStreaming() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.isPlaying }
#expect(engines().count == 1)
#expect(engines()[0].startCount == 1)
#expect(!engines()[0].scheduledBuffers.isEmpty)
// Push-to-talk pressed while the burst plays: capture escalates the
// session category. The playback engine must restart under the new
// configuration, not die. (Escalation fan-out is delivered before
// acquire returns, so no waiting is needed here.)
let capture = try await coordinator.acquire(.capture) {}
#expect(engines().count == 2)
#expect(engines()[0].stopCount == 1)
#expect(engines()[1].startCount == 1)
#expect(player.isPlaying)
// Frames arriving after the restart keep playing on the new engine.
player.enqueue(frames)
#expect(!engines()[1].scheduledBuffers.isEmpty)
coordinator.release(capture)
player.stop()
#expect(!player.isPlaying)
}
@Test func categoryEscalationReplaysOnlyUnfinishedTailAfterBurstEnd() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.isPlaying }
let originalEngine = engines()[0]
let originallyScheduled = originalEngine.scheduledBuffers.count
try #require(originallyScheduled > 1)
// One buffer has played, but deliberately do not yield for its
// MainActor completion task. The completion latch itself must keep
// the rebuild from replaying this already-completed prefix.
originalEngine.fireNextDataPlayedBackCallback()
player.finishAfterDrain()
// A real AVAudioPlayerNode also invokes requested callbacks when its
// engine is stopped by a configuration change. Those callbacks must
// leave the unheard tail pending for the fresh engine.
originalEngine.firePlaybackStoppedCallbacks()
// Capture joins after END while the remaining tail is still handed
// to the old engine. The fresh engine must replay that tail instead
// of looking empty and stopping immediately.
let capture = try await coordinator.acquire(.capture) {}
try #require(engines().count == 2)
let restartedEngine = engines()[1]
#expect(restartedEngine.scheduledBuffers.count == originallyScheduled - 1)
#expect(player.isPlaying)
#expect(!player.stopped)
// Only the fresh engine's audible completions may stop this finished
// burst; the stop callbacks above did not drain it.
#expect(player.isPlaying)
#expect(!player.stopped)
restartedEngine.fireDataPlayedBackCallbacks()
await waitUntil { player.stopped }
#expect(!player.isPlaying)
coordinator.release(capture)
}
@Test func realInterruptionStillStopsPlayback() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.isPlaying }
// A system interruption (phone call) is not an escalation: stop.
await coordinator.handleInterruptionBegan()
#expect(!player.isPlaying)
#expect(engines().count == 1)
#expect(engines()[0].stopCount == 1)
// A stopped burst stays stopped.
let before = engines()[0].scheduledBuffers.count
player.enqueue(frames)
#expect(engines()[0].scheduledBuffers.count == before)
}
// MARK: - Burst END racing the async session acquire
/// Models production ownership (`ChatLiveVoiceCoordinator.finalize`): the
/// assembly — the player's sole strong owner — is discarded on burst END,
/// and only the parked draining reference keeps the player alive. The
/// audio must still play out and the session token must come back once
/// the drain finishes.
@Test func burstEndDuringSessionAcquireStillPlaysWithOnlyDrainOwner() async throws {
let session = GatedAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
final class EngineBox { var engines: [MockPlaybackEngine] = [] }
let box = EngineBox()
var owner: PTTBurstPlayer? = PTTBurstPlayer(
coordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator(),
makeEngine: {
let engine = MockPlaybackEngine()
box.engines.append(engine)
return engine
}
)
try #require(owner != nil)
let weakPlayer = { [weak owner] in owner }
let frames = try encodeSineFrames()
owner?.enqueue(frames)
// END lands while activation is still blocked on the session queue.
// With nothing scheduled yet, the drain check must not mistake the
// not-yet-started burst for a played-out one and drop all its audio.
var draining: PTTBurstPlayer? = owner
owner?.onStopped = { draining = nil }
owner?.finishAfterDrain()
#expect(owner?.stopped == false)
owner = nil
session.open()
await waitUntil { weakPlayer()?.isPlaying == true }
#expect(box.engines.count == 1)
#expect(box.engines[0].startCount == 1)
#expect(!box.engines[0].scheduledBuffers.isEmpty)
// Play the tail out: the drain must stop the player, hand the token
// back (deactivation reaches the mock), and unpark the drain owner.
box.engines[0].fireDataPlayedBackCallbacks()
await waitUntil { session.activationCalls == [true, false] }
#expect(draining == nil)
#expect(weakPlayer() == nil)
}
/// Backstop: if every owner drops the player before it stopped, `deinit`
/// must hand the registered session token back — the coordinator retains
/// tokens strongly, so a leaked one would keep the session active (and
/// pin any escalated category) for the app's lifetime.
@Test func ownerlessPlayerDeinitReleasesSessionToken() async throws {
let session = GatedAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var player: PTTBurstPlayer? = PTTBurstPlayer(
coordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator(),
makeEngine: { MockPlaybackEngine() }
)
try #require(player != nil)
let frames = try encodeSineFrames()
player?.enqueue(frames)
// Make sure the acquire is in flight (holding the player alive
// through its call frame) before the last external reference drops.
await waitUntil { session.categoryCallCount == 1 }
player?.finishAfterDrain()
player = nil
session.open()
// The acquire task keeps the player alive just long enough to start;
// when it deallocates, deinit must release the freshly stored token.
await waitUntil { session.activationCalls == [true, false] }
}
// MARK: - Session acquire failure
@Test func sessionAcquireFailureDoesNotStartUnregisteredPlayback() async throws {
let session = StubAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let (player, engines) = try makePlayer(coordinator: coordinator)
// Playing without a registered holder would leave the engine exposed
// to another holder's last-release deactivating the session under it.
session.setCategoryError = StubSessionError()
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.stopped }
#expect(engines().count == 1)
#expect(engines()[0].startCount == 0)
#expect(!player.isPlaying)
// The failed start latched the player off; later frames are ignored.
player.enqueue(frames)
#expect(engines()[0].scheduledBuffers.isEmpty)
#expect(!player.isPlaying)
}
// MARK: - Engine start failure
@Test func engineStartFailureRebuildsOnceBeforeGivingUp() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
// A capture racing the start can reconfigure the session while the
// engine spins up (its escalation fan-out no-ops on a never-started
// player): the player must rebuild once against the settled
// configuration instead of latching off.
engines()[0].startError = StubSessionError()
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.isPlaying }
#expect(engines().count == 2)
#expect(engines()[0].startCount == 0)
#expect(engines()[1].startCount == 1)
#expect(!engines()[1].scheduledBuffers.isEmpty)
player.stop()
}
}