Skip to content

fix(offline): serialize tileRegionPacks access on main thread, keep OfflineManager alive until tasks complete#4253

Merged
mfazekas merged 1 commit into
mainfrom
fix/offline-tile-region-packs-main-thread
Jul 12, 2026
Merged

fix(offline): serialize tileRegionPacks access on main thread, keep OfflineManager alive until tasks complete#4253
mfazekas merged 1 commit into
mainfrom
fix/offline-tile-region-packs-main-thread

Conversation

@mfazekas

Copy link
Copy Markdown
Contributor

Description

Fixes #4252

The SIGABRT reported in #4252 ("object deallocated with non-zero retain count", in a closure in startLoading) turned out to be a data race on tileRegionPacks: the loadStylePack/loadTileRegion progress callbacks are delivered on Mapbox SDK background threads (com.mapbox.common.MB TileStore RW) and mutated the dictionary concurrently with the main thread. Crash reports from the reproducer below show both threads inside the tileRegionPacks setter/subscript at the moment of the abort (also seen as malloc "pointer being freed was not allocated"). The progress closures now hop to the main queue, as the completion handlers already did. The RCT entry points that touch the same state (getPackStatus, resumePackDownload, pausePackDownload, deletePack — these run on the module's background method queue) and convertRegionsToJSON's TileStore callbacks are serialized on main as well.

The issue's original finding is also addressed: the function-local OfflineManager was deallocated ~0ms after startLoading returned (instrumentation showed deinit before the style pack completion in 910/914 packs), so taskGroup.notify now holds it via withExtendedLifetime until both tasks complete.

Verification with the reproducer below on the iOS simulator: unfixed build crashed 3 times within minutes (by ~1,400 packs); fixed build ran 10,341 packs in ~9 minutes with no crash, and the manager outlived its tasks in 3,402/3,402 observed packs.

Checklist

  • I've read CONTRIBUTING.md
  • I updated the doc/other generated code with running yarn generate in the root folder
  • I have tested the new feature on /example app.
    • In V11 mode/ios
    • In New Architecture mode/ios
    • In V11 mode/android
    • In New Architecture mode/android
  • I added/updated a sample - if a new feature was implemented (/example)

Component to reproduce the issue you're fixing

Hammer reproducer — put into example/src/examples/BugReportExample.js, open "Bug Report Template", press Start; the unfixed build aborts within a few minutes
import React from 'react';
import { Button, SafeAreaView, ScrollView, Text, View } from 'react-native';
import { MapView, Camera, offlineManager } from '@rnmapbox/maps';

// Reproducer for https://github.com/rnmapbox/maps/issues/4252
// Hammers createPack so the SDK's background-thread progress callbacks race
// main-thread access to tileRegionPacks. A nonexistent style makes
// loadStylePack fail fast, which tightens the timing.

const styles = {
  mapView: { flex: 1 },
  panel: { padding: 10, gap: 4 },
  counters: { fontFamily: 'Menlo', fontSize: 12 },
};

const BOUNDS = [
  [14.6, 35.8],
  [14.4, 35.9],
];

class BugReportExample extends React.Component {
  state = {
    running: false,
    created: 0,
    completed: 0,
    errors: 0,
    progressEvents: 0,
    lastError: null,
  };

  packSeq = 0;

  componentWillUnmount() {
    this.stop();
  }

  start = () => {
    this.setState({ running: true }, () => {
      this.timer = setInterval(this.tick, 250);
    });
  };

  stop = () => {
    clearInterval(this.timer);
    this.timer = null;
    this.setState({ running: false });
  };

  tick = () => {
    // burst of packs per tick; alternate a real style (fast-completes once
    // cached) with a nonexistent one (fails early)
    for (let i = 0; i < 5; i++) {
      const seq = this.packSeq++;
      const name = `repro-4252-${seq}`;
      const styleURL =
        seq % 2 === 0
          ? 'mapbox://styles/mapbox/streets-v12'
          : 'mapbox://styles/mapbox/nonexistent-repro-4252';
      offlineManager
        .createPack(
          {
            name,
            styleURL,
            bounds: BOUNDS,
            minZoom: 10,
            maxZoom: 10,
          },
          (pack, status) => {
            if (status.percentage === 100) {
              this.setState((s) => ({ completed: s.completed + 1 }));
            } else {
              this.setState((s) => ({ progressEvents: s.progressEvents + 1 }));
            }
          },
          (pack, err) => {
            this.setState((s) => ({
              errors: s.errors + 1,
              lastError: String(err?.message ?? err),
            }));
          },
        )
        .then(() => {
          this.setState((s) => ({ created: s.created + 1 }));
          setTimeout(() => {
            offlineManager.deletePack(name).catch(() => {});
          }, 3000);
        })
        .catch((e) => {
          this.setState((s) => ({
            errors: s.errors + 1,
            lastError: String(e?.message ?? e),
          }));
        });
    }
  };

  render() {
    const { running, created, completed, errors, progressEvents, lastError } =
      this.state;
    return (
      <SafeAreaView style={{ flex: 1 }}>
        <View style={styles.panel}>
          <Button
            title={running ? 'Stop' : 'Start hammering createPack'}
            onPress={running ? this.stop : this.start}
          />
          <Text style={styles.counters} testID="counters">
            created={created} completed={completed} errors={errors}{' '}
            progress={progressEvents}
          </Text>
          {lastError ? (
            <ScrollView style={{ maxHeight: 60 }}>
              <Text style={styles.counters}>lastError: {lastError}</Text>
            </ScrollView>
          ) : null}
        </View>
        <MapView style={styles.mapView}>
          <Camera
            defaultSettings={{ centerCoordinate: [14.5, 35.85], zoomLevel: 9 }}
          />
        </MapView>
      </SafeAreaView>
    );
  }
}

export default BugReportExample;
Crash stacks from the unfixed build (3 aborts within ~7 min)

Main thread aborting while the TileStore thread is in the same dictionary:

Thread 0 (main), SIGABRT:
  swift_deallocClassInstance.cold.1   ("deallocated with non-zero retain count")
  RNMBXOfflineModule.tileRegionPacks.setter
  closure #1 in RNMBXOfflineModule.startLoading(pack:)   <- loadStylePack progress
  thunk for MBMStylePackLoadProgress

Thread 15 "com.mapbox.common.MB TileStore RW" (concurrent):
  Dictionary.subscript.modify / _NativeDictionary.copy()
  closure #3 in RNMBXOfflineModule.startLoading(pack:)   <- loadTileRegion progress
  thunk for MBXTileRegionLoadProgress

And the TileStore thread itself aborting on the corrupted heap:

Thread 15 "com.mapbox.common.MB TileStore RW", SIGABRT:
  ___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED
  _swift_release_dealloc
  RNMBXOfflineModule.tileRegionPacks.setter
  closure #3 in RNMBXOfflineModule.startLoading(pack:)

…neManager alive until its tasks complete

The loadStylePack/loadTileRegion progress callbacks are delivered on
Mapbox SDK background threads ("com.mapbox.common.MB TileStore RW") and
mutated the tileRegionPacks dictionary concurrently with the main
thread, corrupting it and aborting with "object deallocated with
non-zero retain count" or malloc "pointer being freed was not
allocated". The RCT methods (getPackStatus, resumePackDownload,
pausePackDownload, deletePack) run on the module's background method
queue and touched the same state, as did convertRegionsToJSON's
TileStore callbacks. All of these now hop to the main queue, matching
the completion handlers.

Also keep the function-local OfflineManager alive until both of the
async tasks it started complete: it was deallocated ~0ms after
startLoading returned, before the style pack load finished.

Fixes #4252
@mfazekas mfazekas merged commit e497912 into main Jul 12, 2026
7 checks passed
@mfazekas mfazekas deleted the fix/offline-tile-region-packs-main-thread branch July 12, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant