Skip to content

[Bug]: Crash in sync:reject: — TSLocationManager passes NSString instead of NSError to failure block (typing mismatch) #2576

@LudoLamerre

Description

@LudoLamerre

Required Reading

  • Confirmed

Plugin Version

5.1.1

Mobile operating-system(s)

  • iOS
  • Android

Device Manufacturer(s) and Model(s)

iPhone 15 & iPhone 14 Plus

Device operating-systems(s)

iOS 26.3.1

React Native / Expo version

"react-native": "0.81.5", "expo": "~54.0.32",

What happened?

Your Environment

  • Plugin version: 5.1.1
  • Platform: iOS
  • OS version: iOS 26.3.1 (multiple users, also seen on earlier iOS 17/18 builds)
  • Device manufacturer / model: iPhone 14 Plus and others
  • React Native version (react-native --version): 0.76+
  • Plugin config: Standard production setup with autoSync: true, custom HTTP endpoint via url + headers

Expected Behaviour

When BackgroundGeolocation.sync() fails (HTTP timeout, server error, network drop), the JS promise should reject with a normal Error object that can be caught with try/catch.

Actual Behaviour

The app hard-crashes natively (process killed by iOS) with the following exception:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException',
reason: '-[__NSCFString code]: unrecognized selector sent to instance 0x600000e6aa00'

Stack trace (relevant frames):

0   CoreFoundation                   __exceptionPreprocess
1   libobjc.A.dylib                  objc_exception_throw
3   CoreFoundation                   ___forwarding___
5   React                            RCTJSErrorFromCodeMessageAndNSError + 844
7   App                              __39-[RNBackgroundGeolocation sync:reject:]_block_invoke_2 + 116
8   App                              __30-[TSHttpService finish:error:]_block_invoke.163 + 264

The JS-side try/catch cannot catch this — the crash happens in the native reject callback before the promise reaches the JS bridge.

Steps to Reproduce

Hard to reproduce on demand, but the pattern is:

  1. App is in foreground or just resumed
  2. Some points are queued in the local SQLite buffer
  3. BackgroundGeolocation.sync() is called (manually or via autoSync)
  4. The HTTP layer (TSHttpService) fails for any reason (timeout, 5xx, connection drop, slow Wi-Fi)
  5. App crashes immediately

We have 3 reproducible TestFlight feedbacks from the same user on iOS 26.3.1 over the past 10 days, and a clean Xcode crash report attached below.

Root Cause Analysis

Looking at RNBackgroundGeolocation.mm line 396–403:

RCT_EXPORT_METHOD(sync:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
{
    [locationManager sync:^(NSArray* records) {
        resolve(records);
    } failure:^(NSError* error) {                                  // ← typed as NSError*
        reject(@"sync_error", error.localizedDescription, error);  // ← line 401, the crash
    }];
}

The failure block is typed ^(NSError* error), but TSLocationManager.sync actually passes an NSString* in some failure paths (likely when the error originates from the HTTP/Foundation layer rather than a structured NSError).

When reject is called with the NSString masquerading as NSError*:

  1. RCTJSErrorFromCodeMessageAndNSError (React Native) calls [error code]
  2. NSCFString does not respond to code
  3. unrecognized selector → crash

This is consistent with other RCT_EXPORT_METHODs in the same file that are correctly typed:

  • getLocations (line 391) → ^(NSString* error)
  • getGeofences (line 411) → ^(NSString* error)
  • addGeofence (line 437) → ^(NSString* error)

So the typing of sync (and possibly setOdometer line 514, requestFullAccuracy line 669, getCurrentPosition line 339) as ^(NSError*) looks like a typing inconsistency on the Objective-C side.

Proposed Fix

Defensive wrap in the failure block that ensures reject always receives a proper NSError:

RCT_EXPORT_METHOD(sync:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
{
    [locationManager sync:^(NSArray* records) {
        resolve(records);
    } failure:^(NSError* error) {
        NSError *safeError = error;
        if (![error isKindOfClass:[NSError class]]) {
            // TSLocationManager sometimes passes NSString* instead of NSError* here.
            // Wrap into a real NSError so RCTJSErrorFromCodeMessageAndNSError doesn't crash.
            NSString *message = [error isKindOfClass:[NSString class]]
                ? (NSString *)error
                : @"Unknown sync error";
            safeError = [NSError errorWithDomain:@"RNBackgroundGeolocation"
                                            code:-1
                                        userInfo:@{NSLocalizedDescriptionKey: message}];
        }
        reject(@"sync_error", safeError.localizedDescription, safeError);
    }];
}

Questions for the maintainer

  1. Is this fix safe? From our analysis it doesn't change any documented behaviour:

    • JS still receives a rejected promise with code: 'sync_error' and a meaningful message
    • Neither the SDK's own JS code nor consumer apps rely on the native userInfo of the rejected error
    • All it does is prevent the crash and let try/catch work as expected
  2. Is the underlying typing inconsistency in TSLocationManager intentional? I.e., are there cases where you want the failure callback to receive a non-NSError? Or should TSLocationManager.sync always pass NSError* and this is the real bug?

  3. Should we apply the same defensive wrap to setOdometer (line 514), requestFullAccuracy (line 669), and getCurrentPosition (line 339)? They have the same ^(NSError* error) signature and could in theory exhibit the same issue.

  4. We will apply a patch-package workaround on our side. Could you confirm whether this defensive wrap is the right approach, or whether you have a different fix in mind that we should wait for?

Number of users impacted

We see several reports with similar stack traces involving RCTJSErrorFromCodeMessageAndNSError + unrecognized selector".


Crash logs / sysdiagnose: just below.

Thank you for your time on this — happy to PR the fix if you're open to it.

Plugin Code and/or Config

{
activity = {
activityRecognitionInterval = 10000;
disableMotionActivityUpdates = 0;
disableStopDetection = 0;
minimumActivityRecognitionConfidence = 50;
stopDetectionDelay = 0;
stopOnStationary = 0;
triggerActivities = "";
};
activityRecognitionInterval = 10000;
activityType = 1;
app = {
heartbeatInterval = 60;
preventSuspend = 1;
schedule = (
);
startOnBoot = 1;
stopOnTerminate = 0;
};
authorization = {
accessToken = "";
expires = "-1";
refreshHeaders = {
Authorization = "Bearer {accessToken}";
};
refreshPayload = {
};
refreshToken = "";
refreshUrl = "";
strategy = JWT;
};
autoSync = 1;
autoSyncThreshold = 10;
batchSync = 1;
debug = 0;
desiredAccuracy = "-1";
didDeviceReboot = 0;
didLaunchInBackground = 0;
didRequestUpgradeLocationAuthorization = 0;
disableAutoSyncOnCellular = 0;
disableElasticity = 0;
disableLocationAuthorizationAlert = 1;
disableMotionActivityUpdates = 0;
disableStopDetection = 0;
distanceFilter = 10;
elasticityMultiplier = "1.2";
enableTimestampMeta = 0;
enabled = 1;
extras = {
installationId = "*************";
userId = "**************";
};
geofenceInitialTriggerEntry = 1;
geofenceProximityRadius = 2000;
geofenceTemplate = "";
geolocation = {
activityType = 1;
desiredAccuracy = "-1";
disableElasticity = 0;
disableLocationAuthorizationAlert = 1;
distanceFilter = 10;
elasticityMultiplier = "1.2";
enableTimestampMeta = 0;
filter = {
burstWindow = 10;
filterDebug = 0;
kalmanDebug = 0;
kalmanProfile = 0;
maxBurstDistance = 300;
maxImpliedSpeed = 60;
odometerAccuracyThreshold = 20;
odometerUseKalmanFilter = 1;
policy = 2;
rollingWindow = 5;
trackingAccuracyThreshold = 100;
useKalman = 1;
};
geofenceInitialTriggerEntry = 1;
geofenceProximityRadius = 2000;
locationAuthorizationAlert = {
cancelButton = Cancel;
instructions = "To use background location, you must enable '{locationAuthorizationRequest}' in the Location Services settings";
settingsButton = Settings;
titleWhenNotEnabled = "Background location is not enabled";
titleWhenOff = "Location services are off";
};
locationAuthorizationRequest = Always;
locationTimeout = 60;
pausesLocationUpdatesAutomatically = 1;
showsBackgroundLocationIndicator = 1;
stationaryRadius = 25;
stopAfterElapsedMinutes = "-1";
stopTimeout = 5;
useSignificantChangesOnly = 0;
};
headers = {
"X-App-Key" = ************;
};
heartbeatInterval = 60;
http = {
autoSync = 1;
autoSyncThreshold = 10;
batchSync = 1;
disableAutoSyncOnCellular = 0;
headers = {
"X-App-Key" = ************;
};
maxBatchSize = 50;
method = POST;
params = {
};
rootProperty = locations;
timeout = 60000;
url = "http://192.168.1.118:3333/v1/locations";
};
httpRootProperty = locations;
httpTimeout = 60000;
iOSHasWarnedLocationServicesOff = 0;
includeDeprecatedPropertiesInDictionary = 1;
isFirstBoot = 0;
isMoving = 0;
lastLocationAuthorizationStatus = 0;
locationAuthorizationAlert = {
cancelButton = Cancel;
instructions = "To use background location, you must enable '{locationAuthorizationRequest}' in the Location Services settings";
settingsButton = Settings;
titleWhenNotEnabled = "Background location is not enabled";
titleWhenOff = "Location services are off";
};
locationAuthorizationRequest = Always;
locationTemplate = "";
locationTimeout = 60;
locationsOrderDirection = ASC;
logLevel = 5;
logMaxDays = 3;
logger = {
debug = 0;
logLevel = 5;
logMaxDays = 3;
};
maxBatchSize = 50;
maxDaysToPersist = 60;
maxRecordsToPersist = "-1";
method = POST;
minimumActivityRecognitionConfidence = 50;
odometer = "0.0002807877272674074";
odometerError = "27.53921569316209";
params = {
};
pausesLocationUpdatesAutomatically = 1;
persistMode = 2;
persistence = {
extras = {
installationId = "*************";
userId = "**************";
};
geofenceTemplate = "";
locationTemplate = "";
locationsOrderDirection = ASC;
maxDaysToPersist = 60;
maxRecordsToPersist = "-1";
persistMode = 2;
timestampFormat = iso;
};
preventSuspend = 1;
schedule = (
);
schedulerEnabled = 0;
showsBackgroundLocationIndicator = 1;
startOnBoot = 1;
stationaryRadius = 25;
stopAfterElapsedMinutes = "-1";
stopDetectionDelay = 0;
stopOnStationary = 0;
stopOnTerminate = 0;
stopTimeout = 5;
timestampFormat = iso;
trackingMode = 1;
triggerActivities = "";
url = "http://192.168.1.118:3333/v1/locations";
useSignificantChangesOnly = 0;
}

Relevant log output

On Xcode, from `RNBackgroundGeolocation.mm`

RCT_EXPORT_METHOD(sync:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
{
    [locationManager sync:^(NSArray* records) {
        resolve(records);
    } failure:^(NSError* error) {
        reject(@"sync_error", error.localizedDescription, error);
    }];
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions