39d3534c57
El fix anterior arreglo los permisos —el crash se movio de start a beginSession, y de TCC a AVAudioNodeTap::CheckEmitBuffer— pero el tap seguia petando. Mi error: quitar el acceso a self del closure no elimina la inferencia de aislamiento. La inferencia viene de DONDE se escribe el closure, no de lo que captura. Escrito dentro de un metodo @MainActor, seguia siendo main-actor isolated, y el tap se dispara desde el hilo de audio en tiempo real. Ahora el handler se construye en makeTapHandler, que es nonisolated, y se pasa a installTap. La estructura se auto-verifica: quitarle nonisolated a makeTapHandler no compila, porque installTap es nonisolated y no puede llamar a un metodo aislado. El bug pasa de crash en produccion a error de compilacion. Tests: testTapHandlerRunsOffTheMainThread invoca el handler desde una cola de fondo, que es exactamente la condicion que trapeaba. No hace falta microfono; el intento anterior con AVAudioEngine se saltaba siempre porque el simulador no tiene entrada de audio utilizable. Barrido del mismo patron en el resto de servicios @MainActor: CalendarService y NotificationService usan las variantes async/await, que estan anotadas y no tienen closures. SpeechDictationService era el unico sitio. Lanes feedback y crashlog para leer los reportes de TestFlight desde la API: spaceship apunta a v1/betaFeedbacks, que Apple ya retiro; el endpoint vivo es v1/apps/<id>/betaFeedbackCrashSubmissions y el log viene inline en logText. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
74 lines
3.2 KiB
Swift
74 lines
3.2 KiB
Swift
import XCTest
|
|
import AVFoundation
|
|
import Speech
|
|
@testable import MealMood
|
|
|
|
/// Smoke cover for the dictation entry point.
|
|
///
|
|
/// **This does not reproduce the crash it was written for.** The production
|
|
/// crash (`dispatch_assert_queue` inside the `requestAuthorization` handler)
|
|
/// needs TCC to deliver the callback asynchronously on a background queue, which
|
|
/// is what happens on device when the permission prompt is answered. The
|
|
/// simulator delivers it synchronously on the calling thread instead, so the
|
|
/// isolation check passes and the pre-fix code runs clean here — verified by
|
|
/// reverting the fix and watching these tests still pass.
|
|
///
|
|
/// What it does cover: `start` settles instead of hanging, and does not report
|
|
/// recording when permission is unavailable. Verifying the crash itself needs a
|
|
/// real device with the speech and microphone permissions reset.
|
|
@MainActor
|
|
final class SpeechDictationServiceTests: XCTestCase {
|
|
|
|
func testStartSettlesWithoutRecordingWhenPermissionIsUnavailable() async throws {
|
|
let service = SpeechDictationService()
|
|
XCTAssertEqual(service.state, .idle)
|
|
|
|
service.start(localeIdentifier: "en-US")
|
|
|
|
let deadline = Date().addingTimeInterval(10)
|
|
while service.state == .idle && Date() < deadline {
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
}
|
|
|
|
XCTAssertNotEqual(service.state, .recording,
|
|
"No permission is granted in this environment, so it must not record")
|
|
}
|
|
|
|
/// This one *does* reproduce the crash class, without needing a microphone.
|
|
///
|
|
/// In 2.0.3 build 68 the app still died at the tap: `AVAudioNodeTapBlock` is
|
|
/// pre-concurrency, so a closure literal written inside a `@MainActor` method
|
|
/// was inferred main-actor isolated, and the tap fires from the realtime
|
|
/// audio thread — Swift 6's isolation check tripped `dispatch_assert_queue`.
|
|
/// Dropping the `self` capture had not been enough; isolation is inferred
|
|
/// from where the closure is written.
|
|
///
|
|
/// Calling the handler off the main thread is the exact condition that
|
|
/// trapped, so this fails loudly if the isolation ever creeps back.
|
|
func testTapHandlerRunsOffTheMainThread() async throws {
|
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
|
let handler = SpeechDictationService.makeTapHandler(feeding: request)
|
|
|
|
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1))
|
|
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 1024))
|
|
buffer.frameLength = 1024
|
|
|
|
let ran = expectation(description: "tap handler ran on a background queue")
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
XCTAssertFalse(Thread.isMainThread)
|
|
handler(buffer, AVAudioTime(sampleTime: 0, atRate: 44_100))
|
|
ran.fulfill()
|
|
}
|
|
await fulfillment(of: [ran], timeout: 5)
|
|
|
|
request.endAudio()
|
|
}
|
|
|
|
func testStartTwiceDoesNotBlowUp() {
|
|
let service = SpeechDictationService()
|
|
service.start(localeIdentifier: "en-US")
|
|
service.start(localeIdentifier: "en-US")
|
|
XCTAssertNotEqual(service.state, .recording)
|
|
}
|
|
}
|