Skip to content

RUM-16123: Move broadcast-receiver dispatch off the main thread to fix ANRs#3420

Merged
hamorillo merged 2 commits into
developfrom
hector.morilloprieto/RUM-16123
May 6, 2026
Merged

RUM-16123: Move broadcast-receiver dispatch off the main thread to fix ANRs#3420
hamorillo merged 2 commits into
developfrom
hector.morilloprieto/RUM-16123

Conversation

@hamorillo

Copy link
Copy Markdown
Contributor

What does this PR do?

Moves BroadcastReceiverSystemInfoProvider.onReceive() and BroadcastReceiverNetworkInfoProvider.onReceive() off the main thread by trampolining them through a new dedicated single-threaded BackPressureExecutorService (broadcastReceiverExecutorService) owned by CoreFeature. The initial sticky-seed path stays synchronous, so Datadog.initialize() still returns with system/network info populated. Adds @Volatile to systemInfo, networkInfo, and lastNetworkInfo for cross-thread visibility. CallbackNetworkInfoProvider (Android-N+) gets only @Volatile since its callbacks already dispatch off the main thread.

Motivation

Fixes ANRs on the Android SDK. Root cause: Context.registerReceiver(...) without a Handler argument dispatches on the main thread by default. On low-end devices protected by PairIP (Google Play Integrity Code Transparency), each onReceive call incurred non-deterministic overhead from com.pairip.VMRunner.invoke/executeVM.

Review checklist (to be filled by reviewers)

  • Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
  • Make sure you discussed the feature or bugfix with the maintaining team in an Issue
  • Make sure each commit and the PR mention the Issue number (cf the CONTRIBUTING doc)

@hamorillo

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines 40 to +42
override fun onReceive(context: Context, intent: Intent?) {
executorService.executeSafe(HANDLE_INTENT_OPERATION_NAME, internalLogger) {
handleIntent(context)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@hamorillo
hamorillo marked this pull request as ready for review May 5, 2026 13:14
@hamorillo
hamorillo requested review from a team as code owners May 5, 2026 13:14

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we have broadcasts fired very often? I think having notifications processed on the context executor is safe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@0xnm 0xnm May 6, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same comment as in other test

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.02%. Comparing base (d320acd) to head (e7e9d48).
⚠️ Report is 4 commits behind head on develop.

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     
Files with missing lines Coverage Δ
...n/com/datadog/android/core/internal/CoreFeature.kt 86.87% <100.00%> (+0.15%) ⬆️
...l/net/info/BroadcastReceiverNetworkInfoProvider.kt 97.00% <100.00%> (+0.12%) ⬆️
...e/internal/net/info/CallbackNetworkInfoProvider.kt 95.06% <ø> (ø)
...rnal/system/BroadcastReceiverSystemInfoProvider.kt 96.55% <100.00%> (+0.19%) ⬆️

... and 40 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hamorillo
hamorillo requested a review from 0xnm May 5, 2026 19:26
// region BroadcastReceiver

override fun onReceive(context: Context, intent: Intent?) {
executorService.executeSafe(HANDLE_INTENT_OPERATION_NAME, internalLogger) {

@0xnm 0xnm May 6, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@hamorillo
hamorillo merged commit 6bcf501 into develop May 6, 2026
27 checks passed
@hamorillo
hamorillo deleted the hector.morilloprieto/RUM-16123 branch May 6, 2026 06:28
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.

3 participants