RUM-16123: Move broadcast-receiver dispatch off the main thread to fix ANRs#3420
Conversation
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 83d70594d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| override fun onReceive(context: Context, intent: Intent?) { | ||
| executorService.executeSafe(HANDLE_INTENT_OPERATION_NAME, internalLogger) { | ||
| handleIntent(context) |
There was a problem hiding this comment.
Keep the broadcast active for executor handoff
When these context-registered broadcasts arrive while the app is backgrounded/cached, onReceive() now returns as soon as the task is queued, so Android considers the broadcast complete before handleIntent() reads the new state. The BroadcastReceiver contract requires goAsync()/PendingResult.finish() for work handed to another thread, or registering the receiver with a background Handler; otherwise the process can be reclaimed before the update runs, leaving network/system info stale after exactly the broadcasts this change moves off the main thread.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
As the receivers are context-registered against the application context, they live as long as the process. So they aren't reinstantiated per broadcast.
I think the comment doesn't apply in this case.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 83d70594d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| val filter = IntentFilter() | ||
| filter.addAction(action) | ||
| registerReceiver(context, filter)?.let { onReceive(context, it) } | ||
| registerReceiver(context, filter)?.let { handleIntent(context, it) } |
There was a problem hiding this comment.
Serialize sticky seeds with async broadcasts
When this receiver is registered, live broadcasts can start arriving on broadcastReceiverExecutorService while the returned sticky intent is still handled synchronously here on the initialization thread. handleBatteryIntent() and handlePowerSaveIntent() both write systemInfo = systemInfo.copy(...), so an overlapping battery/power-save update during initialization can have one thread copy from a stale systemInfo and then overwrite fields written by the other, leaving the initial system info missing one update until another broadcast arrives. The sticky seed should be run through the same serialization path or protected by synchronization.
Useful? React with 👍 / 👎.
| backPressureStrategy = backpressureStrategy, | ||
| timeProvider = timeProvider | ||
| ) | ||
| broadcastReceiverExecutorService = executorServiceFactory.create( |
There was a problem hiding this comment.
Threads are not coming for free: they take some memory on the stack and also add more work to the scheduler.
I think it is better to reuse contextExecutorService.
There was a problem hiding this comment.
This is a good point. In fact, that was exactly my first thought.
However, I decided to create a new one to avoid introducing more load to the contextExecutorService. That said, I agree that a new thread will use some resources and also take some time during the SDK initialization for its creation (which may also be an important thing to consider against my proposal).
In terms of memory usage, I'm not sure about the real impact. I didn't expect it to be high, but I'm open to reusing contextExecutorService if you think it's safer.
There was a problem hiding this comment.
Do we have broadcasts fired very often? I think having notifications processed on the context executor is safe.
There was a problem hiding this comment.
Do we have broadcasts fired very often?
Given the broadcast we are subscribing to, I don't think they fire very often.
| // region BroadcastReceiver | ||
|
|
||
| override fun onReceive(context: Context, intent: Intent?) { | ||
| executorService.executeSafe(HANDLE_INTENT_OPERATION_NAME, internalLogger) { |
There was a problem hiding this comment.
I think we need to apply this change only to the places where we call system services, maybe? Like for Intent.ACTION_BATTERY_CHANGED we just unwrap intent, I don't think there is IPC there, but in case PowerManager.ACTION_POWER_SAVE_MODE_CHANGED there is IPC.
Or you want to keep consistent approach everywhere?
There was a problem hiding this comment.
I don't think the problem is the IPC itself. I would say that the expensive part is PairIP wrapping method bodies used in the onReceive. For example calling getIntExtra, getSystemService, etc. each pay their own PairIP cost, so moving it entirely to the background thread saves the accumulative cost. (PairIP transforms method bodies, so each call inside onReceive pays its own wrapper cost — moving the chain off-main pays only the entry cost on main, but I may be wrong here.)
Also, I think it's good for consistency.
There was a problem hiding this comment.
I'm not aware of PairIP, but wouldn't it wrap just system calls? The stacktrace provided doesn't make it clear though.
I would assume a simple intent unboxing is fine, since it is just a payload, but I may be wrong.
There was a problem hiding this comment.
Although thinking about it more and if we push to the context thread only certain intents for a given receiver, it means we can have a race condition if it receives 2 intents in a short period of time if processing these intents results in the updating the same property.
| onItemDropped = {}, | ||
| backpressureMitigation = BackPressureMitigation.IGNORE_NEWEST | ||
| ) | ||
| val realExecutor = BackPressureExecutorService( |
There was a problem hiding this comment.
I don't think we need to use the real executor, some mock is still enough - we can simply capture the argument of execute, run it manually and assert that the value changed as we expected.
But not a blocker if you want to keep a real executor, just setup is more complicated.
| onItemDropped = {}, | ||
| backpressureMitigation = BackPressureMitigation.IGNORE_NEWEST | ||
| ) | ||
| val realExecutor = BackPressureExecutorService( |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #3420 +/- ##
===========================================
- Coverage 72.06% 72.02% -0.04%
===========================================
Files 961 961
Lines 35406 35417 +11
Branches 5880 5880
===========================================
- Hits 25512 25507 -5
- Misses 8278 8286 +8
- Partials 1616 1624 +8
🚀 New features to boost your workflow:
|
| // region BroadcastReceiver | ||
|
|
||
| override fun onReceive(context: Context, intent: Intent?) { | ||
| executorService.executeSafe(HANDLE_INTENT_OPERATION_NAME, internalLogger) { |
There was a problem hiding this comment.
Although thinking about it more and if we push to the context thread only certain intents for a given receiver, it means we can have a race condition if it receives 2 intents in a short period of time if processing these intents results in the updating the same property.
What does this PR do?
Moves
BroadcastReceiverSystemInfoProvider.onReceive()andBroadcastReceiverNetworkInfoProvider.onReceive()off the main thread by trampolining them through a new dedicated single-threadedBackPressureExecutorService(broadcastReceiverExecutorService) owned byCoreFeature. The initial sticky-seed path stays synchronous, soDatadog.initialize()still returns with system/network info populated. Adds@VolatiletosystemInfo,networkInfo, andlastNetworkInfofor cross-thread visibility.CallbackNetworkInfoProvider(Android-N+) gets only@Volatilesince its callbacks already dispatch off the main thread.Motivation
Fixes ANRs on the Android SDK. Root cause:
Context.registerReceiver(...)without aHandlerargument dispatches on the main thread by default. On low-end devices protected by PairIP (Google Play Integrity Code Transparency), eachonReceivecall incurred non-deterministic overhead fromcom.pairip.VMRunner.invoke/executeVM.Review checklist (to be filled by reviewers)