Skip to content

Conversation

@vargajacint
Copy link

Summary

This PR aims to resolve a production crash on iOS (NSInternalInconsistencyException). The crash occurs when the SDK attempts to interact with the view hierarchy from a background thread.

We observed intermittent but fatal crashes on iOS with the following stack trace:

Fatal Exception: NSInternalInconsistencyException
0  CoreFoundation                 ...
2  CoreAutoLayout                 ... -[NSISEngine optimize]
...
28 AmplitudeSessionReplay         ...
29 AmplitudeSessionReplay         ...
...
40 libdispatch.dylib              ... _dispatch_workloop_worker_thread

Root Cause

The crash is an NSInternalInconsistencyException originating from [NSISEngine optimize], which is part of the Auto Layout engine. The stack trace shows that AmplitudeSessionReplay is the caller. This exception is triggered because the Amplitude SDK attempts to access or modify UI layout information (to record the session) from a background worker thread (_dispatch_workloop_worker_thread).
Accessing the main thread is strictly forbidden and unsafe in that kinda way, leading to race conditions and crashes.

In my config, I used the autoStart: true option. Here is my config:

const AMPLITUDE_CONFIG: ReactNativeOptions = {
  logLevel: __DEV__ ? LogLevel.Debug : LogLevel.Error,
  sessionTimeout: 30_000,
  disableCookies: true,
  minIdLength: 3,
};

const SESSION_REPLAY_CONFIG: SessionReplayConfig = {
  logLevel: __DEV__ ? LogLevel.Debug : LogLevel.Error,
  enableRemoteConfig: true,
  autoStart: true,
  sampleRate: 1,
};

Solution

Changes in PluginSessionReplayReactNative.swift:

The native start(), stop(), and teardown() methods were modified to wrap their execution in DispatchQueue.main.async. This forces the execution of the SDK's recording logic to jump to the main thread before touching any UI or layout components.

Checklist

  • Does your PR title have the correct title format?
  • Does your PR have a breaking change?: No

@daniel-graham-amplitude
Copy link
Collaborator

@aliaksandr-kazarez are you available to look at this?

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a critical production crash on iOS (NSInternalInconsistencyException) that occurs when the session replay SDK attempts to interact with the view hierarchy from a background thread. The solution wraps UI-related operations in DispatchQueue.main.async to ensure they execute on the main thread.

Key Changes:

  • Added main thread dispatch for sessionReplay.start() when autoStart is enabled in the setup() method
  • Wrapped sessionReplay.start() execution in the start() method with DispatchQueue.main.async
  • Wrapped sessionReplay.stop() execution in both the stop() and teardown() methods with DispatchQueue.main.async

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +70 to +73
DispatchQueue.main.async {
self.sessionReplay.start()
resolve(nil)
}
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent promise resolution pattern: In this method, resolve(nil) is called inside the DispatchQueue.main.async block (line 72), but in the setup() method when autoStart is true (lines 41-43), the resolve(nil) is called outside the async block (line 45).

For consistency, either:

  1. Move resolve(nil) outside the async block here to match setup() (resolves immediately, fire-and-forget pattern)
  2. Or move the resolve(nil) in setup() inside its async block (waits for completion)

The choice depends on whether JavaScript callers need to wait for the operation to complete on the main thread before the promise resolves.

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +82
DispatchQueue.main.async {
self.sessionReplay.stop()
resolve(nil)
}
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent promise resolution pattern: In this method, resolve(nil) is called inside the DispatchQueue.main.async block (line 81), but in the setup() method when autoStart is true (lines 41-43), the resolve(nil) is called outside the async block (line 45).

For consistency, consider using the same pattern as in setup() where resolve(nil) is called immediately after dispatching the async work, unless you specifically need to wait for the stop operation to complete on the main thread before resolving the promise.

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +98
resolve(nil)
}
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent promise resolution pattern: In this method, resolve(nil) is called inside the DispatchQueue.main.async block (line 97), but in the setup() method when autoStart is true (lines 41-43), the resolve(nil) is called outside the async block (line 45).

For consistency, consider using the same pattern as in setup() where resolve(nil) is called immediately after dispatching the async work, unless you specifically need to wait for the stop operation to complete on the main thread before resolving the promise.

Suggested change
resolve(nil)
}
}
resolve(nil)

Copilot uses AI. Check for mistakes.
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.

2 participants