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

This commit is contained in:
Marc Froehlich
2026-08-14 00:36:53 +02:00
parent a4475e6d12
commit 9037adf6eb
14 changed files with 2210 additions and 399 deletions
+1
View File
@@ -445,6 +445,7 @@
<addmodule>java.sql</addmodule>
<addmodule>java.net.http</addmodule>
<addmodule>jdk.crypto.ec</addmodule>
<addmodule>jdk.net</addmodule>
</addmodules>
<mainclass>${main.class}</mainclass>
<input>${project.build.directory}/modules</input>
@@ -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.
*
* <p>The compatibility flags remain derived values. No caller may set them to
* infer socket health; only the connection manager owns that decision.</p>
*
* @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.
*
* <p>The session-id check prevents a slow connection attempt from overwriting a
* newer socket and its queues.</p>
*/
synchronized void installOn4KstSession(
long connectionSessionId,
Socket sessionSocket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> 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<ContestSked> activeSkeds = FXCollections.observableArrayList();
// public ObservableList<ContestSked> getActiveSkeds() {
// return activeSkeds;
@@ -1416,6 +1610,10 @@ private ObservableList<String>
private final List<ChatMessage> pendingChatMessages = new ArrayList<>();
private boolean chatMessageFlushScheduled = false;
private static final int RECENT_INBOUND_MESSAGE_KEYS_MAX = 5_000;
private final LinkedHashMap<String, Boolean> recentInboundMessageKeys =
new LinkedHashMap<>();
/*
* Same idea for DXCluster messages.
*/
@@ -1572,6 +1770,55 @@ private ObservableList<String>
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<ChatMember> completeMembers
) {
if (category == null
|| !on4KstConnectionManager.isActiveSession(connectionSessionId)) {
return;
}
int categoryNumber = category.getCategoryNumber();
List<ChatMember> 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<String>
if (message == null) {
return;
}
if (isDuplicateInboundMessage(message)) {
return;
}
synchronized (pendingChatMessagesLock) {
pendingChatMessages.add(message);
@@ -1927,6 +2177,47 @@ private ObservableList<String>
Platform.runLater(this::flushPendingChatMessagesToUi);
}
/**
* Suppresses the small replay overlap deliberately requested after a reconnect.
*
* <p>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.</p>
*/
private boolean isDuplicateInboundMessage(ChatMessage message) {
String timestamp = message.getMessageGeneratedTime();
if (timestamp == null || timestamp.isBlank()) {
return false;
}
String sender = message.getSender() == null
? "" : String.valueOf(message.getSender().getCallSign());
String receiver = message.getReceiver() == null
? "" : String.valueOf(message.getReceiver().getCallSign());
int category = message.getChatCategory() == null
? -1 : message.getChatCategory().getCategoryNumber();
String key = category + "|" + timestamp + "|" + sender + "|"
+ receiver + "|" + String.valueOf(message.getMessageText());
synchronized (recentInboundMessageKeys) {
if (recentInboundMessageKeys.containsKey(key)) {
return true;
}
recentInboundMessageKeys.put(key, Boolean.TRUE);
while (recentInboundMessageKeys.size()
> RECENT_INBOUND_MESSAGE_KEYS_MAX) {
Iterator<String> iterator = recentInboundMessageKeys.keySet().iterator();
if (!iterator.hasNext()) {
break;
}
iterator.next();
iterator.remove();
}
return false;
}
}
private void flushPendingChatMessagesToUi() {
List<ChatMessage> batch;
@@ -2615,6 +2906,10 @@ private ObservableList<String>
*/
public void execute() throws InterruptedException, IOException {
if (on4KstConnectionManager != null) {
executeManaged();
return;
}
chatController = this;
@@ -2837,6 +3132,50 @@ private ObservableList<String>
}
private synchronized void executeManaged() throws IOException {
chatController = this;
setDisconnectionPerformedByUser(false);
startScoreScheduler();
if (readUDPbyUCXThread == null || !readUDPbyUCXThread.isAlive()) {
readUDPbyUCXThread = new ReadUDPbyUCXMessageThread(
chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort(),
this, this);
readUDPbyUCXThread.setName("readUDPbyUCXThread");
readUDPbyUCXThread.start();
}
if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
startWintestUdpListener();
}
if (airScoutUDPReaderThread == null || !airScoutUDPReaderThread.isAlive()) {
airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(
chatPreferences.getAirScout_asCommunicationPort(),
this, this);
airScoutUDPReaderThread.setName("airscoutudpreaderThread");
airScoutUDPReaderThread.start();
}
cancelTimer(userActualizationtimer);
userActualizationtimer = new Timer("UserActualizationTimer", true);
userActualizationtimer.schedule(
new UserActualizationTask(this), 4_000L, 60_000L);
cancelTimer(ASQueryTimer);
ASQueryTimer = new Timer("AirScoutQueryTimer", true);
ASQueryTimer.schedule(
new AirScoutPeriodicalAPReflectionInquirerTask(this),
10_000L, 60_000L);
if (chatPreferences.isStn_pstRotatorEnabled() && rotatorClient == null) {
initRotor();
}
startDxClusterServerIfEnabled();
on4KstConnectionManager.start();
}
/**
* Returns the background reachability service used by the station table.
@@ -2964,6 +3303,11 @@ private ObservableList<String>
*/
public void initialize23001() throws InterruptedException, IOException {
if (on4KstConnectionManager != null) {
on4KstConnectionManager.start();
return;
}
messageTXBus.clear();
ChatMessage message = new ChatMessage();
@@ -19,6 +19,8 @@ import kst4contest.locatorUtils.DirectionUtils;
import kst4contest.locatorUtils.Location;
import kst4contest.model.*;
import kst4contest.logic.FrequencyTextParser;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.LongPredicate;
/**
*
@@ -37,6 +39,11 @@ public class MessageBusManagementThread extends Thread {
private PrintWriter writer;
// private Socket socket;
private ChatController client;
private final long connectionSessionId;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LongPredicate connectionSessionIsActive;
// private File fileLogRAW;
// private TimerTask userActualizationTask; // Is used as a temporary userout-print
// private TimerTask userActualizationTask; //kst4contest.test 4 23001
@@ -118,9 +125,22 @@ public class MessageBusManagementThread extends Thread {
}
public MessageBusManagementThread(ChatController client, ThreadStatusCallback callBack) {
this(client, callBack, 0L, client.getMessageRXBus(), ignored -> true);
}
public MessageBusManagementThread(
ChatController client,
ThreadStatusCallback callBack,
long connectionSessionId,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LongPredicate connectionSessionIsActive
) {
this.callBackToController = callBack;
this.client = client;
this.connectionSessionId = connectionSessionId;
this.receiveQueue = receiveQueue;
this.connectionSessionIsActive = connectionSessionIsActive;
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
@@ -522,7 +542,8 @@ public class MessageBusManagementThread extends Thread {
messageToProcess.setMessageText(reduce);
if (messageToProcess.getMessageText().isEmpty()) {
if (messageToProcess.getMessageText() == null
|| messageToProcess.getMessageText().isEmpty()) {
// System.out.println("[MSGBUSMGTT:] ###################### no processable data");
} else {
@@ -754,6 +775,50 @@ public class MessageBusManagementThread extends Thread {
return fallbackReceiver;
}
private boolean validateInboundUserFrame(String[] fields) {
if (fields == null || fields.length < 6) {
logRejectedInboundUserFrame(
"Ignoring truncated ON4KST user frame", fields);
return false;
}
if (fields[2] == null || fields[2].isBlank()) {
logRejectedInboundUserFrame(
"Ignoring ON4KST user frame with empty callsign", fields);
return false;
}
try {
On4KstProtocol.category(Integer.parseInt(fields[1]));
On4KstProtocol.locator(fields[4]);
Integer.parseInt(fields[5]);
return true;
} catch (IllegalArgumentException invalidUser) {
logRejectedInboundUserFrame(
"Ignoring malformed ON4KST user '" + fields[2]
+ "': " + invalidUser.getMessage(), fields);
return false;
}
}
/**
* Records a rejected user frame without writing the complete raw frame or user
* name to the diagnostic log.
*
* <p>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.</p>
*/
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();
/***********************************************
@@ -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.
*
* <p>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.</p>
*/
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<Long> 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.
*
* <p>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.</p>
*
* @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.
*
* <p>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.</p>
*/
void start() {
long token;
synchronized (this) {
if (!stopRequested && state.isConnectionAttemptActive()) {
return;
}
try {
validateConfiguration();
} catch (IllegalArgumentException invalidConfiguration) {
stopRequested = true;
transition(On4KstConnectionState.DISCONNECTED,
"Invalid ON4KST configuration: "
+ invalidConfiguration.getMessage(), true);
return;
}
stopRequested = false;
reconnectAttempt = 0;
token = generation.incrementAndGet();
transition(On4KstConnectionState.CONNECTING,
"Opening ON4KST connection", false);
}
scheduler.execute(() -> openConnection(token));
}
/**
* Stops the current session and invalidates every scheduled callback or reconnect
* belonging to it.
*/
void stopByUser() {
Session oldSession;
synchronized (this) {
stopRequested = true;
generation.incrementAndGet();
transition(On4KstConnectionState.STOPPING,
"Disconnecting from ON4KST", false);
oldSession = activeSession;
activeSession = null;
}
closeSession(oldSession);
controller.onOn4KstConnectionLost();
transition(On4KstConnectionState.DISCONNECTED,
"Disconnected by user", false);
}
/**
* Records one received protocol line as proof of application-level liveness.
*
* <p>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.</p>
*
* @param sessionId immutable source-session id
* @param line complete protocol line received from ON4KST
*/
void onInboundActivity(long sessionId, String line) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
long now = System.currentTimeMillis();
session.lastInboundMillis.set(now);
session.lastProgressMillis.set(now);
String opcode = opcode(line);
if ("CK".equals(opcode)) {
sendHeartbeat(session);
}
if (!session.loginSent
&& line != null
&& line.toLowerCase(Locale.ROOT).contains("login")) {
scheduler.execute(() -> sendLogin(sessionId));
}
if ("CH".equals(opcode) || "CR".equals(opcode)) {
recordHistoryTimestamp(line);
}
}
void onLogstat(long sessionId, String[] fields) {
String[] copy = fields == null ? new String[0] : fields.clone();
scheduler.execute(() -> handleLogstat(sessionId, copy));
}
void stageInitialChatMember(long sessionId, ChatMember member) {
Session session = activeSession;
if (session == null || session.id != sessionId || member == null
|| member.getChatCategory() == null || member.getCallSign() == null) {
return;
}
int category = member.getChatCategory().getCategoryNumber();
session.initialMembers
.computeIfAbsent(category, ignored -> new ConcurrentHashMap<>())
.put(member.getCallSign().trim().toUpperCase(Locale.ROOT), member);
session.lastProgressMillis.set(System.currentTimeMillis());
}
void onInitialUserListCompleted(long sessionId, ChatCategory category) {
if (category == null) {
return;
}
scheduler.execute(() -> completeInitialUserList(
sessionId, category.getCategoryNumber()));
}
private void openConnection(long token) {
if (!mayOpen(token)) {
return;
}
LOGGER.log(Level.INFO,
"Opening ON4KST TCP session {0}", token);
Socket socket = new Socket();
try {
ChatPreferences preferences = controller.getChatPreferences();
socket.connect(new InetSocketAddress(
preferences.getStn_on4kstServersDns(),
preferences.getStn_on4kstServersPort()),
CONNECT_TIMEOUT_MILLIS);
configureSocket(socket);
LOGGER.log(Level.INFO,
"ON4KST TCP session {0} connected to {1}",
new Object[] {token, socket.getRemoteSocketAddress()});
LinkedBlockingQueue<ChatMessage> receiveQueue =
new LinkedBlockingQueue<>();
LinkedBlockingQueue<ChatMessage> transmitQueue =
new LinkedBlockingQueue<>();
Session session = new Session(token, socket, receiveQueue, transmitQueue);
ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession,
line -> onInboundActivity(token, line),
failure -> onConnectionFailure(token, failure));
WriteThread writeThread = new WriteThread(
token, socket, transmitQueue,
controller.getChatPreferences().getLoginChatCategoryMain()
.getCategoryNumber(),
this::isActiveSession,
failure -> onConnectionFailure(token, failure),
controller::onOn4KstOutboundFrameRejected);
MessageBusManagementThread messageProcessor =
new MessageBusManagementThread(
controller, controller, token, receiveQueue,
this::isActiveSession);
session.readThread = readThread;
session.writeThread = writeThread;
session.messageProcessor = messageProcessor;
synchronized (this) {
if (!mayOpen(token)) {
closeSession(session);
return;
}
activeSession = session;
controller.installOn4KstSession(
token, socket, receiveQueue, transmitQueue,
readThread, writeThread, messageProcessor);
transition(On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
"TCP connected; waiting for ON4KST login prompt", false);
}
messageProcessor.start();
writeThread.start();
readThread.start();
scheduler.schedule(
() -> sendLogin(token), LOGIN_FALLBACK_MILLIS,
TimeUnit.MILLISECONDS);
} catch (Exception exception) {
try {
socket.close();
} catch (IOException ignored) {
// The original connection exception is more useful.
}
scheduler.execute(() -> handleOpenFailure(token, exception));
}
}
private boolean mayOpen(long token) {
return !stopRequested && generation.get() == token;
}
private void sendLogin(long sessionId) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.loginSent
|| session.closed || stopRequested) {
return;
}
try {
ChatPreferences preferences = controller.getChatPreferences();
int mainCategory = preferences.getLoginChatCategoryMain()
.getCategoryNumber();
long historyFrom = Math.max(
0L, lastReceivedMessageTimestamp.get() - 1L);
String login = On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
mainCategory,
"KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION,
historyFrom);
session.loginSent = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"Sending ON4KST login for session {0}, main category {1}",
new Object[] {sessionId, mainCategory});
transition(On4KstConnectionState.AUTHENTICATING,
"ON4KST login sent", false);
sendControl(session, login);
} catch (IllegalArgumentException invalidConfiguration) {
failPermanently(session,
"Invalid ON4KST login configuration: "
+ invalidConfiguration.getMessage());
}
}
private void handleLogstat(long sessionId, String[] fields) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
String code = fields.length > 1 ? fields[1] : "";
if (!"100".equals(code)) {
String serverText = fields.length > 2 ? fields[2] : "Login rejected";
failPermanently(session,
"ON4KST login rejected (" + code + "): " + serverText);
return;
}
if (session.authenticated) {
return;
}
session.authenticated = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"ON4KST login accepted for session {0}", sessionId);
int mainCategory = controller.getChatPreferences()
.getLoginChatCategoryMain().getCategoryNumber();
transition(On4KstConnectionState.SYNCING_MAIN_CHAT,
"Login accepted; loading main chat", false);
sendControl(session, On4KstProtocol.settingsDone(mainCategory));
}
/**
* Publishes the initial user snapshot for one chat category exactly once.
*
* <p>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.</p>
*
* <p>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.</p>
*
* @param sessionId immutable id of the socket session that received the frame
* @param categoryNumber numeric ON4KST category terminated by {@code UE}
*/
private void completeInitialUserList(long sessionId, int categoryNumber) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
if (!session.completedInitialUserLists.add(categoryNumber)) {
LOGGER.log(Level.FINE,
"ON4KST session {0}: ignoring duplicate user-list end "
+ "marker for category {1}; the initial snapshot "
+ "has already been published",
new Object[] {sessionId, categoryNumber});
return;
}
Map<String, ChatMember> staged =
session.initialMembers.remove(categoryNumber);
Collection<ChatMember> completeMembers = staged == null
? List.of()
: new ArrayList<>(staged.values());
LOGGER.log(Level.INFO,
"ON4KST session {0}: complete user list for category {1} "
+ "contains {2} valid users",
new Object[] {
sessionId,
categoryNumber,
completeMembers.size()
});
controller.replaceActiveChatMembersForCategory(
sessionId,
new ChatCategory(categoryNumber),
completeMembers);
ChatPreferences preferences = controller.getChatPreferences();
int mainCategory =
preferences.getLoginChatCategoryMain().getCategoryNumber();
if (categoryNumber == mainCategory && !session.mainListComplete) {
session.mainListComplete = true;
configureMainChat(session);
if (hasDistinctSecondChat(preferences)) {
int secondCategory =
preferences.getLoginChatCategorySecond()
.getCategoryNumber();
transition(
On4KstConnectionState.SYNCING_SECOND_CHAT,
"Main chat ready; loading second chat",
false);
sendControl(
session,
On4KstProtocol.addChat(
secondCategory,
Math.max(
0L,
lastReceivedMessageTimestamp.get() - 1L)));
} else {
markOnline(session);
}
return;
}
if (hasDistinctSecondChat(preferences)
&& categoryNumber
== preferences.getLoginChatCategorySecond()
.getCategoryNumber()
&& !session.secondListComplete) {
session.secondListComplete = true;
configureSecondChat(session);
markOnline(session);
}
}
private void configureMainChat(Session session) {
ChatPreferences preferences = controller.getChatPreferences();
int category = preferences.getLoginChatCategoryMain().getCategoryNumber();
sendControl(session, On4KstProtocol.setLocator(
category, preferences.getStn_loginLocatorMainCat()));
if (preferences.getStn_loginNameMainCat() != null
&& !preferences.getStn_loginNameMainCat().isBlank()) {
sendControl(session, On4KstProtocol.setName(
category, preferences.getStn_loginNameMainCat()));
}
sendControl(session, On4KstProtocol.back(category));
String secondLocator = preferences.getStn_loginLocatorSecondCat();
String mainLocator = preferences.getStn_loginLocatorMainCat();
if (preferences.isLoginToSecondChatEnabled()
&& secondLocator != null && !secondLocator.isBlank()
&& !secondLocator.equalsIgnoreCase(mainLocator)) {
controller.onOn4KstConnectionWarning(
"ON4KST uses one locator per TCP session. The second-chat locator '"
+ secondLocator + "' is ignored; using '" + mainLocator + "'.");
}
}
private void configureSecondChat(Session session) {
ChatPreferences preferences = controller.getChatPreferences();
int category = preferences.getLoginChatCategorySecond().getCategoryNumber();
if (preferences.getStn_loginNameSecondCat() != null
&& !preferences.getStn_loginNameSecondCat().isBlank()) {
sendControl(session, On4KstProtocol.setName(
category, preferences.getStn_loginNameSecondCat()));
}
sendControl(session, On4KstProtocol.back(category));
}
private void markOnline(Session session) {
if (!isActiveSession(session.id)) {
return;
}
reconnectAttempt = 0;
session.online = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"ON4KST session {0} is authenticated and synchronized",
session.id);
transition(On4KstConnectionState.ONLINE,
"ON4KST session is authenticated and synchronized", false);
controller.onOn4KstConnectionOnline();
}
private void sendControl(Session session, String frame) {
if (session == null || !isActiveSession(session.id)) {
return;
}
ChatMessage message = new ChatMessage();
message.setMessageDirectedToServer(true);
message.setMessageText(frame);
session.transmitQueue.offer(message);
}
private void sendHeartbeat(Session session) {
if (session == null || !isActiveSession(session.id)) {
return;
}
long now = System.currentTimeMillis();
session.lastHeartbeatMillis.set(now);
LOGGER.log(Level.FINE,
"Sending application heartbeat for ON4KST session {0}",
session.id);
ChatMessage heartbeat = new ChatMessage();
heartbeat.setMessageDirectedToServer(true);
heartbeat.setMessageText("");
session.transmitQueue.offer(heartbeat);
}
private void onConnectionFailure(long sessionId, Throwable failure) {
scheduler.execute(() -> failSession(sessionId, failure));
}
private void failSession(long sessionId, Throwable failure) {
Session failedSession;
synchronized (this) {
failedSession = activeSession;
if (failedSession == null || failedSession.id != sessionId
|| failedSession.closed) {
return;
}
activeSession = null;
failedSession.closed = true;
}
closeSession(failedSession);
controller.onOn4KstConnectionLost();
if (stopRequested) {
transition(On4KstConnectionState.DISCONNECTED,
"ON4KST connection stopped", false);
return;
}
scheduleReconnect(failure);
}
private void handleOpenFailure(long token, Throwable failure) {
if (!mayOpen(token)) {
return;
}
controller.onOn4KstConnectionLost();
scheduleReconnect(failure);
}
private void scheduleReconnect(Throwable failure) {
if (stopRequested) {
transition(On4KstConnectionState.DISCONNECTED,
"ON4KST connection stopped", false);
return;
}
String reason = describeFailure(failure);
LOGGER.log(Level.WARNING,
"ON4KST connection lost; automatic reconnect scheduled", failure);
long delay = RECONNECT_DELAYS_MILLIS.get(Math.min(
reconnectAttempt, RECONNECT_DELAYS_MILLIS.size() - 1));
reconnectAttempt++;
transition(On4KstConnectionState.RECONNECT_WAIT,
"Connection lost (" + reason + "); reconnecting in "
+ Duration.ofMillis(delay).toSeconds() + " s", true);
long nextToken = generation.incrementAndGet();
scheduler.schedule(() -> {
if (!mayOpen(nextToken)) {
return;
}
transition(On4KstConnectionState.CONNECTING,
"Reconnecting to ON4KST", false);
openConnection(nextToken);
}, delay, TimeUnit.MILLISECONDS);
}
private void failPermanently(Session session, String reason) {
if (session == null || !isActiveSession(session.id)) {
return;
}
stopRequested = true;
generation.incrementAndGet();
activeSession = null;
session.closed = true;
closeSession(session);
controller.onOn4KstConnectionLost();
LOGGER.log(Level.WARNING, reason);
transition(On4KstConnectionState.DISCONNECTED, reason, true);
}
private void monitorActiveSession() {
try {
Session session = activeSession;
if (session == null || session.closed || stopRequested) {
return;
}
if (session.socket.isClosed()) {
failSession(session.id,
new SocketException("Socket is closed"));
return;
}
long now = System.currentTimeMillis();
if (!session.online
&& now - session.lastProgressMillis.get()
> HANDSHAKE_TIMEOUT_MILLIS) {
failSession(session.id,
new SocketException("ON4KST handshake timed out"));
return;
}
long inboundIdle = now - session.lastInboundMillis.get();
if (inboundIdle > INBOUND_STALE_AFTER_MILLIS) {
failSession(session.id,
new SocketException("No ON4KST data received for "
+ inboundIdle / 1_000L + " seconds"));
return;
}
if (inboundIdle > APPLICATION_HEARTBEAT_AFTER_MILLIS
&& session.lastHeartbeatMillis.get()
< session.lastInboundMillis.get()) {
sendHeartbeat(session);
}
} catch (RuntimeException exception) {
LOGGER.log(Level.WARNING,
"ON4KST connection monitor failed", exception);
}
}
private void validateConfiguration() {
ChatPreferences preferences = controller.getChatPreferences();
On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
preferences.getLoginChatCategoryMain().getCategoryNumber(),
"KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION,
0L);
On4KstProtocol.locator(preferences.getStn_loginLocatorMainCat());
if (preferences.getStn_loginNameMainCat() != null
&& !preferences.getStn_loginNameMainCat().isBlank()) {
On4KstProtocol.field(
preferences.getStn_loginNameMainCat(), "main chat name");
}
if (preferences.isLoginToSecondChatEnabled()) {
if (preferences.getLoginChatCategorySecond() == null) {
throw new IllegalArgumentException("Second chat has no category");
}
On4KstProtocol.category(
preferences.getLoginChatCategorySecond().getCategoryNumber());
if (preferences.getLoginChatCategorySecond().getCategoryNumber()
== preferences.getLoginChatCategoryMain().getCategoryNumber()) {
controller.onOn4KstConnectionWarning(
"Second ON4KST chat equals the main chat and will not be added twice.");
}
if (preferences.getStn_loginNameSecondCat() != null
&& !preferences.getStn_loginNameSecondCat().isBlank()) {
On4KstProtocol.field(
preferences.getStn_loginNameSecondCat(), "second chat name");
}
}
}
private boolean hasDistinctSecondChat(ChatPreferences preferences) {
return preferences.isLoginToSecondChatEnabled()
&& preferences.getLoginChatCategorySecond() != null
&& preferences.getLoginChatCategorySecond().getCategoryNumber()
!= preferences.getLoginChatCategoryMain().getCategoryNumber();
}
private void configureSocket(Socket socket) throws IOException {
socket.setTcpNoDelay(true);
socket.setKeepAlive(true);
try {
socket.setOption(ExtendedSocketOptions.TCP_KEEPIDLE, 45);
socket.setOption(ExtendedSocketOptions.TCP_KEEPINTERVAL, 15);
socket.setOption(ExtendedSocketOptions.TCP_KEEPCOUNT, 3);
} catch (UnsupportedOperationException | IOException exception) {
LOGGER.log(Level.INFO,
"Platform does not support configurable TCP keepalive; "
+ "application heartbeat remains active", exception);
}
}
private void closeSession(Session session) {
if (session == null) {
return;
}
LOGGER.log(Level.FINE,
"Closing ON4KST session {0}", session.id);
session.closed = true;
if (session.readThread != null) {
session.readThread.interrupt();
}
if (session.writeThread != null) {
session.writeThread.interrupt();
}
if (session.messageProcessor != null) {
session.messageProcessor.interrupt();
}
try {
session.socket.close();
} catch (IOException exception) {
LOGGER.log(Level.FINE, "Error closing obsolete ON4KST socket", exception);
}
}
private void transition(
On4KstConnectionState newState,
String detail,
boolean critical
) {
On4KstConnectionState previousState = state;
state = newState;
Level level = critical
|| newState == On4KstConnectionState.DISCONNECTED
|| newState == On4KstConnectionState.RECONNECT_WAIT
? Level.WARNING : Level.INFO;
LOGGER.log(level,
"ON4KST state {0} -> {1}; detail: {2}",
new Object[] {previousState, newState, detail});
controller.updateOn4KstConnectionState(newState, detail, critical);
}
private void recordHistoryTimestamp(String line) {
long timestamp = parseMessageTimestamp(line);
if (timestamp > 0L) {
lastReceivedMessageTimestamp.accumulateAndGet(timestamp, Math::max);
}
}
static long parseMessageTimestamp(String line) {
String[] fields = line == null ? new String[0] : line.split("\\|", -1);
if (fields.length < 3) {
return 0L;
}
try {
long numeric = Long.parseLong(fields[2]);
long now = System.currentTimeMillis() / 1_000L;
if (numeric > 0L && numeric <= now + 86_400L) {
return numeric;
}
} catch (NumberFormatException ignored) {
return 0L;
}
try {
return LocalDateTime.parse(fields[2], LIVE_MESSAGE_TIMESTAMP)
.toEpochSecond(ZoneOffset.UTC);
} catch (DateTimeParseException ignored) {
return 0L;
}
}
private String opcode(String line) {
if (line == null) {
return "";
}
int separator = line.indexOf('|');
return (separator < 0 ? line : line.substring(0, separator))
.trim().toUpperCase(Locale.ROOT);
}
private String describeFailure(Throwable failure) {
if (failure == null) {
return "unknown error";
}
String message = failure.getMessage();
return message == null || message.isBlank()
? failure.getClass().getSimpleName() : message;
}
private static final class Session {
private final Set<Integer> completedInitialUserLists =
ConcurrentHashMap.newKeySet();
private final long id;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final long connectedMillis = System.currentTimeMillis();
private final AtomicLong lastInboundMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastProgressMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastHeartbeatMillis = new AtomicLong();
private final Map<Integer, Map<String, ChatMember>> initialMembers =
new ConcurrentHashMap<>();
private volatile ReadThread readThread;
private volatile WriteThread writeThread;
private volatile MessageBusManagementThread messageProcessor;
private volatile boolean loginSent;
private volatile boolean authenticated;
private volatile boolean mainListComplete;
private volatile boolean secondListComplete;
private volatile boolean online;
private volatile boolean closed;
private Session(
long id,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> transmitQueue
) {
this.id = id;
this.socket = socket;
this.receiveQueue = receiveQueue;
this.transmitQueue = transmitQueue;
}
}
}
@@ -0,0 +1,50 @@
package kst4contest.controller;
/**
* Observable lifecycle of the ON4KST TCP session.
*
* <p>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.</p>
*/
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.
*
* <p>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.</p>
*
* @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;
};
}
}
@@ -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.
*
* <p>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.</p>
*/
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;
}
}
@@ -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|"));
}
}
@@ -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<Void> 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<ChatMessage> queue = new LinkedBlockingQueue<>();
AtomicBoolean active = new AtomicBoolean(true);
CompletableFuture<Throwable> 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<String> firstLine = new CompletableFuture<>();
CompletableFuture<String> secondLine = new CompletableFuture<>();
CompletableFuture<Void> 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<ChatMessage> 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;
}
}
@@ -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
* <p>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.</p>
*/
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<ChatMessage> receiveQueue;
private final LongPredicate sessionIsActive;
private final Consumer<String> inboundActivity;
private final Consumer<Throwable> 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<ChatMessage> receiveQueue,
LongPredicate sessionIsActive,
Consumer<String> inboundActivity,
Consumer<Throwable> 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;
}
}
@@ -17,4 +17,20 @@ public interface StatusUpdateListener {
void onUserListUpdated(String reason);
// new: userlist-update
}
/**
* Called whenever the authoritative ON4KST session changes lifecycle state.
*
* <p>The callback may originate from a background connection supervisor. A UI
* implementation must marshal control changes onto its application thread.</p>
*
* @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.
}
}
@@ -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.
*
* <p>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.</p>
*/
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<ChatMessage> transmitQueue;
private final LongPredicate sessionIsActive;
private final Consumer<Throwable> connectionFailure;
private final Consumer<String> 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. <b> The message text will be modified to fit kst
* messageformat</b>
* 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<ChatMessage> transmitQueue,
int defaultCategory,
LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure,
Consumer<String> 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;
}
}
@@ -4,6 +4,13 @@ import java.util.TimerTask;
import kst4contest.model.ChatMessage;
/**
* Enqueues the empty application-level reply expected by ON4KST keepalive
* handling.
*
* <p>The writer owns line termination and appends exactly one CR/LF sequence.
* Supplying an empty payload here avoids the historic double-terminator frame.</p>
*/
public class keepAliveMessageSenderTask extends TimerTask {
private ChatController client;
@@ -16,13 +23,14 @@ public class keepAliveMessageSenderTask extends TimerTask {
@Override
public void run() {
Thread.currentThread().setName("KeepAliveMessageSenderTask");
// System.out.println("[keepalive: ] Thread runned now");
ChatMessage keepAliveMSG = new ChatMessage();
keepAliveMSG.setMessageText("\r");
// WriteThread appends exactly one CRLF. An empty frame is the protocol reply.
keepAliveMSG.setMessageText("");
keepAliveMSG.setMessageDirectedToServer(true);
// System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + " [keepaliveTask]: Sending keepalive: "
@@ -33,4 +41,4 @@ public class keepAliveMessageSenderTask extends TimerTask {
this.client.getMessageTXBus().add(keepAliveMSG);
}
}
}
@@ -1,5 +1,6 @@
package kst4contest.view;
import kst4contest.utils.VersionUtils;
import kst4contest.controller.On4KstConnectionState;
import javafx.scene.image.Image;
import java.io.File;
@@ -81,6 +82,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// Enables optional color highlighting of the QRA/grid cell.
// The status text itself remains visible independently of this flag.
private static final Logger LOGGER = Logger.getLogger(
Kst4ContestApplication.class.getName());
private boolean gridSquareHighlightEnabled = false;
@@ -113,6 +118,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private StationMapView stationMapView; //view class for the avl stn map
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
private final Button btnConnectionStateIndicator = new Button("LINK");
private final Tooltip tipConnectionStateIndicator = new Tooltip();
private On4KstConnectionState lastDisplayedConnectionState;
private String lastDisplayedConnectionDetail = "";
private final Button btnBandUpgradeIndicator = new Button("BAND+");
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
private Timeline bandUpgradeBlinkTimeline;
@@ -5320,8 +5330,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
menuItemOptionsAwayBack.setDisable(false);
menuItemOptionsSetFrequencyAsName.setDisable(false);
chatcontroller.setConnectedAndLoggedIn(true);
chatcontroller.setDisconnected(false);
// chatcontroller.setConnectedAndLoggedIn(true);
// chatcontroller.setDisconnected(false);
} catch (InterruptedException | IOException e) {
e.printStackTrace();
@@ -5643,6 +5653,121 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
btnSkedWarnIndicator.setTooltip(tipSkedWarnIndicator);
}
private void initConnectionStateIndicatorButton() {
LOGGER.fine("Initializing compact ON4KST connection-state indicator");
btnConnectionStateIndicator.setMouseTransparent(true);
btnConnectionStateIndicator.setFocusTraversable(false);
btnConnectionStateIndicator.setMnemonicParsing(false);
btnConnectionStateIndicator.setMinSize(44, 22);
btnConnectionStateIndicator.setPrefSize(44, 22);
btnConnectionStateIndicator.setMaxSize(44, 22);
btnConnectionStateIndicator.setTooltip(tipConnectionStateIndicator);
btnConnectionStateIndicator.setAccessibleText(
"ON4KST connection state");
FlowPane.setMargin(
btnConnectionStateIndicator, new Insets(2, 4, 2, 4));
updateConnectionStateIndicator(
chatcontroller.getOn4KstConnectionState(),
"No ON4KST connection");
}
private void updateConnectionStateIndicator(
On4KstConnectionState state,
String detail
) {
if (!Platform.isFxApplicationThread()) {
LOGGER.warning(
"Connection indicator update arrived outside the JavaFX thread; "
+ "rescheduling it safely");
Platform.runLater(() -> updateConnectionStateIndicator(state, detail));
return;
}
On4KstConnectionState effectiveState = state == null
? On4KstConnectionState.DISCONNECTED : state;
String stateDetail = detail == null || detail.isBlank()
? effectiveState.name() : detail;
logConnectionIndicatorTransition(effectiveState, stateDetail);
tipConnectionStateIndicator.setText(
"ON4KST link: " + effectiveState.name() + "\n" + stateDetail);
btnConnectionStateIndicator.setAccessibleHelp(stateDetail);
String commonStyle =
"-fx-font-size: 10px;"
+ "-fx-font-weight: bold;"
+ "-fx-padding: 1 5 1 5;"
+ "-fx-background-radius: 6;"
+ "-fx-border-radius: 6;"
+ "-fx-border-width: 2;";
switch (effectiveState) {
case ONLINE -> {
btnConnectionStateIndicator.setText("LINK");
btnConnectionStateIndicator.setStyle(
commonStyle
+ "-fx-background-color: #238636;"
+ "-fx-border-color: #56d364;"
+ "-fx-text-fill: white;"
+ "-fx-effect: dropshadow(three-pass-box, "
+ "rgba(35,134,54,0.55), 5, 0.25, 0, 0);");
}
case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING,
SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT, STOPPING -> {
btnConnectionStateIndicator.setText("LINK…");
btnConnectionStateIndicator.setStyle(
commonStyle
+ "-fx-background-color: #ffb300;"
+ "-fx-border-color: #ffe082;"
+ "-fx-text-fill: #1b1b1b;"
+ "-fx-effect: dropshadow(three-pass-box, "
+ "rgba(255,179,0,0.65), 6, 0.3, 0, 0);");
}
case DISCONNECTED, RECONNECT_WAIT -> {
btnConnectionStateIndicator.setText("LINK!");
btnConnectionStateIndicator.setStyle(
commonStyle
+ "-fx-background-color: #d50000;"
+ "-fx-border-color: #ff6b6b;"
+ "-fx-text-fill: white;"
+ "-fx-effect: dropshadow(three-pass-box, "
+ "rgba(255,0,0,0.95), 10, 0.55, 0, 0);");
}
}
}
private void logConnectionIndicatorTransition(
On4KstConnectionState newState,
String newDetail
) {
boolean stateChanged = newState != lastDisplayedConnectionState;
boolean detailChanged = !Objects.equals(
newDetail, lastDisplayedConnectionDetail);
if (!stateChanged && !detailChanged) {
return;
}
On4KstConnectionState previousState = lastDisplayedConnectionState;
Level logLevel = switch (newState) {
case DISCONNECTED, RECONNECT_WAIT -> Level.WARNING;
default -> Level.INFO;
};
LOGGER.log(logLevel,
"ON4KST connection indicator: {0} -> {1}; detail: {2}",
new Object[] {
previousState == null ? "UNINITIALIZED" : previousState,
newState,
newDetail
});
lastDisplayedConnectionState = newState;
lastDisplayedConnectionDetail = newDetail;
}
private void showBlinkingSkedWarnIndicator(String text) {
@@ -6590,6 +6715,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
flwpne_StatusBar.getChildren().add(mainScreenMenuBar);
bPaneChatWindow.setTop(flwpne_StatusBar);
initConnectionStateIndicatorButton();
flwpne_StatusBar.getChildren().add(btnConnectionStateIndicator);
initSkedWarnIndicatorButton();
chatcontroller.lastUiReminderEventProperty().addListener(
@@ -6949,9 +7077,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
else {
chatState = "DISCONNECTED!";
On4KstConnectionState connectionState =
chatcontroller.getOn4KstConnectionState();
chatState = switch (connectionState) {
case RECONNECT_WAIT ->
"CONNECTION LOST reconnect scheduled";
case CONNECTING ->
"Connecting to ON4KST…";
case WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING ->
"Connected authenticating with ON4KST…";
case SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT ->
"Connected synchronizing ON4KST chat data…";
default ->
"DISCONNECTED!";
};
chatcontroller.getChatPreferences().setChatState(chatState);
}
if (chatcontroller.isDisconnected()) {
chatState = "DISCONNECTED!";
chatcontroller.getChatPreferences().setChatState(chatState);
@@ -11578,8 +11722,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
txtFldstn_maxQRBDefault.setDisable(true);
btnOptionspnlConnect.setDisable(true);
btnOptionspnlDisconnect.setDisable(false);
chatcontroller.setConnectedAndLoggedIn(true);
chatcontroller.setDisconnected(false);
// chatcontroller.setConnectedAndLoggedIn(true);
// chatcontroller.setDisconnected(false);
station_chkBxEnableSecondChat.setDisable(true);
stn_choiceBxChatChategorySecond.setDisable(true);
}
@@ -11882,7 +12026,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
String logDir = Path.of(System.getProperty("user.home"), ".praktiKST").toString();
new File(logDir).mkdirs();
FileHandler fileHandler = new FileHandler(logDir + "/kst4contest-errors.log", true);
fileHandler.setLevel(Level.SEVERE);
// Connection loss/reconnect diagnostics are warnings, not fatal crashes.
fileHandler.setLevel(Level.WARNING);
fileHandler.setFormatter(new SimpleFormatter());
Logger rootLogger = Logger.getLogger("");
rootLogger.addHandler(fileHandler);
@@ -11892,17 +12037,77 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
@Override
public void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage) {
public void onThreadStatusChanged(
String key,
ThreadStateMessage threadStateMessage
) {
if (threadStateMessage == null) {
LOGGER.log(Level.WARNING,
"Ignoring empty thread-status update from source {0}",
key == null ? "UNKNOWN" : key);
return;
}
if (key == null || key.isBlank()) {
LOGGER.log(Level.WARNING,
"Ignoring thread-status update without a source key. Detail: {0}",
threadStateMessage.getRunningInformation());
return;
}
Platform.runLater(() -> {
updateStatusButton(key, threadStateMessage);
});
if ("ON4KST".equalsIgnoreCase(key)) {
String detail = threadStateMessage.getRunningInformation();
Platform.runLater(() -> updateConnectionStateIndicator(
chatcontroller.getOn4KstConnectionState(), detail));
return;
}
Platform.runLater(() -> updateStatusButton(key, threadStateMessage));
maybeShowBandUpgradeIndicator(key, threadStateMessage);
//if we receive a threadstatemessage for sked warning, enable the sked warning
}
}
public void onConnectionStateChanged(
On4KstConnectionState state,
String detail
) {
On4KstConnectionState effectiveState = state;
if (effectiveState == null) {
LOGGER.log(Level.WARNING,
"Received ON4KST connection callback without a state; "
+ "treating it as disconnected. Detail: {0}",
detail);
effectiveState = On4KstConnectionState.DISCONNECTED;
}
On4KstConnectionState stateToDisplay = effectiveState;
Platform.runLater(() -> {
boolean active = stateToDisplay.isConnectionAttemptActive();
boolean online = stateToDisplay.isOnline();
updateConnectionStateIndicator(stateToDisplay, detail);
if (menuItemFileConnect != null) {
menuItemFileConnect.setDisable(active);
}
if (menuItemFileDisconnect != null) {
menuItemFileDisconnect.setDisable(!active);
}
if (menuItemOptionsAwayBack != null) {
menuItemOptionsAwayBack.setDisable(!online);
}
if (menuItemOptionsSetFrequencyAsName != null) {
menuItemOptionsSetFrequencyAsName.setDisable(!online);
}
if (btnOptionspnlConnect != null) {
btnOptionspnlConnect.setDisable(active);
}
if (sendButton != null) {
sendButton.setDisable(!online);
}
if (txt_chatMessageUserInput != null) {
txt_chatMessageUserInput.setDisable(!online);
}
});
}
/**
+1
View File
@@ -10,6 +10,7 @@ module praktiKST {
requires java.net.http;
requires java.desktop;
requires jdk.crypto.ec;
requires jdk.net;
requires org.junit.jupiter.api;
requires org.mockito;
exports kst4contest.controller.interfaces;