Compare commits

..
Author SHA1 Message Date
Marc Froehlich 643ac3e693 fix: filter public messages correctly
Fixes #162
2026-11-03 23:15:09 +01:00
Rsclub2_2 c23982c339 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
2026-10-26 11:21:43 +01:00
3 changed files with 148 additions and 8 deletions

No files matched your search

@@ -3032,6 +3032,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener;
private java.util.function.Consumer<java.util.List<ClusterMessage>> composeClusterListener;
/**
* Fills the three global Compose message tables from their filtered
* controller views.
*/
/* package */ static void refreshComposeMessageTables(
final ChatController controller,
final DataTableState<ChatMessage> directed,
final DataTableState<ChatMessage> publicMessages,
final DataTableState<ChatMessage> qsoOfTheOther) {
directed.replaceRows(controller.toMeMessages());
publicMessages.replaceRows(controller.toAllMessages());
qsoOfTheOther.replaceRows(controller.toOtherMessages());
}
/**
* Opens the Compose main window beside the JavaFX one.
*
@@ -3342,11 +3356,11 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
return kotlin.Unit.INSTANCE;
});
composeChatListener = rows -> uiDispatcher.runOnUi(() -> {
directed.replaceRows(chatcontroller.toMeMessages());
publicMessages.replaceRows(rows);
qsoOfTheOther.replaceRows(chatcontroller.toOtherMessages());
});
composeChatListener = rows -> uiDispatcher.runOnUi(() -> refreshComposeMessageTables(
chatcontroller,
directed,
publicMessages,
qsoOfTheOther));
chatcontroller.getLst_globalChatMessageList().addListener(composeChatListener);
composeClusterListener = rows -> uiDispatcher.runOnUi(() -> cluster.replaceRows(rows));
@@ -3361,10 +3375,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
stations.replaceRows(initialMembers);
newJoined.updateActiveMembers(initialMembers);
newJoined.updateEvents(chatcontroller.getRecentChatJoins().snapshot());
directed.replaceRows(chatcontroller.toMeMessages());
publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot());
refreshComposeMessageTables(chatcontroller, directed, publicMessages, qsoOfTheOther);
cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
qsoOfTheOther.replaceRows(chatcontroller.toOtherMessages());
/*
* The only push that does not check the feed for null, and deliberately so: the
* feed is assigned earlier in this same method, so a guard here could never fire
@@ -0,0 +1,60 @@
package kst4contest.view
import kst4contest.controller.ChatController
import kst4contest.model.ChatMessage
import kst4contest.view.compose.DataTableState
import kst4contest.view.compose.RowKeys
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
class ComposeMessageTableRefreshTest {
@Test
fun `refresh keeps public table on the filtered controller view`() {
val controller = mock(ChatController::class.java)
val privateToMe = message("private to me")
val publicInitial = message("public initial")
val betweenOthers = message("between others")
val directed = table()
val publicMessages = table()
val qsoOfTheOther = table()
`when`(controller.toMeMessages()).thenReturn(listOf(privateToMe))
`when`(controller.toAllMessages()).thenReturn(listOf(publicInitial))
`when`(controller.toOtherMessages()).thenReturn(listOf(betweenOthers))
Kst4ContestApplication.refreshComposeMessageTables(
controller,
directed,
publicMessages,
qsoOfTheOther,
)
assertEquals(listOf(privateToMe), directed.rows)
assertEquals(listOf(publicInitial), publicMessages.rows)
assertEquals(listOf(betweenOthers), qsoOfTheOther.rows)
val publicLater = message("public later")
`when`(controller.toAllMessages()).thenReturn(listOf(publicLater, publicInitial))
Kst4ContestApplication.refreshComposeMessageTables(
controller,
directed,
publicMessages,
qsoOfTheOther,
)
assertEquals(listOf(publicLater, publicInitial), publicMessages.rows)
}
private fun table() = DataTableState(
columns = emptyList(),
rowKey = RowKeys.byReference<ChatMessage>(),
)
private fun message(text: String) = ChatMessage().apply {
messageText = text
}
}
@@ -0,0 +1,68 @@
# Public Message Tab Filter Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the main-window **Public messages** tab show only ON4KST messages whose protocol receiver is public (`0`, represented as `ALL`).
**Architecture:** Keep the global message roster as the complete event store and reuse `ChatController.toAllMessages()` as the single existing public-message view. A small package-visible refresh method updates all three Compose message tables so initial population and live updates use the same filtered sources.
**Tech Stack:** Java 21, Kotlin 2.2, Compose Multiplatform, JUnit 5, Mockito, Gradle
**Spec:** `github_docs/en-User-Interface.md` and `github_docs/de-Benutzeroberflaeche.md` document the intended global-tab semantics.
## Global Constraints
- Preserve ON4KST parsing, the complete global message history, message order and chat-category identity.
- Preserve the selected-station message modes and the existing `pm to me`/`pm to other` semantics.
- Do not stage, commit, push or publish without separate authorization.
- Source comments remain English.
## Review Focus
- A genuine public frame remains in the public tab after initial population and a later refresh.
- A PM to the local callsign never enters the public tab.
- A directed message between other stations never enters the public tab.
- Directed and QSO-of-the-other tables continue to receive their existing filtered views.
- Repeated table refreshes replace rows without changing their newest-first order.
---
### Task 1: Route the Compose public table through the existing filtered view
**Files:**
- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
- Create: `app-desktop/src/test/kotlin/kst4contest/view/ComposeMessageTableRefreshTest.kt`
**Interfaces:**
- Consumes: `ChatController.toMeMessages()`, `ChatController.toAllMessages()`, and `ChatController.toOtherMessages()`
- Produces: package-visible `Kst4ContestApplication.refreshComposeMessageTables(...)`
- [x] **Step 1: Write the failing regression test**
Create a mixed-message test whose mocked controller exposes separate directed, public and other-station views. Assert that the public table receives only `toAllMessages()`, then change the mocked public result, refresh again and assert the replacement remains filtered.
- [x] **Step 2: Run the focused test and verify it fails**
Run: `./gradlew.bat :app-desktop:test --tests kst4contest.view.ComposeMessageTableRefreshTest --console=plain`
Expected: compilation failure because `refreshComposeMessageTables(...)` does not yet exist.
- [x] **Step 3: Implement the shared refresh method**
Add package-visible static `refreshComposeMessageTables(ChatController, DataTableState<ChatMessage>, DataTableState<ChatMessage>, DataTableState<ChatMessage>)`. It must populate the tables from `toMeMessages()`, `toAllMessages()` and `toOtherMessages()` respectively.
- [x] **Step 4: Use the method for both update paths**
Call the shared method from the global-message listener on the UI dispatcher and from the initial Compose table population. Remove both raw writes to the public table.
- [x] **Step 5: Run focused and related tests**
Run the new test plus `MessageTabsScreenTest`, `MessageBusManagementThreadChatFrameTest`, `SelectedStationMessageFlowTest`, and `SelectedStationMessagePredicateTest`.
- [x] **Step 6: Run full verification**
Run `./gradlew.bat build --console=plain`, inspect test-result XML and changed-file static-analysis reports, then run `git diff --check` and `git status --short --branch`.
- [x] **Step 7: Assess documentation impact**
Confirm the German and English manuals already state that the global public tab contains public messages only. Update no documentation unless implementation reveals a conflicting invariant.