From e8a031075b64b2d9645c0a02f5b0e79fc1745b89 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:39:06 +0800 Subject: [PATCH] fix: dispatch_assert_queue crash in PreviewASRController.start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- OSGKeyboard/Views/PreviewASRController.swift | 66 ++++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/OSGKeyboard/Views/PreviewASRController.swift b/OSGKeyboard/Views/PreviewASRController.swift index 882b94b..9bc11f3 100644 --- a/OSGKeyboard/Views/PreviewASRController.swift +++ b/OSGKeyboard/Views/PreviewASRController.swift @@ -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) 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) in + AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) } + } + } + } + + private nonisolated static func requestSpeechRecognitionPermission() async -> Bool { + await withCheckedContinuation { (cont: CheckedContinuation) in + SFSpeechRecognizer.requestAuthorization { status in + cont.resume(returning: status == .authorized) + } + } + } }