Skip to content

[Bug]: [sensors_plus] Termination crash fix from 1.3.3 is unreachable: plugin instance never published, detach hook has wrong selector #3955

Description

@thinklanguages

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.

--- 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions