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
+286 -221

No files matched your search

@@ -55,45 +55,47 @@ class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Pat
} }
val filters = StationFilterState() val filters = StationFilterState()
val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) { return onEventThread {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) {
Surface( Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
modifier = Modifier.fillMaxSize(), Surface(
color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize(),
) { color = MaterialTheme.colorScheme.background,
Column(Modifier.padding(4.dp)) { ) {
TimelineView( Column(Modifier.padding(4.dp)) {
state = TimelineState(), TimelineView(
onCandidateClicked = {}, state = TimelineState(),
modifier = Modifier.fillMaxWidth(), onCandidateClicked = {},
) modifier = Modifier.fillMaxWidth(),
Spacer(Modifier.height(8.dp)) )
StationFilterBar( Spacer(Modifier.height(8.dp))
filters = filters, StationFilterBar(
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296), filters = filters,
reachability = ReachabilityState( activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296), reachability = ReachabilityState(
), EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
onChanged = {}, ),
) onChanged = {},
Spacer(Modifier.height(12.dp)) )
ChatInputRow( Spacer(Modifier.height(12.dp))
state = chatInputState, ChatInputRow(
prefs = preferences, state = chatInputState,
ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()), prefs = preferences,
ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()), ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()),
antennaQtf = ObservedValue(preferences.getActualQTF()), ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()),
antennaQtf2 = ObservedValue(preferences.getActualQTF2()), antennaQtf = ObservedValue(preferences.getActualQTF()),
so2rEnabled = false, antennaQtf2 = ObservedValue(preferences.getActualQTF2()),
) so2rEnabled = false,
)
}
} }
} }
} }
} try {
return try { scene.render().encodeToData()!!.bytes
scene.render().encodeToData()!!.bytes } finally {
} finally { scene.close()
scene.close() }
} }
} }
@@ -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,37 +84,39 @@ class SelectedStationMessageUiVisualRenderingTest {
replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked"))) replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked")))
} }
val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) { return onEventThread {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) {
Surface( Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
modifier = Modifier.fillMaxSize(), Surface(
color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize(),
) { color = MaterialTheme.colorScheme.background,
Row(Modifier.fillMaxSize().padding(6.dp)) { ) {
MessageTabs( Row(Modifier.fillMaxSize().padding(6.dp)) {
publicMessages = publicMessages, MessageTabs(
clusterMessages = clusterTable(), publicMessages = publicMessages,
qsoOfTheOther = qsoMessages, clusterMessages = clusterTable(),
modifier = Modifier.weight(1f), qsoOfTheOther = qsoMessages,
) modifier = Modifier.weight(1f),
SelectedStationPanel( )
state = selectedState, SelectedStationPanel(
messages = selectedMessages, state = selectedState,
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296), messages = selectedMessages,
openInBrowser = Consumer {}, activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
onShowOnMap = {}, openInBrowser = Consumer {},
onShowPathInAirScout = {}, onShowOnMap = {},
modifier = Modifier.width(panelWidth).padding(start = 6.dp), onShowPathInAirScout = {},
) modifier = Modifier.width(panelWidth).padding(start = 6.dp),
)
}
} }
} }
} }
}
return try { try {
scene.render().encodeToData()!!.bytes scene.render().encodeToData()!!.bytes
} finally { } finally {
scene.close() scene.close()
}
} }
} }
@@ -87,62 +87,64 @@ class StationWorkspaceVisualRenderingTest {
RowKeys.byReference<ChatMessage>(), RowKeys.byReference<ChatMessage>(),
) )
val scene = ImageComposeScene( return onEventThread {
width = 1366, val scene = ImageComposeScene(
height = scenario.height, width = 1366,
density = Density(scale), height = scenario.height,
) { density = Density(scale),
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { ) {
Surface( Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
modifier = Modifier.fillMaxSize(), Surface(
color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize(),
) { color = MaterialTheme.colorScheme.background,
Box(Modifier.fillMaxSize()) {
Column(
Modifier
.width(scenario.width.dp)
.fillMaxHeight()
.align(Alignment.TopEnd)
.padding(4.dp),
) { ) {
StationFilterBar( Box(Modifier.fillMaxSize()) {
filters = StationFilterState(), Column(
activeBands = EnumSet.of(Band.B_144, Band.B_432), Modifier
reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)), .width(scenario.width.dp)
onChanged = {}, .fillMaxHeight()
) .align(Alignment.TopEnd)
DataTableView( .padding(4.dp),
state = stations, ) {
modifier = Modifier.weight(0.45f), StationFilterBar(
emphasizeCell = { member, column -> filters = StationFilterState(),
column.id == "callsign" && newJoined.isRecent(member) activeBands = EnumSet.of(Band.B_144, Band.B_432),
}, reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)),
) onChanged = {},
TopPriorityBar( )
state = priority, DataTableView(
newJoined = newJoined, state = stations,
onShowMore = {}, modifier = Modifier.weight(0.45f),
) emphasizeCell = { member, column ->
SelectedStationPanel( column.id == "callsign" && newJoined.isRecent(member)
state = selected, },
messages = selectedMessages, )
activeBands = EnumSet.of(Band.B_144, Band.B_432), TopPriorityBar(
contentSplitter = SplitterState(2, doubleArrayOf(0.48)) {}, state = priority,
openInBrowser = Consumer {}, newJoined = newJoined,
onShowOnMap = {}, onShowMore = {},
onShowPathInAirScout = {}, )
modifier = Modifier.weight(0.55f), SelectedStationPanel(
) state = selected,
} messages = selectedMessages,
activeBands = EnumSet.of(Band.B_144, Band.B_432),
contentSplitter = SplitterState(2, doubleArrayOf(0.48)) {},
openInBrowser = Consumer {},
onShowOnMap = {},
onShowPathInAirScout = {},
modifier = Modifier.weight(0.55f),
)
}
}
} }
} }
} }
}
return try { try {
scene.render().encodeToData()!!.bytes scene.render().encodeToData()!!.bytes
} finally { } finally {
scene.close() scene.close()
}
} }
} }
@@ -32,17 +32,19 @@ 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
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { onEventThread {
TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) { val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
picked = it.displayCallSign TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) {
picked = it.displayCallSign
}
}
try {
scene.render()
block(scene)
scene.render()
} finally {
scene.close()
} }
}
try {
scene.render()
block(scene)
scene.render()
} finally {
scene.close()
} }
return picked return picked
} }
@@ -78,13 +80,15 @@ 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 ->
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { onEventThread {
TopPriorityCandidatesWindow.CandidateListForTest(ranking) { } val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
} TopPriorityCandidatesWindow.CandidateListForTest(ranking) { }
try { }
scene.render() try {
} finally { scene.render()
scene.close() } finally {
scene.close()
}
} }
} }
} }
@@ -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,27 +72,29 @@ class ComposeStationMapDrawingTest {
workedGrossFields: Set<String> = emptySet(), workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true, darkMode: Boolean = true,
): BufferedImage { ): BufferedImage {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) { return onEventThread {
ComposeStationMap( val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
markers = markers, ComposeStationMap(
tileFetcher = offlineTileFetcher, markers = markers,
mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0), tileFetcher = offlineTileFetcher,
groupingEnabled = false, mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
selectedMarkerCallsign = null, groupingEnabled = false,
ownLocator6 = ownLocator6, selectedMarkerCallsign = null,
antennaAzimuthDeg = 90.0, ownLocator6 = ownLocator6,
antennaBeamWidthDeg = 0.0, antennaAzimuthDeg = 90.0,
maxQrbKm = 0.0, antennaBeamWidthDeg = 0.0,
onMarkerClicked = null, maxQrbKm = 0.0,
workedGrossFields = workedGrossFields, onMarkerClicked = null,
darkMode = darkMode, workedGrossFields = workedGrossFields,
modifier = Modifier.fillMaxSize(), darkMode = darkMode,
) modifier = Modifier.fillMaxSize(),
} )
try { }
return ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes)) try {
} finally { ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes))
scene.close() } finally {
scene.close()
}
} }
} }
@@ -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,30 +34,32 @@ class ComposeStationMapLayoutTest {
) )
private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) { private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) {
val scene = ImageComposeScene( onEventThread {
width = widthPx, val scene = ImageComposeScene(
height = heightPx, width = widthPx,
density = Density(1f), height = heightPx,
) { density = Density(1f),
ComposeStationMap( ) {
markers = stations, ComposeStationMap(
tileFetcher = TileFetcher(), markers = stations,
mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0), tileFetcher = TileFetcher(),
groupingEnabled = true, mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
selectedMarkerCallsign = "F5JMI", groupingEnabled = true,
ownLocator6 = "JO50JP", selectedMarkerCallsign = "F5JMI",
antennaAzimuthDeg = 229.0, ownLocator6 = "JO50JP",
antennaBeamWidthDeg = 60.0, antennaAzimuthDeg = 229.0,
maxQrbKm = 800.0, antennaBeamWidthDeg = 60.0,
onMarkerClicked = null, maxQrbKm = 800.0,
darkMode = true, onMarkerClicked = null,
modifier = Modifier.fillMaxSize(), darkMode = true,
) modifier = Modifier.fillMaxSize(),
} )
try { }
scene.render() try {
} finally { scene.render()
scene.close() } finally {
scene.close()
}
} }
} }
@@ -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,21 +26,23 @@ class PathAnalysisDetailsLayoutTest {
private val narrowWidthPx = 120 private val narrowWidthPx = 120
private fun renderAt(widthPx: Int, result: PathAnalysisResult?) { private fun renderAt(widthPx: Int, result: PathAnalysisResult?) {
val scene = ImageComposeScene( onEventThread {
width = widthPx, val scene = ImageComposeScene(
height = 600, width = widthPx,
density = Density(1f), height = 600,
) { density = Density(1f),
PathAnalysisDetails( ) {
result = result, PathAnalysisDetails(
darkMode = true, result = result,
modifier = Modifier.fillMaxWidth(), darkMode = true,
) modifier = Modifier.fillMaxWidth(),
} )
try { }
scene.render() try {
} finally { scene.render()
scene.close() } finally {
scene.close()
}
} }
} }
@@ -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,18 +32,20 @@ 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 {
val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) { return onEventThread {
PathAnalysisDetails( val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) {
result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"), PathAnalysisDetails(
darkMode = true, result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"),
modifier = Modifier.fillMaxWidth(), darkMode = true,
) modifier = Modifier.fillMaxWidth(),
} )
try { }
val png = scene.render().encodeToData()!!.bytes try {
return ImageIO.read(ByteArrayInputStream(png)) val png = scene.render().encodeToData()!!.bytes
} finally { ImageIO.read(ByteArrayInputStream(png))
scene.close() } finally {
scene.close()
}
} }
} }
@@ -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,17 +19,19 @@ import org.junit.jupiter.api.Test
class PathProfileChartLayoutTest { class PathProfileChartLayoutTest {
private fun renderAt(widthPx: Int, heightPx: Int) { private fun renderAt(widthPx: Int, heightPx: Int) {
val scene = ImageComposeScene( onEventThread {
width = widthPx, val scene = ImageComposeScene(
height = heightPx, width = widthPx,
density = Density(1f), height = heightPx,
) { density = Density(1f),
PathProfileChart(modifier = Modifier.fillMaxSize(), darkMode = true) ) {
} PathProfileChart(modifier = Modifier.fillMaxSize(), darkMode = true)
try { }
scene.render() try {
} finally { scene.render()
scene.close() } finally {
scene.close()
}
} }
} }
@@ -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,13 +89,15 @@ class StationMapBodyLayoutTest {
} }
private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) { private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) {
val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) { onEventThread {
Body(pathAnalysisVisible) val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) {
} Body(pathAnalysisVisible)
try { }
scene.render() try {
} finally { scene.render()
scene.close() } finally {
scene.close()
}
} }
} }