From f8c04e72e1310e998ca7c2ef619288e087bffeae Mon Sep 17 00:00:00 2001
From: praktimarc <61456874+praktimarc@users.noreply.github.com>
Date: Fri, 14 Aug 2026 01:10:22 +0200
Subject: [PATCH] on4kst: replace the legacy connection handling with a
session-scoped supervisor using bounded connect, login and synchronisation
timeouts, heartbeat and stale-link detection, controlled reconnect backoff
and session-safe reader/writer queues; validate outgoing protocol context and
malformed inbound user frames, enforce one locator per TCP session, publish
complete user lists atomically and ignore repeated UE markers, prevent failed
initial connections from entering a busy loop, add a compact high-visibility
LINK state indicator, and remove false unhandled-frame reports. Solves #71
(#75)
Session-based ON4KST connection lifecycle: Each socket, reader, writer, message bus and queue now belongs to an explicitly identified connection session. Delayed threads from an obsolete connection can therefore no longer process data or close its replacement. ONLINE is reported only after the login has been accepted and all requested user lists have been received. Connection setup, login and synchronisation use bounded timeouts, while heartbeats, missing inbound traffic, EOF and read or write failures trigger controlled reconnect attempts with backoff where appropriate.
Validated ON4KST protocol commands: Outgoing frames are built centrally and checked for valid categories, locators and prohibited frame delimiters. Because ON4KST maintains one locator per TCP session, the main locator is used for both chat categories and a conflicting secondary configuration is logged instead of sending contradictory commands to the server.
---
pom.xml | 1 +
.../controller/ChatController.java | 348 +++++++-
.../MessageBusManagementThread.java | 149 +++-
.../controller/On4KstConnectionManager.java | 837 ++++++++++++++++++
.../controller/On4KstConnectionState.java | 50 ++
.../controller/On4KstProtocol.java | 192 ++++
.../controller/On4KstProtocolTest.java | 69 ++
.../controller/On4KstSocketThreadTest.java | 100 +++
.../kst4contest/controller/ReadThread.java | 195 ++--
.../controller/StatusUpdateListener.java | 18 +-
.../kst4contest/controller/WriteThread.java | 402 +++------
.../keepAliveMessageSenderTask.java | 18 +-
.../view/Kst4ContestApplication.java | 229 ++++-
src/main/java/module-info.java | 1 +
14 files changed, 2210 insertions(+), 399 deletions(-)
create mode 100644 src/main/java/kst4contest/controller/On4KstConnectionManager.java
create mode 100644 src/main/java/kst4contest/controller/On4KstConnectionState.java
create mode 100644 src/main/java/kst4contest/controller/On4KstProtocol.java
create mode 100644 src/main/java/kst4contest/controller/On4KstProtocolTest.java
create mode 100644 src/main/java/kst4contest/controller/On4KstSocketThreadTest.java
diff --git a/pom.xml b/pom.xml
index 5cfd89aa..a167eb83 100644
--- a/pom.xml
+++ b/pom.xml
@@ -445,6 +445,7 @@
java.sqljava.net.httpjdk.crypto.ec
+ jdk.net${main.class}
${project.build.directory}/modules
diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java
index 2ec6213f..bb9dccaa 100644
--- a/src/main/java/kst4contest/controller/ChatController.java
+++ b/src/main/java/kst4contest/controller/ChatController.java
@@ -35,6 +35,8 @@ import java.util.function.Consumer;
import java.util.function.Predicate;
import java.nio.charset.StandardCharsets;
import kst4contest.logic.FrequencyTextParser;
+import java.util.logging.Level;
+import java.util.logging.Logger;
@@ -57,6 +59,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
*
*/
+ private static final Logger LOGGER =
+ Logger.getLogger(ChatController.class.getName());
+
private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint
@@ -86,6 +91,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
boolean disconnected;
boolean disconnectionPerformedByUser = false;
+ private final On4KstConnectionManager on4KstConnectionManager =
+ new On4KstConnectionManager(this);
+
public boolean isDisconnectionPerformedByUser() {
return disconnectionPerformedByUser;
@@ -140,6 +148,15 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return disconnected;
}
+ /**
+ * Returns the authoritative ON4KST lifecycle state.
+ *
+ * @return current connection, authentication or synchronization state
+ */
+ public On4KstConnectionState getOn4KstConnectionState() {
+ return on4KstConnectionManager.getState();
+ }
+
public void setDisconnected(boolean disconnected) {
this.disconnected = disconnected;
}
@@ -161,6 +178,112 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} else System.out.println("ERRRRRRRRRRRRRRRRRRRRRRRRRRRÖRRRRRRRRRRRRRRRRRRR");
}
+ /**
+ * Publishes one authoritative connection-state transition to legacy controller
+ * flags, generic worker status listeners and the dedicated UI callback.
+ *
+ *
The compatibility flags remain derived values. No caller may set them to
+ * infer socket health; only the connection manager owns that decision.
+ *
+ * @param state new lifecycle state
+ * @param detail human-readable progress or failure reason
+ * @param critical whether the transition should be emphasized as an error
+ */
+ void updateOn4KstConnectionState(
+ On4KstConnectionState state,
+ String detail,
+ boolean critical
+ ) {
+ setConnectedAndLoggedIn(state == On4KstConnectionState.ONLINE);
+ setConnectedAndNOTLoggedIn(
+ state.isConnectionAttemptActive()
+ && state != On4KstConnectionState.ONLINE);
+ setDisconnected(state == On4KstConnectionState.DISCONNECTED);
+
+ ThreadStateMessage status = new ThreadStateMessage(
+ "ON4KST", state.isConnectionAttemptActive(), detail, critical);
+ status.setRunningInformationTextDescription(state.name());
+ onThreadStatus("ON4KST", status);
+
+ if (statusListener != null) {
+ statusListener.onConnectionStateChanged(state, detail);
+ }
+ }
+
+ /**
+ * Installs all resources belonging to one successfully opened connection
+ * generation.
+ *
+ *
The session-id check prevents a slow connection attempt from overwriting a
+ * newer socket and its queues.
+ */
+ synchronized void installOn4KstSession(
+ long connectionSessionId,
+ Socket sessionSocket,
+ LinkedBlockingQueue receiveQueue,
+ LinkedBlockingQueue transmitQueue,
+ ReadThread sessionReadThread,
+ WriteThread sessionWriteThread,
+ MessageBusManagementThread sessionMessageProcessor
+ ) {
+ if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) {
+ return;
+ }
+ this.socket = sessionSocket;
+ this.messageRXBus = receiveQueue;
+ this.messageTXBus = transmitQueue;
+ this.readThread = sessionReadThread;
+ this.writeThread = sessionWriteThread;
+ this.messageProcessor = sessionMessageProcessor;
+ }
+
+ void onOn4KstLogstat(long connectionSessionId, String[] fields) {
+ on4KstConnectionManager.onLogstat(connectionSessionId, fields);
+ }
+
+ void stageInitialOn4KstChatMember(
+ long connectionSessionId,
+ ChatMember member
+ ) {
+ on4KstConnectionManager.stageInitialChatMember(
+ connectionSessionId, member);
+ }
+
+ void onOn4KstInitialUserListCompleted(
+ long connectionSessionId,
+ ChatCategory category
+ ) {
+ on4KstConnectionManager.onInitialUserListCompleted(
+ connectionSessionId, category);
+ }
+
+ void onOn4KstConnectionOnline() {
+ scheduleBeaconTimer(INITIAL_BEACON_DELAY_MILLIS);
+ }
+
+ void onOn4KstConnectionLost() {
+ stopBeaconTimer();
+ }
+
+ void onOn4KstOutboundFrameRejected(String reason) {
+ onOn4KstConnectionWarning("Outbound frame rejected locally: " + reason);
+ }
+
+ /**
+ * Receives a non-fatal ON4KST diagnostic, writes it to the persistent warning
+ * log and forwards it to the status UI.
+ *
+ * @param warning sanitized diagnostic text; passwords and raw chat frames must
+ * never be included
+ */
+ void onOn4KstConnectionWarning(String warning) {
+ LOGGER.log(Level.WARNING, "ON4KST warning: {0}", warning);
+ ThreadStateMessage status = new ThreadStateMessage(
+ "ON4KST", true, warning, false);
+ status.setRunningInformationTextDescription("WARNING");
+ onThreadStatus("ON4KST", status);
+ }
+
/********************************************************************************
* PSTRotator controlling
@@ -573,6 +696,16 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
*/
public void disconnect(String action) {
+ /*
+ * New connections are owned by the session manager. Keep the historic cleanup
+ * below as a compatibility fallback, but do not let it manipulate resources
+ * belonging to a replacement session.
+ */
+ if (on4KstConnectionManager != null) {
+ disconnectManaged(action);
+ return;
+ }
+
// stopContextLoop(); //stops thread for calculating sked priorities
stopScoreScheduler();
@@ -757,10 +890,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
} catch (IOException e) {
- // TODO Auto-generated catch block
e.printStackTrace();
} catch (Exception e2) {
- // TODO Auto-generated catch block
e2.printStackTrace();
}
@@ -768,6 +899,69 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
+ private void disconnectManaged(String action) {
+ setDisconnectionPerformedByUser(true);
+ on4KstConnectionManager.stopByUser();
+ clearActiveChatMembers();
+ runOnFxThread(() -> lst_clusterMemberList.clear());
+
+ stopBeaconTimer();
+ stopScoreScheduler();
+ stopDxClusterServer();
+ cancelTimer(userActualizationtimer);
+ userActualizationtimer = null;
+ cancelTimer(ASQueryTimer);
+ ASQueryTimer = null;
+
+ stopUdpReader(readUDPbyUCXThread,
+ chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort());
+ readUDPbyUCXThread = null;
+ stopUdpReader(readUDPByWintestThread,
+ chatPreferences.getLogsynch_wintestNetworkPort());
+ stopWintestUdpListener();
+ stopUdpReader(airScoutUDPReaderThread,
+ chatPreferences.getAirScout_asCommunicationPort());
+ airScoutUDPReaderThread = null;
+
+ if (ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE.equals(action)) {
+ if (dbHandler != null) {
+ dbHandler.closeDBConnection();
+ }
+ if (rotatorClient != null) {
+ rotatorClient.stopRotor();
+ rotatorClient.stop();
+ rotatorClient = null;
+ }
+ }
+ }
+
+ private void cancelTimer(Timer timer) {
+ if (timer != null) {
+ timer.cancel();
+ timer.purge();
+ }
+ }
+
+ private void stopUdpReader(Thread readerThread, int listenerPort) {
+ if (readerThread != null) {
+ readerThread.interrupt();
+ }
+ try (DatagramSocket datagramSocket = new DatagramSocket()) {
+ datagramSocket.setBroadcast(true);
+ byte[] poison = ApplicationConstants.DISCONNECT_RDR_POISONPILL.getBytes(
+ StandardCharsets.UTF_8);
+ DatagramPacket packet = new DatagramPacket(
+ poison, poison.length,
+ InetAddress.getByName("255.255.255.255"), listenerPort);
+ datagramSocket.send(packet);
+ } catch (IOException exception) {
+ System.out.println("[ChatController, warning]: could not wake UDP reader on port "
+ + listenerPort + ": " + exception.getMessage());
+ }
+ }
+
+
+
// private ObservableList activeSkeds = FXCollections.observableArrayList();
// public ObservableList getActiveSkeds() {
// return activeSkeds;
@@ -1416,6 +1610,10 @@ private ObservableList
private final List pendingChatMessages = new ArrayList<>();
private boolean chatMessageFlushScheduled = false;
+ private static final int RECENT_INBOUND_MESSAGE_KEYS_MAX = 5_000;
+ private final LinkedHashMap recentInboundMessageKeys =
+ new LinkedHashMap<>();
+
/*
* Same idea for DXCluster messages.
*/
@@ -1572,6 +1770,55 @@ private ObservableList
runOnFxThread(() -> lst_chatMemberList.clear());
}
+ /**
+ * Atomically replaces one category after its terminating UE frame arrived.
+ * Partial UA0 snapshots are never exposed to the TableView.
+ *
+ * @param connectionSessionId session that produced the completed snapshot
+ * @param category chat category whose members are being replaced
+ * @param completeMembers validated members staged before the UE terminator
+ */
+ public void replaceActiveChatMembersForCategory(
+ long connectionSessionId,
+ ChatCategory category,
+ Collection completeMembers
+ ) {
+ if (category == null
+ || !on4KstConnectionManager.isActiveSession(connectionSessionId)) {
+ return;
+ }
+
+ int categoryNumber = category.getCategoryNumber();
+ List safeMembers = completeMembers == null
+ ? List.of() : new ArrayList<>(completeMembers);
+ for (ChatMember member : safeMembers) {
+ initializeFrequencyFromStationNameIfUnambiguous(member);
+ }
+
+ activeChatMembersByCallAndCategory.entrySet().removeIf(entry -> {
+ ChatMember member = entry.getValue();
+ return member != null && member.getChatCategory() != null
+ && member.getChatCategory().getCategoryNumber() == categoryNumber;
+ });
+ for (ChatMember member : safeMembers) {
+ String key = buildActiveChatMemberKey(member);
+ if (key != null) {
+ activeChatMembersByCallAndCategory.put(key, member);
+ }
+ }
+
+ runOnFxThread(() -> {
+ if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) {
+ return;
+ }
+ lst_chatMemberList.removeIf(member -> member != null
+ && member.getChatCategory() != null
+ && member.getChatCategory().getCategoryNumber() == categoryNumber);
+ lst_chatMemberList.addAll(safeMembers);
+ fireUserListUpdate("Complete ON4KST user list received");
+ });
+ }
+
/**
* Resolves a member from the thread-safe active model. This avoids reading the
* TableView backing list from MessageBusManagementThread.
@@ -1913,6 +2160,9 @@ private ObservableList
if (message == null) {
return;
}
+ if (isDuplicateInboundMessage(message)) {
+ return;
+ }
synchronized (pendingChatMessagesLock) {
pendingChatMessages.add(message);
@@ -1927,6 +2177,47 @@ private ObservableList
Platform.runLater(this::flushPendingChatMessagesToUi);
}
+ /**
+ * Suppresses the small replay overlap deliberately requested after a reconnect.
+ *
+ *
The manager asks for messages beginning one timestamp before the last known
+ * message so that a boundary message cannot be lost. This bounded key cache
+ * removes the expected duplicate without growing for the lifetime of the
+ * application.
The opcode, category and callsign are sufficient to identify the offending
+ * list position. Omitting the remaining fields avoids unnecessary disclosure of
+ * free-form profile text.
+ */
+ private void logRejectedInboundUserFrame(String reason, String[] fields) {
+ String opcode = fields != null && fields.length > 0 ? fields[0] : "UNKNOWN";
+ String category = fields != null && fields.length > 1 ? fields[1] : "UNKNOWN";
+ String callsign = fields != null && fields.length > 2 ? fields[2] : "UNKNOWN";
+ client.onOn4KstConnectionWarning(
+ reason + "; opcode=" + opcode
+ + ", category=" + category
+ + ", callsign=" + callsign
+ + ", fieldCount=" + (fields == null ? 0 : fields.length));
+ }
+
+
/**
* Processes received messages via port 23001 (improved telnet Interface)
*
@@ -808,23 +873,33 @@ public class MessageBusManagementThread extends Thread {
* here we have a helper list for identifying questions for my qrg which can be autoanswered later
*/
- if (messageToProcess.getMessageText().isEmpty()) {
-// System.out.println("[MSGBUSMGTT:] no processable data");
-
+ if (messageToProcess.getMessageText() == null
+ || messageToProcess.getMessageText().isEmpty()) {
+ // No processable data.
} else {
- if (messageToProcess.getMessageText().contains(SRVR_LOGSTAT)) {
- String logstatMessage[];
- logstatMessage = messageToProcess.getMessageText().split("\\|");
- if (logstatMessage[1].contains(SRVR_LOGINOK)) {
- this.client.setConnectedAndLoggedIn(true);
- } else {
- this.client.setConnectedAndNOTLoggedIn(true);
- this.client.setConnectedAndLoggedIn(false);
- }
+ if (messageToProcess.getMessageText().startsWith(SRVR_LOGSTAT + "|")) {
+ String[] logstatMessage =
+ messageToProcess.getMessageText().split("\\|", -1);
+ this.client.onOn4KstLogstat(
+ connectionSessionId,
+ logstatMessage);
}
- String splittedMessageLine[] = messageToProcess.getMessageText().split("\\|");
+ String[] splittedMessageLine =
+ messageToProcess.getMessageText().split("\\|");
+
+ String opcode = splittedMessageLine.length == 0
+ ? ""
+ : splittedMessageLine[0];
+
+ if ((INITIALUSERLISTENTRY.equals(opcode)
+ || USERENTEREDCHAT.equals(opcode)
+ || USERENTEREDCHAT2.equals(opcode))
+ && !validateInboundUserFrame(splittedMessageLine)) {
+ return;
+ }
+// String splittedMessageLine[] = messageToProcess.getMessageText().split("\\|");
/**
* Initializes the Userlist if entry fits UA0
@@ -832,7 +907,7 @@ public class MessageBusManagementThread extends Thread {
*
*
*/
- if (splittedMessageLine[0].contains(INITIALUSERLISTENTRY)) {
+ if (splittedMessageLine[0].equals(INITIALUSERLISTENTRY)) {
// System.out.println("MSGBUS: User detected");
ChatMember newMember = new ChatMember();
@@ -853,7 +928,9 @@ public class MessageBusManagementThread extends Thread {
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) {
- this.client.addOrUpdateActiveChatMember(newMember); // the own call will not be in the list
+ this.client.stageInitialOn4KstChatMember(
+ connectionSessionId,
+ newMember);
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
// Reachability is calculated on demand only: map click, selected station, or manual request.
}
@@ -877,7 +954,8 @@ public class MessageBusManagementThread extends Thread {
* UA2|2|W5ADD|Parker|EM40WL|2|
*
*/
- if (splittedMessageLine[0].contains(USERENTEREDCHAT) || splittedMessageLine[0].contains(USERENTEREDCHAT2)) {
+ if (splittedMessageLine[0].equals(USERENTEREDCHAT)
+ || splittedMessageLine[0].equals(USERENTEREDCHAT2)) {
// System.out.println("MSGBUS: User detected");
@@ -1640,18 +1718,29 @@ public class MessageBusManagementThread extends Thread {
/**
* Userinfo-update: UE|2|22562|
*/
- if (splittedMessageLine[0].contains(SRVR_USERLISTEND)) {
+ if (SRVR_USERLISTEND.equals(opcode)) {
+ if (splittedMessageLine.length < 2) {
+ System.out.println(
+ "[MSGBUSMGT, Warning:] Ignoring malformed UE frame: "
+ + messageToProcess.getMessageText());
+ return;
+ }
- // No worthy information, count of users
- } else
+ this.client.onOn4KstInitialUserListCompleted(
+ connectionSessionId,
+ util_getChatCategoryByCategoryNrString(
+ splittedMessageLine[1]));
- if (splittedMessageLine[0].contains(SRVR_DXCEND)) {
+ } else if (SRVR_DXCEND.equals(opcode)) {
- // No worthy information, count of users
- } else
+ // DF marks the end of the initial DX-cluster data.
+ // The frame contains no data that needs to be published.
+
+ } else if (SRVR_COMMUNICATIONK.equals(opcode)) {
+
+ // CK is a regular server delimiter/acknowledgement.
+ // It is intentionally accepted without further processing.
- if (splittedMessageLine[0].contains(SRVR_COMMUNICATIONK)) {
- // No worthy information, end of srvrmsgs
} else
//-> LOGSTAT|114|Wrong password!|
@@ -1931,13 +2020,17 @@ public class MessageBusManagementThread extends Thread {
while (true) {
try {
- messageTextRaw = client.getMessageRXBus().take();
+ messageTextRaw = receiveQueue.take();
- if (messageTextRaw.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL) && messageTextRaw.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
- client.getMessageRXBus().clear();
+ if (ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(messageTextRaw.getMessageText())
+ && ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(messageTextRaw.getMessageSenderName())) {
+ receiveQueue.clear();
break;
}
else {
+ if (!connectionSessionIsActive.test(connectionSessionId)) {
+ break;
+ }
messageLine = messageTextRaw.getMessageText();
/***********************************************
diff --git a/src/main/java/kst4contest/controller/On4KstConnectionManager.java b/src/main/java/kst4contest/controller/On4KstConnectionManager.java
new file mode 100644
index 00000000..ecbac759
--- /dev/null
+++ b/src/main/java/kst4contest/controller/On4KstConnectionManager.java
@@ -0,0 +1,837 @@
+package kst4contest.controller;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.SocketException;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import jdk.net.ExtendedSocketOptions;
+import kst4contest.ApplicationConstants;
+import kst4contest.model.ChatCategory;
+import kst4contest.model.ChatMember;
+import kst4contest.model.ChatMessage;
+import kst4contest.model.ChatPreferences;
+
+/**
+ * Owns the complete lifecycle of the single ON4KST TCP session.
+ *
+ *
Every reader, writer, queue and parser belongs to an immutable session id.
+ * A delayed failure from an old socket can therefore never close or consume data
+ * from its replacement.
+ */
+final class On4KstConnectionManager {
+ private static final Logger LOGGER =
+ Logger.getLogger(On4KstConnectionManager.class.getName());
+ private static final DateTimeFormatter LIVE_MESSAGE_TIMESTAMP =
+ DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ static final int CONNECT_TIMEOUT_MILLIS = 10_000; //TCP-Connect-Timeout
+ static final long LOGIN_FALLBACK_MILLIS = 2_000L; //Login-Fallback
+ static final long HANDSHAKE_TIMEOUT_MILLIS = 45_000L; //Handshake-Timeout
+ static final long APPLICATION_HEARTBEAT_AFTER_MILLIS = 90_000L; //Application-Heartbeat
+ static final long INBOUND_STALE_AFTER_MILLIS = 210_000L; //Stale-Timeout - time without rxed data
+ static final List RECONNECT_DELAYS_MILLIS =
+ List.of(2_000L, 5_000L, 10_000L, 20_000L, 30_000L); //Reconnect-Backoff if no connection possible
+
+ private final ChatController controller;
+ private final ScheduledExecutorService scheduler;
+ private final AtomicLong generation = new AtomicLong();
+ private final AtomicLong lastReceivedMessageTimestamp = new AtomicLong();
+
+ private volatile Session activeSession;
+ private volatile On4KstConnectionState state =
+ On4KstConnectionState.DISCONNECTED;
+ private volatile boolean stopRequested = true;
+ private int reconnectAttempt;
+
+ On4KstConnectionManager(ChatController controller) {
+ this.controller = controller;
+ this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
+ Thread thread = new Thread(runnable, "On4KstConnectionSupervisor");
+ thread.setDaemon(true);
+ return thread;
+ });
+ this.scheduler.scheduleAtFixedRate(
+ this::monitorActiveSession, 5L, 5L, TimeUnit.SECONDS);
+ LOGGER.fine("ON4KST connection supervisor initialized");
+ }
+
+ /**
+ * Returns the last lifecycle state published by the connection supervisor.
+ *
+ * @return current immutable connection-state value
+ */
+ On4KstConnectionState getState() {
+ return state;
+ }
+
+ /**
+ * Verifies that a callback still belongs to the currently installed session.
+ *
+ *
Every reconnect receives a new id. Late EOF, write or parser callbacks from
+ * an obsolete socket therefore become harmless instead of closing the replacement
+ * connection.
+ *
+ * @param sessionId id captured by the calling worker
+ * @return {@code true} only for the current, open and non-stopped session
+ */
+ boolean isActiveSession(long sessionId) {
+ Session session = activeSession;
+ return session != null
+ && session.id == sessionId
+ && !session.closed
+ && !stopRequested;
+ }
+
+ /**
+ * Starts a non-blocking connection attempt.
+ *
+ *
Configuration is validated before a socket is opened. A duplicate Connect
+ * action is ignored while another attempt or session is active. Connection work
+ * runs on the supervisor executor, so an unreachable server cannot block the
+ * JavaFX application thread.
TCP's {@code isConnected()} only states that a connection once succeeded.
+ * It does not prove that the peer is still reachable. Updating the inbound
+ * timestamp here gives the monitor a meaningful end-to-end signal.
ON4KST can send further {@code UE} frames after live user updates or
+ * after commands such as {@code SETNAME} and {@code BACK}. Those frames do
+ * not announce a new, empty snapshot. Treating them as another initial-list
+ * completion would remove the already published members because the staging
+ * map was consumed by the first {@code UE} frame.
+ *
+ *
The completed-category set is updated before the staging map is removed.
+ * This makes the operation idempotent even if completion callbacks should
+ * later be invoked from more than one thread. A genuinely empty initial list
+ * remains valid: the first {@code UE} for a category is always processed,
+ * even when no preceding valid {@code UA0} frame was staged.
A connected TCP socket is deliberately not synonymous with an authenticated
+ * chat session. The intermediate states make that distinction visible to the UI
+ * and prevent application messages from being sent in the wrong protocol context.
+ */
+public enum On4KstConnectionState {
+ DISCONNECTED,
+ CONNECTING,
+ WAITING_FOR_LOGIN_PROMPT,
+ AUTHENTICATING,
+ SYNCING_MAIN_CHAT,
+ SYNCING_SECOND_CHAT,
+ ONLINE,
+ RECONNECT_WAIT,
+ STOPPING;
+
+ /**
+ * Returns whether the complete application-level ON4KST handshake has finished.
+ *
+ * @return {@code true} only after authentication and all requested user lists
+ * have been synchronized
+ */
+ public boolean isOnline() {
+ return this == ONLINE;
+ }
+
+ /**
+ * Returns whether a connection attempt or usable session is currently owned by
+ * the connection manager.
+ *
+ *
This is intentionally broader than {@link #isOnline()}. The UI uses it to
+ * prevent a second Connect action while authentication, synchronization or a
+ * scheduled reconnect is already in progress.
+ *
+ * @return {@code true} while connecting, synchronizing, online or waiting for
+ * an automatic reconnect
+ */
+ public boolean isConnectionAttemptActive() {
+ return switch (this) {
+ case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING,
+ SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT, ONLINE,
+ RECONNECT_WAIT -> true;
+ default -> false;
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kst4contest/controller/On4KstProtocol.java b/src/main/java/kst4contest/controller/On4KstProtocol.java
new file mode 100644
index 00000000..932575c2
--- /dev/null
+++ b/src/main/java/kst4contest/controller/On4KstProtocol.java
@@ -0,0 +1,192 @@
+package kst4contest.controller;
+
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+/**
+ * Builds ON4KST port-23001 frames and rejects values that could break framing or
+ * put the server into an invalid chat context.
+ *
+ *
All outbound protocol construction is concentrated here. User-controlled
+ * values may therefore never introduce a field separator or a second line, and
+ * category and locator validation happens before the frame reaches the socket.
+ */
+final class On4KstProtocol {
+ private static final Pattern LOCATOR_6 =
+ Pattern.compile("^[A-Ra-r]{2}[0-9]{2}[A-Xa-x]{2}$");
+
+ private On4KstProtocol() {
+ }
+
+ /**
+ * Builds the initial authenticated login frame.
+ *
+ * @param callsign login callsign
+ * @param password ON4KST password; never logged by this class
+ * @param category primary chat category
+ * @param clientName client identification sent to the server
+ * @param lastMessageTimestamp earliest history timestamp to request
+ * @return validated frame without CR/LF terminator
+ */
+ static String login(
+ String callsign,
+ String password,
+ int category,
+ String clientName,
+ long lastMessageTimestamp
+ ) {
+ return "LOGINC|" + field(callsign, "callsign")
+ + "|" + password(password)
+ + "|" + category(category)
+ + "|" + field(clientName, "client name")
+ + "|25|0|1|" + Math.max(0L, lastMessageTimestamp) + "|0|";
+ }
+
+ /** Builds the settings-complete frame for the supplied chat category. */
+ static String settingsDone(int category) {
+ return "SDONE|" + category(category) + "|";
+ }
+
+ /** Builds the frame used to add a distinct second chat to the same session. */
+ static String addChat(int category, long lastMessageTimestamp) {
+ return "ACHAT|" + category(category)
+ + "|25|10|2|" + Math.max(0L, lastMessageTimestamp)
+ + "|0|";
+ }
+
+ /** Builds a category-qualified locator command after validating Maidenhead syntax. */
+ static String setLocator(int category, String locator) {
+ return command(category, "/SETLOC " + locator(locator));
+ }
+
+ /** Builds a category-qualified chat-name command. */
+ static String setName(int category, String name) {
+ return command(category, "/SETNAME " + field(name, "chat name"));
+ }
+
+ /** Builds the command that changes the operator state back to available. */
+ static String back(int category) {
+ return command(category, "/BACK");
+ }
+
+ /**
+ * Wraps one validated slash command in an ON4KST message frame.
+ *
+ * @return frame without CR/LF terminator
+ */
+ static String command(int category, String command) {
+ return "MSG|" + category(category) + "|0|"
+ + messageText(command) + "|0|";
+ }
+
+ /**
+ * Wraps one operator chat message in a category-qualified ON4KST frame.
+ *
+ * @return frame without CR/LF terminator
+ */
+ static String chatMessage(int category, String text) {
+ return "MSG|" + category(category) + "|0|"
+ + messageText(text) + "|0|";
+ }
+
+ /**
+ * Removes trailing line terminators from a legacy raw frame while rejecting an
+ * embedded line break that could inject a second server command.
+ *
+ * @param frame legacy raw frame, possibly with trailing CR/LF
+ * @return exactly one normalized protocol line
+ * @throws IllegalArgumentException if the value is {@code null} or contains an
+ * embedded line break
+ */
+ static String normalizeRawFrame(String frame) {
+ if (frame == null) {
+ throw new IllegalArgumentException("ON4KST frame must not be null");
+ }
+
+ int end = frame.length();
+ while (end > 0) {
+ char last = frame.charAt(end - 1);
+ if (last != '\r' && last != '\n') {
+ break;
+ }
+ end--;
+ }
+
+ String normalized = frame.substring(0, end);
+ if (normalized.indexOf('\r') >= 0 || normalized.indexOf('\n') >= 0) {
+ throw new IllegalArgumentException(
+ "ON4KST frame contains an embedded line break");
+ }
+ return normalized;
+ }
+
+ /**
+ * Validates and normalizes a six-character Maidenhead locator.
+ *
+ * @return upper-case locator
+ */
+ static String locator(String locator) {
+ String normalized = field(locator, "locator").toUpperCase(Locale.ROOT);
+ if (!LOCATOR_6.matcher(normalized).matches()) {
+ throw new IllegalArgumentException(
+ "Locator must be a six-character Maidenhead locator: " + normalized);
+ }
+ return normalized;
+ }
+
+ /** Rejects message text containing an ON4KST field or line delimiter. */
+ static String messageText(String text) {
+ String value = field(text, "message text");
+ if (value.indexOf('|') >= 0) {
+ throw new IllegalArgumentException(
+ "Message text contains the ON4KST field separator '|'");
+ }
+ return value;
+ }
+
+ /**
+ * Validates one required, non-password protocol field.
+ *
+ * @param value field value
+ * @param label diagnostic label used in validation errors
+ * @return trimmed value
+ */
+ static String field(String value, String label) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(label + " must not be empty");
+ }
+ if (value.indexOf('|') >= 0
+ || value.indexOf('\r') >= 0
+ || value.indexOf('\n') >= 0) {
+ throw new IllegalArgumentException(
+ label + " contains an ON4KST frame delimiter");
+ }
+ return value.trim();
+ }
+
+ private static String password(String value) {
+ if (value == null || value.isEmpty()) {
+ throw new IllegalArgumentException("password must not be empty");
+ }
+ if (value.indexOf('|') >= 0
+ || value.indexOf('\r') >= 0
+ || value.indexOf('\n') >= 0) {
+ throw new IllegalArgumentException(
+ "password contains an ON4KST frame delimiter");
+ }
+ return value;
+ }
+
+ /**
+ * Validates the category range supported by ON4KST.
+ *
+ * @return the unchanged category for convenient inline use
+ */
+ static int category(int category) {
+ if (category < 1 || category > 12) {
+ throw new IllegalArgumentException(
+ "Unsupported ON4KST chat category: " + category);
+ }
+ return category;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kst4contest/controller/On4KstProtocolTest.java b/src/main/java/kst4contest/controller/On4KstProtocolTest.java
new file mode 100644
index 00000000..f42b1210
--- /dev/null
+++ b/src/main/java/kst4contest/controller/On4KstProtocolTest.java
@@ -0,0 +1,69 @@
+package kst4contest.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+
+class On4KstProtocolTest {
+ @Test
+ void buildsLoginWithReplayOverlap() {
+ assertEquals(
+ "LOGINC|DL1ABC|secret|2|KST4Contest v1.2.3|25|0|1|12344|0|",
+ On4KstProtocol.login(
+ "DL1ABC", "secret", 2,
+ "KST4Contest v1.2.3", 12_344L));
+ }
+
+ @Test
+ void buildsContextSafeSecondChatFrames() {
+ assertEquals("SDONE|2|", On4KstProtocol.settingsDone(2));
+ assertEquals("ACHAT|3|25|10|2|100|0|",
+ On4KstProtocol.addChat(3, 100L));
+ assertEquals("MSG|2|0|/SETLOC JO31AA|0|",
+ On4KstProtocol.setLocator(2, "jo31aa"));
+ assertEquals("MSG|3|0|/SETNAME 10G 10368.200|0|",
+ On4KstProtocol.setName(3, "10G 10368.200"));
+ }
+
+ @Test
+ void stripsOnlyTrailingLineEndings() {
+ assertEquals("CK|", On4KstProtocol.normalizeRawFrame("CK|\r\n"));
+ assertThrows(IllegalArgumentException.class,
+ () -> On4KstProtocol.normalizeRawFrame("CK|\rBROKEN"));
+ }
+
+ @Test
+ void rejectsValuesThatCouldCreateASecondProtocolFrame() {
+ assertThrows(IllegalArgumentException.class,
+ () -> On4KstProtocol.chatMessage(2, "hello|0|"));
+ assertThrows(IllegalArgumentException.class,
+ () -> On4KstProtocol.chatMessage(2, "hello\r\nQUIT|"));
+ assertThrows(IllegalArgumentException.class,
+ () -> On4KstProtocol.login(
+ "DL1ABC", "bad|password", 2, "client", 0L));
+ }
+
+ @Test
+ void rejectsInvalidLocatorAndCategoryBeforeTheyReachTheServer() {
+ assertThrows(IllegalArgumentException.class,
+ () -> On4KstProtocol.setLocator(2, "JO31"));
+ assertThrows(IllegalArgumentException.class,
+ () -> On4KstProtocol.settingsDone(99));
+ }
+
+ @Test
+ void convertsBothHistoryAndLiveMessageTimestampsForReconnect() {
+ assertEquals(1_186_819_108L,
+ On4KstConnectionManager.parseMessageTimestamp(
+ "CR|2|1186819108|EA6VQ|Gabriel|0|msg|0|"));
+ assertEquals(
+ LocalDateTime.of(2026, 8, 13, 12, 34, 56)
+ .toEpochSecond(ZoneOffset.UTC),
+ On4KstConnectionManager.parseMessageTimestamp(
+ "CH|2|20260813123456|DL1ABC|Op|0|msg|0|"));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kst4contest/controller/On4KstSocketThreadTest.java b/src/main/java/kst4contest/controller/On4KstSocketThreadTest.java
new file mode 100644
index 00000000..2c3e8184
--- /dev/null
+++ b/src/main/java/kst4contest/controller/On4KstSocketThreadTest.java
@@ -0,0 +1,100 @@
+package kst4contest.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.BufferedReader;
+import java.io.OutputStreamWriter;
+import java.io.InputStreamReader;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import kst4contest.model.ChatMessage;
+
+class On4KstSocketThreadTest {
+ @Test
+ @Timeout(5)
+ void eofIsReportedImmediately() throws Exception {
+ try (ServerSocket server = new ServerSocket(0)) {
+ CompletableFuture serverDone = CompletableFuture.runAsync(() -> {
+ try (Socket accepted = server.accept();
+ OutputStreamWriter out = new OutputStreamWriter(
+ accepted.getOutputStream(), StandardCharsets.UTF_8)) {
+ out.write("CK|\r\n");
+ out.flush();
+ } catch (Exception exception) {
+ throw new RuntimeException(exception);
+ }
+ });
+
+ try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
+ LinkedBlockingQueue queue = new LinkedBlockingQueue<>();
+ AtomicBoolean active = new AtomicBoolean(true);
+ CompletableFuture failure = new CompletableFuture<>();
+ ReadThread reader = new ReadThread(
+ 7L, client, queue, ignored -> active.get(), ignored -> { },
+ failure::complete);
+ reader.start();
+
+ assertEquals("CK|", queue.poll(2, TimeUnit.SECONDS).getMessageText());
+ failure.get(2, TimeUnit.SECONDS);
+ active.set(false);
+ reader.join(Duration.ofSeconds(2).toMillis());
+ }
+ serverDone.get(2, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ @Timeout(5)
+ void writerUsesOneExactCrLfPerFrameIncludingHeartbeat() throws Exception {
+ try (ServerSocket server = new ServerSocket(0)) {
+ CompletableFuture firstLine = new CompletableFuture<>();
+ CompletableFuture secondLine = new CompletableFuture<>();
+ CompletableFuture serverDone = CompletableFuture.runAsync(() -> {
+ try (Socket accepted = server.accept();
+ BufferedReader in = new BufferedReader(new InputStreamReader(
+ accepted.getInputStream(), StandardCharsets.UTF_8))) {
+ firstLine.complete(in.readLine());
+ secondLine.complete(in.readLine());
+ } catch (Exception exception) {
+ throw new RuntimeException(exception);
+ }
+ });
+
+ try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
+ LinkedBlockingQueue queue = new LinkedBlockingQueue<>();
+ AtomicBoolean active = new AtomicBoolean(true);
+ WriteThread writer = new WriteThread(
+ 11L, client, queue, 2, ignored -> active.get(),
+ ignored -> { }, ignored -> { });
+ writer.start();
+
+ queue.add(serverFrame(""));
+ queue.add(serverFrame("SDONE|2|\r"));
+ assertEquals("", firstLine.get(2, TimeUnit.SECONDS));
+ assertEquals("SDONE|2|", secondLine.get(2, TimeUnit.SECONDS));
+
+ active.set(false);
+ writer.interrupt();
+ writer.join(Duration.ofSeconds(2).toMillis());
+ }
+ serverDone.get(2, TimeUnit.SECONDS);
+ }
+ }
+
+ private ChatMessage serverFrame(String text) {
+ ChatMessage message = new ChatMessage();
+ message.setMessageDirectedToServer(true);
+ message.setMessageText(text);
+ return message;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kst4contest/controller/ReadThread.java b/src/main/java/kst4contest/controller/ReadThread.java
index f26dd2d7..87e5bef5 100644
--- a/src/main/java/kst4contest/controller/ReadThread.java
+++ b/src/main/java/kst4contest/controller/ReadThread.java
@@ -1,119 +1,130 @@
package kst4contest.controller;
-import java.io.*;
-import java.net.*;
+import java.io.BufferedReader;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.Socket;
import java.nio.charset.StandardCharsets;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.function.Consumer;
+import java.util.function.LongPredicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.ChatMessage;
-
+
/**
- * This thread is responsible for reading telnet servers input at port 23001 and printing it
- * to the console.
- * It runs in an infinite loop until the client disconnects from the server.
+ * Reads exactly one immutable ON4KST connection session.
*
- * @author www.codejava.net
+ *
EOF is a connection-loss event, not an empty chat message. Every line is
+ * associated with the session id captured by this reader, so a delayed exception
+ * from an obsolete socket cannot affect a newer reconnect.
*/
public class ReadThread extends Thread {
private static final Logger LOGGER = Logger.getLogger(ReadThread.class.getName());
- private BufferedReader reader;
- private Socket socket;
- private ChatController client;
- public boolean accidentalDisconnected;
-
-
-
- public boolean isAccidentalDisconnected() {
- return accidentalDisconnected;
- }
- public void setAccidentalDisconnected(boolean accidentalDisconnected) {
- this.accidentalDisconnected = accidentalDisconnected;
- }
+ private final long sessionId;
+ private final Socket socket;
+ private final LinkedBlockingQueue receiveQueue;
+ private final LongPredicate sessionIsActive;
+ private final Consumer inboundActivity;
+ private final Consumer connectionFailure;
+ private final BufferedReader reader;
- // private boolean readingFinished = true; //kst4contest.test 4 23001
- private boolean readingFinished = true;
-
- InputStream input;
-
- public ReadThread(Socket socket, ChatController client) {
+ /**
+ * Compatibility constructor for the pre-session controller path.
+ *
+ * @deprecated new connections should be created by
+ * {@link On4KstConnectionManager}
+ */
+ @Deprecated
+ public ReadThread(Socket socket, ChatController client) throws IOException {
+ this(0L, socket, client.getMessageRXBus(), ignored -> true,
+ ignored -> { }, ignored -> { });
+ }
+
+ /**
+ * Creates the reader for one connection generation.
+ *
+ * @param sessionId immutable id of the owning socket session
+ * @param socket connected ON4KST socket
+ * @param receiveQueue private receive queue belonging to this session
+ * @param sessionIsActive guard against callbacks from an obsolete session
+ * @param inboundActivity callback used for liveness and protocol progress
+ * @param connectionFailure callback for EOF, I/O and unexpected runtime errors
+ * @throws IOException if the socket input stream cannot be opened
+ */
+ public ReadThread(
+ long sessionId,
+ Socket socket,
+ LinkedBlockingQueue receiveQueue,
+ LongPredicate sessionIsActive,
+ Consumer inboundActivity,
+ Consumer connectionFailure
+ ) throws IOException {
+ this.sessionId = sessionId;
this.socket = socket;
- this.client = client;
-
- try {
- input = socket.getInputStream();
- reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
-
- } catch (IOException ex) {
- LOGGER.log(Level.SEVERE, "Error getting input stream", ex);
- }
+ this.receiveQueue = receiveQueue;
+ this.sessionIsActive = sessionIsActive;
+ this.inboundActivity = inboundActivity;
+ this.connectionFailure = connectionFailure;
+ this.reader = new BufferedReader(new InputStreamReader(
+ socket.getInputStream(), StandardCharsets.UTF_8));
}
-
+
+ @Override
public void run() {
- Thread.currentThread().setName("ReadFromTelnetThread");
-
- ChatMessage message; //bugfix leak, moved out of while
- while (true) {
-
-// System.out.println("rdth");
-
- try {
-
- String response = reader.readLine();
- message = new ChatMessage();
- message.setMessageText(response);
-
-// message.setDirectedToServer(false);
-// message.setDirectedToServer(false);
-// message.setDirectedToServer(false);
-
-
- if (response != null) {
- client.getMessageRXBus().put(message);
-// System.out.println("[RT]: read message and added it to msgrxqueue --- " + response + " ---");
- } else {
- System.out.println("[RT]: read message responsed a nullstring, do nothing, buffersize = " + socket.getReceiveBufferSize() + ", reader ready? "
- + reader.ready());
-// reader = new BufferedReader(new InputStreamReader(input));
-// response = reader.readLine();
- this.client.getSocket().close();
- this.interrupt();
+ Thread.currentThread().setName("ReadFromOn4Kst-" + sessionId);
+ LOGGER.log(Level.FINE,
+ "ON4KST reader started for session {0}", sessionId);
+ try {
+ while (!isInterrupted() && sessionIsActive.test(sessionId)) {
+ String response = reader.readLine();
+ if (response == null) {
+ throw new EOFException("ON4KST closed the TCP connection");
}
-
- }
- catch (Exception sexc) {
- LOGGER.log(Level.SEVERE, "[ReadThread] Socket closed unexpectedly", sexc);
- try {
- this.client.getSocket().close();
- this.interrupt();
- break;
- } catch (IOException e) {
- LOGGER.log(Level.SEVERE, "[ReadThread] Error closing socket", e);
- }
+ inboundActivity.accept(response);
+ if (!sessionIsActive.test(sessionId)) {
+ break;
+ }
+ ChatMessage message = new ChatMessage();
+ message.setMessageText(response);
+ receiveQueue.put(message);
}
-
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ } catch (IOException exception) {
+ if (sessionIsActive.test(sessionId)) {
+ LOGGER.log(Level.FINE,
+ "ON4KST read failed for session " + sessionId,
+ exception);
+ connectionFailure.accept(exception);
+ }
+ } catch (RuntimeException exception) {
+ if (sessionIsActive.test(sessionId)) {
+ LOGGER.log(Level.SEVERE, "Unexpected ON4KST reader failure", exception);
+ connectionFailure.accept(exception);
+ }
+ } finally {
+ LOGGER.log(Level.FINE,
+ "ON4KST reader stopped for session {0}", sessionId);
}
}
-
+
+ /**
+ * Interrupts the read loop and closes the session socket.
+ *
+ * @return always {@code true} after a successful close
+ * @throws IOException if closing the reader or socket fails
+ */
public boolean terminateConnection() throws IOException {
- this.reader.close();
- this.input.close();
- this.socket.close();
-
- return true;
+ interrupt();
+ reader.close();
+ socket.close();
+ return true;
}
-
- public boolean isReadingFinished() {
- return readingFinished;
- }
-
- public void setReadingFinished(boolean readingReady) {
- this.readingFinished = readingReady;
- }
-
-
}
\ No newline at end of file
diff --git a/src/main/java/kst4contest/controller/StatusUpdateListener.java b/src/main/java/kst4contest/controller/StatusUpdateListener.java
index b866203f..45ba4cf0 100644
--- a/src/main/java/kst4contest/controller/StatusUpdateListener.java
+++ b/src/main/java/kst4contest/controller/StatusUpdateListener.java
@@ -17,4 +17,20 @@ public interface StatusUpdateListener {
void onUserListUpdated(String reason);
// new: userlist-update
-}
+ /**
+ * Called whenever the authoritative ON4KST session changes lifecycle state.
+ *
+ *
The callback may originate from a background connection supervisor. A UI
+ * implementation must marshal control changes onto its application thread.
+ *
+ * @param state new connection, authentication or synchronization state
+ * @param detail human-readable progress or failure reason
+ */
+ default void onConnectionStateChanged(
+ On4KstConnectionState state,
+ String detail
+ ) {
+ // Optional for non-UI listeners.
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/kst4contest/controller/WriteThread.java b/src/main/java/kst4contest/controller/WriteThread.java
index ec764431..8f583c22 100644
--- a/src/main/java/kst4contest/controller/WriteThread.java
+++ b/src/main/java/kst4contest/controller/WriteThread.java
@@ -1,289 +1,173 @@
package kst4contest.controller;
-import java.io.*;
-import java.net.*;
-import java.nio.charset.Charset;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.net.Socket;
import java.nio.charset.StandardCharsets;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.function.Consumer;
+import java.util.function.LongPredicate;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import kst4contest.ApplicationConstants;
+import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
/**
- * This thread is responsible for sending content to the chat. As we only use
- * the tx function, there is no content in run() method
- *
+ * Serializes and writes exactly one immutable ON4KST connection session.
*
+ *
The writer owns one private queue and appends exactly one CR/LF terminator
+ * per frame. All category selection and delimiter validation happens before bytes
+ * reach the socket.
*/
public class WriteThread extends Thread {
- private PrintWriter writer;
- private Socket socket;
- private ChatController client;
- private OutputStream output;
-
- private ChatMessage messageToBeSend;
-
- public WriteThread(Socket socket, ChatController client) throws InterruptedException {
- this.socket = socket;
- this.client = client;
-
- try {
- output = socket.getOutputStream();
-
- writer = new PrintWriter(output, true, StandardCharsets.UTF_8);
-
- } catch (IOException ex) {
- System.out.println("Error getting output stream: " + ex.getMessage());
- ex.printStackTrace();
- }
- }
+ private static final Logger LOGGER =
+ Logger.getLogger(WriteThread.class.getName());
+ private final long sessionId;
+ private final Socket socket;
+ private final LinkedBlockingQueue transmitQueue;
+ private final LongPredicate sessionIsActive;
+ private final Consumer connectionFailure;
+ private final Consumer rejectedFrame;
+ private final BufferedWriter writer;
+ private final int defaultCategory;
/**
- * This method is used to send a message to the server, raw formatted. E.g. for
- * the keepalive message. This method sends only in the main message-Category. To send it in a category
- * "defined by Chatmessage", use txByRxmsgCatOrigin(Chatmessage "toBeSend")
- *
- * @param messageToServer
- * @throws InterruptedException
- */
- public void tx(ChatMessage messageToServer) throws InterruptedException {
-
-// writer.println(messageToServer.getMessage()); //kst4contest.test 4 23001
-// writer.flush(); //kst4contest.test 4 23001
- System.out.println(messageToServer.getMessageText() + "< sended to the writer");
- writer.println(messageToServer.getMessageText());
-
- }
-
-
- /**
- * This method is used to send a message directly to a receiver in a special chatcategory. The receivers category
- * will be read out of the Chatmessage.getChatCategory method. The message text will be modified to fit kst
- * messageformat
+ * Compatibility constructor for the pre-session controller path.
*
- * @param messageToServer
- * @throws InterruptedException
+ * @deprecated new connections should be created by
+ * {@link On4KstConnectionManager}
*/
- public void txByRxmsgCatOrigin(ChatMessage messageToServer) throws InterruptedException {
-
-// writer.println(messageToServer.getMessage()); //kst4contest.test 4 23001
-// writer.flush(); //kst4contest.test 4 23001
-
- String originalMessageText = messageToServer.getMessageText() + "";
-
- String newMessageText = "";
-
- newMessageText = ("MSG|" + messageToServer.getChatCategory().getCategoryNumber()
- + "|0|" + originalMessageText + "|0|"); //original before 1.26
-
-
- System.out.println(newMessageText + "< sended to the writer (DIRECTED REPLY)");
- writer.println(newMessageText);
-
+ @Deprecated
+ public WriteThread(Socket socket, ChatController client) throws IOException {
+ this(0L, socket, client.getMessageTXBus(),
+ client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(),
+ ignored -> true,
+ ignored -> { }, System.out::println);
}
/**
- * This method gets a textmessage to the chat and adds some characters to hit
- * the neccessarry format to send a message in the on4kst chat either to another
- * station or to the public.
- *
- * @param messageToServer
- * @throws InterruptedException
+ * Creates the writer for one connection generation.
+ *
+ * @param sessionId immutable id of the owning socket session
+ * @param socket connected ON4KST socket
+ * @param transmitQueue private transmit queue belonging to this session
+ * @param defaultCategory fallback category for unqualified chat messages
+ * @param sessionIsActive guard against writes from an obsolete session
+ * @param connectionFailure callback for socket and unexpected runtime errors
+ * @param rejectedFrame callback for locally rejected protocol content
+ * @throws IOException if the socket output stream cannot be opened
*/
- public void txKSTFormatted(ChatMessage messageToServer) throws InterruptedException {
+ public WriteThread(
+ long sessionId,
+ Socket socket,
+ LinkedBlockingQueue transmitQueue,
+ int defaultCategory,
+ LongPredicate sessionIsActive,
+ Consumer connectionFailure,
+ Consumer rejectedFrame
+ ) throws IOException {
+ this.sessionId = sessionId;
+ this.socket = socket;
+ this.transmitQueue = transmitQueue;
+ this.defaultCategory = defaultCategory;
+ this.sessionIsActive = sessionIsActive;
+ this.connectionFailure = connectionFailure;
+ this.rejectedFrame = rejectedFrame;
+ this.writer = new BufferedWriter(new OutputStreamWriter(
+ socket.getOutputStream(), StandardCharsets.UTF_8));
+ }
-// writer.println(messageToServer.getMessageText());
- messageToBeSend = messageToServer;
+ @Override
+ public void run() {
+ Thread.currentThread().setName("WriteToOn4Kst-" + sessionId);
+ LOGGER.log(Level.FINE,
+ "ON4KST writer started for session {0}", sessionId);
try {
-
- messageToBeSend = client.getMessageTXBus().take();
-// this.client.getmesetChatsetServerready(true);
-
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
-
- String messageLine = messageToBeSend.getMessageText();
-
- if (messageToBeSend.isMessageDirectedToServer()) {
- /**
- * We have to check if we only commands the server (keepalive) or want do talk
- * to the community
- */
-
- try {
- tx(messageToBeSend);
- System.out.println("BUS: tx: " + messageToBeSend.getMessageText());
-
- } catch (InterruptedException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- }
-
- } else {
-
- ChatMessage ownMSG = new ChatMessage();
-
-// ownMSG.setMessageText(
-// "MSG|" + this.client.getCategory().getCategoryNumber() + "|0|" + messageLine + "|0|");
-
- ownMSG.setMessageText("MSG|" + this.client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber()
- + "|0|" + messageLine + "|0|"); //original before 1.26
-
- try {
- tx(ownMSG);
- System.out.println("BUS: tx: " + ownMSG.getMessageText());
-
- } catch (InterruptedException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- }
- }
-
- if (messageToBeSend.equals("/QUIT")) {
- try {
- this.client.getReadThread().terminateConnection();
- this.client.getReadThread().interrupt();
- this.client.getWriteThread().terminateConnection();
- this.client.getWriteThread().interrupt();
- this.interrupt();
-
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
-
- }
-
- public boolean terminateConnection() throws IOException {
-
- this.output.close();
- this.socket.close();
-
- return true;
- }
-
- public void run() {
- Thread.currentThread().setName("WriteToTelnetThread");
-
- while (true) {
- try {
- messageToBeSend = client.getMessageTXBus().take();
-
- if (messageToBeSend.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)
- && messageToBeSend.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
- client.getMessageRXBus().clear();
- this.interrupt();
+ while (!isInterrupted() && sessionIsActive.test(sessionId)) {
+ ChatMessage message = transmitQueue.take();
+ if (isPoisonPill(message)) {
+ break;
+ }
+ if (!sessionIsActive.test(sessionId)) {
break;
- } else {
- String messageLine = messageToBeSend.getMessageText();
-
- if (messageToBeSend.isMessageDirectedToServer()) {
- /**
- * We have to check if we only commands the server (keepalive) or want do talk
- * to the community
- */
-
- try {
- tx(messageToBeSend);
- System.out.println("BUS: tx: " + messageToBeSend.getMessageText());
-
- } catch (InterruptedException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- }
-
- } else { //message is not directed to the server, it´s directed to all or to a station
-
- if (messageToBeSend.getChatCategory() == this.client.getChatCategoryMain() || messageToBeSend.getChatCategory() == this.client.getChatCategorySecondChat()) {
-
- txByRxmsgCatOrigin(messageToBeSend);
-
- } else { //default bhv if destination cat is not detectable
-
-
- ChatMessage ownMSG = new ChatMessage();
-
- ownMSG.setMessageText(
- "MSG|" + this.client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber() + "|0|"
- + messageLine + "|0|");
-
- try {
- tx(ownMSG);
- System.out.println("WT: tx (raw): " + ownMSG.getMessageText());
-
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
}
- System.out.println("WritheTh: got message out of the queue: " + messageToBeSend.getMessageText());
-
-// this.client.getmesetChatsetServerready(true);
-
- } catch (InterruptedException e) {
- e.printStackTrace();
- client.getMessageTXBus().clear();
- }
-
-// String messageLine = messageTextRaw.getMessageText();
-//
-// if (messageTextRaw.isMessageDirectedToServer()) {
-// /**
-// * We have to check if we only commands the server (keepalive) or want do talk
-// * to the community
-// */
-//
-// try {
-// tx(messageTextRaw);
-// System.out.println("BUS: tx: " + messageTextRaw.getMessageText());
-//
-// } catch (InterruptedException e) {
-// // TODO Auto-generated catch block
-// e.printStackTrace();
-// }
-//
-// } else {
-//
-// ChatMessage ownMSG = new ChatMessage();
-//
-//// ownMSG.setMessageText(
-//// "MSG|" + this.client.getCategory().getCategoryNumber() + "|0|" + messageLine + "|0|");
-//
-// ownMSG.setMessageText(
-// "MSG|" + this.client.getChatPreferences().getLoginChatCategory().getCategoryNumber() + "|0|"
-// + messageLine + "|0|");
-//
-// try {
-// tx(ownMSG);
-// System.out.println("BUS: tx: " + ownMSG.getMessageText());
-//
-// } catch (InterruptedException e) {
-// // TODO Auto-generated catch block
-// e.printStackTrace();
-// }
-// }
+ try {
+ writeFrame(formatFrame(message));
+ } catch (IllegalArgumentException invalidFrame) {
+ LOGGER.log(Level.FINE,
+ "Rejected outbound ON4KST frame in session {0}: {1}",
+ new Object[] {sessionId, invalidFrame.getMessage()});
+ rejectedFrame.accept(invalidFrame.getMessage());
+ }
}
-// if (messageTextRaw.equals("/QUIT")) {
-// try {
-// this.client.getReadThread().terminateConnection();
-// this.client.getReadThread().interrupt();
-// this.client.getWriteThread().terminateConnection();
-// this.client.getWriteThread().interrupt();
-// this.interrupt();
-//
-// } catch (IOException e) {
-// // TODO Auto-generated catch block
-// e.printStackTrace();
-// }
-// }
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ } catch (IOException exception) {
+ if (sessionIsActive.test(sessionId)) {
+ LOGGER.log(Level.FINE,
+ "ON4KST write failed for session " + sessionId,
+ exception);
+ connectionFailure.accept(exception);
+ }
+ } catch (RuntimeException exception) {
+ if (sessionIsActive.test(sessionId)) {
+ LOGGER.log(Level.SEVERE,
+ "Unexpected ON4KST writer failure for session "
+ + sessionId,
+ exception);
+ connectionFailure.accept(exception);
+ }
+ } finally {
+ LOGGER.log(Level.FINE,
+ "ON4KST writer stopped for session {0}", sessionId);
+ }
+ }
-
-// while (true) {
-//
-// }
+ private String formatFrame(ChatMessage message) {
+ if (message == null) {
+ throw new IllegalArgumentException("Cannot send an empty ON4KST message");
+ }
+ if (message.isMessageDirectedToServer()) {
+ return On4KstProtocol.normalizeRawFrame(message.getMessageText());
+ }
+
+ ChatCategory category = message.getChatCategory();
+ return On4KstProtocol.chatMessage(
+ category == null ? defaultCategory : category.getCategoryNumber(),
+ message.getMessageText());
+ }
+
+ private void writeFrame(String frame) throws IOException {
+ writer.write(frame);
+ writer.write("\r\n");
+ writer.flush();
+ }
+
+ private boolean isPoisonPill(ChatMessage message) {
+ return message != null
+ && ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(
+ message.getMessageText())
+ && ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(
+ message.getMessageSenderName());
+ }
+
+ /**
+ * Interrupts the write loop and closes the session socket.
+ *
+ * @return always {@code true} after a successful close
+ * @throws IOException if closing the writer or socket fails
+ */
+ public boolean terminateConnection() throws IOException {
+ interrupt();
+ writer.close();
+ socket.close();
+ return true;
}
}
+
diff --git a/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java b/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java
index 974e92d8..13966634 100644
--- a/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java
+++ b/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java
@@ -4,6 +4,13 @@ import java.util.TimerTask;
import kst4contest.model.ChatMessage;
+/**
+ * Enqueues the empty application-level reply expected by ON4KST keepalive
+ * handling.
+ *
+ *
The writer owns line termination and appends exactly one CR/LF sequence.
+ * Supplying an empty payload here avoids the historic double-terminator frame.