Skip to content

Fling gesture with very close extreme points causes division by zero #2221

Description

@nisenbeck

What happened

A fling (pan-release) gesture can corrupt the map camera's position with
NaN/Infinity, which then crashes with:

Unhandled error: Unsupported operation: Infinity or NaN toInt
#0      double.toInt (dart:core-patch/double.dart)
#1      double.floor (dart:core-patch/double.dart:201:34)
#2      _floor (package:flutter_map/src/layer/tile_layer/tile_range.dart:36:25)
#3      new DiscreteTileRange.fromPixelBounds (package:flutter_map/src/layer/tile_layer/tile_range.dart:61:9)
#4      TileRangeCalculator.calculate (package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart:31:30)
#5      _TileLayerState._onTileUpdateEvent (package:flutter_map/src/layer/tile_layer/tile_layer.dart:632:51)

Once the camera's center is NaN, other layers that read MapCamera.visibleBounds
also fail, e.g. flutter_map_marker_cluster throws:

The north latitude can't be bigger than 90.0: NaN
'package:flutter_map/src/geo/latlng_bounds.dart': line 78: 'north <= maxLatitude'
#2      new LatLngBounds.worldSafe (package:flutter_map/src/geo/latlng_bounds.dart:78:16)
#3      MapCamera._computeVisibleBounds (package:flutter_map/src/map/camera/camera.dart:71:25)

Expected vs actual behaviour

  • Expected: flinging the map, however fast or however the gesture curves,
    never produces a non-finite camera position.
  • Actual: under the above conditions, the camera's center/zoom become
    NaN, and the app crashes on the very next tile-range or visible-bounds
    computation.

Root cause

#2158
(c5e4909, "improve fling behaviour when pointer leaves window", released
in 8.3.0) changed MapInteractiveViewerState._handleScaleEnd to derive the
fling direction from tracked pointer-position deltas instead of from
details.velocity directly:

final flingOffset = _focalStartLocal - _lastFocalLocal;
final finalSegment = _prevFocalLocal - _lastFocalLocal;
final finalSegmentDistance = finalSegment.distance;

final Offset direction;
if (finalSegmentDistance > 0) {
  direction = finalSegment / finalSegmentDistance;
} else {
  final flingDistance = flingOffset.distance;
  direction = flingOffset / flingDistance;
}

The guard above this only checks that details.velocity.pixelsPerSecond.distance
(the recognizer's fitted velocity) is above the fling threshold - it does
not guarantee that the tracked position deltas (finalSegment,
flingOffset) are non-zero. Velocity and sampled position deltas are
independently computed by the gesture recognizer, so it's entirely possible
for a fast flick to report a high velocity while the last two tracked pointer
samples coincide and the overall drag ends back near its start point. In
that case both finalSegment and flingOffset are Offset.zero, so
flingOffset / flingDistance evaluates to 0.0 / 0.0 = NaN. This NaN
direction is fed straight into the fling Tween, and every subsequent
animation tick then moves the camera to a NaN/Infinity position.

I confirmed Flutter's own VelocityTracker / LeastSquaresSolver cannot be
the source of a NaN velocity here - they fall back to Offset.zero /
null on ill-conditioned samples rather than producing NaN - so the bug
is isolated to the division above.

Affected versions

  • Reproduces on 8.3.0 and 8.3.1.
  • Does not reproduce on 8.2.2 (confirmed by manual testing on the
    same device) - 8.2.2 derives the fling direction directly from
    details.velocity.pixelsPerSecond / magnitude, which is safe because
    magnitude is already guarded to be non-zero at that point.

How can we reproduce it?

A minimal reproduction is available at
https://github.com/nisenbeck/flutter_map_mre, branch repro/fling-nan-crash:

git clone https://github.com/nisenbeck/flutter_map_mre
cd flutter_map_mre
git checkout repro/fling-nan-crash
flutter pub get
flutter run

Or in any FlutterMap (any TileLayer, default MapOptions) on a physical
touchscreen device (debug or release):

  1. Perform a fast pan/flick gesture where the finger ends close to where it
    started (e.g. a quick back-and-forth "shake" or a fast flick that curves
    back near the start point) - the pattern that reliably triggers it is a
    fling with high recorded velocity but where the last couple of tracked
    pointer samples happen to coincide.
  2. The app crashes (or, if the exception is caught by a higher-level error
    handler, the map silently jumps to an invalid/blank state).

This only reproduces reliably on a real touchscreen - the exact micro-timing
of pointer samples is very hard to force through synthetic WidgetTester
gestures, which is presumably why this hasn't shown up in the existing test
suite.

Screen recording of the reproduction is attached below.

fling-crash.mp4

Do you have a potential solution?

Fixed in #2220.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    Medium

    Effort

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions