What is the bug?
_getZoomForScale can be handed a non-positive scale, making math.log() return NaN/-Infinity, which then propagates into the camera via moveRaw unvalidated.
In map_interactive_viewer.dart, the pinch-zoom branch guards details.scale but passes details.scale + _scaleCorrector:
// L650-654
if (hasPinchZoom && details.scale > 0.0) {
newZoom = _getZoomForScale(
_mapZoomStart,
details.scale + _scaleCorrector, // <- the sum is never checked
);
_scaleCorrector is set when the finger count changes mid-gesture (L617):
_scaleCorrector = 1.0 - _lastScale;
so it is negative whenever _lastScale > 1 (i.e. the user was zooming in when a finger was added/removed). The sum is then details.scale + 1.0 - _lastScale, which is <= 0 as soon as details.scale <= _lastScale - 1. Concretely: a finger lifts at _lastScale == 2.5 (_scaleCorrector == -1.5), the recognizer restarts the new gesture near details.scale == 1.0, and the sum is -0.5.
_getZoomForScale has no guard, and clampZoom cannot recover it because NaN.clamp() returns NaN in Dart:
// L1404-1408
double _getZoomForScale(double startZoom, double scale) {
final resultZoom =
scale == 1.0 ? startZoom : startZoom + math.log(scale) / math.ln2;
return _camera.clampZoom(resultZoom);
}
log(negative) is NaN and log(0) is -Infinity, so newZoom becomes non-finite and is passed straight to controller.moveRaw(newCenter, newZoom, ...). _calculatePinchZoomAndMove also consumes that same zoomAfterPinchZoom, so the resulting center goes NaN too.
Once the camera holds a non-finite center/zoom, every layer that reads it breaks. The MapCamera constructor's assert(zoom.isFinite) only fires in debug builds, and it covers zoom but not center, so release builds store the poisoned camera silently.
I want to flag how this interacts with the recent NaN work, because I think it makes the bug easier to miss now rather than harder:
The revert removes the exception, but the arithmetic above still produces the NaN. So on 8.3.1+ the same gesture should yield a silently non-finite camera — a stuck/blank map rather than a stack trace. That matches the "Map is stuck" wording in #2199. I mention it only because a silent version of this is harder to diagnose than the crashing one, not to argue the revert was wrong.
Honest caveat on evidence: this is code-path analysis plus production telemetry, not a captured repro. I have Crashlytics reports from a released Android app on 8.3.0 showing LatLng(latitude:NaN, longitude:NaN) reaching Crs.checkLatLng from four different layers (MarkerClusterLayer.didUpdateWidget, MarkerLayer.build, TileLayer._onTileUpdateEvent, _buildMobileTransformStack) — the signature of an already-poisoned camera rather than one bad input. I ruled out my own call sites: every move/fitCamera in the app is validated and instrumented, and the rejection counter never fired once. The pinch path is the one route to the camera that bypasses application code, and the unguarded sum above is a real defect on master regardless of whether it is what my users hit.
How can we reproduce it?
I have not been able to reliably reproduce the gesture by hand — the window where details.scale + _scaleCorrector goes non-positive requires a finger count change while zoomed in, then a rapid continuation. #2214's "rapidly zooming in and out" with varying finger counts on Android is the closest description I have.
The arithmetic itself is directly demonstrable without gestures:
// _scaleCorrector == 1.0 - _lastScale, with _lastScale == 2.5
const scaleCorrector = 1.0 - 2.5; // -1.5
const detailsScale = 1.0; // recognizer restarts near 1.0
final scale = detailsScale + scaleCorrector; // -0.5
// mirrors _getZoomForScale
final resultZoom = 13.0 + math.log(scale) / math.ln2;
print(resultZoom); // NaN
print(resultZoom.clamp(0, 20)); // NaN — clampZoom cannot recover it
I am happy to try to build a proper WidgetTester gesture repro (driving ScaleUpdateDetails with a changing pointerCount) if that would be useful — I did not want to guess at the harness conventions before checking whether you would want the test at all.
Do you have a potential solution?
Guard the value actually passed to math.log, rather than details.scale alone. Either check the sum at the call site:
final scale = details.scale + _scaleCorrector;
if (hasPinchZoom && scale > 0.0) {
newZoom = _getZoomForScale(_mapZoomStart, scale);
or make _getZoomForScale total, which also covers the other two call sites (L745 and L908):
double _getZoomForScale(double startZoom, double scale) {
if (scale <= 0.0 || !scale.isFinite) return _camera.clampZoom(startZoom);
final resultZoom =
scale == 1.0 ? startZoom : startZoom + math.log(scale) / math.ln2;
return _camera.clampZoom(resultZoom);
}
Separately, it may be worth rejecting non-finite values in moveRaw itself. With checkLatLng reverted there is now no runtime backstop between a non-finite gesture computation and a permanently poisoned camera, and a single bad frame persists until the app is restarted. Happy to open a PR for either or both if you have a preference on the approach.
What is the bug?
_getZoomForScalecan be handed a non-positive scale, makingmath.log()returnNaN/-Infinity, which then propagates into the camera viamoveRawunvalidated.In
map_interactive_viewer.dart, the pinch-zoom branch guardsdetails.scalebut passesdetails.scale + _scaleCorrector:_scaleCorrectoris set when the finger count changes mid-gesture (L617):so it is negative whenever
_lastScale > 1(i.e. the user was zooming in when a finger was added/removed). The sum is thendetails.scale + 1.0 - _lastScale, which is<= 0as soon asdetails.scale <= _lastScale - 1. Concretely: a finger lifts at_lastScale == 2.5(_scaleCorrector == -1.5), the recognizer restarts the new gesture neardetails.scale == 1.0, and the sum is-0.5._getZoomForScalehas no guard, andclampZoomcannot recover it becauseNaN.clamp()returnsNaNin Dart:log(negative)isNaNandlog(0)is-Infinity, sonewZoombecomes non-finite and is passed straight tocontroller.moveRaw(newCenter, newZoom, ...)._calculatePinchZoomAndMovealso consumes that samezoomAfterPinchZoom, so the resultingcentergoesNaNtoo.Once the camera holds a non-finite center/zoom, every layer that reads it breaks. The
MapCameraconstructor'sassert(zoom.isFinite)only fires in debug builds, and it coverszoombut notcenter, so release builds store the poisoned camera silently.I want to flag how this interacts with the recent NaN work, because I think it makes the bug easier to miss now rather than harder:
LatLngis non-finite #2182 (in 8.3.0) added theCrs.checkLatLngthrow. That surfaced this as a hard crash, which I believe is why Exception thrown when during rapid zooming #2214 reported "only happens starting with version 8.3, no problems with 8.2" — the NaN was likely already being produced, and 8.3.0 just made it fatal.LatLngis non-finite #2218 reverted fix: throw whenLatLngis non-finite #2182 for 8.3.1, andcheckLatLngis now gone from master.The revert removes the exception, but the arithmetic above still produces the NaN. So on 8.3.1+ the same gesture should yield a silently non-finite camera — a stuck/blank map rather than a stack trace. That matches the "Map is stuck" wording in #2199. I mention it only because a silent version of this is harder to diagnose than the crashing one, not to argue the revert was wrong.
Honest caveat on evidence: this is code-path analysis plus production telemetry, not a captured repro. I have Crashlytics reports from a released Android app on 8.3.0 showing
LatLng(latitude:NaN, longitude:NaN)reachingCrs.checkLatLngfrom four different layers (MarkerClusterLayer.didUpdateWidget,MarkerLayer.build,TileLayer._onTileUpdateEvent,_buildMobileTransformStack) — the signature of an already-poisoned camera rather than one bad input. I ruled out my own call sites: everymove/fitCamerain the app is validated and instrumented, and the rejection counter never fired once. The pinch path is the one route to the camera that bypasses application code, and the unguarded sum above is a real defect on master regardless of whether it is what my users hit.How can we reproduce it?
I have not been able to reliably reproduce the gesture by hand — the window where
details.scale + _scaleCorrectorgoes non-positive requires a finger count change while zoomed in, then a rapid continuation. #2214's "rapidly zooming in and out" with varying finger counts on Android is the closest description I have.The arithmetic itself is directly demonstrable without gestures:
I am happy to try to build a proper
WidgetTestergesture repro (drivingScaleUpdateDetailswith a changingpointerCount) if that would be useful — I did not want to guess at the harness conventions before checking whether you would want the test at all.Do you have a potential solution?
Guard the value actually passed to
math.log, rather thandetails.scalealone. Either check the sum at the call site:or make
_getZoomForScaletotal, which also covers the other two call sites (L745 and L908):Separately, it may be worth rejecting non-finite values in
moveRawitself. WithcheckLatLngreverted there is now no runtime backstop between a non-finite gesture computation and a permanently poisoned camera, and a single bad frame persists until the app is restarted. Happy to open a PR for either or both if you have a preference on the approach.