Skip to content

A non-finite camera makes MarkerLayer's world-wrap loops unbounded (OOM/ANR) — _projectPoints guards the points but nothing guards the camera #2240

Description

@ed-apostol

What is the bug?

#2178 fixed the case where a Marker.point is NaN, by validating points in
_MarkerLayerState._projectPoints. The same out-of-memory failure is still
reachable from the other side: when a camera value is non-finite, every
marker point is perfectly valid, so that guard never fires.

In _MarkerLayerState.build, culling decides when to stop wrapping:

Positioned? getPositioned(double worldShift) {
  if (!pixelBounds.overlaps(Rect.fromPoints(...))) return null;   // cull
  ...
}

for (double shift = -worldWidth;; shift -= worldWidth) {
  final additional = getPositioned(shift);
  if (additional == null) break;      // <- only exit
  yield additional;
}

If the camera is non-finite, pixelBounds is Rect.fromLTRB(NaN, NaN, NaN, NaN).
Rect.overlaps early-returns false on ordered comparisons, and every
comparison against NaN is false
, so it falls through and returns true
culling never culls, getPositioned never returns null, and the sync*
generator yields Positioned widgets until the process dies.

feature_layer_utils.workAcrossWorlds (polyline/polygon/circle) already has a
maxShiftsCount = 30 backstop from #2052/#2111. MarkerLayer has no equivalent.

How can we reproduce it?

Twelve lines, no gestures — flutter test this against flutter_map: 8.3.1:

testWidgets('non-finite camera rotation hangs MarkerLayer', (tester) async {
  final controller = MapController();
  await tester.pumpWidget(MaterialApp(
    home: FlutterMap(
      mapController: controller,
      options: const MapOptions(
        initialCenter: LatLng(16.6, 120.9),
        initialZoom: 12,
      ),
      children: const [
        MarkerLayer(markers: [
          Marker(
            point: LatLng(16.6, 120.9),   // finite: #2178's guard cannot fire
            width: 40,
            height: 40,
            child: SizedBox.shrink(),
          ),
        ]),
      ],
    ),
  ));

  controller.rotate(double.nan);   // any non-finite camera value will do
  await tester.pump();             // never returns
});

flutter_tester reached 16 GB resident before we killed it. A test
Timeout does not fire, because the loop is synchronous.

MapController.rotate(double.nan) is just the cheapest injection point. We hit
this in production from an uncalibrated magnetometer: the sensor emitted a NaN
heading which our own code passed to rotate(). Because NaN comparisons are all
false, it slipped through several range checks on the way — which is the same
property that defeats the culling here. That was our bug to fix, and we fixed it;
we're reporting the library-side amplification, where one bad value costs the
whole process instead of one bad frame.

Note camera.dart:148 already asserts zoom.isFinite, so a non-finite zoom
is caught in debug. There is no equivalent assert for rotation, and asserts are
stripped in release builds, so both fields reach MarkerLayer in production.

Suggested fix

Two independent options; we apply both locally.

  1. Bail out of the wrap loops on non-finite input (mirrors the _projectPoints
    guard, on the camera side):
if (worldWidth == 0 ||
    !worldWidth.isFinite ||
    !pxPoint.dx.isFinite || !pxPoint.dy.isFinite ||
    !pixelBounds.left.isFinite || !pixelBounds.right.isFinite) {
  continue;
}
  1. Give MarkerLayer the same maxShiftsCount backstop that
    feature_layer_utils.workAcrossWorlds already has, so any future
    never-culls bug costs an assertion instead of the process.

Asserting rotation.isFinite in MapCamera alongside the existing
zoom.isFinite assert would also surface the root cause much earlier in debug.

Environment

  • flutter_map 8.3.1
  • Reproduced in flutter test (host) and on a Galaxy S24 Ultra, Android 16
    (10s+ input-dispatch ANR, am_anr in logcat)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions