@_spi(Fixtures) import CallsKit import Foundation /// `CallsInterop --audio-start-stall`: the main thread is never held /// longer than `limit` while the audio starts — Test audio before a call, /// and a call's audio as a join starts it — on the real engine module. /// /// 0.3.8 held it for a pause and then a beachball (Paul's AirPods, /// 2026-09-27): building the factory, applying the loopback's answer, and /// `initRecording` waiting out the headset's switch to its hands-free /// profile, all on the main thread. The switch is stood in for by holding /// the engine's start on WebRTC's worker thread for 1.5 s /// (AudioStartProbe.engineStartDelay), where a real one holds it. /// /// First a control: a deliberate 300 ms block of the main thread must be /// seen, so a pass means the watchdog was looking. Prints one PASS or FAIL /// line per run, with the longest stall; exits 0 only if all passed. @MainActor enum AudioStartStallHarness { static let limit = 0.1 static let switchDelay = 1.5 static func run() async -> Bool { let dog = MainThreadWatchdog() dog.start() try? await Task.sleep(for: .milliseconds(200)) var passed = await control(dog) passed = await testAudio(dog, label: "Test audio start", delay: 0) && passed passed = await testAudio(dog, label: "Test audio start, Bluetooth switch simulated", delay: switchDelay) && passed passed = await testAudio( dog, label: "Test audio closed while its microphone starts", delay: switchDelay, closeAfter: 0.3) && passed passed = await callJoin(dog) && passed _ = dog.stop() return passed } private static func control(_ dog: MainThreadWatchdog) async -> Bool { let label = "audio start stall control (the watchdog sees a 300 ms block)" dog.reset() Thread.sleep(forTimeInterval: 0.3) // The watchdog's posts answer once the main thread is free again. try? await Task.sleep(for: .milliseconds(50)) let seen = dog.longestSoFar if seen >= 0.25 { print("PASS \(label): saw \(ms(seen))") return true } print("FAIL \(label): saw only \(ms(seen))") return false } private static func testAudio( _ dog: MainThreadWatchdog, label: String, delay: TimeInterval, closeAfter: Double? = nil ) async -> Bool { AudioStartProbe.engineStartDelay = delay defer { AudioStartProbe.engineStartDelay = 0 } await settle() dog.reset() let report = await AudioStartProbe.testAudio(closeAfter: closeAfter) let stall = dog.longestSoFar var problems: [String] = [] if stall > limit { problems.append("main thread held \(ms(stall)), limit \(ms(limit))") } let firstShown = report.phases.first { $0.phase != .idle } if let firstShown, firstShown.at > limit { problems.append("the window said nothing for \(ms(firstShown.at))") } if firstShown == nil { problems.append("the window never said it was starting") } let starting = report.phases.first { if case .starting = $0.phase { return true } else { return false } } let ended = report.phases.last { $0.phase == .running || $0.phase == .failed || $0.phase == .cancelled } if closeAfter == nil { if !report.asked { problems.append("the module was never asked for the microphone") } if delay > 0, let starting, let ended, ended.at - starting.at < delay * 0.9 { problems.append( "'Starting microphone…' showed for \(ms(ended.at - starting.at)), not for the \(ms(delay)) switch") } } else if ended?.phase != .cancelled { problems.append("closing partway left the phase at \(String(describing: ended?.phase))") } if report.recordingAfter { problems.append("the microphone was left open") } let phases = report.phases.map { "\(String(describing: $0.phase)) at \(ms($0.at))" }.joined(separator: ", ") let detail = "longest main-thread stall \(ms(stall)); start took \(ms(report.startSeconds)); phases: \(phases); asked \(report.asked); problem: \(report.problem ?? "none"); \(report.detail)" if problems.isEmpty { print("PASS \(label): \(detail)") return true } print("FAIL \(label): \(problems.joined(separator: "; ")) [\(detail)]") return false } private static func callJoin(_ dog: MainThreadWatchdog) async -> Bool { let label = "Call join audio start, Bluetooth switch simulated" AudioStartProbe.engineStartDelay = switchDelay defer { AudioStartProbe.engineStartDelay = 0 } await settle() dog.reset() let report = await AudioStartProbe.callJoin(camera: Interop.camera) let stall = dog.longestSoFar var problems: [String] = [] if stall > limit { problems.append("main thread held \(ms(stall)), limit \(ms(limit))") } if !report.asked { problems.append("the module was never asked for the microphone") } if let problem = report.problem { problems.append(problem) } if report.recordingAfter { problems.append("the microphone was left open") } let detail = "longest main-thread stall \(ms(stall)); audio start took \(ms(report.startSeconds)); asked \(report.asked); \(report.detail)" if problems.isEmpty { print("PASS \(label): \(detail)") return true } print("FAIL \(label): \(problems.joined(separator: "; ")) [\(detail)]") return false } /// Lets the last run's queued stops and closes finish before the next /// is measured. private static func settle() async { try? await Task.sleep(for: .milliseconds(300)) } static func ms(_ seconds: Double) -> String { "\(Int((seconds * 1000).rounded())) ms" } }