fluttercommunity / fluttercommunity/plus_plugins
[Bug]: [sensors_plus] Termination crash fix from 1.3.3 is unreachable: plugin instance never published, detach hook has wrong selector
- Dominant language
- Dart
- Stars
- 1.9k
- Forks
- 1.3k
- PR merge metrics
- No merged PRs in 30d
Description
### Platform
iOS (crash observed on Mac Catalyst / "iPad app on Mac", iOS 18.6 and 26.5 hosts; the affected code path is the shared iOS plugin)
### Plugin
sensors_plus
### Version
7.1.0 (latest at time of filing; 7.0.0 identical in the affected code)
### Flutter SDK
3.41.6
### Steps to reproduce
1. Subscribe to `accelerometerEventStream()` (any motion stream reproduces it).
2. Run the app on Mac Catalyst ("Designed for iPad" on Apple Silicon).
3. Quit the app (Cmd+Q / `-[NSApplication terminate:]`) while the subscription is active, or shortly after cancelling it.
4. The process dies with an uncaught `NSInternalInconsistencyException`: `Sending a message before the FlutterEngine has been run.`
This is the same crash previously reported in #956, #862, #863 (and the connectivity_plus twin #865). Those were closed after the `_isCleanUp` guards were added in sensors_plus 1.3.3, but **the fix is unreachable code in every release since** - details below. We still receive these crashes from production users (Sentry, symbolicated: `FPPStreamHandlerPlus.swift` `sendFlutter` -> `sink(FlutterStandardTypedData...)` -> `-[FlutterEngine sendOnChannel:message:binaryReply:]` `NSAssert`, drained from the main queue inside `-[NSApplication _shouldTerminate]`, with `com.apple.CoreMotion.MotionThread` still live at termination).
### Why the existing `_isCleanUp` fix never runs
Three independent reasons, all in `ios/sensors_plus/Sources/sensors_plus/`:
1. **No plugin instance is ever published.** `FPPSensorsPlusPlugin.register(with:)` is entirely static: it never constructs the plugin and never calls `registrar.publish(...)`, so the registrar holds no object to deliver a detach callback to. (Compare `video_player_avfoundation`'s `register(with:)`, which publishes the instance specifically "so that it receives detachFromEngine".)
2. **The detach hook has the wrong signature.** It is declared `func detachFromEngineForRegistrar(registrar: NSObject!)`. The `FlutterPlugin` protocol member imports into Swift as `detachFromEngine(for registrar: FlutterPluginRegistrar)`; the declared method is neither `public` nor that selector, so it satisfies no protocol requirement and is never invoked. `_cleanUp()` therefore never runs and `_isCleanUp` stays `false` forever. Every other Swift plugin in our dependency set (`connectivity_plus`, `record_ios`, `video_player_avfoundation`, `camera_avfoundation`) uses the correct signature; sensors_plus is the sole outlier.
3. **The `dealloc()` methods on the stream handlers are dead too.** ARC never calls a Swift method named `dealloc`.
And even with the flag flipped, every `if _isCleanUp { return }` check runs **before** the `DispatchQueue.main.async` post in `sendFlutter` (and in the barometer handler's own async block), so it cannot stop a block that is already sitting on the main queue when teardown begins. On Catalyst quit, AppKit drains exactly those blocks after the FlutterEngine is destroyed.
Note that app-side teardown cannot fix this: cancelling the Dart `StreamSubscription` on `AppLifecycleState.detached` is an async platform-channel round trip, and CoreMotion callbacks already in flight have already posted their main-queue blocks. We ship that teardown and still crash.
### Suggested fix (tested as a vendored patch)
Four minimal edits. We run these in production as a vendored copy; happy to open a PR.
```diff
--- a/ios/sensors_plus/Sources/sensors_plus/FPPSensorsPlusPlugin.swift
+++ b/ios/sensors_plus/Sources/sensors_plus/FPPSensorsPlusPlugin.swift
@@ public class FPPSensorsPlusPlugin: NSObject, FlutterPlugin {
_isCleanUp = false
+
+ // Publish the instance so that it receives detachFromEngine.
+ let instance = FPPSensorsPlusPlugin()
+ registrar.publish(instance)
}
- func detachFromEngineForRegistrar(registrar: NSObject!) {
+ public func detachFromEngine(for registrar: FlutterPluginRegistrar) {
FPPSensorsPlusPlugin._cleanUp()
}
```
```diff
--- a/ios/sensors_plus/Sources/sensors_plus/FPPStreamHandlerPlus.swift
+++ b/ios/sensors_plus/Sources/sensors_plus/FPPStreamHandlerPlus.swift
@@ func sendFlutter(...)
DispatchQueue.main.async {
+ // The pre-post check cannot stop a block that is already queued when
+ // teardown starts; re-check before touching the sink.
+ if _isCleanUp {
+ return
+ }
let timestampSince1970Micro = timestampMicroAtBoot + (timestamp * 1000000)
```
(same re-check inside the barometer handler's own `DispatchQueue.main.async` block)
```diff
func onCancel(withArguments arguments: Any?) -> FlutterError? {
- _motionManager.stopAccelerometerUpdates()
+ _motionManager?.stopAccelerometerUpdates()
```
The fourth edit (nil-safe `onCancel` on all five handlers) is **required**, not polish: `_motionManager` / `_altimeter` are implicitly-unwrapped globals created lazily in `onListen`, and `_cleanUp()` cancels every registered handler including ones that never listened. Today that nil-deref is masked only because `_cleanUp()` is dead code; once the detach hook actually runs, an app that never listened to (say) the barometer would crash on `_altimeter.stopRelativeAltitudeUpdates()` at every engine detach.
### Code Sample
```dart
// Any minimal app that keeps a subscription alive:
final sub = accelerometerEventStream(
samplingPeriod: SensorInterval.uiInterval,
).listen((e) {});
// Run on Mac Catalyst, then quit the app with Cmd+Q.
```
### Logs
```shell
Fatal Exception: NSInternalInconsistencyException
Sending a message before the FlutterEngine has been run.
Symbolicated (Sentry, production, sensors_plus 7.1.0 / Flutter 3.41.6):
-[NSApplication terminate:] / _handleAEQuit
-[NSApplication _shouldTerminate]
_dispatch_main_queue_drain
closure #1 in sendFlutter(x:y:z:timestamp:sink:) FPPStreamHandlerPlus.swift:45
Array.withUnsafeBufferPointer / sink(FlutterStandardTypedData...) FPPStreamHandlerPlus.swift:46
SetStreamHandlerMessageHandlerOnChannel FlutterChannels.mm:400
-[FlutterEngine sendOnChannel:message:binaryReply:] FlutterEngine.mm:1315 (NSAssert -> abort)
Thread com.apple.CoreMotion.MotionThread live at termination.
```
### Flutter Doctor
```shell
Flutter 3.41.6 (stable), Dart 3.11.4, DevTools 2.54.2
Crash observed in release builds shipped via the App Store; not toolchain-specific.
```
### Checklist before submitting a bug
- [x] I searched issues in this repository and couldn't find such bug/problem
- [x] I Google'd a solution and I couldn't find it
- [x] I searched on StackOverflow for a solution and I couldn't find it
- [x] I read the README.md file of the plugin
- [x] I'm using the latest version of the plugin
- [x] All dependencies are up to date with `flutter pub upgrade`
- [x] I did a `flutter clean`
- [x] I tried running the example project
Contributor guide
Research direction
Start with ios/sensors_plus/Sources/sensors_plus/FPPSensorsPlusPlugin.swift and the stream-handler files, comparing the detach hook and instance publication with the other Swift plugins named in the issue. Reproduce the crash from the example app on Mac Catalyst with an active accelerometer subscription. Done means engine teardown no longer sends queued sensor events or crashes, including when handlers never listened.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart, swift
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100