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
|
errorMessage = nil
|
||||||
level = 0
|
level = 0
|
||||||
|
|
||||||
// 1. Microphone permission.
|
// 1. Microphone permission. The helper is `nonisolated` so the
|
||||||
let micGranted: Bool
|
// (iOS < 17) callback closure does not inherit `@MainActor` —
|
||||||
if #available(iOS 17.0, *) {
|
// `AVAudioSession.requestRecordPermission` delivers on a TCC
|
||||||
switch AVAudioApplication.shared.recordPermission {
|
// reply queue, and a `@MainActor`-inferred closure body there
|
||||||
case .granted: micGranted = true
|
// hits `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift`.
|
||||||
case .denied: micGranted = false
|
let micGranted = await Self.requestMicrophonePermission()
|
||||||
case .undetermined: micGranted = await AVAudioApplication.requestRecordPermission()
|
|
||||||
@unknown default: micGranted = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
micGranted = await withCheckedContinuation { cont in
|
|
||||||
AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard micGranted else {
|
guard micGranted else {
|
||||||
phase = .denied("麦克风被拒绝 · Mic denied")
|
phase = .denied("麦克风被拒绝 · Mic denied")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Speech recognition permission.
|
// 2. Speech recognition permission. Same reasoning as above:
|
||||||
let speechGranted = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
// the callback fires on TCC's reply queue, NOT the main queue.
|
||||||
SFSpeechRecognizer.requestAuthorization { status in
|
let speechGranted = await Self.requestSpeechRecognitionPermission()
|
||||||
cont.resume(returning: status == .authorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard speechGranted else {
|
guard speechGranted else {
|
||||||
phase = .denied("语音识别被拒绝 · Speech denied")
|
phase = .denied("语音识别被拒绝 · Speech denied")
|
||||||
return
|
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