Skip to content

RUM-15390: Fix memory leak in app launch#3349

Merged
aleksandr-gringauz merged 3 commits into
developfrom
aleksandr-gringauz/RUM-15390/fix-memory-leak-2
Apr 14, 2026
Merged

RUM-15390: Fix memory leak in app launch#3349
aleksandr-gringauz merged 3 commits into
developfrom
aleksandr-gringauz/RUM-15390/fix-memory-leak-2

Conversation

@aleksandr-gringauz

@aleksandr-gringauz aleksandr-gringauz commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixing the memory leak.

How it happens:

  1. Suppose we have a situation when we have an interstitial Activity that launches another Activity in its onCreate method.
  2. RumWindowCallbacksRegistry.addListener is called for the interstitial Activity.
  3. The interstitial Activity calls finish in its onCreate, thus removeListener for it is never called here.
  4. The interstitial Activity is leaked here (Activity implements Window.Callback).

Changes in the PR

  1. The fix is done by changing the API of subscribeToFirstFrameDrawn method. Now it returns an instance of Handle that makes it possible to unsubscribe the Activity in onActivityDestroyed.
  2. Also added LeakCanary to the integration tests about app launch with interstitial Activities. They failed without my fix exactly with this memory leak.
  3. Also added LeakCanary to the sample app. This is just so that it can catch some other leaks in the future during local SDK development.

The number of lines in the diff is large, but mostly because of the tests.

Motivation

What inspired you to submit this pull request?

Additional Notes

Anything else we should know when reviewing?

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)

@aleksandr-gringauz aleksandr-gringauz changed the title RUM-15390: Fix memory leak an app launch RUM-15390: Fix memory leak in app launch Apr 8, 2026
@aleksandr-gringauz
aleksandr-gringauz force-pushed the aleksandr-gringauz/RUM-15390/fix-memory-leak-2 branch from 9cf862c to cdfe385 Compare April 8, 2026 15:31
@datadog-official

This comment has been minimized.

@codecov-commenter

codecov-commenter commented Apr 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.57143% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.79%. Comparing base (e71690e) to head (39eb9dc).
⚠️ Report is 7 commits behind head on develop.

Files with missing lines Patch % Lines
...m/internal/startup/RumFirstDrawTimeReporterImpl.kt 0.00% 7 Missing ⚠️
...rnal/startup/RumFirstDrawTimeReporterHandleImpl.kt 93.65% 2 Missing and 2 partials ⚠️
.../rum/internal/startup/RumAppStartupDetectorImpl.kt 96.15% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #3349      +/-   ##
===========================================
+ Coverage    71.49%   71.79%   +0.30%     
===========================================
  Files          947      948       +1     
  Lines        34919    34952      +33     
  Branches      5921     5804     -117     
===========================================
+ Hits         24965    25093     +128     
+ Misses        8290     8255      -35     
+ Partials      1664     1604      -60     
Files with missing lines Coverage Δ
...lin/com/datadog/android/rum/internal/RumFeature.kt 91.98% <100.00%> (-0.68%) ⬇️
...roid/rum/internal/startup/RumAppStartupDetector.kt 100.00% <100.00%> (ø)
...d/rum/internal/startup/RumFirstDrawTimeReporter.kt 100.00% <ø> (ø)
.../rum/internal/startup/RumAppStartupDetectorImpl.kt 96.63% <96.15%> (-0.39%) ⬇️
...rnal/startup/RumFirstDrawTimeReporterHandleImpl.kt 93.65% <93.65%> (ø)
...m/internal/startup/RumFirstDrawTimeReporterImpl.kt 41.67% <0.00%> (-52.78%) ⬇️

... and 108 files with indirect coverage changes

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

@aleksandr-gringauz
aleksandr-gringauz force-pushed the aleksandr-gringauz/RUM-15390/fix-memory-leak-2 branch from cdfe385 to 4fc463a Compare April 9, 2026 12:31
@aleksandr-gringauz

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: 4fc463a8b2

ℹ️ 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 +59 to +60
val nowNs = timeProviderNs()
callback.onFirstFrameDrawn(nowNs)

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 Capture TTID timestamp at draw time

onDraw() is the event boundary for TTID, but this refactor now reads timeProviderNs() inside reportFirstFrame() after the callback has been queued, so the recorded timestamp can drift later than the actual first draw when the main thread is busy. That inflates durationNs and makes startup metrics less accurate. Capture the timestamp in onFirstDraw() and pass that fixed value into the queued runnable (as the previous implementation did).

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.

This is a good comment. However this change is on purpose. In the original RFC I wanted to do exactly this (read here).

But at some point (I don't remember when) apparently I made a mistake and instead of using the time of when the Runnable is executed, I used the time of when onDraw was called.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤔 Just to understand, why don't we want to use the time when onDraw is called?

@aleksandr-gringauz aleksandr-gringauz Apr 10, 2026

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.

It is explained in the link to the RFC that I mentioned above. In short - it is more precise.

@aleksandr-gringauz
aleksandr-gringauz force-pushed the aleksandr-gringauz/RUM-15390/fix-memory-leak-2 branch from 4fc463a to c106edc Compare April 9, 2026 15:51
true
}.doWait(timeoutMs = FINAL_WAIT_MS)

LeakAssertions.assertNoLeaks()

@aleksandr-gringauz aleksandr-gringauz Apr 9, 2026

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 do it individually in each test, not in one place, because:

  1. If we do it here, some strange memory leak is detected. It is 100% not a problem in our SDK, but rather in our tests. I haven't managed to find a solution to this.
  2. We can't use the test rule, because it checks the leaks after Datadog.stop is called here which clears all features including RumFeature that itself clears this. It allows interstitial Activity to be garbage collected and the leak isn't detected.

@aleksandr-gringauz
aleksandr-gringauz marked this pull request as ready for review April 9, 2026 20:47
@aleksandr-gringauz
aleksandr-gringauz requested review from a team as code owners April 9, 2026 20:47

@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: c106edcd9f

ℹ️ 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 thread sample/kotlin/build.gradle.kts Outdated
Comment thread sample/kotlin/build.gradle.kts Outdated
Comment on lines 36 to +38
private val trackedActivities = Collections.newSetFromMap(WeakHashMap<Activity, Boolean>())
private val firstFrameHandles = WeakHashMap<Activity, RumFirstDrawTimeReporter.Handle>()

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.

just curious: Won't it be enough just to keep identityHashCode as a key? Don't we risk to have a minor leak here by keeping the key longer than needed (it will be GC-ed eventually since it is a WeakHashMap)?

But I guess onActivityDestroyed is called all the time during the normal app lifecycle.

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.

My current understanding is that identityHashCode should not be used as a key in hash maps.

identityHashCode returns an int. So it can represent only 2^32 distinct values. This means that for 100 000 items the probability of at least one collision is approximately 68% (see this and to calculate this). We will not have 100 000 Activities - yes, but some other things (some kind of spans maybe) - why not. So I don't think it is a good practice in general.

Also see this.

But I guess onActivityDestroyed is called all the time during the normal app lifecycle.

yes, exactly. Here I use WeakHashMap just as a precaution against some kind of mistake. It could be replaced by a normal Map.

@aleksandr-gringauz aleksandr-gringauz Apr 10, 2026

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.

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.

That is true. My idea is that since it is just used as a key in the map, where lookup is done by first checking hashCode for a bucket and then reference comparison, then maybe we can capture some identity property of Activity instead and use it.

But as long as it is guaranteed to do a cleanup in onActivityDestroyed - it is okay.

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.

Looking at the reproducer: I think it is expected behavior, because obj can be GC-ed on each loop end, so system can give the int which was already seen before, but it is not attached to any object (since previous owner was already GC-ed).

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.

@aleksandr-gringauz aleksandr-gringauz Apr 10, 2026

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.

Also the interesting thing about this https://pl.kotl.in/mp61XH2LK is that every time you run it it finds the collision at exactly the same iteration 105668 - which proves the point here that some kind of pseudo-random number generator is involved in generating the identity hash code.

@hamorillo hamorillo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Left some minor comments, LGTM overall 🚀

Comment on lines +59 to +60
val nowNs = timeProviderNs()
callback.onFirstFrameDrawn(nowNs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤔 Just to understand, why don't we want to use the time when onDraw is called?

@aleksandr-gringauz
aleksandr-gringauz force-pushed the aleksandr-gringauz/RUM-15390/fix-memory-leak-2 branch from d04be48 to 737adf2 Compare April 10, 2026 15:26
hamorillo
hamorillo previously approved these changes Apr 13, 2026

@hamorillo hamorillo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀 Nice work!

0xnm
0xnm previously approved these changes Apr 13, 2026
@aleksandr-gringauz
aleksandr-gringauz dismissed stale reviews from 0xnm and hamorillo via 39eb9dc April 13, 2026 15:18
@aleksandr-gringauz
aleksandr-gringauz merged commit a1fc7c4 into develop Apr 14, 2026
28 of 29 checks passed
@aleksandr-gringauz
aleksandr-gringauz deleted the aleksandr-gringauz/RUM-15390/fix-memory-leak-2 branch April 14, 2026 08:19
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.

4 participants