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:
Rocky
2026-06-18 20:15:08 +08:00
parent 3e3eb0f766
commit ffeb18c8d3
+46 -8
View File
@@ -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<Bool, Never>) 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<Bool, Never>) in
SFSpeechRecognizer.requestAuthorization { status in
cont.resume(returning: status == .authorized)
}
SFSpeechRecognizer.requestAuthorization(
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)
}
}