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:
Rocky
2026-06-18 19:39:06 +08:00
parent aeb28f4b36
commit e8a031075b
+46 -20
View File
@@ -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)
}
}
}
}