Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SES-1936] Fix memory leaks #1493

Merged
merged 8 commits into from
May 28, 2024
Merged

Conversation

simophin
Copy link

Description

There are a lot of memory leaks in the app, primarily around ConversationActivityV2, basically every conversation page you open it never gets GCed. And soon enough you'll run out of memory.

This is due to a lot of misuse of context.

This PR tries to address these misuses.

@simophin simophin changed the title Fixes SES-1936 [SES-1936] Fixes memory leaks May 21, 2024
import com.bumptech.glide.load.Key
import java.security.MessageDigest

class PlaceholderAvatarPhoto(val context: Context,
Copy link
Author

Choose a reason for hiding this comment

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

Key is a used to cache an image for Glide, context should not be part of an image Key (it doesn't make sense anyway), and the cache key is stored in a global object.

performAnimation()
}, dotAnimationStartDelay)
}

private fun performAnimation() {
expand()
Copy link
Author

Choose a reason for hiding this comment

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

performAnimation is a "recursive" call, basically keep running in the main thread. This "loop" is stored globally so it never gets cleaned up when the view disappears. Here a circuit breaker is introduced so that when the view goes out of windows the animation stops.

Comment on lines 37 to 41
for (job in updateJobs) {
job.cancel()
}
updateJobs.clear()
}
Copy link
Author

Choose a reason for hiding this comment

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

The updateJobs keep activity context in a globally referenced memory so they have to be cleaned up when viewModel destroys. There's code smells here in terms of how the LiveData is used but that's another story.

Choose a reason for hiding this comment

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

Is this necessary?

Jobs are run in this scope: executor = viewModelScope + SupervisorJob()

so I would've thought they'd be canceled when the VM scope is over.

Copy link
Author

Choose a reason for hiding this comment

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

You are right, I didn't see executor comes from a viewModelScope, I sort of assumed it was one of those Java ExecutorService I saw somewhere else.

It is interesting it shows up in the memory reference graph though so I took a closer look at the implementation of ContentResolver.observeQuery, which comes from a third party lib: app.cash.copper.flow: The snippet of that piece of code is:

@CheckResult
fun ContentResolver.observeQuery(
  uri: Uri,
  projection: Array<String>? = null,
  selection: String? = null,
  selectionArgs: Array<String>? = null,
  sortOrder: String? = null,
  notifyForDescendants: Boolean = false
): Flow<Query> {
  val query = ContentResolverQuery(this, uri, projection, selection, selectionArgs, sortOrder)
  return flow {
    emit(query)

    val channel = Channel<Unit>(CONFLATED)
    val observer = object : ContentObserver(mainThread) {
      override fun onChange(selfChange: Boolean) {
        channel.offer(Unit)
      }
    }

    registerContentObserver(uri, notifyForDescendants, observer)
    try {
      for (item in channel) {
        emit(query)
      }
    } finally {
      unregisterContentObserver(observer)
    }
  }
}

I believe the clean up code is not set up correctly, as unregisterContentObserver is never called when debugging the code. It surprises me that it's not called but if I have to DIY I'd use callbackFlow so that I can register a cancel hook to proper clean up the observer.

Copy link
Author

Choose a reason for hiding this comment

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

Let me come up with something

@simophin simophin changed the title [SES-1936] Fixes memory leaks [SES-1936] Fix memory leaks May 21, 2024
@simophin
Copy link
Author

There's one instance of leak left that is AudioSlidePlayer, who keeps the context in a global reference. It will leak up to one ConversationActivity I haven't got to check if changing to application context will break the player, will probably include it in a separate PR.

Copy link

@bemusementpark bemusementpark left a comment

Choose a reason for hiding this comment

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

Looks pretty reasonable, maybe double check the animation stuff?

if (isAttachedToWindow) {
// Should only update the timer if the view is still attached to the window.
// Otherwise, the timer will keep running even after the view is detached.
snHandler.postDelayed({ updateTimer() }, 500)

Choose a reason for hiding this comment

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

do we need to restart if it's reattached?

Copy link
Author

@simophin simophin May 21, 2024

Choose a reason for hiding this comment

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

No we don't, I did check it still works. Originally I had the same thought as I didn't know whether putting a view into background will count as a detach but it seems like the window still exists in the background.

Just for a fun reference, this is an answer I got from chatgpt:

No, putting an Android activity into the background does not destroy its window. The activity's window is only destroyed when the activity is finished (i.e., its finish() method is called) or the system needs to reclaim memory and decides to kill the activity's process. When an activity goes into the background, it is in the stopped state but its state is preserved. This means that the system maintains all view hierarchy and data associated with the activity. The activity's window is hidden, but all of its state such as member variables is preserved. However, it's important to note that when the system is low on memory, it may decide to kill background activities to reclaim resources. In this case, the activity's window and state would be destroyed. The system would later recreate the activity if it becomes visible again, calling onCreate() with a non-null Bundle that has the saved state.

Choose a reason for hiding this comment

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

ok, still a little brittle to stop on remove, and not start again if a dev was

There's another issue:

ConActivityV2#showVoiceMessageUI calls InputBarRecordingView#show calls updateTimer()

So every time we start a new voice recording we add another one of these async-recursive calls.

Maybe we can address that too?

Copy link
Author

Choose a reason for hiding this comment

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

Hmm ok I can try to address the "what if the dev wants to re-use the view but keep animation running" scenario, I was been lazy.

I'm not sure about your second question, updateTimer doesn't seem to "add" anything?

Choose a reason for hiding this comment

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

updateTimer posts a delayed call to updateTimer

Copy link
Author

Choose a reason for hiding this comment

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

Ah I see what you mean: a duplicated call into show will have started another timer, which will do nothing but increase the frequency of timer update?

Choose a reason for hiding this comment

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

Yeah, every time you record a message, we'll create a new "async-recursive" loop.

Copy link
Collaborator

@AL-Session AL-Session left a comment

Choose a reason for hiding this comment

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

All looking good to me! Approved.

@simophin
Copy link
Author

@bemusementpark Please have a look again with the latest change. I've also added the missing animation resume on PathActivity


if (isAttachedToWindow) {
// Make sure there's only one runnable in the handler at a time.
removeCallbacks(updateTimerRunnable)
Copy link

@bemusementpark bemusementpark May 22, 2024

Choose a reason for hiding this comment

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

nice, though maybe we should stop the callback when we stop recording, as right now the callbacks will not stop when you finish a recording.

You could make it fun updateTimer(scope) and pass in lifecycleScope and stop when the activity is destroyed for free.

Make some stop() that cancels the job.

Then we can just remove the onAttach stuff.

Comment on lines 37 to 41
for (job in updateJobs) {
job.cancel()
}
updateJobs.clear()
}

Choose a reason for hiding this comment

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

Is this necessary?

Jobs are run in this scope: executor = viewModelScope + SupervisorJob()

so I would've thought they'd be canceled when the VM scope is over.

fanchao added 2 commits May 23, 2024 13:48
@simophin
Copy link
Author

Hey @bemusementpark @AL-Session , apologies I needed to make additional changes to this PR:

  1. Tidy up the usage of Kotlin Flow in HomeViewModel, now it should be much cleaner on what it's trying to do.
  2. Adds a ContentResolver.observeChanges which unlike app.cash.copper.flow.observeQuery, it cleans up properly
  3. Another leaking place comes from MnemonicCodec.Language where it stores a lambda into a global cache :-(

Comment on lines -213 to -219
val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
}
}
this.broadcastReceiver = broadcastReceiver
LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged"))
Copy link
Author

Choose a reason for hiding this comment

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

This piece of code is useless: it doesn't update any data but asking RecyclerView to refresh itself completely on the same data. The only two data source for the home adapter is List<ThreadRecord> and Set<TypingID>, which are totally immutable: trying to reload the immutable data will always result in the same UI representation.

The "blocked contact" will actually be updated through the change to the thread itself. I have tested and it still works without this code here.

Choose a reason for hiding this comment

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

yeah, it looked like there's no other usage of "blockedContactsChanged"

Comment on lines -429 to -431
ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds ->
homeAdapter.typingThreadIDs = (threadIds ?: setOf())
}
Copy link
Author

Choose a reason for hiding this comment

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

I have moved this change detection into HomeViewModel so we have a uniform data coming from the VM instead.

Copy link
Author

Choose a reason for hiding this comment

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

This diff util now takes the "typing status" into account so we don't need to refresh the whole list when one of the thread status changes.

@@ -287,8 +287,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (hexEncodedSeed == null) {
hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account
}

val appContext = applicationContext

Choose a reason for hiding this comment

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

remove and inline?

Copy link
Author

Choose a reason for hiding this comment

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

This actually can't be inlined. As the syntax val appContext = applicationContext is equivalent to val appContext = this.getApplicationContext(), if you move applicationContext into the lambda, you move this.getApplicationContext() there hence moving this into the lambda, where this is where the memory leak comes from

Choose a reason for hiding this comment

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

looks like loadFileContents is used once, synchronously in the next line, so Activity Context was fine.

Copy link
Author

Choose a reason for hiding this comment

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

If you look at MnemonicCodec.encode, it creates a new Language and calls Language.loadWordSet, where Language instance is stored in static cache, in whichloadFileContents is a field of.

Copy link
Author

Choose a reason for hiding this comment

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

One of those dodgy "Putting everything you can think of into cache" scenarios

override fun onAttachedToWindow() {
super.onAttachedToWindow()

if (isVisible) {

Choose a reason for hiding this comment

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

isVisible won't take parent visibility into account, so this will probably start the timer onAttach.

Copy link
Author

Choose a reason for hiding this comment

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

My intention is to fix the memory leak and potential bug that animation won't run on reattach. What you are saying is a performance future proof (i.e. prevent excessive timer run) which I think doesn't need to happen in this PR.

To be able to control the timer correctly I think you'd need to do the timer thing in dispatchOnDraw, which I think it's too big a rabbit hole to get into.

Copy link
Author

Choose a reason for hiding this comment

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

Or rewrite in compose haha..

Choose a reason for hiding this comment

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

I'm saying that now this animation will start when the activity starts, because when this view is inflated and attached, it is visible, even though it's parent layout is not, so the manual call to updateTimer is basically a no-op, because updateTimer is already running.

It'd be nice for this animation to not run while the Activity is in the background.

Comment on lines -213 to -219
val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
}
}
this.broadcastReceiver = broadcastReceiver
LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged"))

Choose a reason for hiding this comment

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

yeah, it looked like there's no other usage of "blockedContactsChanged"

Copy link

@bemusementpark bemusementpark left a comment

Choose a reason for hiding this comment

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

looking good!

@bemusementpark bemusementpark merged commit b757691 into oxen-io:dev May 28, 2024
1 check failed
@simophin simophin deleted the ses-1936-oom branch May 28, 2024 04:55
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