test: render every ImageComposeScene on the AWT event thread

The rare CI hang is a lock-order deadlock in Compose Multiplatform 1.8.2,
shown by the thread dump of run 40299943329:

- the test thread renders the scene and holds the FlushCoroutineDispatcher
  runLock while LazyListState.scrollToItem measures and wants the
  SnapshotStateObserver's observedScopeMapsLock;
- the AWT event thread sends the global snapshot notifications and holds
  observedScopeMapsLock in drainChanges while a re-read derived state wakes
  the scene's snapshotFlow collector, whose dispatch wants runLock.

The application renders on the event thread, where both monitors are simply
re-entered. The tests now do the same: onEventThread builds, renders and
closes each scene there and rethrows what the block throws.

Claude-Session: https://claude.ai/code/session_01FJBfPYm6rCH4zD6T7bcC7Y
This commit is contained in:
Rsclub2_2 committed 2026-10-26 11:02:17 +01:00
1 parent 05874d6ddb
commit ec7ea8ea1d
11 files changed
+70 -5

No files matched your search

@@ -55,6 +55,7 @@ class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Pat
} }
val filters = StationFilterState() val filters = StationFilterState()
return onEventThread {
val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) { val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface( Surface(
@@ -90,12 +91,13 @@ class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Pat
} }
} }
} }
return try { try {
scene.render().encodeToData()!!.bytes scene.render().encodeToData()!!.bytes
} finally { } finally {
scene.close() scene.close()
} }
} }
}
private class RenderActions(private val preferences: ChatPreferences) : ChatInputActions { private class RenderActions(private val preferences: ChatPreferences) : ChatInputActions {
override fun panelSelection(): ChatMember? = null override fun panelSelection(): ChatMember? = null
@@ -0,0 +1,37 @@
package kst4contest.view.compose
import java.awt.EventQueue
import java.lang.reflect.InvocationTargetException
/*
* An ImageComposeScene must live on the AWT event thread, the thread the application renders
* on. Compose Multiplatform 1.8.2 sends the global snapshot notifications there
* (GlobalSnapshotManager), and doing so takes two locks in the opposite order from a render
* on any other thread:
*
* - the render holds the scene's FlushCoroutineDispatcher runLock while a task it runs
* (here LazyListState.scrollToItem) measures and wants the SnapshotStateObserver's
* observedScopeMapsLock;
* - the event thread holds observedScopeMapsLock in drainChanges while a derived state it
* re-reads wakes a snapshotFlow collector of that scene, whose dispatch wants runLock.
*
* Both waited forever in CI runs 40252077018, 40252089738 and 40299943329; the thread dump of
* the last one shows both stacks. On one thread the two monitors are simply re-entered, which
* is why the application never hangs this way.
*/
/**
* Runs [block] on the AWT event thread, waits for it and returns its result. Whatever
* [block] throws is rethrown here unwrapped, so a failed assertion still reads as one.
*/
internal fun <T> onEventThread(block: () -> T): T {
if (EventQueue.isDispatchThread()) return block()
var result: Result<T>? = null
try {
EventQueue.invokeAndWait { result = runCatching(block) }
} catch (wrapped: InvocationTargetException) {
throw wrapped.cause ?: wrapped
}
return result!!.getOrThrow()
}
@@ -84,6 +84,7 @@ class SelectedStationMessageUiVisualRenderingTest {
replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked"))) replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked")))
} }
return onEventThread {
val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) { val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface( Surface(
@@ -111,12 +112,13 @@ class SelectedStationMessageUiVisualRenderingTest {
} }
} }
return try { try {
scene.render().encodeToData()!!.bytes scene.render().encodeToData()!!.bytes
} finally { } finally {
scene.close() scene.close()
} }
} }
}
private fun messageTable() = DataTableState( private fun messageTable() = DataTableState(
columns = listOf( columns = listOf(
@@ -87,6 +87,7 @@ class StationWorkspaceVisualRenderingTest {
RowKeys.byReference<ChatMessage>(), RowKeys.byReference<ChatMessage>(),
) )
return onEventThread {
val scene = ImageComposeScene( val scene = ImageComposeScene(
width = 1366, width = 1366,
height = scenario.height, height = scenario.height,
@@ -139,12 +140,13 @@ class StationWorkspaceVisualRenderingTest {
} }
} }
return try { try {
scene.render().encodeToData()!!.bytes scene.render().encodeToData()!!.bytes
} finally { } finally {
scene.close() scene.close()
} }
} }
}
private fun member(callSign: String, category: Int) = ChatMember().apply { private fun member(callSign: String, category: Int) = ChatMember().apply {
this.callSign = callSign this.callSign = callSign
@@ -32,6 +32,7 @@ class TopPriorityCandidatesWindowTest {
/** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */ /** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */
private fun picking(block: (ImageComposeScene) -> Unit): String? { private fun picking(block: (ImageComposeScene) -> Unit): String? {
var picked: String? = null var picked: String? = null
onEventThread {
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) { TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) {
picked = it.displayCallSign picked = it.displayCallSign
@@ -44,6 +45,7 @@ class TopPriorityCandidatesWindowTest {
} finally { } finally {
scene.close() scene.close()
} }
}
return picked return picked
} }
@@ -78,6 +80,7 @@ class TopPriorityCandidatesWindowTest {
@Test @Test
fun `the list renders with candidates and without`() { fun `the list renders with candidates and without`() {
listOf(emptyList(), twoCandidates).forEach { ranking -> listOf(emptyList(), twoCandidates).forEach { ranking ->
onEventThread {
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(ranking) { } TopPriorityCandidatesWindow.CandidateListForTest(ranking) { }
} }
@@ -88,6 +91,7 @@ class TopPriorityCandidatesWindowTest {
} }
} }
} }
}
/** /**
* A double click picks the candidate under it, and picks the right one. * A double click picks the candidate under it, and picks the right one.
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.MapViewport import kst4contest.view.map.MapViewport
import kst4contest.view.map.TileFetcher import kst4contest.view.map.TileFetcher
@@ -71,6 +72,7 @@ class ComposeStationMapDrawingTest {
workedGrossFields: Set<String> = emptySet(), workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true, darkMode: Boolean = true,
): BufferedImage { ): BufferedImage {
return onEventThread {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) { val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
ComposeStationMap( ComposeStationMap(
markers = markers, markers = markers,
@@ -89,11 +91,12 @@ class ComposeStationMapDrawingTest {
) )
} }
try { try {
return ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes)) ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes))
} finally { } finally {
scene.close() scene.close()
} }
} }
}
private fun countColour(image: BufferedImage, rgb: Int): Int { private fun countColour(image: BufferedImage, rgb: Int): Int {
var count = 0 var count = 0
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.TileFetcher import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -33,6 +34,7 @@ class ComposeStationMapLayoutTest {
) )
private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) { private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) {
onEventThread {
val scene = ImageComposeScene( val scene = ImageComposeScene(
width = widthPx, width = widthPx,
height = heightPx, height = heightPx,
@@ -59,6 +61,7 @@ class ComposeStationMapLayoutTest {
scene.close() scene.close()
} }
} }
}
@Test @Test
fun `renders at a normal window size and integer zoom`() = renderAt(900, 600, 6f) fun `renders at a normal window size and integer zoom`() = renderAt(900, 600, 6f)
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -25,6 +26,7 @@ class PathAnalysisDetailsLayoutTest {
private val narrowWidthPx = 120 private val narrowWidthPx = 120
private fun renderAt(widthPx: Int, result: PathAnalysisResult?) { private fun renderAt(widthPx: Int, result: PathAnalysisResult?) {
onEventThread {
val scene = ImageComposeScene( val scene = ImageComposeScene(
width = widthPx, width = widthPx,
height = 600, height = 600,
@@ -42,6 +44,7 @@ class PathAnalysisDetailsLayoutTest {
scene.close() scene.close()
} }
} }
}
@Test @Test
fun `renders when the column is narrower than the assessment label`() { fun `renders when the column is narrower than the assessment label`() {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -31,6 +32,7 @@ class PathAnalysisDetailsWidthTest {
private val assessmentBackground = 0x33383E private val assessmentBackground = 0x33383E
private fun render(paneWidthPx: Int, heightPx: Int = 600): BufferedImage { private fun render(paneWidthPx: Int, heightPx: Int = 600): BufferedImage {
return onEventThread {
val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) { val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) {
PathAnalysisDetails( PathAnalysisDetails(
result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"), result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"),
@@ -40,11 +42,12 @@ class PathAnalysisDetailsWidthTest {
} }
try { try {
val png = scene.render().encodeToData()!!.bytes val png = scene.render().encodeToData()!!.bytes
return ImageIO.read(ByteArrayInputStream(png)) ImageIO.read(ByteArrayInputStream(png))
} finally { } finally {
scene.close() scene.close()
} }
} }
}
/** The smallest x at which the assessment background appears, or -1 when it does not. */ /** The smallest x at which the assessment background appears, or -1 when it does not. */
private fun assessmentLeftEdge(image: BufferedImage): Int { private fun assessmentLeftEdge(image: BufferedImage): Int {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
/** /**
@@ -18,6 +19,7 @@ import org.junit.jupiter.api.Test
class PathProfileChartLayoutTest { class PathProfileChartLayoutTest {
private fun renderAt(widthPx: Int, heightPx: Int) { private fun renderAt(widthPx: Int, heightPx: Int) {
onEventThread {
val scene = ImageComposeScene( val scene = ImageComposeScene(
width = widthPx, width = widthPx,
height = heightPx, height = heightPx,
@@ -31,6 +33,7 @@ class PathProfileChartLayoutTest {
scene.close() scene.close()
} }
} }
}
@Test @Test
fun `renders at the height the window gives it`() = renderAt(800, 210) fun `renders at the height the window gives it`() = renderAt(800, 210)
@@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp
import kst4contest.view.compose.SplitterOrientation import kst4contest.view.compose.SplitterOrientation
import kst4contest.view.compose.SplitterPane import kst4contest.view.compose.SplitterPane
import kst4contest.view.compose.SplitterState import kst4contest.view.compose.SplitterState
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.TileFetcher import kst4contest.view.map.TileFetcher
@@ -88,6 +89,7 @@ class StationMapBodyLayoutTest {
} }
private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) { private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) {
onEventThread {
val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) { val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) {
Body(pathAnalysisVisible) Body(pathAnalysisVisible)
} }
@@ -97,6 +99,7 @@ class StationMapBodyLayoutTest {
scene.close() scene.close()
} }
} }
}
@Test @Test
fun `renders at the window's minimum size`() = renderAt(900, 600) fun `renders at the window's minimum size`() = renderAt(900, 600)