fix: el tap del microfono seguia aislado al main actor (2.0.3 build 68)
El fix anterior arreglo los permisos —el crash se movio de start a beginSession, y de TCC a AVAudioNodeTap::CheckEmitBuffer— pero el tap seguia petando. Mi error: quitar el acceso a self del closure no elimina la inferencia de aislamiento. La inferencia viene de DONDE se escribe el closure, no de lo que captura. Escrito dentro de un metodo @MainActor, seguia siendo main-actor isolated, y el tap se dispara desde el hilo de audio en tiempo real. Ahora el handler se construye en makeTapHandler, que es nonisolated, y se pasa a installTap. La estructura se auto-verifica: quitarle nonisolated a makeTapHandler no compila, porque installTap es nonisolated y no puede llamar a un metodo aislado. El bug pasa de crash en produccion a error de compilacion. Tests: testTapHandlerRunsOffTheMainThread invoca el handler desde una cola de fondo, que es exactamente la condicion que trapeaba. No hace falta microfono; el intento anterior con AVAudioEngine se saltaba siempre porque el simulador no tiene entrada de audio utilizable. Barrido del mismo patron en el resto de servicios @MainActor: CalendarService y NotificationService usan las variantes async/await, que estan anotadas y no tienen closures. SpeechDictationService era el unico sitio. Lanes feedback y crashlog para leer los reportes de TestFlight desde la API: spaceship apunta a v1/betaFeedbacks, que Apple ya retiro; el endpoint vivo es v1/apps/<id>/betaFeedbackCrashSubmissions y el log viene inline en logText. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
This commit is contained in:
@@ -73,6 +73,35 @@ final class SpeechDictationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Installs the microphone tap from a `nonisolated` context.
|
||||||
|
///
|
||||||
|
/// `AVAudioNodeTapBlock` is pre-concurrency, so a closure literal written
|
||||||
|
/// inside a `@MainActor` method is inferred main-actor isolated — and the tap
|
||||||
|
/// is fired from the realtime audio thread, so Swift 6's isolation check
|
||||||
|
/// trapped there. Dropping the `self` access was not enough: the inference
|
||||||
|
/// comes from where the closure is *written*, not from what it captures.
|
||||||
|
/// Declaring these helpers `nonisolated` is what actually removes it.
|
||||||
|
///
|
||||||
|
/// The handler is built here, in a `nonisolated` context, and handed to
|
||||||
|
/// `installTap` rather than written inline at the call site — that is the
|
||||||
|
/// whole point, and it is also what makes it testable: a test can call this
|
||||||
|
/// and invoke the result off the main thread, which is exactly the condition
|
||||||
|
/// that trapped, without needing a working microphone.
|
||||||
|
nonisolated static func makeTapHandler(
|
||||||
|
feeding request: SFSpeechAudioBufferRecognitionRequest
|
||||||
|
) -> (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||||
|
{ buffer, _ in
|
||||||
|
request.append(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func installTap(on node: AVAudioInputNode,
|
||||||
|
format: AVAudioFormat,
|
||||||
|
feeding request: SFSpeechAudioBufferRecognitionRequest) {
|
||||||
|
node.installTap(onBus: 0, bufferSize: 1024, format: format,
|
||||||
|
block: makeTapHandler(feeding: request))
|
||||||
|
}
|
||||||
|
|
||||||
private func beginSession(localeIdentifier: String) {
|
private func beginSession(localeIdentifier: String) {
|
||||||
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)),
|
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)),
|
||||||
recognizer.isAvailable else {
|
recognizer.isAvailable else {
|
||||||
@@ -96,12 +125,7 @@ final class SpeechDictationService: ObservableObject {
|
|||||||
let inputNode = audioEngine.inputNode
|
let inputNode = audioEngine.inputNode
|
||||||
let format = inputNode.outputFormat(forBus: 0)
|
let format = inputNode.outputFormat(forBus: 0)
|
||||||
inputNode.removeTap(onBus: 0)
|
inputNode.removeTap(onBus: 0)
|
||||||
// Captures the request directly instead of reaching through `self`:
|
Self.installTap(on: inputNode, format: format, feeding: request)
|
||||||
// this runs on the realtime audio thread, which must never touch
|
|
||||||
// main-actor state. `append` is designed to be fed from that thread.
|
|
||||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
|
|
||||||
request.append(buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
audioEngine.prepare()
|
audioEngine.prepare()
|
||||||
try audioEngine.start()
|
try audioEngine.start()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
import AVFoundation
|
||||||
|
import Speech
|
||||||
@testable import MealMood
|
@testable import MealMood
|
||||||
|
|
||||||
/// Smoke cover for the dictation entry point.
|
/// Smoke cover for the dictation entry point.
|
||||||
@@ -32,6 +34,36 @@ final class SpeechDictationServiceTests: XCTestCase {
|
|||||||
"No permission is granted in this environment, so it must not record")
|
"No permission is granted in this environment, so it must not record")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This one *does* reproduce the crash class, without needing a microphone.
|
||||||
|
///
|
||||||
|
/// In 2.0.3 build 68 the app still died at the tap: `AVAudioNodeTapBlock` is
|
||||||
|
/// pre-concurrency, so a closure literal written inside a `@MainActor` method
|
||||||
|
/// was inferred main-actor isolated, and the tap fires from the realtime
|
||||||
|
/// audio thread — Swift 6's isolation check tripped `dispatch_assert_queue`.
|
||||||
|
/// Dropping the `self` capture had not been enough; isolation is inferred
|
||||||
|
/// from where the closure is written.
|
||||||
|
///
|
||||||
|
/// Calling the handler off the main thread is the exact condition that
|
||||||
|
/// trapped, so this fails loudly if the isolation ever creeps back.
|
||||||
|
func testTapHandlerRunsOffTheMainThread() async throws {
|
||||||
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
let handler = SpeechDictationService.makeTapHandler(feeding: request)
|
||||||
|
|
||||||
|
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1))
|
||||||
|
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 1024))
|
||||||
|
buffer.frameLength = 1024
|
||||||
|
|
||||||
|
let ran = expectation(description: "tap handler ran on a background queue")
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
XCTAssertFalse(Thread.isMainThread)
|
||||||
|
handler(buffer, AVAudioTime(sampleTime: 0, atRate: 44_100))
|
||||||
|
ran.fulfill()
|
||||||
|
}
|
||||||
|
await fulfillment(of: [ran], timeout: 5)
|
||||||
|
|
||||||
|
request.endAudio()
|
||||||
|
}
|
||||||
|
|
||||||
func testStartTwiceDoesNotBlowUp() {
|
func testStartTwiceDoesNotBlowUp() {
|
||||||
let service = SpeechDictationService()
|
let service = SpeechDictationService()
|
||||||
service.start(localeIdentifier: "en-US")
|
service.start(localeIdentifier: "en-US")
|
||||||
|
|||||||
@@ -95,6 +95,50 @@ platform :ios do
|
|||||||
UI.success(created ? "App Store version #{version} ready" : "App Store version #{version} already editable")
|
UI.success(created ? "App Store version #{version} ready" : "App Store version #{version} already editable")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
desc "Show the latest TestFlight beta feedback (tester reports and crashes)"
|
||||||
|
lane :feedback do |options|
|
||||||
|
app = connect_api_app
|
||||||
|
# spaceship's get_beta_feedback hits v1/betaFeedbacks, a private endpoint
|
||||||
|
# Apple has since removed. These are the current public ones.
|
||||||
|
client = Spaceship::ConnectAPI.test_flight_request_client
|
||||||
|
limit = (options[:limit] || 10).to_i
|
||||||
|
|
||||||
|
["v1/apps/#{app.id}/betaFeedbackCrashSubmissions", "v1/apps/#{app.id}/betaFeedbackScreenshotSubmissions"].each do |path|
|
||||||
|
UI.header(path)
|
||||||
|
begin
|
||||||
|
resp = client.get(path, {
|
||||||
|
"include" => "build,tester",
|
||||||
|
"limit" => limit,
|
||||||
|
"sort" => "-createdDate"
|
||||||
|
})
|
||||||
|
rows = resp.body["data"] || []
|
||||||
|
UI.important("none") if rows.empty?
|
||||||
|
rows.each do |row|
|
||||||
|
a = row["attributes"] || {}
|
||||||
|
UI.message("── #{a['createdDate']} — #{a['deviceModel']} — #{a['osVersion']} — id #{row['id']}")
|
||||||
|
UI.message(" #{a['comment']}") if a["comment"]
|
||||||
|
UI.message(" crashLog: #{a['crashLog'] || row.dig('relationships', 'crashLog', 'links', 'related')}")
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
UI.error("#{path}: #{e.message[0, 200]}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Download the crash log of a TestFlight feedback submission (id from `feedback`)"
|
||||||
|
lane :crashlog do |options|
|
||||||
|
UI.user_error!("pass id:<submission id>") unless options[:id]
|
||||||
|
connect_api_app
|
||||||
|
client = Spaceship::ConnectAPI.test_flight_request_client
|
||||||
|
resp = client.get("v1/betaFeedbackCrashSubmissions/#{options[:id]}/crashLog", {})
|
||||||
|
# The log comes back inline as logText, not as a download URL.
|
||||||
|
text = resp.body.dig("data", "attributes", "logText")
|
||||||
|
UI.user_error!("No logText in response: #{resp.body.to_s[0, 300]}") unless text
|
||||||
|
out = File.expand_path("../crashlog-#{options[:id]}.crash", __dir__)
|
||||||
|
File.write(out, text)
|
||||||
|
UI.success("Saved to #{out}")
|
||||||
|
end
|
||||||
|
|
||||||
desc "Show the App Store state of each version (review status, release type)"
|
desc "Show the App Store state of each version (review status, release type)"
|
||||||
lane :status do
|
lane :status do
|
||||||
app = connect_api_app
|
app = connect_api_app
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
|
|||||||
|
|
||||||
Push a new beta build to TestFlight
|
Push a new beta build to TestFlight
|
||||||
|
|
||||||
|
### ios feedback
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the latest TestFlight beta feedback (tester reports and crashes)
|
||||||
|
|
||||||
|
### ios crashlog
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios crashlog
|
||||||
|
```
|
||||||
|
|
||||||
|
Download the crash log of a TestFlight feedback submission (id from `feedback`)
|
||||||
|
|
||||||
### ios status
|
### ios status
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
Reference in New Issue
Block a user