compose-multiplatform icon indicating copy to clipboard operation
compose-multiplatform copied to clipboard

Using `ComposeScene`s with anything but `MainUIDispatcher` results in a deadlock

Open jakobkmar opened this issue 1 year ago • 19 comments

Describe the bug

MultiLayerComposeScene and SingleLayerComposeScene provide the option to set a coroutineContext parameter. FrameDispatcher also provides the option to pass a CoroutineScope or CoroutineContext.

However, using a scene with any other dispatcher other than MainUIDispatcher (provided by Skiko) will result in deadlock at some point when using the application.

Since the render call on a scene must also be called from the same thread, this also means that the FrameDispatcher must use MainUIDispatcher as well.

This causes several issues. For example, GLFW requires the use of a specific thread to be able to draw to a Window, which clashes with the requirement for MainUIDispatcher which is an AWT event queue.

The following seems to be unsupported for now: ~~I consider this a major bug, since this makes it impossible to render multiple scenes at once using different threads (as MainUIDispatcher is always the same). Multiple scenes are needed if you wish to render multiple completely separate scenes in one Kotlin application. The coroutineContext paramters are also completly obsolete this way.~~

Please note: In past Compose versions (for example 1.2.0) ComposeScene worked fine with other dispatchers, so this is a new bug which has been introduced with recent updates.

To Reproduce

  1. create a custom MultiLayerComposeScene
  2. pass a Dispatchers.Default.limitedParallelism(1) to it
  3. use the same limited dispatcher for the FrameDispatcher
  4. render the scene continuously using the FrameDispatcher
  5. do something, e.g. scroll a lot etc
  6. observe deadlock in IntelliJ ThreadDump using debugger

Deadlock:

image

Detailed Thread Dump Stack Traces
"AWT-EventQueue-0 @coroutine#8@24000" tid=0x9a nid=NA waiting for monitor entry
  java.lang.Thread.State: BLOCKED
	 blocks DefaultDispatcher-worker-5 @coroutine#5@23693
	 waiting for DefaultDispatcher-worker-5 @coroutine#5@23693 to release lock on <0x5e17> (a androidx.compose.runtime.SynchronizedObject)
	  at androidx.compose.runtime.BroadcastFrameClock.getHasAwaiters(Synchronization.kt:33)
	  at androidx.compose.runtime.Recomposer.getHasBroadcastFrameClockAwaitersLocked(Recomposer.kt:289)
	  at androidx.compose.runtime.Recomposer.deriveStateLocked(Recomposer.kt:326)
	  at androidx.compose.runtime.Recomposer.access$deriveStateLocked(Recomposer.kt:127)
	  at androidx.compose.runtime.Recomposer$recompositionRunner$2$unregisterApplyObserver$1.invoke(Recomposer.kt:989)
	  - locked <0x5e19> (a androidx.compose.runtime.SynchronizedObject)
	  at androidx.compose.runtime.Recomposer$recompositionRunner$2$unregisterApplyObserver$1.invoke(Recomposer.kt:976)
	  at androidx.compose.runtime.snapshots.SnapshotKt.advanceGlobalSnapshot(Snapshot.kt:1816)
	  at androidx.compose.runtime.snapshots.SnapshotKt.advanceGlobalSnapshot(Snapshot.kt:1831)
	  at androidx.compose.runtime.snapshots.SnapshotKt.access$advanceGlobalSnapshot(Snapshot.kt:1)
	  at androidx.compose.runtime.snapshots.Snapshot$Companion.sendApplyNotifications(Snapshot.kt:584)
	  at androidx.compose.ui.platform.GlobalSnapshotManager$ensureStarted$1.invokeSuspend(GlobalSnapshotManager.skiko.kt:46)
"DefaultDispatcher-worker-5 @coroutine#5@23693" tid=0x73 nid=NA waiting for monitor entry
  java.lang.Thread.State: BLOCKED
	 blocks AWT-EventQueue-0 @coroutine#8@24000
	 waiting for AWT-EventQueue-0 @coroutine#8@24000 to release lock on <0x5e19> (a androidx.compose.runtime.SynchronizedObject)
	  at androidx.compose.runtime.Recomposer.composeInitial$runtime(Synchronization.kt:33)
	  at androidx.compose.runtime.ComposerImpl$CompositionContextImpl.composeInitial$runtime(Composer.kt:3600)
	  at androidx.compose.runtime.CompositionImpl.composeInitial(Composition.kt:633)
	  at androidx.compose.runtime.CompositionImpl.setContentWithReuse(Composition.kt:625)
	  at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcomposeInto(SubcomposeLayout.kt:502)
	  at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcompose(SubcomposeLayout.kt:472)
	  at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcompose(SubcomposeLayout.kt:463)
	  at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcompose(SubcomposeLayout.kt:447)
	  at androidx.compose.ui.layout.LayoutNodeSubcompositionsState$Scope.subcompose(SubcomposeLayout.kt:872)
	  at androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScopeImpl.measure-0kLqBqw(LazyLayoutMeasureScope.kt:125)
	  at androidx.compose.foundation.lazy.LazyListMeasuredItemProvider.getAndMeasure(LazyListMeasuredItemProvider.kt:48)
	  at androidx.compose.foundation.lazy.LazyListMeasureKt.measureLazyList-5IMabDg(LazyListMeasure.kt:195)
	  at androidx.compose.foundation.lazy.LazyListKt$rememberLazyListMeasurePolicy$1$1.invoke-0kLqBqw(LazyList.kt:313)
	  at androidx.compose.foundation.lazy.LazyListKt$rememberLazyListMeasurePolicy$1$1.invoke(LazyList.kt:178)
	  at androidx.compose.foundation.lazy.layout.LazyLayoutKt$LazyLayout$3$2$1.invoke-0kLqBqw(LazyLayout.kt:107)
	  at androidx.compose.foundation.lazy.layout.LazyLayoutKt$LazyLayout$3$2$1.invoke(LazyLayout.kt:100)
	  at androidx.compose.ui.layout.LayoutNodeSubcompositionsState$createMeasurePolicy$1.measure-3p2s80s(SubcomposeLayout.kt:709)
	  at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:126)
	  at androidx.compose.ui.graphics.SimpleGraphicsLayerModifier.measure-3p2s80s(GraphicsLayerModifier.kt:646)
	  at androidx.compose.ui.node.LayoutModifierNodeCoordinator.measure-BRTryo0(LayoutModifierNodeCoordinator.kt:116)
	  at androidx.compose.ui.node.LayoutNodeLayoutDelegate$performMeasureBlock$1.invoke(LayoutNodeLayoutDelegate.kt:252)
	  at androidx.compose.ui.node.LayoutNodeLayoutDelegate$performMeasureBlock$1.invoke(LayoutNodeLayoutDelegate.kt:251)
	  at androidx.compose.runtime.snapshots.Snapshot$Companion.observe(Snapshot.kt:132)
	  at androidx.compose.runtime.snapshots.SnapshotStateObserver$ObservedScopeMap.observe(SnapshotStateObserver.kt:504)
	  at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:260)
	  at androidx.compose.ui.node.OwnerSnapshotObserver.observeReads$ui(OwnerSnapshotObserver.kt:133)
	  at androidx.compose.ui.node.OwnerSnapshotObserver.observeMeasureSnapshotReads$ui(OwnerSnapshotObserver.kt:113)
	  at androidx.compose.ui.node.LayoutNodeLayoutDelegate.performMeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:1617)
	  at androidx.compose.ui.node.LayoutNodeLayoutDelegate.access$performMeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:36)
	  at androidx.compose.ui.node.LayoutNodeLayoutDelegate$MeasurePassDelegate.remeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:620)
	  at androidx.compose.ui.node.LayoutNode.remeasure-_Sx5XlM$ui(LayoutNode.kt:1145)
	  at androidx.compose.ui.node.MeasureAndLayoutDelegate.doRemeasure-sdFAvZA(MeasureAndLayoutDelegate.kt:354)
	  at androidx.compose.ui.node.MeasureAndLayoutDelegate.measureAndLayout-0kLqBqw(MeasureAndLayoutDelegate.kt:439)
	  at androidx.compose.ui.node.RootNodeOwner$OwnerImpl.measureAndLayout-0kLqBqw(RootNodeOwner.skiko.kt:322)
	  at androidx.compose.ui.node.LayoutNode.forceRemeasure(LayoutNode.kt:1219)
	  at androidx.compose.foundation.lazy.LazyListState.onScroll$foundation(LazyListState.kt:352)
	  at androidx.compose.foundation.lazy.LazyListState$scrollableState$1.invoke(LazyListState.kt:187)
	  at androidx.compose.foundation.lazy.LazyListState$scrollableState$1.invoke(LazyListState.kt:187)
	  at androidx.compose.foundation.gestures.DefaultScrollableState$scrollScope$1.scrollBy(ScrollableState.kt:166)
	  at androidx.compose.foundation.gestures.ScrollingLogic$dispatchScroll$performScroll$1.invoke-MK-Hz9U(Scrollable.kt:693)
	  at androidx.compose.foundation.gestures.ScrollingLogic$dispatchScroll$performScroll$1.invoke(Scrollable.kt:683)
	  at androidx.compose.foundation.gestures.ScrollingLogic.dispatchScroll-3eAAhYA(Scrollable.kt:707)
	  at androidx.compose.foundation.gestures.MouseWheelScrollNode.dispatchMouseWheelScroll(MouseWheelScrollable.kt:312)
	  at androidx.compose.foundation.gestures.MouseWheelScrollNode.access$dispatchMouseWheelScroll(MouseWheelScrollable.kt:54)
	  at androidx.compose.foundation.gestures.MouseWheelScrollNode$animateMouseWheelScroll$2.invoke(MouseWheelScrollable.kt:297)
	  at androidx.compose.foundation.gestures.MouseWheelScrollNode$animateMouseWheelScroll$2.invoke(MouseWheelScrollable.kt:287)
	  at androidx.compose.animation.core.SuspendAnimationKt.doAnimationFrame(SuspendAnimation.kt:361)
	  at androidx.compose.animation.core.SuspendAnimationKt.doAnimationFrameWithScale(SuspendAnimation.kt:339)
	  at androidx.compose.animation.core.SuspendAnimationKt.access$doAnimationFrameWithScale(SuspendAnimation.kt:1)
	  at androidx.compose.animation.core.SuspendAnimationKt$animate$9.invoke(SuspendAnimation.kt:279)
	  at androidx.compose.animation.core.SuspendAnimationKt$animate$9.invoke(SuspendAnimation.kt:278)
	  at androidx.compose.animation.core.SuspendAnimationKt$callWithFrameNanos$2.invoke(SuspendAnimation.kt:304)
	  at androidx.compose.animation.core.SuspendAnimationKt$callWithFrameNanos$2.invoke(SuspendAnimation.kt:303)
	  at androidx.compose.runtime.BroadcastFrameClock$FrameAwaiter.resume(BroadcastFrameClock.kt:42)
	  at androidx.compose.runtime.BroadcastFrameClock.sendFrame(BroadcastFrameClock.kt:71)
	  - locked <0x5e17> (a androidx.compose.runtime.SynchronizedObject)
	  at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke(Recomposer.kt:558)
	  at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke(Recomposer.kt:551)
	  at androidx.compose.runtime.BroadcastFrameClock$FrameAwaiter.resume(BroadcastFrameClock.kt:42)
	  at androidx.compose.runtime.BroadcastFrameClock.sendFrame(BroadcastFrameClock.kt:71)
	  - locked <0x5e18> (a androidx.compose.runtime.SynchronizedObject)
	  at androidx.compose.ui.scene.BaseComposeScene.render(BaseComposeScene.skiko.kt:160)

Expected behavior

It should be possible to pass a custom dispatcher to the scene APIs. E.g. your own Dispatchers.Default.limitedParallelism(1). Alternatively, it could be possible to configure what MainUIDispatcher actually is under the hood.

~~This is needed for having multiple and completely separate scenes at once in one Kotlin application.~~ (not supported, but it is still needed for reasons explained above)

Custom scenes should not depend on the AWT event queue, since they won't be rendered to an AWT or Swing Window anyways.

Affected platforms

  • Desktop (Windows, Linux, macOS)

Versions

  • Libraries:
    • Compose Multiplatform version: 1.6.2 and 1.6.10-rc01
  • Kotlin version: 1.9.23
  • OS version(s) (required for Desktop and iOS issues): Windows 11 and Linux
  • OS architecture (x86 or arm64): x86
  • JDK (for desktop issues): 21

Additional context

  • with previous versions (when ComposeScene was still completely provided by Skiko) the API worked perfectly fine with any dispatcher
  • Compose Multiplatform with its own use of FrameDispatcher and ComposeScenes on the desktop seems to always use the MainUIDispatcher, which would explain why this issue has not occurred yet.

jakobkmar avatar May 09 '24 02:05 jakobkmar

Unfortunately this is not currently a supported use-case.

We tried to allow concurrent use of ComposeScene here, but ran into limitations (mentioned in that ticket).

It's possible that we have since introduced our own, additional, limitations, since we've stopped trying to support concurrent use of ComposeScene ourselves.

m-sasha avatar May 09 '24 04:05 m-sasha

Hasn't the linked issue been caused by multiple threads being used for the same scene due to Dispatchers.Unconfined being the default context for ImageComposeScene? If yes, then this is not what I want. I do not want to use the same scene concurrently, but multiple scenes each on their own separate (but only that single) thread.

jakobkmar avatar May 09 '24 05:05 jakobkmar

No, it's about using multiple scenes concurrently.

Why do you need to use multiple ComposeScenes each in their own thread?

m-sasha avatar May 09 '24 06:05 m-sasha

I have been using multiple scenes for a server-side GUI, where the server handles multiple users at once.

The performance degraded severely now that everything has to use MainUIDispatcher.

jakobkmar avatar May 09 '24 16:05 jakobkmar

Can you try using a single-thread dispatcher instead of Dispatchers.Default.limitedParallelism(1), e.g.

Executors.newSingleThreadExecutor().asCoroutineDispatcher()

?

m-sasha avatar May 09 '24 16:05 m-sasha

Yes, I tried that as well - it results in the same deadlock between that single-thread and the AWT event queue.

jakobkmar avatar May 09 '24 16:05 jakobkmar

Also, does this mean that using Compose to render into GLFW windows (for example using LWJGL) is unsupported now as well? (you have to use the glfw thread there, which clashes with the requirement for MainUIDispatcher)

jakobkmar avatar May 09 '24 17:05 jakobkmar

We can't support this use-case well because of the aforementioned limitations upstream (i.e. you could still get deadlocks before), and it's complicated to support it badly.

m-sasha avatar May 13 '24 09:05 m-sasha

The LWJGL thing mentioned in my last comment is something different though, no concurrent use of ComposeScenes, still not possible anymore due to the newly introduced requirement for MainUIDispatcher. Is this use-case also not supported, even though there is only a single ComposeScene in use?

I don't think it caused any issues or deadlocks before - it was basically the same just with the difference that the entire logic did not have to run on MainUIDispatcher (= AWT event queue).

No multithreading there, no concurrent scenes - it simply needs a different (single) thread because you cannot start a GLFW window in the AWT event queue.

jakobkmar avatar May 13 '24 10:05 jakobkmar

Is this use-case also not supported, even though there is only a single ComposeScene in use?

@igordmn Can you answer? Is this a use-case we need to support?

I don't think it caused any issues or deadlocks before

It did; perhaps you just haven't encountered them. See the issues described here: https://github.com/JetBrains/compose-multiplatform/issues/1396

m-sasha avatar May 13 '24 10:05 m-sasha

In all shared code snippets in the linked issue there are always thousand concurrent scenes started on different threads. I am not sure how one scene on a single thread could lead to the same issues described there.

jakobkmar avatar May 13 '24 10:05 jakobkmar

There are global structures in Compose that are shared between all instances, as explained in these two tickets: https://issuetracker.google.com/issues/283162626 https://issuetracker.google.com/issues/283216580

m-sasha avatar May 13 '24 12:05 m-sasha

But I am not talking about multiple instances right now. Only a single scene, a single application, a single thread - so only one instance.

The main issue is that a requirement for MainUIDispatcher was introduced, which is not related to the concurrent scene issue. For example, requiring MainUIDispatcher makes Compose not usable with GLFW anymore, which it previously was. This is not at all related to the concurrent scene issues, since there are no concurrent scenes in a single thread and single scene GLFW application.

I opened this issue because of the requirement for MainUIDispatcher, not because of scene concurrency.

jakobkmar avatar May 13 '24 12:05 jakobkmar

Also, this is not an upstream issue, since upstream does not work with the AWT event queue and therefore cannot be the reason why it is required (except if somewhere in upstream Compose the coroutineContext is ignored and MainUIDispatcher has been hardcoded).

jakobkmar avatar May 13 '24 12:05 jakobkmar

I don't know whether supporting GLFW is an important goal for us. @igordmn ?

m-sasha avatar May 13 '24 12:05 m-sasha

We discussed it with @igordmn recently - it was a conscious, explicit decision to not depend on a specific dispatcher. ComposeScene should allow to be reused in GLFW or JavaFX environments. So I guess the issue is valid

MatkovIvan avatar May 13 '24 12:05 MatkovIvan

The problem is likely that GlobalSnapshotManager runs on the AWT thread.

m-sasha avatar May 13 '24 13:05 m-sasha

ComposeScene is by design should be independent of any hardcoded Main thread. The issue that it interferes with GlobalSnapshotManager and have races with it is a Compose Runtime bug that needs to be fixed. Though, it is not trivial, and isn't in priority unfortunately.

A simple alternative fix can be adding ability to redefine GlobalSnapshotManager's dispatcher

igordmn avatar May 14 '24 12:05 igordmn

A simple alternative fix can be adding ability to redefine GlobalSnapshotManager's dispatcher

Or:

  1. make a flag as suggested here
  2. make a @InternalComposeUiApi public fun startGlobalSnapshotManager(coroutineDispatcher)

igordmn avatar May 14 '24 12:05 igordmn

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.

okushnikov avatar Jul 14 '24 14:07 okushnikov