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.
- 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;
}
- 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
What is the bug?
#2178fixed the case where aMarker.pointisNaN, by validating points in_MarkerLayerState._projectPoints. The same out-of-memory failure is stillreachable 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:If the camera is non-finite,
pixelBoundsisRect.fromLTRB(NaN, NaN, NaN, NaN).Rect.overlapsearly-returnsfalseon ordered comparisons, and everycomparison against NaN is false, so it falls through and returns
true—culling never culls,
getPositionednever returnsnull, and thesync*generator yields
Positionedwidgets until the process dies.feature_layer_utils.workAcrossWorlds(polyline/polygon/circle) already has amaxShiftsCount = 30backstop from #2052/#2111.MarkerLayerhas no equivalent.How can we reproduce it?
Twelve lines, no gestures —
flutter testthis againstflutter_map: 8.3.1:flutter_testerreached 16 GB resident before we killed it. A testTimeoutdoes not fire, because the loop is synchronous.MapController.rotate(double.nan)is just the cheapest injection point. We hitthis in production from an uncalibrated magnetometer: the sensor emitted a
NaNheading which our own code passed to
rotate(). Because NaN comparisons are allfalse, 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:148already assertszoom.isFinite, so a non-finite zoomis caught in debug. There is no equivalent assert for
rotation, and asserts arestripped in release builds, so both fields reach
MarkerLayerin production.Suggested fix
Two independent options; we apply both locally.
_projectPointsguard, on the camera side):
MarkerLayerthe samemaxShiftsCountbackstop thatfeature_layer_utils.workAcrossWorldsalready has, so any futurenever-culls bug costs an assertion instead of the process.
Asserting
rotation.isFiniteinMapCameraalongside the existingzoom.isFiniteassert would also surface the root cause much earlier in debug.Environment
flutter test(host) and on a Galaxy S24 Ultra, Android 16(10s+ input-dispatch ANR,
am_anrin logcat)Related
LatLngwithdouble.naninMarker/MarkerLayertriggers memory leak #2178 — same OOM, marker-point side, fixed; camera side still openfeature_layer_utilsgainedmaxShiftsCountamplification, and would still matter once Pinch zoom can pass a non-positive scale to math.log, poisoning the camera with NaN #2237 lands)