fix: dispatch_assert_queue crash in PreviewASRController.start
Crash: EXC_BREAKPOINT on first call to `requestSpeechAuthorization`. Backtrace: closure #1 in closure #2 in PreviewASRController.start(locale:) + 96 thunk for @escaping (@unowned SFSpeechRecognizerAuthorizationStatus) -> () __TCCAccessRequest_block_invoke_8 _dispatch_assert_queue_fail _swift_task_checkIsolatedSwift Root cause: `SFSpeechRecognizer.requestAuthorization` (and the iOS < 17 `AVAudioSession.requestRecordPermission`) deliver their callbacks on a TCC reply queue, NOT the main queue. My previous commit wrapped those callbacks inline inside `start(locale:)`, which is `@MainActor`. Swift 6 strict concurrency infers the inner closure body as `@MainActor`, so as soon as TCC delivers the callback on its own queue, the runtime's `_swift_task_checkIsolatedSwift` asserts we're on @MainActor, sees we're not, and traps. Fix: extract both permission dances to `private nonisolated static func` helpers. The helpers have no isolation, the callback closures defined inside them have no isolation, and `CheckedContinuation.resume` is itself thread-safe, so the TCC queue can resume it without dispatching through the main actor. private nonisolated static func requestMicrophonePermission() async -> Bool private nonisolated static func requestSpeechRecognitionPermission() async -> Bool `start(locale:)` now just `await`s those two helpers and proceeds to the engine / ASR setup on @MainActor as before. No behavior change; the user-visible flow is identical. Build: BUILD SUCCEEDED. Tests: 21/21 pass. Runtime: app launches and stays running on the simulator without the EXC_BREAKPOINT that the previous build hit immediately after tapping the keyboard preview's record disc. 🤖 Generated with Claude Code
This commit is contained in:
@@ -71,31 +71,20 @@ final class PreviewASRController: ObservableObject {
|
||||
errorMessage = nil
|
||||
level = 0
|
||||
|
||||
// 1. Microphone permission.
|
||||
let micGranted: Bool
|
||||
if #available(iOS 17.0, *) {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted: micGranted = true
|
||||
case .denied: micGranted = false
|
||||
case .undetermined: micGranted = await AVAudioApplication.requestRecordPermission()
|
||||
@unknown default: micGranted = false
|
||||
}
|
||||
} else {
|
||||
micGranted = await withCheckedContinuation { cont in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) }
|
||||
}
|
||||
}
|
||||
// 1. Microphone permission. The helper is `nonisolated` so the
|
||||
// (iOS < 17) callback closure does not inherit `@MainActor` —
|
||||
// `AVAudioSession.requestRecordPermission` delivers on a TCC
|
||||
// reply queue, and a `@MainActor`-inferred closure body there
|
||||
// hits `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift`.
|
||||
let micGranted = await Self.requestMicrophonePermission()
|
||||
guard micGranted else {
|
||||
phase = .denied("麦克风被拒绝 · Mic denied")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Speech recognition permission.
|
||||
let speechGranted = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
cont.resume(returning: status == .authorized)
|
||||
}
|
||||
}
|
||||
// 2. Speech recognition permission. Same reasoning as above:
|
||||
// the callback fires on TCC's reply queue, NOT the main queue.
|
||||
let speechGranted = await Self.requestSpeechRecognitionPermission()
|
||||
guard speechGranted else {
|
||||
phase = .denied("语音识别被拒绝 · Speech denied")
|
||||
return
|
||||
@@ -269,4 +258,41 @@ final class PreviewASRController: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission helpers (nonisolated)
|
||||
//
|
||||
// `SFSpeechRecognizer.requestAuthorization` and (iOS < 17)
|
||||
// `AVAudioSession.requestRecordPermission` deliver their callbacks
|
||||
// on a TCC reply queue, NOT the main queue. If we wrap those
|
||||
// callbacks inline in `start(locale:)` — which is `@MainActor` —
|
||||
// Swift 6 strict concurrency infers the closure body as
|
||||
// `@MainActor`, and the runtime crashes on
|
||||
// `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift` as
|
||||
// soon as TCC calls us back. Extracting to `nonisolated static
|
||||
// func` breaks the inference: the helper has no isolation, the
|
||||
// callback closure body has no isolation, and `cont.resume(...)`
|
||||
// is itself thread-safe on `CheckedContinuation`.
|
||||
|
||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||
if #available(iOS 17.0, *) {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted: return true
|
||||
case .denied: return false
|
||||
case .undetermined: return await AVAudioApplication.requestRecordPermission()
|
||||
@unknown default: return false
|
||||
}
|
||||
} else {
|
||||
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestSpeechRecognitionPermission() async -> Bool {
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
cont.resume(returning: status == .authorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user