From ffeb18c8d3c1de81998352d34527dd7bb647c2b8 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:15:08 +0800 Subject: [PATCH] fix: SFSpeechRecognizer callback dispatch_assert_queue (function ref) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same `dispatch_assert_queue_fail` family as `3e3eb0f` (audio tap), but on a different code path: `SFSpeechRecognizer.requestAuthorization`. The crash signature was the giveaway: closure #1 in closure #2 in PreviewASRController.start(locale:)+96 thunk for @escaping @callee_guaranteed (@unowned SFSpeechRecognizerAuthorizationStatus) -> () __TCCAccessRequest_block_invoke_8 `closure #2` is the `withCheckedContinuation` body, `closure #1` is the requestAuthorization callback. Both end up attributed to `start(locale:)` — which is `@MainActor` — because Swift 6 *inlined* the `nonisolated static func` helper (`e8a0310`'s fix) back into the caller. After inlining, the inner closure is retyped in the caller's @MainActor context, the runtime asserts main-queue, and TCC's reply queue (where the callback actually fires) trips the assert. Why `nonisolated static` alone is not enough: the optimizer aggressively inlines small async helpers, and when it does, the isolation of the closures inside the body re-infers from the inlined context. Same trap as the audio tap — function bodies defined inside a `@MainActor` method can't escape @MainActor just because their enclosing function was marked nonisolated. The *function-reference* pattern (the fix in `3e3eb0f`) is what works: SFSpeechRecognizer.requestAuthorization( Self.makeSpeechAuthHandler(continuation: cont) ) `makeSpeechAuthHandler` is `nonisolated static`, returns the `(Status) -> Void` handler, and crucially the closure body is *constructed* in a nonisolated context. When Swift tries to inline the callback, it has nothing to inline — only a function reference to a non-inlinable (because it crosses an isolation boundary implicitly) helper. The runtime sees a nonisolated closure on a non-main queue and is satisfied. Same treatment for the iOS < 17 mic path (`makeMicAuthHandler`), which has the same TCC-reply-queue dispatch shape. Build: BUILD SUCCEEDED. Tests: 21/21 pass. 🤖 Generated with Claude Code --- OSGKeyboard/Views/PreviewASRController.swift | 54 +++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/OSGKeyboard/Views/PreviewASRController.swift b/OSGKeyboard/Views/PreviewASRController.swift index e993590..709b3ac 100644 --- a/OSGKeyboard/Views/PreviewASRController.swift +++ b/OSGKeyboard/Views/PreviewASRController.swift @@ -249,10 +249,30 @@ final class PreviewASRController: ObservableObject { // 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`. + // soon as TCC calls us back. + // + // The first attempt (commit `e8a0310`) extracted the entire + // permission request into a `nonisolated static func` helper. + // That worked in isolation, but the Swift 6 optimizer + // inlined those helpers back into `start(locale:)`. After + // inlining, the `withCheckedContinuation` body and the + // `requestAuthorization` callback were re-typed in the + // `@MainActor` context of the caller, and the runtime + // assertion came right back — same crash, different symbol: + // `closure #1 in closure #2 in PreviewASRController.start(locale:)`. + // + // The fix that survives inlining is the *function-reference* + // pattern, the same one used for `installTap` in + // `makeAudioTapBlock` below. The callback is built in a + // `nonisolated` static helper that takes a `CheckedContinuation` + // and returns the `(Status) -> Void` handler. The body of that + // helper has no enclosing actor, so the closure is created in + // nonisolated context. When TCC calls us back, the runtime + // sees a nonisolated closure on a non-main queue and is happy. + // + // `cont.resume(...)` is itself thread-safe on + // `CheckedContinuation`, so we don't need to hop back to the + // main actor before resuming. private nonisolated static func requestMicrophonePermission() async -> Bool { if #available(iOS 17.0, *) { @@ -264,16 +284,34 @@ final class PreviewASRController: ObservableObject { } } else { return await withCheckedContinuation { (cont: CheckedContinuation) in - AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) } + AVAudioSession.sharedInstance().requestRecordPermission( + Self.makeMicAuthHandler(continuation: cont) + ) } } } private nonisolated static func requestSpeechRecognitionPermission() async -> Bool { await withCheckedContinuation { (cont: CheckedContinuation) in - SFSpeechRecognizer.requestAuthorization { status in - cont.resume(returning: status == .authorized) - } + SFSpeechRecognizer.requestAuthorization( + Self.makeSpeechAuthHandler(continuation: cont) + ) + } + } + + private nonisolated static func makeSpeechAuthHandler( + continuation: CheckedContinuation + ) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void { + return { status in + continuation.resume(returning: status == .authorized) + } + } + + private nonisolated static func makeMicAuthHandler( + continuation: CheckedContinuation + ) -> @Sendable (Bool) -> Void { + return { granted in + continuation.resume(returning: granted) } }