From c23982c339a66cd004d58078db7881dbc30a97e4 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Mon, 26 Oct 2026 11:21:43 +0100 Subject: [PATCH] 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 --- .../CompactControlsVisualRenderingTest.kt | 74 +++++++------ .../kst4contest/view/compose/OnEventThread.kt | 37 +++++++ ...ctedStationMessageUiVisualRenderingTest.kt | 56 +++++----- .../StationWorkspaceVisualRenderingTest.kt | 104 +++++++++--------- .../TopPriorityCandidatesWindowTest.kt | 38 ++++--- .../map/ComposeStationMapDrawingTest.kt | 45 ++++---- .../map/ComposeStationMapLayoutTest.kt | 51 +++++---- .../map/PathAnalysisDetailsLayoutTest.kt | 33 +++--- .../map/PathAnalysisDetailsWidthTest.kt | 27 +++-- .../compose/map/PathProfileChartLayoutTest.kt | 25 +++-- .../compose/map/StationMapBodyLayoutTest.kt | 17 +-- build.gradle.kts | 7 ++ 12 files changed, 293 insertions(+), 221 deletions(-) create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/OnEventThread.kt diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsVisualRenderingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsVisualRenderingTest.kt index 720fc450..b3c5fff7 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsVisualRenderingTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsVisualRenderingTest.kt @@ -55,45 +55,47 @@ class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Pat } val filters = StationFilterState() - val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) { - Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Column(Modifier.padding(4.dp)) { - TimelineView( - state = TimelineState(), - onCandidateClicked = {}, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - StationFilterBar( - filters = filters, - activeBands = 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 = {}, - ) - Spacer(Modifier.height(12.dp)) - ChatInputRow( - state = chatInputState, - prefs = preferences, - ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()), - ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()), - antennaQtf = ObservedValue(preferences.getActualQTF()), - antennaQtf2 = ObservedValue(preferences.getActualQTF2()), - so2rEnabled = false, - ) + return onEventThread { + val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column(Modifier.padding(4.dp)) { + TimelineView( + state = TimelineState(), + onCandidateClicked = {}, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + StationFilterBar( + filters = filters, + activeBands = 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 = {}, + ) + Spacer(Modifier.height(12.dp)) + ChatInputRow( + state = chatInputState, + prefs = preferences, + ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()), + ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()), + antennaQtf = ObservedValue(preferences.getActualQTF()), + antennaQtf2 = ObservedValue(preferences.getActualQTF2()), + so2rEnabled = false, + ) + } } } } - } - return try { - scene.render().encodeToData()!!.bytes - } finally { - scene.close() + try { + scene.render().encodeToData()!!.bytes + } finally { + scene.close() + } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/OnEventThread.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/OnEventThread.kt new file mode 100644 index 00000000..4e5d682a --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/OnEventThread.kt @@ -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 onEventThread(block: () -> T): T { + if (EventQueue.isDispatchThread()) return block() + + var result: Result? = null + try { + EventQueue.invokeAndWait { result = runCatching(block) } + } catch (wrapped: InvocationTargetException) { + throw wrapped.cause ?: wrapped + } + return result!!.getOrThrow() +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessageUiVisualRenderingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessageUiVisualRenderingTest.kt index dd312fed..1cd7eb62 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessageUiVisualRenderingTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessageUiVisualRenderingTest.kt @@ -84,37 +84,39 @@ class SelectedStationMessageUiVisualRenderingTest { replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked"))) } - val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) { - Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Row(Modifier.fillMaxSize().padding(6.dp)) { - MessageTabs( - publicMessages = publicMessages, - clusterMessages = clusterTable(), - qsoOfTheOther = qsoMessages, - modifier = Modifier.weight(1f), - ) - SelectedStationPanel( - state = selectedState, - messages = selectedMessages, - activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296), - openInBrowser = Consumer {}, - onShowOnMap = {}, - onShowPathInAirScout = {}, - modifier = Modifier.width(panelWidth).padding(start = 6.dp), - ) + return onEventThread { + val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Row(Modifier.fillMaxSize().padding(6.dp)) { + MessageTabs( + publicMessages = publicMessages, + clusterMessages = clusterTable(), + qsoOfTheOther = qsoMessages, + modifier = Modifier.weight(1f), + ) + SelectedStationPanel( + state = selectedState, + messages = selectedMessages, + activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296), + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = Modifier.width(panelWidth).padding(start = 6.dp), + ) + } } } } - } - return try { - scene.render().encodeToData()!!.bytes - } finally { - scene.close() + try { + scene.render().encodeToData()!!.bytes + } finally { + scene.close() + } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt index b4a3547e..971674ce 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt @@ -87,62 +87,64 @@ class StationWorkspaceVisualRenderingTest { RowKeys.byReference(), ) - val scene = ImageComposeScene( - width = 1366, - height = scenario.height, - density = Density(scale), - ) { - Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Box(Modifier.fillMaxSize()) { - Column( - Modifier - .width(scenario.width.dp) - .fillMaxHeight() - .align(Alignment.TopEnd) - .padding(4.dp), + return onEventThread { + val scene = ImageComposeScene( + width = 1366, + height = scenario.height, + density = Density(scale), + ) { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, ) { - StationFilterBar( - filters = StationFilterState(), - activeBands = EnumSet.of(Band.B_144, Band.B_432), - reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)), - onChanged = {}, - ) - DataTableView( - state = stations, - modifier = Modifier.weight(0.45f), - emphasizeCell = { member, column -> - column.id == "callsign" && newJoined.isRecent(member) - }, - ) - TopPriorityBar( - state = priority, - newJoined = newJoined, - onShowMore = {}, - ) - 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), - ) - } + Box(Modifier.fillMaxSize()) { + Column( + Modifier + .width(scenario.width.dp) + .fillMaxHeight() + .align(Alignment.TopEnd) + .padding(4.dp), + ) { + StationFilterBar( + filters = StationFilterState(), + activeBands = EnumSet.of(Band.B_144, Band.B_432), + reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)), + onChanged = {}, + ) + DataTableView( + state = stations, + modifier = Modifier.weight(0.45f), + emphasizeCell = { member, column -> + column.id == "callsign" && newJoined.isRecent(member) + }, + ) + TopPriorityBar( + state = priority, + newJoined = newJoined, + onShowMore = {}, + ) + 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 { - scene.render().encodeToData()!!.bytes - } finally { - scene.close() + try { + scene.render().encodeToData()!!.bytes + } finally { + scene.close() + } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt index 9ff879a8..43442e6f 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt @@ -32,17 +32,19 @@ class TopPriorityCandidatesWindowTest { /** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */ private fun picking(block: (ImageComposeScene) -> Unit): String? { var picked: String? = null - val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { - TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) { - picked = it.displayCallSign + onEventThread { + val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { + 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 } @@ -78,13 +80,15 @@ class TopPriorityCandidatesWindowTest { @Test fun `the list renders with candidates and without`() { listOf(emptyList(), twoCandidates).forEach { ranking -> - val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { - TopPriorityCandidatesWindow.CandidateListForTest(ranking) { } - } - try { - scene.render() - } finally { - scene.close() + onEventThread { + val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { + TopPriorityCandidatesWindow.CandidateListForTest(ranking) { } + } + try { + scene.render() + } finally { + scene.close() + } } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt index 544d8fc7..00321c41 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Density +import kst4contest.view.compose.onEventThread import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.MapViewport import kst4contest.view.map.TileFetcher @@ -71,27 +72,29 @@ class ComposeStationMapDrawingTest { workedGrossFields: Set = emptySet(), darkMode: Boolean = true, ): BufferedImage { - val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) { - ComposeStationMap( - markers = markers, - tileFetcher = offlineTileFetcher, - mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0), - groupingEnabled = false, - selectedMarkerCallsign = null, - ownLocator6 = ownLocator6, - antennaAzimuthDeg = 90.0, - antennaBeamWidthDeg = 0.0, - maxQrbKm = 0.0, - onMarkerClicked = null, - workedGrossFields = workedGrossFields, - darkMode = darkMode, - modifier = Modifier.fillMaxSize(), - ) - } - try { - return ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes)) - } finally { - scene.close() + return onEventThread { + val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) { + ComposeStationMap( + markers = markers, + tileFetcher = offlineTileFetcher, + mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0), + groupingEnabled = false, + selectedMarkerCallsign = null, + ownLocator6 = ownLocator6, + antennaAzimuthDeg = 90.0, + antennaBeamWidthDeg = 0.0, + maxQrbKm = 0.0, + onMarkerClicked = null, + workedGrossFields = workedGrossFields, + darkMode = darkMode, + modifier = Modifier.fillMaxSize(), + ) + } + try { + ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes)) + } finally { + scene.close() + } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt index e4adeaa0..a9d7f75f 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Density +import kst4contest.view.compose.onEventThread import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.TileFetcher import org.junit.jupiter.api.Test @@ -33,30 +34,32 @@ class ComposeStationMapLayoutTest { ) private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) { - val scene = ImageComposeScene( - width = widthPx, - height = heightPx, - density = Density(1f), - ) { - ComposeStationMap( - markers = stations, - tileFetcher = TileFetcher(), - mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0), - groupingEnabled = true, - selectedMarkerCallsign = "F5JMI", - ownLocator6 = "JO50JP", - antennaAzimuthDeg = 229.0, - antennaBeamWidthDeg = 60.0, - maxQrbKm = 800.0, - onMarkerClicked = null, - darkMode = true, - modifier = Modifier.fillMaxSize(), - ) - } - try { - scene.render() - } finally { - scene.close() + onEventThread { + val scene = ImageComposeScene( + width = widthPx, + height = heightPx, + density = Density(1f), + ) { + ComposeStationMap( + markers = stations, + tileFetcher = TileFetcher(), + mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0), + groupingEnabled = true, + selectedMarkerCallsign = "F5JMI", + ownLocator6 = "JO50JP", + antennaAzimuthDeg = 229.0, + antennaBeamWidthDeg = 60.0, + maxQrbKm = 800.0, + onMarkerClicked = null, + darkMode = true, + modifier = Modifier.fillMaxSize(), + ) + } + try { + scene.render() + } finally { + scene.close() + } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt index 77e8b77e..461bdd8e 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Density +import kst4contest.view.compose.onEventThread import kst4contest.view.map.PathAnalysisResult import org.junit.jupiter.api.Test @@ -25,21 +26,23 @@ class PathAnalysisDetailsLayoutTest { private val narrowWidthPx = 120 private fun renderAt(widthPx: Int, result: PathAnalysisResult?) { - val scene = ImageComposeScene( - width = widthPx, - height = 600, - density = Density(1f), - ) { - PathAnalysisDetails( - result = result, - darkMode = true, - modifier = Modifier.fillMaxWidth(), - ) - } - try { - scene.render() - } finally { - scene.close() + onEventThread { + val scene = ImageComposeScene( + width = widthPx, + height = 600, + density = Density(1f), + ) { + PathAnalysisDetails( + result = result, + darkMode = true, + modifier = Modifier.fillMaxWidth(), + ) + } + try { + scene.render() + } finally { + scene.close() + } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt index 3de304f9..d03b7f9f 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Density +import kst4contest.view.compose.onEventThread import kst4contest.view.map.PathAnalysisResult import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -31,18 +32,20 @@ class PathAnalysisDetailsWidthTest { private val assessmentBackground = 0x33383E private fun render(paneWidthPx: Int, heightPx: Int = 600): BufferedImage { - val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) { - PathAnalysisDetails( - result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"), - darkMode = true, - modifier = Modifier.fillMaxWidth(), - ) - } - try { - val png = scene.render().encodeToData()!!.bytes - return ImageIO.read(ByteArrayInputStream(png)) - } finally { - scene.close() + return onEventThread { + val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) { + PathAnalysisDetails( + result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"), + darkMode = true, + modifier = Modifier.fillMaxWidth(), + ) + } + try { + val png = scene.render().encodeToData()!!.bytes + ImageIO.read(ByteArrayInputStream(png)) + } finally { + scene.close() + } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt index 3884b9b6..7e20cb29 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Density +import kst4contest.view.compose.onEventThread import org.junit.jupiter.api.Test /** @@ -18,17 +19,19 @@ import org.junit.jupiter.api.Test class PathProfileChartLayoutTest { private fun renderAt(widthPx: Int, heightPx: Int) { - val scene = ImageComposeScene( - width = widthPx, - height = heightPx, - density = Density(1f), - ) { - PathProfileChart(modifier = Modifier.fillMaxSize(), darkMode = true) - } - try { - scene.render() - } finally { - scene.close() + onEventThread { + val scene = ImageComposeScene( + width = widthPx, + height = heightPx, + density = Density(1f), + ) { + PathProfileChart(modifier = Modifier.fillMaxSize(), darkMode = true) + } + try { + scene.render() + } finally { + scene.close() + } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt index 3e44f7fd..ac43f073 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import kst4contest.view.compose.SplitterOrientation import kst4contest.view.compose.SplitterPane import kst4contest.view.compose.SplitterState +import kst4contest.view.compose.onEventThread import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.TileFetcher @@ -88,13 +89,15 @@ class StationMapBodyLayoutTest { } private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) { - val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) { - Body(pathAnalysisVisible) - } - try { - scene.render() - } finally { - scene.close() + onEventThread { + val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) { + Body(pathAnalysisVisible) + } + try { + scene.render() + } finally { + scene.close() + } } } diff --git a/build.gradle.kts b/build.gradle.kts index d8629c56..f21724dc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -151,6 +151,13 @@ subprojects { */ systemProperty("junit.jupiter.execution.timeout.default", "2m") 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 // timed-out test is the line it hung in but not the call that blocked there. testLogging {