-
Notifications
You must be signed in to change notification settings - Fork 1.9k
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
UndispatchedCoroutine leaks CoroutineContext via thread local when using custom ContinuationInterceptor #4296
Comments
Thanks for a really comprehensive and easy-to-follow bug report! The issue is clear, and the potential solution is easy to implement (though hard to accept :)) and is hidden in the problem statement: The undispatched handling path should just be aware of that -- either by having an explicit |
I have a prototype that passes your tests (again, thanks!), I'll polish it up tomorrow morning and open a PR. It should make it to 1.10 coroutines with Kotlin 2.1 |
…d continuations There was a one codepath not covered by undispatched thread local cleanup procedure: when a custom ContinuationInterceptor is used and the scoped coroutine (i.e. withContext) is completed in-place without suspensions. Fixed with the introduction of the corresponding machinery for ScopeCoroutine Fixes #4296
Thank you for fixing!! |
Could I request a backport for 1.8.x, we'd like to pick up this fix in compose without forcing our clients to kotlin 2.0? |
@liutikas, typically, we don't publish any backports for |
Bug description
To fix #2930 (thread locals using
ThreadContextElement
not cleaned up), #3252 introduce a little hack (source) inUndispatchedCoroutine
which immediately saves the previous thread context toUndispatchedCoroutine.threadStateToRecover
within theUndispatchedCoroutine
constructor, only when the ContinuationInterceptor is not a CoroutineDispatcher:If the continuation doesn't suspend, then
UndispatchedCoroutine.afterResume()
isn't invoked andUndispatchedCoroutine.threadStateToRecover.remove()
is not called.One thing of note, the javadoc for
UndispatchedCoroutine.threadStateToRecover
claims this:Unfortunately, that's not exactly correct. When the ThreadLocal is garbage collected, its value actually stays in the
ThreadLocalMap
, until either the thread itself gets garbage collected, or the ThreadLocal map cleans up stale entries, which only happens when using thread locals some more.So when the right conditions apply, the CoroutineContext instance ends up being held in memory long after it's no longer needed.
To reproduce this, you need:
withContext()
, changing the CoroutineContext but without changing the ContinuationInterceptor (to trigger thewithContext()
fast path 2 that starts the coroutine on the same ContinuationInterceptor, wrapping it with UndispatchedCoroutine.Here's a repro: #4295
Fix
I see a few options:
if (uCont.context[ContinuationInterceptor] !is CoroutineDispatcher) {
check somehow (e.g. introduce an additional funky interface..)Workaround
Override equals in any custom
ContinuationInterceptor
implementation and have it return always false, to skip the fast way that's creating the UndispatchedCoroutine creation.How we found this
I ran into some Compose related memory leaks in Square's UI tests back in February, asked the Compose team if they knew anything about that. They didn't. Then yesterday folks from the Compose team reached out because they're enabling leak detection in their test suite and were running into the exact same leak. We worked together on analyzing a heap dump and debugging and eventually root cause it to this bug.
The Compose team is looking into whether they can start using CoroutineDispatcher implementations directly in Compose tests (for other reasons as well, e.g. see this issue)
Here's the leaktrace as reported by leakcanary:
For anyone with access to the ASG slack, here's the investigation thread: https://androidstudygroup.slack.com/archives/CJH03QASH/p1733875296139909?thread_ts=1708204699.328019&cid=CJH03QASH
The text was updated successfully, but these errors were encountered: