Skip to content

fix(offline): keep OfflineManager alive until its async tasks complete#4252

Open
OxMarco wants to merge 1 commit into
rnmapbox:mainfrom
OxMarco:fix/offline-manager-lifetime
Open

fix(offline): keep OfflineManager alive until its async tasks complete#4252
OxMarco wants to merge 1 commit into
rnmapbox:mainfrom
OxMarco:fix/offline-manager-lifetime

Conversation

@OxMarco

@OxMarco OxMarco commented Jul 12, 2026

Copy link
Copy Markdown

Summary

RNMBXOfflineModule.startLoading creates a function-local OfflineManager, kicks off two asynchronous operations with it, and then returns, dropping the only strong reference to it. Nothing keeps the manager alive for the lifetime of the work it started, so Swift can deinitialize it while the Mapbox SDK still holds internal state tied to it.

This shows up as a SIGABRT with the Swift runtime message "object deallocated with non-zero retain count", in a closure in startLoading.

The ownership problem

358:    let threadedOfflineManager = OfflineManager()   // only strong reference, function-local
359:    taskGroup.enter()
360:    let stylePackTask = threadedOfflineManager.loadStylePack(for: styleURI, ...) { ... }
...
383:    let tilesetDescriptor = threadedOfflineManager.createTilesetDescriptor(for: descriptorOptions)
...
396:    taskGroup.enter()
397:    let task = self.tileStore.loadTileRegion(forId: id, ...) { ... }
...
427:    taskGroup.notify(queue: .main) {
428:      self.tileRegionPacks[id]!.cancelables = []
429:      self.tileRegionPacks[id]!.state = downloadError ? .inactive : .complete
430:    }
431:
432:    self.tileRegionPacks[id]!.cancelables = [task, stylePackTask]
433:  }

threadedOfflineManager is a local let on line 358. It is used to start loadStylePack (line 360) and to vend the tileset descriptor (line 383). Neither the closures nor cancelables (line 432, which stores the tasks, not the manager) capture it, so its lifetime ends when startLoading returns, while both async operations are still in flight.

OfflineManager is not a fire-and-forget value: it owns native state that the in-flight style pack load and the descriptor it vended are tied to. Releasing it mid-flight lets Swift deinitialize it while the SDK still holds references into it, which is exactly what the abort reports.

The fix

The existing DispatchGroup already marks the point at which both tasks have completed, so capture the manager in its notify block and hold it until then.

+    taskGroup.notify(queue: .main) { [threadedOfflineManager] in
       self.tileRegionPacks[id]!.cancelables = []
       self.tileRegionPacks[id]!.state = downloadError ? .inactive : .complete
+      withExtendedLifetime(threadedOfflineManager) {}
     }

An alternative you may prefer

There is already a lazy var offlineManager: OfflineManager property on the module (line 33), and it is not obvious why startLoading creates a second, separate instance rather than reusing it. If the per-call instance is not deliberate, holding a single manager as a property would be cleaner than extending the local's lifetime.

The name threadedOfflineManager hints there may be a threading reason for the separate instance, which is why I went with the minimal lifetime fix rather than changing the ownership model. Happy to switch to whichever you prefer.

Testing

  • The Swift file parses cleanly.
  • yarn generate is a no-op for this change: it touches no components, style properties, or codepart-generated regions.
  • I have not been able to build and run the example app against this, as that needs a Mapbox downloads token and a full pod install on my side. If you would like me to verify in the example app before merging, let me know and I will set that up.

Honest caveat

This is not reproduced deterministically. It is a race, and it fired once across our user base in production. The component below exercises the code path, but whether it aborts depends on the timing of deinit against the SDK's cleanup, and it is most likely when the download completes very quickly or fails early.

The ownership bug itself is established by reading the source rather than by a repro: the local manager is provably unretained past the end of startLoading, while the work it started is provably still running.

import React, { useEffect } from 'react';
import { MapView, offlineManager } from '@rnmapbox/maps';

const BugReportExample = () => {
  useEffect(() => {
    offlineManager.createPack(
      {
        name: 'repro-pack',
        styleURL: 'mapbox://styles/mapbox/streets-v12',
        bounds: [
          [14.6, 35.8],
          [14.4, 35.9],
        ],
        minZoom: 10,
        maxZoom: 12,
      },
      (pack, status) => console.log('progress', status.percentage),
      (pack, err) => console.log('error', err)
    );
  }, []);

  return <MapView style={{ flex: 1 }} />;
};

export default BugReportExample;

Environment

  • @rnmapbox/maps 10.3.2
  • Mapbox Maps SDK 11.20.1
  • React Native 0.85.3, New Architecture enabled
  • Platform: iOS

`startLoading` creates a function-local `OfflineManager`, uses it to start
`loadStylePack` and to vend a tileset descriptor, then returns. Nothing retains
it: the progress and completion closures do not capture it, and `cancelables`
holds the tasks rather than the manager.

The manager is therefore deinitialized as soon as `startLoading` returns, while
both asynchronous operations are still running and the Mapbox SDK still holds
state tied to it. That aborts with the Swift runtime error "object deallocated
with non-zero retain count" in a closure in `startLoading`.

The existing DispatchGroup already marks the point at which both tasks are
finished, so capture the manager in its `notify` block and hold it until then.
@OxMarco OxMarco requested a deployment to CI with Mapbox Tokens July 12, 2026 15:48 — with GitHub Actions Waiting
@OxMarco OxMarco requested a deployment to CI with Mapbox Tokens July 12, 2026 15:48 — with GitHub Actions Waiting
@OxMarco OxMarco requested a deployment to CI with Mapbox Tokens July 12, 2026 15:48 — with GitHub Actions Waiting
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