feat(ios): the speech pair closes the last deferral — ratchet 28 -> 26 - #101
Merged
Conversation
✅ Binary load timeWhat this measures
Both binaries are measured interleaved on this runner and compared by |
✅ Binary Size Report
Size limits
|
glennmichael123
force-pushed
the
feat/ios-speech-pair
branch
from
September 2, 2026 12:04
a70c98d to
9c1d566
Compare
`startListening` and `stopListening` move from the Swift shim into `bridge_mobile_speech.zig`, which until now served nothing on purpose: an earlier round researched both actions in full, found four blockers, and wrote down five preconditions instead of shipping. All five are met here. The load-bearing one is the realtime tap. `installTapOnBus:` runs its block on the audio I/O thread, which nothing else in this tree touches, and Swift's `recognitionRequest?.append(buffer)` gets a free ARC retain that Zig does not. Releasing the request while a tap call is in flight is a use-after-free on a realtime thread. The handoff is a published pointer plus an in-flight counter, both `.seq_cst` — the dangerous interleaving is Dekker's, and acquire/release permits exactly the case where teardown believes no tap is running while one holds a pointer it is about to free. Teardown stops the engine, removes the tap, clears the pointer, then drains for a bounded 20ms; if it ever fails to drain it leaks the request rather than racing, and says so. Two other preconditions were crashes waiting to happen. With no input route `-outputFormatForBus:0` answers a zero format and `installTapOnBus:` raises an NSException, which is an uncatchable SIGABRT — Swift aborts there today, and `formatCarriesInput` refuses instead. And the recognition handler fires on an internal Speech queue, so it hops to the main queue before touching AVAudioEngine. Verified on a simulator, not asserted from the headers. The fixture drives both actions and asserts `craftSpeechStart` and `craftSpeechEnd`; the start event is emitted on the far side of the plist guard, the authorization block, the main-queue hop, the audio session, a live recognizer, a non-zero input format, the recognition task, the tap, and the engine actually starting. Getting there needed a finding worth recording: `simctl privacy` documents nine services and none is speech, but it does not validate the argument — the raw `kTCCServiceSpeechRecognition` is accepted where `speech-recognition` is not. Without it the run stopped dead at a system alert no script can answer, which is what a screenshot of the simulator showed rather than what the logs said. Divergences, all documented in the module header: the plist key stands in for `config.enableSpeechRecognition` (it is written from that flag and nothing else); a zero format reports "Audio engine failed" rather than inventing a fifth message Swift never sends; a second `startListening` while running stops first, where Swift installs a second tap and raises; and a stop that stops nothing still emits `craftSpeechEnd`, because that is what a page's listening indicator binds to. `triggerHapticChecked` becomes `pub` for one caller: Swift fires a light impact on start and stop directly, bypassing the `enableHaptics` gate its own dispatcher applies to `case "haptic"`. That bypass is the spec's.
glennmichael123
force-pushed
the
feat/ios-speech-pair
branch
from
September 2, 2026 12:23
9c1d566 to
cb82c0e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
startListeningandstopListeningmove from the Swift shim intobridge_mobile_speech.zig, which until now served nothing on purpose. An earlier round researched both actions in full, found four blockers, and wrote down five preconditions rather than shipping. All five are met here, so the file goes from a record of a decision to the implementation it described.80 of 106 actions in Zig. Ratchet 28 → 26.
The realtime tap
-[AVAudioNode installTapOnBus:bufferSize:format:block:]runs its block on the audio I/O thread, which nothing else in this tree touches. Swift'srecognitionRequest?.append(buffer)gets a free ARC retain for the duration of the call; Zig gets nothing, so releasing the request while a tap call is in flight is a use-after-free on a realtime thread.The handoff is a published pointer plus an in-flight counter, and every access is
.seq_cstrather than acquire/release. That is not caution — the dangerous interleaving is Dekker's: the tap increments then loads the pointer while teardown clears the pointer then loads the counter, and acquire/release permits both sides to see the other's old value. That case is precisely "teardown believes no tap is running while a tap holds a pointer it is about to free". Onexchgper audio buffer (~86/sec) is not worth reasoning around.Teardown stops the engine, removes the tap, clears the pointer, then drains for a bounded 20ms. Engine-then-pointer in that order, not the reverse: clearing first would silently drop buffers the tap had already delivered. If the drain ever fails, the request is leaked rather than released, and the fact is logged.
Two crashes that were waiting
-outputFormatForBus:0answers a format whosesampleRateandchannelCountare both 0, andinstallTapOnBus:then raises anNSException— an uncatchable SIGABRT. Swift aborts there today (CraftApp.swift:2535-2538);formatCarriesInputrefuses instead, using the checkAVAudioEngine.h:459-461spells out.AVAudioEngine.Verified on a simulator, not asserted from the headers
The fixture drives both actions and asserts two new events.
craftSpeechStartis emitted on the far side of the whole chain — plist guard,requestAuthorization:'s block on an arbitrary queue, the main-queue hop, the audio session, a liveSFSpeechRecognizer, a non-zero input format, the recognition task, the tap installed on the audio I/O thread, andAVAudioEngineactually starting. Any link short of the last emitscraftSpeechErrorinstead.Both assertions were mutation-tested: forcing
startAndReturnError:to fail produces thei=74refusal note and fails oni=68; removing the end emit fails oni=72. The second mutation initially reported the wrong cause —openSettingsis chained offcraftSpeechEndand its assertion ran first — so the speech block now sits ahead of it and the failure names the real thing.One finding worth recording.
simctl privacydocuments nine services and none is speech, but it does not validate the argument: rawkTCCServiceSpeechRecognitionis accepted wherespeech-recognitionis not. Without it the run stopped dead at a "would like to access Speech Recognition" alert no script can answer — which a screenshot of the simulator showed, and the logs did not.Divergences, each in the module header
NSSpeechRecognitionUsageDescriptionis written fromenableSpeechRecognitionand nothing else (no||), so a missing key means the flag was off — and the message emitted is the one Swift's nil-recognizer guard would have emitted. The check runs beforerequestAuthorization:, where Swift's runs after, because asking without the key terminates the process rather than failing."Audio engine failed"rather than inventing a fifth message Swift has never sent.startListeningwhile running stops first. Swift cancels only the task and then installs a second tap on bus 0, which raises.craftSpeechEnd. Swift'sstopSpeechRecognition()has no early return, and that event is what a page's listening indicator binds to.craftSpeechError— Swift discards theNSError, Android does not, and inventing a string here would prejudge a contract question that belongs to the JS surface.triggerHapticCheckedbecomespubfor one caller:CraftApp.swift:2544and:2558fire a light impact directly, bypassing theenableHapticsgate the dispatcher applies tocase "haptic". That bypass is the spec's, not a choice this port makes.Verification
zig build test,zig fmt --check,zig build,build-ios,build-ios-simulator— all greenpackages/ios/fixtures/zig-slice/build-and-run.sh— PASS, 39 assertions, from a clean uninstall/install