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
- Subscribe to
accelerometerEventStream() (any motion stream reproduces it).
- Run the app on Mac Catalyst ("Designed for iPad" on Apple Silicon).
- Quit the app (Cmd+Q /
-[NSApplication terminate:]) while the subscription is active, or shortly after cancelling it.
- 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/:
- 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".)
- 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.
- 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.
--- 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()
}
--- 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)
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
// 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
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
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
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
accelerometerEventStream()(any motion stream reproduces it).-[NSApplication terminate:]) while the subscription is active, or shortly after cancelling it.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
_isCleanUpguards 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.swiftsendFlutter->sink(FlutterStandardTypedData...)->-[FlutterEngine sendOnChannel:message:binaryReply:]NSAssert, drained from the main queue inside-[NSApplication _shouldTerminate], withcom.apple.CoreMotion.MotionThreadstill live at termination).Why the existing
_isCleanUpfix never runsThree independent reasons, all in
ios/sensors_plus/Sources/sensors_plus/:FPPSensorsPlusPlugin.register(with:)is entirely static: it never constructs the plugin and never callsregistrar.publish(...), so the registrar holds no object to deliver a detach callback to. (Comparevideo_player_avfoundation'sregister(with:), which publishes the instance specifically "so that it receives detachFromEngine".)func detachFromEngineForRegistrar(registrar: NSObject!). TheFlutterPluginprotocol member imports into Swift asdetachFromEngine(for registrar: FlutterPluginRegistrar); the declared method is neitherpublicnor that selector, so it satisfies no protocol requirement and is never invoked._cleanUp()therefore never runs and_isCleanUpstaysfalseforever. 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.dealloc()methods on the stream handlers are dead too. ARC never calls a Swift method nameddealloc.And even with the flag flipped, every
if _isCleanUp { return }check runs before theDispatchQueue.main.asyncpost insendFlutter(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
StreamSubscriptiononAppLifecycleState.detachedis 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.
(same re-check inside the barometer handler's own
DispatchQueue.main.asyncblock)func onCancel(withArguments arguments: Any?) -> FlutterError? { - _motionManager.stopAccelerometerUpdates() + _motionManager?.stopAccelerometerUpdates()The fourth edit (nil-safe
onCancelon all five handlers) is required, not polish:_motionManager/_altimeterare implicitly-unwrapped globals created lazily inonListen, 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
Logs
Flutter Doctor
Checklist before submitting a bug
flutter pub upgradeflutter clean