Skip to content

Heading subscription is orphaned by a null position, then crashes with "Null check operator used on a null value" after dispose #170

Description

@guysaldanha

Describe the bug

Two related defects in _CurrentLocationLayerState combine to crash the app after the layer is disposed.

1. _subscriptHeadingStream() has no re-entrancy guard, so a null position orphans a subscription.

A null position resets the status without cancelling the heading subscription:

if (position == null) {
  if (_status != _Status.initialing) {
    setState(() {
      _status = _Status.initialing;   // heading subscription NOT cancelled
      ...

When a position fix returns, _status != _Status.ready passes again and _subscriptHeadingStream() runs a second time. Unlike its siblings, it has no guard and overwrites the field:

void _subscriptAlignPositionStream() {
  if (_alignPositionSubscription != null) return;   // guard
  ...
}
void _subscriptAlignDirectionStream() {
  if (_alignDirectionSubscription != null) return;  // guard
  ...
}
void _subscriptHeadingStream() {
  final headingStream = ...;                        // no guard
  _headingSubscription = headingStream.listen(...); // overwrites, orphans the previous one
}

The first subscription is now unreachable, so dispose()'s _headingSubscription?.cancel() cancels only the newest. The orphan outlives the widget and keeps the rotation sensor streaming — EventChannel's onCancel never fires while a listener remains, so the compass keeps running after the map is gone. Every dropout leaks another.

2. The heading stream's onError calls setState without a mounted check.

It is the only callback in the class without one — onData at the top of the same listen() has it, as do both align-stream callbacks:

_headingSubscription = headingStream.listen(
  (heading) {
    if (!mounted) return;             // guarded
    ...
  },
  onError: (error) {
    error = widget.errorHandler(error);
    if (error is UnsupportedException) { if (kDebugMode) print(error); }
    if (_animatingHeading != null) {
      setState(() => _animatingHeading = null);   // NOT guarded -> crash
    }
  },
);

A sensor error reaching an orphan's onError calls setState on a defunct State. In debug this throws setState() called after dispose(); in release the asserts are stripped and it reaches _element!.markNeedsBuild(), throwing Null check operator used on a null value.

Note errorHandler cannot prevent this — its return value only gates the debug print; the setState runs regardless. Passing headingStream: const Stream.empty() is currently the only way for a consumer to opt out, since the layer subscribes to the sensor regardless of LocationMarkerStyle.showHeadingSector.

To Reproduce

This widget test reproduces both defects deterministically (no device or real sensor needed):

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_location_marker/flutter_map_location_marker.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';

void main() {
  testWidgets('null position orphans the heading subscription', (tester) async {
    final positions = StreamController<LocationMarkerPosition?>.broadcast();
    final headings = StreamController<LocationMarkerHeading?>.broadcast();

    Widget build({bool withLayer = true}) => MaterialApp(
      home: FlutterMap(
        options: const MapOptions(
          initialCenter: LatLng(52.52, 13.405),
          initialZoom: 13,
        ),
        children: [
          if (withLayer)
            CurrentLocationLayer(
              positionStream: positions.stream,
              headingStream: headings.stream,
            ),
        ],
      ),
    );

    await tester.pumpWidget(build());

    // 1. Position fix -> heading subscription #1 created, status becomes ready.
    positions.add(
      LocationMarkerPosition(latitude: 52.52, longitude: 13.405, accuracy: 10),
    );
    await tester.pump(const Duration(seconds: 1));

    // 2. Heading arrives -> _animatingHeading becomes non-null.
    headings.add(LocationMarkerHeading(heading: 1, accuracy: 0.3));
    await tester.pump(const Duration(seconds: 1));

    // 3. GPS dropout -> _status resets to initialing, subscription #1 NOT cancelled.
    positions.add(null);
    await tester.pump(const Duration(seconds: 1));

    // 4. Fix returns -> _subscriptHeadingStream() runs again, orphaning #1.
    positions.add(
      LocationMarkerPosition(latitude: 52.52, longitude: 13.405, accuracy: 10),
    );
    await tester.pump(const Duration(seconds: 1));

    // 5. Remove the layer -> dispose() cancels only subscription #2.
    await tester.pumpWidget(build(withLayer: false));
    await tester.pump(const Duration(seconds: 1));

    // Defect 1: the orphan is still listening, so the sensor never stops.
    expect(headings.hasListener, isTrue);

    // Defect 2: an error now reaches the orphan's unguarded onError.
    headings.addError(Exception('sensor fault'));
    await tester.pump(const Duration(seconds: 1));
    // => setState() called after dispose(): _CurrentLocationLayerState#...
    //    (release builds: Null check operator used on a null value)
  });
}

Stack trace from the failing pump:

setState() called after dispose(): _CurrentLocationLayerState#9563c(lifecycle state: defunct, not mounted, ...)
#0  State.setState.<anonymous closure> (package:flutter/src/widgets/framework.dart:1163:9)
#1  State.setState (package:flutter/src/widgets/framework.dart:1198:6)
#2  _CurrentLocationLayerState._subscriptHeadingStream.<anonymous closure> (package:flutter_map_location_marker/src/widgets/current_location_layer.dart:526:11)

On device, the equivalent steps are: open a map with CurrentLocationLayer, acquire a position fix and a heading, lose GPS (tunnel / underground car park / toggle location services), regain it, navigate away from the map, then have the compass emit an error.

Expected behavior

  1. _subscriptHeadingStream() should not orphan a live subscription. Cancelling first is the smallest fix that also keeps didUpdateWidget's existing cancel-then-resubscribe working:
void _subscriptHeadingStream() {
  _headingSubscription?.cancel();
  final headingStream = ...;
  _headingSubscription = headingStream.listen(...);
}

(An early-return guard matching the align-stream methods would need _headingSubscription = null after each cancel(), or didUpdateWidget would silently stop resubscribing.)

  1. The heading onError should check mounted before setState, as every other callback in the class does:
onError: (error) {
  if (!mounted) return;
  ...
}

After (1), dispose() cancels the only live subscription and no error can arrive late; (2) is defence in depth and makes the class internally consistent.

Screenshots

N/A — no visual symptom; the app terminates.

Desktop (please complete the following information):

N/A.

Smartphone (please complete the following information):

  • Device: iPad Air 11-inch (M2)
  • OS: iPadOS 26.4.2
  • Browser: N/A (Flutter app, not web)
  • Version: 10.3.0

Additional context

  • Affects 10.3.0 and 10.2.0 — the unguarded setState in the heading onError is present in both (10.2.0 line 498, 10.3.0 line 526). It is not a 10.3.0 regression. The reproduction above is against 10.3.0.
  • LocationMarkerStyle(showHeadingSector: false) does not avoid this: the layer subscribes to the rotation sensor regardless of style, so apps that never display a heading sector are still exposed.
  • The crash is only visible as Null check operator used on a null value in release/profile builds, where setState's asserts are stripped and execution reaches _element!.markNeedsBuild(). Debug builds show setState() called aft er dispose() instead. Crash reporters will therefore show the null-check-operator form with a framework.dart State.setState frame.
  • Related: Crash on iOS with Position Accuracy 0 #169 (non-finite accuracy reaching CirclePainter) surfaces the same "Null check operator used on a null value" message from a completely different cause. They are distinct bugs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions