capacitor-community / capacitor-community/speech-recognition

iOS: crash on `start()` — `Unexpectedly found nil` at Plugin.swift:86 when a session is restarted

Open
#128 0 comments 0 reactions 0 assignees View on GitHub
needs: triage
Dominant language
Java
Stars
130
Forks
69
PR merge metrics
No merged PRs in 30d

Description

## Summary

On a physical iOS device, calling `start()` repeatedly (the normal pattern for keeping a mic "open" across a listening window) reliably crashes the app with a Swift runtime trap:

```
CapacitorCommunitySpeechRecognition/Plugin.swift:86: Fatal error: Unexpectedly found nil while unwrapping an Optional value
```

The force-unwrap is `self.recognitionRequest!` on line 86:

```swift
self.recognitionTask = self.speechRecognizer?.recognitionTask(with: self.recognitionRequest!, resultHandler: { ... })
```

This does **not** reproduce in the Simulator — only on a real device, where the recognizer's callback timing differs.

## Root cause

`recognitionRequest` is shared mutable state, and it is set to `nil` from the `resultHandler` on both the final-result path (line 111) and the error path (line 118). Those callbacks are delivered asynchronously by the Speech framework and can arrive **well after** their session has ended.

`start()` assigns `self.recognitionRequest` on line 80 and force-unwraps the same property on line 86. If a previous session's `resultHandler` fires in that window, the property is `nil` and the unwrap traps.

This is easy to hit in practice because `SFSpeechRecognizer` ends a session on its own with `"No speech detected"` after a stretch of silence. An app that restarts recognition on that event has a stale callback in flight against every new `start()`.

The same aliasing causes a second, quieter bug: a stale teardown calls `self.audioEngine!.stop()`, which stops whatever engine is *currently* assigned — i.e. the newly started session's engine — so a fresh session can be silently disarmed by an old one's cleanup.

## Reproduction

1. Run on a physical device (reproduced on iPhone 15, iOS 26.5.2).
2. Call `start({ partialResults: true })`.
3. When the session ends (e.g. `"No speech detected"`), call `start()` again — as you would to keep listening across a window.
4. Within a few cycles the app terminates with `EXC_BREAKPOINT (SIGTRAP)`.

Console output leading into the crash:

```
To Native -> SpeechRecognition start 87744227
ERROR MESSAGE: {"errorMessage":"No speech detected"}
To Native -> SpeechRecognition start 87744233
CapacitorCommunitySpeechRecognition/Plugin.swift:86: Fatal error: Unexpectedly found nil while unwrapping an Optional value
ERROR MESSAGE: {"errorMessage":"No speech detected"}
```

Note the trailing `"No speech detected"` arriving *after* the next `start()` had begun — that is the stale callback landing mid-setup.

## Suggested fix

Hold the request and engine in locals so each session is self-consistent, and guard the shared-state teardown with an identity check so a stale callback cannot clear a newer session:

```swift
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = partialResults
self.recognitionRequest = request

let engine: AVAudioEngine = self.audioEngine!
let inputNode: AVAudioInputNode = engine.inputNode
let format: AVAudioFormat = inputNode.outputFormat(forBus: 0)

self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: { (result, error) in
// ...
if result!.isFinal {
engine.stop()
engine.inputNode.removeTap(onBus: 0)
self.notifyListeners("listeningState", data: ["status": "stopped"])
if self.recognitionRequest === request { // only clear if still ours
self.recognitionTask = nil
self.recognitionRequest = nil
}
}

if error != nil {
engine.stop()
engine.inputNode.removeTap(onBus: 0)
if self.recognitionRequest === request {
self.recognitionRequest = nil
self.recognitionTask = nil
}
self.notifyListeners("listeningState", data: ["status": "stopped"])
call.reject(error!.localizedDescription)
}
})

inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { (buffer, _) in
request.append(buffer)
}
```

This has been running without a crash since applying it.

## Secondary issue: audio session is left in a record category

`start()` sets the session to `.playAndRecord` and nothing ever sets it back. After the first use of the mic, the app's own audio (Web Audio in the web view) is inaudible for the rest of the process — and because a record category honours the ring/silent switch, it is silenced outright when the device is muted.

Restoring a playback category in `stop()` before resolving fixes it:

```swift
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .default, options: [])
try? session.setActive(true)
call.resolve()
```

Happy to split this into its own issue if preferred — it is unrelated to the crash.

## Environment

| | |
|---|---|
| Plugin | `@capacitor-community/speech-recognition` 7.0.1 |
| Capacitor | `@capacitor/core` 8.4.2, `@capacitor/ios` 8.4.2 |
| Device | iPhone 15, iOS 26.5.2 (physical device; not reproducible in Simulator) |
| Xcode | 17C529 |
| Install | CocoaPods |

Contributor guide

Open the contributing guide

Research direction

Start in Plugin.swift at start(), the recognitionTask result handler, and stop(), focusing on the shared recognitionRequest and audioEngine state across callbacks. Reproduce on a physical iOS device by restarting after “No speech detected”; done means repeated sessions no longer crash or stop a newer engine, and stop() restores playback audio.

Written by the indexing model from the issue text.

Assessment

Tech stack
ios, swift
Domain
audio-video-rtc, mobile-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.