Repository navigation
[Bug]: [sensors_plus] Termination crash fix from 1.3.3 is unreachable: plugin instance never published, detach hook has wrong selector #3955
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
- 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 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".) - The detach hook has the wrong signature. It is declared
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. - The
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 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
- I searched issues in this repository and couldn't find such bug/problem
- I Google'd a solution and I couldn't find it
- I searched on StackOverflow for a solution and I couldn't find it
- I read the README.md file of the plugin
- I'm using the latest version of the plugin
- All dependencies are up to date with
flutter pub upgrade - I did a
flutter clean - I tried running the example project
Activity
FeliksLv01 commentedon Aug 18, 2026
Additional multi-FlutterEngine impact: this is not only an application-termination issue. The current iOS implementation keeps event channels, stream handlers, the cleanup flag, and CoreMotion objects in file-global state. If an application registers sensors_plus on a long-lived engine and a short-lived secondary FlutterEngine, the later registration overwrites the former engine's handlers; cleanup for either engine can then unregister/cancel the other engine's streams. A follow-up PR will address the crash fixes described above and make the plugin/channel/handler state engine-instance-owned, so detach only tears down the engine being detached.