diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index 6e9d0bcd..43c247cf 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -1083,9 +1083,35 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList rotatorClient.stop(); rotatorClient = null; } + + releaseBackgroundExecutors(); } } + /** + * Stops the background executors that live as long as this controller. + * + *

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.

+ */ + 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) { if (timer != null) { timer.cancel(); diff --git a/src/main/java/kst4contest/controller/On4KstConnectionManager.java b/src/main/java/kst4contest/controller/On4KstConnectionManager.java index a60e351a..64598999 100644 --- a/src/main/java/kst4contest/controller/On4KstConnectionManager.java +++ b/src/main/java/kst4contest/controller/On4KstConnectionManager.java @@ -139,6 +139,20 @@ final class On4KstConnectionManager { scheduler.execute(() -> openConnection(token)); } + /** + * Stops the supervisor thread of this manager for good. + * + *

{@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.

+ */ + void shutdown() { + scheduler.shutdownNow(); + LOGGER.fine("ON4KST connection supervisor shut down"); + } + /** * Stops the current session and invalidates every scheduled callback or reconnect * belonging to it. diff --git a/src/main/java/kst4contest/controller/SkedReminderService.java b/src/main/java/kst4contest/controller/SkedReminderService.java index 99e16f90..aaa4f83d 100644 --- a/src/main/java/kst4contest/controller/SkedReminderService.java +++ b/src/main/java/kst4contest/controller/SkedReminderService.java @@ -33,6 +33,24 @@ public final class SkedReminderService { this.controller = controller; } + /** + * Cancels every armed reminder and stops the scheduler thread. + * + *

Called when the runtime that owns this service is torn down, so a discarded + * runtime does not keep a thread and pending reminders alive.

+ */ + public void shutdown() { + + for (List> 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. * diff --git a/src/main/java/kst4contest/view/map/StationMapBridge.java b/src/main/java/kst4contest/view/map/StationMapBridge.java index 2441788e..120d59e7 100644 --- a/src/main/java/kst4contest/view/map/StationMapBridge.java +++ b/src/main/java/kst4contest/view/map/StationMapBridge.java @@ -2,6 +2,7 @@ package kst4contest.view.map; import javafx.animation.PauseTransition; import javafx.application.Platform; +import javafx.beans.value.ChangeListener; import javafx.collections.ListChangeListener; import javafx.scene.control.TableView; import javafx.util.Duration; @@ -54,6 +55,16 @@ public final class StationMapBridge { 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 chatMemberListListener; + private ChangeListener selectedChatMemberListener; + private ChangeListener antennaDirectionListener; + private ListChangeListener> filterPredicateListener; + public StationMapBridge(ChatController chatController, TableView chatMemberTable, StationMapView stationMapView, @@ -81,25 +92,59 @@ public final class StationMapBridge { stationMapView.setOnResetView(this::handleMapReset); - chatController.getLst_chatMemberSortedFilteredList().addListener( - (ListChangeListener) change -> scheduleRefresh() - ); + chatMemberListListener = change -> scheduleRefresh(); + chatController.getLst_chatMemberSortedFilteredList().addListener(chatMemberListListener); - chatController.getScoreService().selectedChatMemberProperty().addListener( - (obs, oldValue, newValue) -> requestImmediateRefresh() - ); + selectedChatMemberListener = (obs, oldValue, newValue) -> requestImmediateRefresh(); + chatController.getScoreService().selectedChatMemberProperty() + .addListener(selectedChatMemberListener); - chatController.getChatPreferences().getActualQTF().addListener( - (obs, oldValue, newValue) -> scheduleRefresh() - ); + antennaDirectionListener = (obs, oldValue, newValue) -> scheduleRefresh(); + chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener); - chatController.getLst_chatMemberListFilterPredicates().addListener( - (ListChangeListener>) change -> requestImmediateRefresh() - ); + filterPredicateListener = change -> requestImmediateRefresh(); + chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener); requestImmediateRefresh(); } + /** + * Removes everything {@link #install()} registered and stops the coalescing timer. + * + *

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.

+ */ + 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() { Runnable resetAction = () -> { /* diff --git a/src/main/java/kst4contest/view/map/StationMapView.java b/src/main/java/kst4contest/view/map/StationMapView.java index a9f4cc4f..6c4bb98c 100644 --- a/src/main/java/kst4contest/view/map/StationMapView.java +++ b/src/main/java/kst4contest/view/map/StationMapView.java @@ -244,6 +244,25 @@ public final class StationMapView { stage.hide(); } + /** + * Releases every resource this map window owns. + * + *

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.

+ */ + public void dispose() { + + if (tileProxyServer != null) { + tileProxyServer.stop(); + tileProxyServer = null; + } + + webEngine.load(null); + stage.close(); + } + public boolean isShowing() { return stage.isShowing(); }