Skip to content

Run GlobalSnapshotManager on trampolineDispatcher#3175

Open
Ivan Matkov (MatkovIvan) wants to merge 6 commits into
jb-mainfrom
ivan.matkov/gsm-on-trampoline
Open

Run GlobalSnapshotManager on trampolineDispatcher#3175
Ivan Matkov (MatkovIvan) wants to merge 6 commits into
jb-mainfrom
ivan.matkov/gsm-on-trampoline

Conversation

@MatkovIvan

@MatkovIvan Ivan Matkov (MatkovIvan) commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

CMP-10397 Compose drops frame on iOS when dragging
CMP-10411 Register GlobalSnapshotManager on the trampoline dispatcher

Supersedes #3171

It registers GlobalSnapshotManager on the trampoline dispatcher, so a scheduled global apply becomes just another trampoline task — and the flush drains in a loop that keeps polling until the queue is empty. That means an apply notification enqueued by running an earlier task (or a write made during pointer dispatch) is picked up in the same synchronous flush, instead of leaking to the next frame.

Release Notes

Fixes - iOS

  • (prerelease fix) Fix frame drops when dragging scrollable content

@MatkovIvan Ivan Matkov (MatkovIvan) force-pushed the ivan.matkov/gsm-on-trampoline branch 3 times, most recently from 567510e to 41293b5 Compare July 1, 2026 22:55
@MatkovIvan Ivan Matkov (MatkovIvan) marked this pull request as ready for review July 1, 2026 22:56
"FrameRecomposer requires a ContinuationInterceptor in its coroutineContext"
}
val hostDispatcher = coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher
if (hostDispatcher != null && !hostDispatcher.isDispatchNeeded(hostDispatcher)) {

@ASalavei Andrei Salavei (ASalavei) Jul 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In what cases it's needed? Is it a temporary solution?
Could you please add comment there?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not temporary. For things like Dispatchers.Unconfined (default in ImageComposeScene) or Dispatchers.Main.immediate,

Also, it's not CoroutineDispatcher in our test framework

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Please add comment then... because it looks a bit weird (or maybe update constructor params to make the usage clear.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

But it already has comments about that (on the next line), so I'm not sure what else to add here. Any particular suggestions?

For the "public" parameters - it doesn't seem that adjustment is required, it's an implementation detail, and previous behavior is still here

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reading the code, it looks like it just skipping the flush. But if we just use FlushCoroutineDispatcher, the flush shouldn't do anything, because the dispatcher should perform the task during the dispatch.

If I am correct, and behavior is the same, I would prefer the old code - it looks cleaner and understandable. If I am not correct - can you explain what difference does this code make?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If FlushCoroutineDispatcher doesn't work correctly with Dispatchers.Unconfined - it looks like a bug of FlushCoroutineDispatcher that we should fix

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The issue here is that GSM doesn't work with Dispatchers.Unconfined. Register GSM on FlushCoroutineDispatcher that inlines the block leads to a deadlock. This particular behavior is explicitly marked as unsupported in compose:runtime, so it's about ensuring that

  • No GSM on FlushCoroutineDispatcher
  • No inlining in FlushCoroutineDispatcher if it reports isDispatchNeeded == true
    Currently these checks and behaviors made explicit and in one place (not across 3 classes)

Creating FlushCoroutineDispatcher that has only one function - flush in cases where it does not work does not seem as readability improvement, but vice versa - it makes it quite tricky to understand what's going on there.

If we do not want such a cleanup and explicitness, it might be revered back, but do we?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reserved most of it for now and replaced by delegation implementation of FlushCoroutineDispatcher.isDispatchNeeded

"FrameRecomposer requires a ContinuationInterceptor in its coroutineContext"
}
val hostDispatcher = coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher
if (hostDispatcher != null && !hostDispatcher.isDispatchNeeded(hostDispatcher)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reading the code, it looks like it just skipping the flush. But if we just use FlushCoroutineDispatcher, the flush shouldn't do anything, because the dispatcher should perform the task during the dispatch.

If I am correct, and behavior is the same, I would prefer the old code - it looks cleaner and understandable. If I am not correct - can you explain what difference does this code make?

@MatkovIvan Ivan Matkov (MatkovIvan) force-pushed the ivan.matkov/gsm-on-trampoline branch 2 times, most recently from a89f262 to 205f010 Compare July 2, 2026 11:26
@MatkovIvan Ivan Matkov (MatkovIvan) marked this pull request as draft July 2, 2026 11:29
@MatkovIvan Ivan Matkov (MatkovIvan) force-pushed the ivan.matkov/gsm-on-trampoline branch from 205f010 to 8bd66ac Compare July 2, 2026 11:46
@MatkovIvan Ivan Matkov (MatkovIvan) marked this pull request as ready for review July 2, 2026 11:52
@MatkovIvan Ivan Matkov (MatkovIvan) force-pushed the ivan.matkov/gsm-on-trampoline branch from 0a151ca to 0c01547 Compare July 2, 2026 15:45
@MatkovIvan

Copy link
Copy Markdown
Collaborator Author

rebased on jb-main + reverted #3171

private class Registration(val dispatcher: CoroutineDispatcher) {
/** Number of live handles. Guarded by [GlobalSnapshotManager.lock]. */
var refCount = 0
var threadId: Long? = null

Choose a reason for hiding this comment

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

Should probably be @Volatile.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done

Comment on lines +115 to +116
threadId = getCurrentThreadId()
warnIfMultipleThreads()

Choose a reason for hiding this comment

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

You could add this also inside the channel.consumeEach to catch multi-thread dispatchers (like Dispatchers.Default).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm avoiding this for a few reasons:

  • No guarantee that it will be printed once without more complex tracking
  • .distinct() is not so cheap to run it on each snapshot writing

}

private fun warnIfMultipleThreads() = synchronized(lock) {
if (registrations.values.mapNotNull { it.threadId }.distinct().size > 1) {

Choose a reason for hiding this comment

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

This could be much faster.
It's ok if it's only done on registration, but if you also call it on each value in channel, it should be rewritten.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I don't really want to store additional sets with complicated logic of clearing this. One time shot is enough for prinitting a warning purposes

@m-sasha

Copy link
Copy Markdown

It would help to explain what is a/the "trampoline dispatcher".

@MatkovIvan

Copy link
Copy Markdown
Collaborator Author

It would help to explain what is a/the "trampoline dispatcher".

It's in KDoc to it:

/**
* Trampoline queue (Android's `toRunTrampolined`):
* - Coroutine dispatch
* - Composition effects
* - Scheduled apply notifications
* Rolled synchronously by [performTrampolineDispatch].
*/
private val trampolineDispatcher = FlushCoroutineDispatcher(coroutineScope)

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