fix: SFSpeechRecognizer callback dispatch_assert_queue (function ref)
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
This commit is contained in:
@@ -249,10 +249,30 @@ final class PreviewASRController: ObservableObject {
|
|||||||
// Swift 6 strict concurrency infers the closure body as
|
// Swift 6 strict concurrency infers the closure body as
|
||||||
// `@MainActor`, and the runtime crashes on
|
// `@MainActor`, and the runtime crashes on
|
||||||
// `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift` as
|
// `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift` as
|
||||||
// soon as TCC calls us back. Extracting to `nonisolated static
|
// soon as TCC calls us back.
|
||||||
// func` breaks the inference: the helper has no isolation, the
|
//
|
||||||
// callback closure body has no isolation, and `cont.resume(...)`
|
// The first attempt (commit `e8a0310`) extracted the entire
|
||||||
// is itself thread-safe on `CheckedContinuation`.
|
// 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 {
|
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
@@ -264,16 +284,34 @@ final class PreviewASRController: ObservableObject {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||||
AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) }
|
AVAudioSession.sharedInstance().requestRecordPermission(
|
||||||
|
Self.makeMicAuthHandler(continuation: cont)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func requestSpeechRecognitionPermission() async -> Bool {
|
private nonisolated static func requestSpeechRecognitionPermission() async -> Bool {
|
||||||
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||||
SFSpeechRecognizer.requestAuthorization { status in
|
SFSpeechRecognizer.requestAuthorization(
|
||||||
cont.resume(returning: status == .authorized)
|
Self.makeSpeechAuthHandler(continuation: cont)
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func makeSpeechAuthHandler(
|
||||||
|
continuation: CheckedContinuation<Bool, Never>
|
||||||
|
) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void {
|
||||||
|
return { status in
|
||||||
|
continuation.resume(returning: status == .authorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func makeMicAuthHandler(
|
||||||
|
continuation: CheckedContinuation<Bool, Never>
|
||||||
|
) -> @Sendable (Bool) -> Void {
|
||||||
|
return { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user