fix: el tap del microfono seguia aislado al main actor (2.0.3 build 68)

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
This commit is contained in:
alexandrev-tibco
2026-08-20 09:21:52 +02:00
parent 6ba1ef1430
commit 39d3534c57
4 changed files with 122 additions and 6 deletions
@@ -1,4 +1,6 @@
import XCTest
import AVFoundation
import Speech
@testable import MealMood
/// Smoke cover for the dictation entry point.
@@ -32,6 +34,36 @@ final class SpeechDictationServiceTests: XCTestCase {
"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")