test: fix the CI hang in the Compose rendering tests (#157)

* test: print all threads when a test times out

The rare CI hang in SelectedStationMessageUiVisualRenderingTest blocks on a
monitor in SnapshotStateObserver.observeReads (line 643 is the monitorenter
of observedScopeMapsLock in Compose 1.8.2). The timed-out thread's stack
shows where it waits but not which thread holds the lock. With the thread
dump enabled, JUnit prints every thread to the test's standard output, which
ends up in the XML report of the test-results artifact.

Claude-Session: https://claude.ai/code/session_01FJBfPYm6rCH4zD6T7bcC7Y

* 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 authored and GitHub committed 2026-10-26 11:21:43 +01:00
1 parent 834f4b131a
commit c23982c339
12 files changed
+293 -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()
}
} }
} }
+7
View File
@@ -151,6 +151,13 @@ subprojects {
*/ */
systemProperty("junit.jupiter.execution.timeout.default", "2m") systemProperty("junit.jupiter.execution.timeout.default", "2m")
systemProperty("junit.jupiter.execution.timeout.thread.mode.default", "SEPARATE_THREAD") systemProperty("junit.jupiter.execution.timeout.thread.mode.default", "SEPARATE_THREAD")
/*
* The timed-out thread's stack shows where a test waits, not who it waits for: the
* rare CI hang in SelectedStationMessageUiVisualRenderingTest blocks on a monitor
* that another thread holds. On a timeout JUnit prints all threads to the test's
* standard output, which lands in its XML report (the test-results artifact).
*/
systemProperty("junit.jupiter.execution.timeout.threaddump.enabled", "true")
// The default short format prints only the first frame of each cause, which for a // The default short format prints only the first frame of each cause, which for a
// timed-out test is the line it hung in but not the call that blocked there. // timed-out test is the line it hung in but not the call that blocked there.
testLogging { testLogging {