Release the background resources a discarded runtime owns

Several resources outlived a disconnect on purpose, which was harmless while a
process ran exactly one session for its whole life. They are now released when
the controller itself is closed:

- the ON4KST connection supervisor thread, which stopByUser did not touch
- the sked reminder scheduler, which had no shutdown at all
- the reachability executor, whose shutdown method existed but was never called
- the PSTRotator retry scheduler and its pending retry
- the map tile proxy, whose stop method existed but was never called, leaving a
  server socket and a twelve thread pool behind
- the station map bridge listeners and its coalescing animation, which would
  otherwise keep firing into a dead user interface

These are real leaks today; they only become visible when a second runtime is
built in the same process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
This commit is contained in:
Claude
2026-09-07 22:22:45 +02:00
committed by Rsclub2_2
parent 63a4bae858
commit 74ac1139ad
5 changed files with 134 additions and 12 deletions
@@ -1083,9 +1083,35 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
rotatorClient.stop(); rotatorClient.stop();
rotatorClient = null; rotatorClient = null;
} }
releaseBackgroundExecutors();
} }
} }
/**
* Stops the background executors that live as long as this controller.
*
* <p>These are not bound to one ON4KST session, so disconnecting leaves them running
* on purpose. When the controller itself is discarded they have to go, otherwise a
* discarded controller stays reachable through its own threads.</p>
*/
private void releaseBackgroundExecutors() {
on4KstConnectionManager.shutdown();
skedReminderService.shutdown();
if (reachabilityService != null) {
reachabilityService.shutdown();
}
if (pendingRotatorRetry != null) {
pendingRotatorRetry.cancel(false);
pendingRotatorRetry = null;
}
rotatorCommandScheduler.shutdownNow();
}
private void cancelTimer(Timer timer) { private void cancelTimer(Timer timer) {
if (timer != null) { if (timer != null) {
timer.cancel(); timer.cancel();
@@ -139,6 +139,20 @@ final class On4KstConnectionManager {
scheduler.execute(() -> openConnection(token)); scheduler.execute(() -> openConnection(token));
} }
/**
* Stops the supervisor thread of this manager for good.
*
* <p>{@link #stopByUser()} only ends the current ON4KST session; the periodic
* session monitor keeps running. That is correct while the application lives, but a
* manager belonging to a discarded runtime must release its thread, otherwise every
* operator profile switch would leave another supervisor behind holding the dead
* controller.</p>
*/
void shutdown() {
scheduler.shutdownNow();
LOGGER.fine("ON4KST connection supervisor shut down");
}
/** /**
* Stops the current session and invalidates every scheduled callback or reconnect * Stops the current session and invalidates every scheduled callback or reconnect
* belonging to it. * belonging to it.
@@ -33,6 +33,24 @@ public final class SkedReminderService {
this.controller = controller; this.controller = controller;
} }
/**
* Cancels every armed reminder and stops the scheduler thread.
*
* <p>Called when the runtime that owns this service is torn down, so a discarded
* runtime does not keep a thread and pending reminders alive.</p>
*/
public void shutdown() {
for (List<ScheduledFuture<?>> remindersOfOneCall : scheduledByCallRaw.values()) {
for (ScheduledFuture<?> armedReminder : remindersOfOneCall) {
armedReminder.cancel(false);
}
}
scheduledByCallRaw.clear();
scheduler.shutdownNow();
}
/** /**
* Arms reminders for one sked. Existing reminders for this call are cancelled. * Arms reminders for one sked. Existing reminders for this call are cancelled.
* *
@@ -2,6 +2,7 @@ package kst4contest.view.map;
import javafx.animation.PauseTransition; import javafx.animation.PauseTransition;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.collections.ListChangeListener; import javafx.collections.ListChangeListener;
import javafx.scene.control.TableView; import javafx.scene.control.TableView;
import javafx.util.Duration; import javafx.util.Duration;
@@ -54,6 +55,16 @@ public final class StationMapBridge {
private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0)); private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0));
/*
* The listeners are kept so install() can be undone. Without that, the coalescing
* animation and the registered listeners would keep a discarded runtime reachable
* after an operator profile switch.
*/
private ListChangeListener<ChatMember> chatMemberListListener;
private ChangeListener<ChatMember> selectedChatMemberListener;
private ChangeListener<Number> antennaDirectionListener;
private ListChangeListener<Predicate<ChatMember>> filterPredicateListener;
public StationMapBridge(ChatController chatController, public StationMapBridge(ChatController chatController,
TableView<ChatMember> chatMemberTable, TableView<ChatMember> chatMemberTable,
StationMapView stationMapView, StationMapView stationMapView,
@@ -81,25 +92,59 @@ public final class StationMapBridge {
stationMapView.setOnResetView(this::handleMapReset); stationMapView.setOnResetView(this::handleMapReset);
chatController.getLst_chatMemberSortedFilteredList().addListener( chatMemberListListener = change -> scheduleRefresh();
(ListChangeListener<ChatMember>) change -> scheduleRefresh() chatController.getLst_chatMemberSortedFilteredList().addListener(chatMemberListListener);
);
chatController.getScoreService().selectedChatMemberProperty().addListener( selectedChatMemberListener = (obs, oldValue, newValue) -> requestImmediateRefresh();
(obs, oldValue, newValue) -> requestImmediateRefresh() chatController.getScoreService().selectedChatMemberProperty()
); .addListener(selectedChatMemberListener);
chatController.getChatPreferences().getActualQTF().addListener( antennaDirectionListener = (obs, oldValue, newValue) -> scheduleRefresh();
(obs, oldValue, newValue) -> scheduleRefresh() chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener);
);
chatController.getLst_chatMemberListFilterPredicates().addListener( filterPredicateListener = change -> requestImmediateRefresh();
(ListChangeListener<Predicate<ChatMember>>) change -> requestImmediateRefresh() chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener);
);
requestImmediateRefresh(); requestImmediateRefresh();
} }
/**
* Removes everything {@link #install()} registered and stops the coalescing timer.
*
* <p>Needed when the runtime owning this bridge is discarded, for example during an
* operator profile switch. A running {@link PauseTransition} would otherwise keep
* firing into a dead user interface.</p>
*/
public void uninstall() {
refreshCoalescer.stop();
stationMapView.setOnCallsignRawSelected(null);
stationMapView.setOnTriggerClusterSpot(null);
stationMapView.setOnResetView(null);
if (chatMemberListListener != null) {
chatController.getLst_chatMemberSortedFilteredList().removeListener(chatMemberListListener);
chatMemberListListener = null;
}
if (selectedChatMemberListener != null) {
chatController.getScoreService().selectedChatMemberProperty()
.removeListener(selectedChatMemberListener);
selectedChatMemberListener = null;
}
if (antennaDirectionListener != null) {
chatController.getChatPreferences().getActualQTF().removeListener(antennaDirectionListener);
antennaDirectionListener = null;
}
if (filterPredicateListener != null) {
chatController.getLst_chatMemberListFilterPredicates().removeListener(filterPredicateListener);
filterPredicateListener = null;
}
}
private void handleMapReset() { private void handleMapReset() {
Runnable resetAction = () -> { Runnable resetAction = () -> {
/* /*
@@ -244,6 +244,25 @@ public final class StationMapView {
stage.hide(); stage.hide();
} }
/**
* Releases every resource this map window owns.
*
* <p>The tile proxy is a local server socket with its own thread pool. It used to
* live until the process ended, which was harmless while the map existed exactly
* once per process. A runtime that is discarded, for example during an operator
* profile switch, has to hand it back.</p>
*/
public void dispose() {
if (tileProxyServer != null) {
tileProxyServer.stop();
tileProxyServer = null;
}
webEngine.load(null);
stage.close();
}
public boolean isShowing() { public boolean isShowing() {
return stage.isShowing(); return stage.isShowing();
} }