fix: crash al dictar por closures de callbacks aislados al main actor

Crashlytics: dispatch_assert_queue_fail en com.apple.root.default-qos, dentro de
closure #1 in SpeechDictationService.start, llamado desde __TCCAccessRequest.

El proyecto compila en Swift 6 (activo desde febrero, antes del dictado). Los
callbacks de APIs ObjC pre-concurrency escritos dentro de este tipo @MainActor se
infieren aislados al main actor, y Swift 6 inserta una comprobacion de aislamiento
al entrar. TCC invoca el handler en cola de fondo, la comprobacion falla y mata el
proceso. El Task { @MainActor } de dentro no ayuda: el crash es antes de llegar.

Cuatro sitios con el mismo patron, tres enmascarados detras del primero porque
nadie pasaba de los permisos:

- requestAuthorization y requestRecordPermission -> wrappers nonisolated con
  continuation, el handler deja de estar aislado
- recognitionTask -> closure @Sendable explicito; solo cruzan valores Sendable
  porque SFSpeechRecognitionResult no lo es
- installTap -> captura el request local en vez de ir por self, que el hilo de
  audio en tiempo real no debe tocar estado del main actor

requestAuthorization llama al handler aunque el permiso ya este concedido, asi
que afectaba tambien a usuarios recurrentes: el dictado probablemente no ha
funcionado nunca desde la 2.0.

Los tests que acompanan NO reproducen el crash: el simulador entrega el callback
sincrono en el hilo principal y el codigo previo pasa igual. Comprobado
revirtiendo el fix. Queda pendiente validarlo en dispositivo real.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
This commit is contained in:
alexandrev-tibco
2026-08-19 21:28:29 +02:00
parent a6dcb5605c
commit 896c78d260
3 changed files with 95 additions and 20 deletions
+6 -2
View File
@@ -35,6 +35,7 @@
5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */; }; 5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */; };
5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; }; 5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; };
5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */; }; 5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */; };
64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */; };
6553473B007AFD04079878C6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */; }; 6553473B007AFD04079878C6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */; };
6685C591F837FAFB29A322F7 /* DeduplicationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */; }; 6685C591F837FAFB29A322F7 /* DeduplicationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */; };
670A11A9850B321D9FF76B41 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */; }; 670A11A9850B321D9FF76B41 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */; };
@@ -170,6 +171,7 @@
623EDDC4CD134883FC9D242D /* Dish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dish.swift; sourceTree = "<group>"; }; 623EDDC4CD134883FC9D242D /* Dish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dish.swift; sourceTree = "<group>"; };
62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PremiumSyncServiceTests.swift; sourceTree = "<group>"; }; 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PremiumSyncServiceTests.swift; sourceTree = "<group>"; };
69B309A43E486B5A723324BA /* WeekCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekCalendarView.swift; sourceTree = "<group>"; }; 69B309A43E486B5A723324BA /* WeekCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekCalendarView.swift; sourceTree = "<group>"; };
69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpeechDictationServiceTests.swift; sourceTree = "<group>"; };
6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = "<group>"; }; 6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = "<group>"; };
70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = "<group>"; }; 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = "<group>"; };
@@ -399,6 +401,7 @@
168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */, 168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */,
17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */, 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */,
62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */, 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */,
69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */,
); );
path = MealMoodTests; path = MealMoodTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -761,6 +764,7 @@
016D78C0DC1B1F6E671F36F7 /* OnboardingViewModelTests.swift in Sources */, 016D78C0DC1B1F6E671F36F7 /* OnboardingViewModelTests.swift in Sources */,
ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */, ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */,
2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */, 2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */,
64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -1017,7 +1021,7 @@
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 66; CURRENT_PROJECT_VERSION = 67;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist; INFOPLIST_FILE = MealMood/Resources/Info.plist;
@@ -1140,7 +1144,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 66; CURRENT_PROJECT_VERSION = 67;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist; INFOPLIST_FILE = MealMood/Resources/Info.plist;
+47 -17
View File
@@ -32,21 +32,43 @@ final class SpeechDictationService: ObservableObject {
guard state != .recording else { return } guard state != .recording else { return }
transcript = "" transcript = ""
SFSpeechRecognizer.requestAuthorization { [weak self] speechAuth in
guard let self else { return }
Task { @MainActor in Task { @MainActor in
guard speechAuth == .authorized else { self.state = .denied; return } guard await Self.requestSpeechAuthorization() == .authorized else {
self.requestMicAndStart(localeIdentifier: localeIdentifier) state = .denied
return
}
guard await Self.requestMicrophoneAccess() else {
state = .denied
return
}
beginSession(localeIdentifier: localeIdentifier)
}
}
// MARK: Permissions
//
// Both are pre-concurrency Objective-C APIs. A closure literal written
// inside this `@MainActor` type is inferred as main-actor isolated, and
// Swift 6 then inserts a runtime isolation check on entry. TCC invokes these
// handlers on a background queue, so that check tripped `dispatch_assert_queue`
// and killed the app the moment the user answered the permission prompt
// every time, including when permission had already been granted.
//
// Declaring the wrappers `nonisolated` keeps the handlers free of isolation;
// the continuation resumes safely from whatever queue TCC used.
private nonisolated static func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
} }
} }
} }
private func requestMicAndStart(localeIdentifier: String) { private nonisolated static func requestMicrophoneAccess() async -> Bool {
AVAudioApplication.requestRecordPermission { [weak self] granted in await withCheckedContinuation { continuation in
guard let self else { return } AVAudioApplication.requestRecordPermission { granted in
Task { @MainActor in continuation.resume(returning: granted)
guard granted else { self.state = .denied; return }
self.beginSession(localeIdentifier: localeIdentifier)
} }
} }
} }
@@ -74,21 +96,29 @@ final class SpeechDictationService: ObservableObject {
let inputNode = audioEngine.inputNode let inputNode = audioEngine.inputNode
let format = inputNode.outputFormat(forBus: 0) let format = inputNode.outputFormat(forBus: 0)
inputNode.removeTap(onBus: 0) inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in // Captures the request directly instead of reaching through `self`:
self?.request?.append(buffer) // this runs on the realtime audio thread, which must never touch
// main-actor state. `append` is designed to be fed from that thread.
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
request.append(buffer)
} }
audioEngine.prepare() audioEngine.prepare()
try audioEngine.start() try audioEngine.start()
state = .recording state = .recording
task = recognizer.recognitionTask(with: request) { [weak self] result, error in // Same pre-concurrency shape as the permission handlers: this is
guard let self else { return } // called off the main thread, so the closure must not be isolated.
// The result is not Sendable, so only plain values cross the hop.
task = recognizer.recognitionTask(with: request) { @Sendable [weak self] result, error in
let text = result?.bestTranscription.formattedString
let hasFinished = error != nil || (result?.isFinal ?? false)
Task { @MainActor in Task { @MainActor in
if let result { guard let self else { return }
self.transcript = result.bestTranscription.formattedString if let text {
self.transcript = text
} }
if error != nil || (result?.isFinal ?? false) { if hasFinished {
self.teardownAudio() self.teardownAudio()
if self.state == .recording { self.state = .idle } if self.state == .recording { self.state = .idle }
} }
@@ -0,0 +1,41 @@
import XCTest
@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")
}
func testStartTwiceDoesNotBlowUp() {
let service = SpeechDictationService()
service.start(localeIdentifier: "en-US")
service.start(localeIdentifier: "en-US")
XCTAssertNotEqual(service.state, .recording)
}
}