From 81993afd60b4e6232e76b3ceaaa119e4d909b0aa Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sat, 10 Oct 2026 23:09:46 -0600
Subject: [PATCH 1/8] Prevent shutdown exceptions after partial startup
failures
---
.../votingplugin/VotingPluginMain.java | 108 ++++---
.../VotingPluginMainShutdownTest.java | 269 ++++++++++++++++++
docs/startup-failure-shutdown.md | 6 +
3 files changed, 344 insertions(+), 39 deletions(-)
create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java
create mode 100644 docs/startup-failure-shutdown.md
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
index cde4d636b..331a0ec0c 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
@@ -317,6 +317,7 @@ public File getLoadedPluginJarFile() {
private volatile VotingPluginBackgroundTask backgroundTask;
private volatile AtomicBoolean basicBungeeUpdateRunning;
+ private final AtomicBoolean unloadCompleted = new AtomicBoolean();
private synchronized AtomicBoolean basicBungeeUpdateAdmission() {
if (basicBungeeUpdateRunning == null) basicBungeeUpdateRunning = new AtomicBoolean();
@@ -1903,6 +1904,7 @@ private void migrateVoteBroadcast(Config configFile) {
@Override
public void onPreLoad() {
plugin = this;
+ unloadCompleted.set(false);
setupFiles();
if ("SQLITE".equalsIgnoreCase(configFile.getData().getString("DataStorage", "SQLITE"))) {
@@ -1944,11 +1946,13 @@ public void onFinish() {
@Override
public void onDisable() {
- if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
try {
- shutdownVoteReminders();
+ runShutdownStep("offline vote handoffs", () -> {
+ if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
+ });
} finally {
- super.onDisable();
+ try { shutdownVoteReminders(); }
+ finally { super.onDisable(); }
}
}
@@ -1961,48 +1965,74 @@ private void shutdownVoteReminders() {
@Override
public void onUnLoad() {
+ if (!unloadCompleted.compareAndSet(false, true)) return;
placeholderPlayerPresence.clear();
- stopBackendHostedControlLifecycle();
- stopBackendControlConnectorLifecycle();
- if (getBackendProxyHandler() != null) {
- try {
- getBackendProxyHandler().closeForShutdown();
- } catch (Exception e) {
- debug(e);
+ runShutdownStep("backend hosted Control", this::stopBackendHostedControlLifecycle);
+ runShutdownStep("backend Control connector", this::stopBackendControlConnectorLifecycle);
+ runShutdownStep("backend proxy", () -> {
+ if (getBackendProxyHandler() != null) getBackendProxyHandler().closeForShutdown();
+ });
+ runShutdownStep("backend ordered vote queue", () -> {
+ if (backendOrderedVoteOverflowQueue != null) {
+ backendOrderedVoteOverflowQueue.close();
+ backendOrderedVoteOverflowQueue = null;
}
- }
- if (backendOrderedVoteOverflowQueue != null) {
- backendOrderedVoteOverflowQueue.close();
- backendOrderedVoteOverflowQueue = null;
- }
- if (webhookManager != null) {
- webhookManager.shutdown();
- }
- voteTimer.shutdown();
+ });
+ runShutdownStep("webhook manager", () -> {
+ if (webhookManager != null) webhookManager.shutdown();
+ });
+ runShutdownStep("vote timer", this::shutdownVoteTimer);
+ runShutdownStep("Votifier vote queue", () -> {
+ if (votifierVoteOverflowQueue != null) {
+ votifierVoteOverflowQueue.close();
+ votifierVoteOverflowQueue = null;
+ }
+ });
+ runShutdownStep("time queue", () -> {
+ if (timeQueueHandler != null) timeQueueHandler.save();
+ });
+ runShutdownStep("vote reminders", this::shutdownVoteReminders);
+ runShutdownStep("cooldown checker", () -> {
+ if (coolDownCheck != null) {
+ coolDownCheck.shutdown();
+ coolDownCheck = null;
+ }
+ });
+ runShutdownStep("signs", () -> {
+ if (signs != null) signs.storeSigns();
+ });
+ runShutdownStep("event handlers", () -> HandlerList.unregisterAll(this));
+ if (plugin == this) plugin = null;
+ }
+
+ private void runShutdownStep(String name, Runnable step) {
try {
- voteTimer.awaitTermination(1, TimeUnit.SECONDS);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- voteTimer.shutdownNow();
- if (votifierVoteOverflowQueue != null) {
- votifierVoteOverflowQueue.close();
- votifierVoteOverflowQueue = null;
- }
- if (timeQueueHandler != null) {
- timeQueueHandler.save();
+ step.run();
+ } catch (RuntimeException | Error failure) {
+ try {
+ getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down " + name, failure);
+ } catch (RuntimeException | Error ignored) {
+ // Logging must not prevent the next independent cleanup step.
+ }
}
+ }
- shutdownVoteReminders();
-
- if (coolDownCheck != null) {
- coolDownCheck.shutdown();
- coolDownCheck = null;
+ private void shutdownVoteTimer() {
+ if (voteTimer == null) return;
+ try {
+ voteTimer.shutdown();
+ if (!voteTimer.awaitTermination(1, TimeUnit.SECONDS)) voteTimer.shutdownNow();
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ voteTimer.shutdownNow();
+ } catch (RuntimeException failure) {
+ try {
+ getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down vote timer", failure);
+ } catch (RuntimeException | Error ignored) { }
+ voteTimer.shutdownNow();
+ } finally {
+ voteTimer = null;
}
-
- getSigns().storeSigns();
- HandlerList.unregisterAll(plugin);
- plugin = null;
}
/**
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java
new file mode 100644
index 000000000..9187cff1a
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java
@@ -0,0 +1,269 @@
+package com.bencodez.votingplugin;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.concurrent.ScheduledExecutorService;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.votingplugin.signs.Signs;
+import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
+import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence;
+import com.bencodez.votingplugin.webhook.VotingPluginWebhookManager;
+
+class VotingPluginMainShutdownTest {
+ @Test
+ void lateVoteTimerIsOptionalDuringPartialStartup() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ invokeShutdownVoteTimer(plugin);
+ assertNull(field(plugin, "voteTimer"));
+ }
+
+ @Test
+ void voteTimerCleanupIsRepeatable() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ ScheduledExecutorService timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
+ setField(plugin, "voteTimer", timer);
+ invokeShutdownVoteTimer(plugin);
+ assertDoesNotThrow(() -> invokeShutdownVoteTimer(plugin));
+ assertNull(field(plugin, "voteTimer"));
+ }
+
+ @Test
+ void publicUnloadSkipsLateOptionalResourcesAndIsIdempotent() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ setField(plugin, "plugin", plugin);
+ setField(plugin, "unloadCompleted", new java.util.concurrent.atomic.AtomicBoolean());
+ setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
+ initializeEarlyLifecycle(plugin);
+
+ assertDoesNotThrow(plugin::onUnLoad);
+ assertDoesNotThrow(plugin::onUnLoad);
+ assertNull(field(plugin, "signs"));
+ assertNull(field(plugin, "timeQueueHandler"));
+ verify((java.util.concurrent.ExecutorService) field(plugin, "backendHostedControlLifecycle")).shutdownNow();
+ verify((java.util.concurrent.ExecutorService) field(plugin, "backendControlConnectorLifecycle")).shutdownNow();
+ }
+
+ @Test
+ void publicUnloadCleansInitializedQueueAndSignResources() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ setField(plugin, "plugin", plugin);
+ setField(plugin, "unloadCompleted", new java.util.concurrent.atomic.AtomicBoolean());
+ setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
+ initializeEarlyLifecycle(plugin);
+ Signs signs = mock(Signs.class);
+ TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
+ setField(plugin, "signs", signs);
+ setField(plugin, "timeQueueHandler", timeQueue);
+
+ plugin.onUnLoad();
+
+ verify(signs).storeSigns();
+ verify(timeQueue).save();
+ plugin.onUnLoad();
+ verify(signs).storeSigns();
+ }
+
+ @Test
+ void failedCleanupDoesNotSkipLaterResources() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ setField(plugin, "plugin", plugin);
+ setField(plugin, "unloadCompleted", new java.util.concurrent.atomic.AtomicBoolean());
+ setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
+ initializeEarlyLifecycle(plugin);
+ Signs signs = mock(Signs.class);
+ TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
+ VotingPluginWebhookManager webhook = mock(VotingPluginWebhookManager.class);
+ doThrow(new IllegalStateException("webhook close failed")).when(webhook).shutdown();
+ doThrow(new IllegalStateException("queue save failed")).when(timeQueue).save();
+ setField(plugin, "webhookManager", webhook);
+ setField(plugin, "signs", signs);
+ setField(plugin, "timeQueueHandler", timeQueue);
+
+ assertDoesNotThrow(plugin::onUnLoad);
+
+ verify(webhook).shutdown();
+ verify(timeQueue).save();
+ verify(signs).storeSigns();
+ }
+
+ @Test
+ void gracefulTimerShutdownDoesNotForceStop() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
+ org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
+ setField(plugin, "voteTimer", timer);
+
+ invokeShutdownVoteTimer(plugin);
+
+ verify(timer).shutdown();
+ verify(timer, never()).shutdownNow();
+ }
+
+ @Test
+ void timerTimeoutForcesStop() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
+ org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(false);
+ setField(plugin, "voteTimer", timer);
+
+ invokeShutdownVoteTimer(plugin);
+
+ verify(timer).shutdownNow();
+ }
+
+ @Test
+ void interruptedTimerShutdownRestoresInterruptAndForcesStop() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
+ doThrow(new InterruptedException()).when(timer).awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS);
+ setField(plugin, "voteTimer", timer);
+
+ invokeShutdownVoteTimer(plugin);
+
+ try {
+ org.junit.jupiter.api.Assertions.assertTrue(Thread.currentThread().isInterrupted());
+ } finally {
+ Thread.interrupted();
+ }
+ verify(timer).shutdownNow();
+ }
+
+ @Test
+ void failedForcedTimerStopStillClosesInitializedQueuesAndListenersOnce() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ setField(plugin, "plugin", plugin);
+ setField(plugin, "unloadCompleted", new java.util.concurrent.atomic.AtomicBoolean());
+ setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
+ initializeEarlyLifecycle(plugin);
+ ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
+ doThrow(new IllegalStateException("controlled forced-stop failure")).when(timer).shutdownNow();
+ setField(plugin, "voteTimer", timer);
+ var queue = mock(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class);
+ var cooldown = mock(com.bencodez.votingplugin.cooldown.CoolDownCheck.class);
+ var reminders = mock(com.bencodez.votingplugin.votereminding.VoteRemindersManager.class);
+ setField(plugin, "votifierVoteOverflowQueue", queue);
+ setField(plugin, "coolDownCheck", cooldown);
+ setField(plugin, "voteRemindersManager", reminders);
+ try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
+ assertDoesNotThrow(plugin::onUnLoad);
+ assertDoesNotThrow(plugin::onUnLoad);
+ verify(queue).close();
+ verify(cooldown).shutdown();
+ verify(reminders).shutdown();
+ handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
+ }
+ assertNull(field(plugin, "voteTimer"));
+ assertNull(field(plugin, "votifierVoteOverflowQueue"));
+ }
+
+ @Test
+ void rejectedMysqlStartupRetainsItsCauseAndFullDisableCleansEarlyResources() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ org.mockito.Mockito.doReturn(options).when(plugin).getOptions();
+ org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("startup-fixture")).when(plugin).getLogger();
+ org.mockito.Mockito.when(options.getDebug().isDebug()).thenReturn(true);
+ setField(plugin, "plugin", plugin);
+ setField(plugin, "unloadCompleted", new java.util.concurrent.atomic.AtomicBoolean());
+ setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
+ initializeEarlyLifecycle(plugin);
+ var logger = mock(java.util.logging.Logger.class);
+ org.mockito.Mockito.doReturn(logger).when(plugin).getLogger();
+ ScheduledExecutorService coreTimer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
+ setField(plugin, "timer", coreTimer);
+ setField(plugin, "loadUserData", true);
+ var configuration = new org.bukkit.configuration.file.YamlConfiguration();
+ configuration.set("Host", "127.0.0.1"); configuration.set("Port", 3306);
+ configuration.set("Username", "fixture"); configuration.set("Password", "fixture");
+ configuration.set("Database", "fixture"); configuration.set("MaxConnections", 1);
+ configuration.set("ConnectionTimeout", 1000); configuration.set("MinimumIdle", 0);
+ configuration.set("Driver", RejectingDriver.class.getName());
+ configuration.set("Debug", true);
+ try {
+ IllegalStateException failure = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class,
+ () -> new com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL(plugin, "Users", configuration));
+ Throwable original = failure;
+ while (original.getCause() != null) original = original.getCause();
+ org.junit.jupiter.api.Assertions.assertInstanceOf(java.sql.SQLException.class, original);
+ org.junit.jupiter.api.Assertions.assertEquals("controlled authentication rejection", original.getMessage());
+ assertNull(plugin.getMysql(), "rejected constructor must not publish storage");
+ assertDoesNotThrow(plugin::onDisable);
+ org.junit.jupiter.api.Assertions.assertTrue(coreTimer.isTerminated());
+ assertNull(field(plugin, "voteTimer"));
+ assertNull(field(plugin, "signs"));
+ assertDoesNotThrow(plugin::onDisable);
+ org.junit.jupiter.api.Assertions.assertSame(original, rootCause(failure));
+ verify(logger, never()).warning(org.mockito.ArgumentMatchers.anyString());
+ } finally { coreTimer.shutdownNow(); }
+ }
+
+ private static Throwable rootCause(Throwable failure) {
+ while (failure.getCause() != null) failure = failure.getCause();
+ return failure;
+ }
+
+ public static final class RejectingDriver implements java.sql.Driver {
+ public java.sql.Connection connect(String url, java.util.Properties properties) throws java.sql.SQLException {
+ throw new java.sql.SQLException("controlled authentication rejection", "28000", 1045);
+ }
+ public boolean acceptsURL(String url) { return url != null && url.startsWith("jdbc:"); }
+ public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties properties) {
+ return new java.sql.DriverPropertyInfo[0];
+ }
+ public int getMajorVersion() { return 1; }
+ public int getMinorVersion() { return 0; }
+ public boolean jdbcCompliant() { return false; }
+ public java.util.logging.Logger getParentLogger() { return java.util.logging.Logger.getGlobal(); }
+ }
+
+ private static void initializeEarlyLifecycle(VotingPluginMain plugin) throws Exception {
+ var hosted = mock(java.util.concurrent.ExecutorService.class);
+ var connector = mock(ScheduledExecutorService.class);
+ org.mockito.Mockito.when(hosted.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
+ org.mockito.Mockito.when(connector.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
+ setField(plugin, "pendingNativeUserStorageCloseLock", new Object());
+ setField(plugin, "pendingNativeUserStorageCloses", new java.util.ArrayList<>());
+ setField(plugin, "backendHostedControlLifecycle", hosted);
+ setField(plugin, "backendControlConnectorLifecycle", connector);
+ setField(plugin, "backendHostedControlManagers", java.util.concurrent.ConcurrentHashMap.newKeySet());
+ org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("shutdown-fixture")).when(plugin).getLogger();
+ }
+
+ private static void invokeShutdownVoteTimer(VotingPluginMain plugin) throws Exception {
+ Method method = VotingPluginMain.class.getDeclaredMethod("shutdownVoteTimer");
+ method.setAccessible(true);
+ method.invoke(plugin);
+ }
+
+ private static Object field(VotingPluginMain plugin, String name) throws Exception {
+ Field field = VotingPluginMain.class.getDeclaredField(name);
+ field.setAccessible(true);
+ return field.get(plugin);
+ }
+
+ private static void setField(VotingPluginMain plugin, String name, Object value) throws Exception {
+ Field field = findField(name);
+ field.setAccessible(true);
+ field.set(plugin, value);
+ }
+
+ private static Field findField(String name) throws Exception {
+ Class> type = VotingPluginMain.class;
+ while (type != null) {
+ try { return type.getDeclaredField(name); }
+ catch (NoSuchFieldException ignored) { type = type.getSuperclass(); }
+ }
+ throw new NoSuchFieldException(name);
+ }
+}
diff --git a/docs/startup-failure-shutdown.md b/docs/startup-failure-shutdown.md
new file mode 100644
index 000000000..b2a5b987b
--- /dev/null
+++ b/docs/startup-failure-shutdown.md
@@ -0,0 +1,6 @@
+# Startup failure and shutdown
+
+VotingPlugin may be unloaded after a partial startup. Shutdown therefore treats each optional subsystem independently:
+a failed close does not prevent the remaining queues, reminders, cooldown checks, signs, handlers, or executors from being
+cleaned up. The vote timer uses a bounded termination wait, forces cancellation if it does not terminate, and restores
+the interrupt flag when shutdown is interrupted. Repeated unload callbacks are safe after the first unload completes.
From 4409f8fdd93740933b21f50b3e7268002c05c268 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sat, 10 Oct 2026 23:25:48 -0600
Subject: [PATCH 2/8] Assert partial-startup shutdown emits no cleanup failure
warnings
---
.../com/bencodez/votingplugin/VotingPluginMainShutdownTest.java | 2 ++
1 file changed, 2 insertions(+)
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java
index 9187cff1a..74b3c6169 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java
@@ -205,6 +205,8 @@ void rejectedMysqlStartupRetainsItsCauseAndFullDisableCleansEarlyResources() thr
assertDoesNotThrow(plugin::onDisable);
org.junit.jupiter.api.Assertions.assertSame(original, rootCause(failure));
verify(logger, never()).warning(org.mockito.ArgumentMatchers.anyString());
+ verify(logger, never()).log(org.mockito.ArgumentMatchers.eq(java.util.logging.Level.WARNING),
+ org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(Throwable.class));
} finally { coreTimer.shutdownNow(); }
}
From a89bb5594e9e6daa16091e1cba0e86ba3fff808c Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 11 Oct 2026 10:25:34 -0600
Subject: [PATCH 3/8] Build startup regressions against corrected SQL
dependencies
---
.github/workflows/maven.yml | 21 ++++++++++++++++++++-
docs/startup-failure-shutdown.md | 21 +++++++++++++++++++++
2 files changed, 41 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index 8eceaf272..56a5b8627 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -22,8 +22,27 @@ jobs:
java-version: '21'
distribution: 'temurin'
cache: maven
+ # Build the corrected SQL lifecycle dependencies until their published snapshots catch up.
+ - name: Check out SimpleAPI SQL lifecycle dependency
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ repository: BenCodez/SimpleAPI
+ ref: fdbf26eec885ec3f1c1dbd937a243a0acfaa0a5e
+ path: .ci/simpleapi
+ persist-credentials: false
+ - name: Build and install SimpleAPI dependency
+ run: mvn -B -f .ci/simpleapi/SimpleAPI/pom.xml clean install
+ - name: Check out AdvancedCore SQL lifecycle dependency
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ repository: BenCodez/AdvancedCore
+ ref: a68cc2dfe6e44c1c0854e659c614708492fcd58d
+ path: .ci/advancedcore
+ persist-credentials: false
+ - name: Build and install AdvancedCore dependency
+ run: mvn -B -nsu -f .ci/advancedcore/AdvancedCore/pom.xml clean install
- name: Build with Maven
- run: mvn -B -f VotingPlugin/pom.xml package
+ run: mvn -B -nsu -f VotingPlugin/pom.xml package
dependency-submission:
if: github.event_name == 'push'
diff --git a/docs/startup-failure-shutdown.md b/docs/startup-failure-shutdown.md
index b2a5b987b..80441898a 100644
--- a/docs/startup-failure-shutdown.md
+++ b/docs/startup-failure-shutdown.md
@@ -4,3 +4,24 @@ VotingPlugin may be unloaded after a partial startup. Shutdown therefore treats
a failed close does not prevent the remaining queues, reminders, cooldown checks, signs, handlers, or executors from being
cleaned up. The vote timer uses a bounded termination wait, forces cancellation if it does not terminate, and restores
the interrupt flag when shutdown is interrupted. Repeated unload callbacks are safe after the first unload completes.
+
+## Building the complete SQL failure fix
+
+The shutdown change works independently, but the MySQL authentication-failure regression also exercises the corrected
+SimpleAPI and AdvancedCore constructors. SimpleAPI PR #111 is merged; AdvancedCore PR #361 supplies the remaining SQL
+initialization fix. Build these dependencies in that order before building VotingPlugin. CI pins the merged SimpleAPI
+revision `fdbf26eec885ec3f1c1dbd937a243a0acfaa0a5e` and AdvancedCore revision
+`a68cc2dfe6e44c1c0854e659c614708492fcd58d` so an older published snapshot cannot silently replace the tested fixes.
+
+With those source revisions checked out in sibling directories, run:
+
+```shell
+mvn -B -f ../SimpleAPI/SimpleAPI/pom.xml clean install
+mvn -B -nsu -f ../AdvancedCore/AdvancedCore/pom.xml clean install
+mvn -B -nsu -f VotingPlugin/pom.xml clean package
+```
+
+`-nsu` preserves the locally installed snapshot dependencies for these builds. The CI pins can be changed deliberately
+once corrected dependencies are reliably published. This is a build dependency only: the distributable VotingPlugin JAR
+includes the required libraries, and server owners do not need to install separate SimpleAPI or AdvancedCore JARs.
+Invalid MySQL credentials still abort startup; no storage fallback or configuration migration is introduced.
From a9b4af5fc5ea6c91064dce3fb9ac7fb192dd6d39 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 11 Oct 2026 10:58:49 -0600
Subject: [PATCH 4/8] Retain timed votes across shutdown races and failed
persistence
---
.github/workflows/maven.yml | 13 +-
VotingPlugin/pom.xml | 2 +-
.../votingplugin/VotingPluginMain.java | 9 +-
.../votingplugin/data/ServerData.java | 44 ++++-
.../timequeue/TimeQueueHandler.java | 119 ++++++++---
.../VotingPluginMainShutdownTest.java | 24 ++-
.../TimeQueueShutdownPersistenceTest.java | 187 ++++++++++++++++++
docs/startup-failure-shutdown.md | 26 ++-
8 files changed, 363 insertions(+), 61 deletions(-)
create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownPersistenceTest.java
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index 56a5b8627..9a0894776 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -22,21 +22,12 @@ jobs:
java-version: '21'
distribution: 'temurin'
cache: maven
- # Build the corrected SQL lifecycle dependencies until their published snapshots catch up.
- - name: Check out SimpleAPI SQL lifecycle dependency
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- with:
- repository: BenCodez/SimpleAPI
- ref: fdbf26eec885ec3f1c1dbd937a243a0acfaa0a5e
- path: .ci/simpleapi
- persist-credentials: false
- - name: Build and install SimpleAPI dependency
- run: mvn -B -f .ci/simpleapi/SimpleAPI/pom.xml clean install
+ # AdvancedCore startup fix is still under review; SimpleAPI is pinned in the POM.
- name: Check out AdvancedCore SQL lifecycle dependency
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: BenCodez/AdvancedCore
- ref: a68cc2dfe6e44c1c0854e659c614708492fcd58d
+ ref: 4ca3d3530e002bcafd0d815e752d4f61c9bd0a8f
path: .ci/advancedcore
persist-credentials: false
- name: Build and install AdvancedCore dependency
diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml
index ea1707452..7a628e6dc 100644
--- a/VotingPlugin/pom.xml
+++ b/VotingPlugin/pom.xml
@@ -519,7 +519,7 @@
com.bencodez
simpleapi
- 1.0.2-SNAPSHOT
+ 1.0.2-20261011.161601-40
compile
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
index 331a0ec0c..37e32e1e6 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
@@ -1965,7 +1965,12 @@ private void shutdownVoteReminders() {
@Override
public void onUnLoad() {
- if (!unloadCompleted.compareAndSet(false, true)) return;
+ if (!unloadCompleted.compareAndSet(false, true)) {
+ runShutdownStep("time queue retry", () -> {
+ if (timeQueueHandler != null) timeQueueHandler.saveForShutdown();
+ });
+ return;
+ }
placeholderPlayerPresence.clear();
runShutdownStep("backend hosted Control", this::stopBackendHostedControlLifecycle);
runShutdownStep("backend Control connector", this::stopBackendControlConnectorLifecycle);
@@ -1989,7 +1994,7 @@ public void onUnLoad() {
}
});
runShutdownStep("time queue", () -> {
- if (timeQueueHandler != null) timeQueueHandler.save();
+ if (timeQueueHandler != null) timeQueueHandler.saveForShutdown();
});
runShutdownStep("vote reminders", this::shutdownVoteReminders);
runShutdownStep("cooldown checker", () -> {
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java
index a255b8d58..66f703dff 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java
@@ -87,10 +87,12 @@ public void addSign(Location location, String data, int position) {
* @param vote the vote time queue entry
*/
public void addTimeVoted(int num, VoteTimeQueue vote) {
- getData().set("TimedVoteCache." + num + ".Name", vote.getName());
- getData().set("TimedVoteCache." + num + ".Service", vote.getService());
- getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
- saveData();
+ synchronized (plugin.getServerDataFile()) {
+ getData().set("TimedVoteCache." + num + ".Name", vote.getName());
+ getData().set("TimedVoteCache." + num + ".Service", vote.getService());
+ getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
+ saveTimedVoteCache();
+ }
}
/**
@@ -106,8 +108,10 @@ public void addVoteShopPurchase(String ident) {
* Clears the timed vote cache.
*/
public void clearTimedVoteCache() {
- getData().set("TimedVoteCache", null);
- saveData();
+ synchronized (plugin.getServerDataFile()) {
+ getData().set("TimedVoteCache", null);
+ saveTimedVoteCache();
+ }
}
/**
@@ -384,11 +388,31 @@ public void removeSign(String sign) {
saveData();
}
- /**
- * Save data.
- */
+ /** Retain an ambiguous event delivery without making it eligible for automatic replay. */
+ public void addUncertainTimedVote(java.util.UUID id, VoteTimeQueue vote) {
+ synchronized (plugin.getServerDataFile()) {
+ String path = "UncertainTimedVotes." + id;
+ getData().set(path + ".Name", vote.getName());
+ getData().set(path + ".Service", vote.getService());
+ getData().set(path + ".Time", vote.getTime());
+ saveTimedVoteCache();
+ }
+ }
+
+ /** Timed votes must observe write failures before their in-memory queue is retired. */
+ private void saveTimedVoteCache() {
+ try {
+ plugin.getServerDataFile().getData().save(plugin.getServerDataFile().getdFile());
+ } catch (java.io.IOException failure) {
+ throw new java.io.UncheckedIOException("Failed to save timed vote cache", failure);
+ }
+ }
+
+ /** Saves server data through the existing general-purpose file helper. */
public synchronized void saveData() {
- plugin.getServerDataFile().saveData();
+ synchronized (plugin.getServerDataFile()) {
+ plugin.getServerDataFile().saveData();
+ }
}
/**
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java
index 3bf50342a..1755fbeae 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java
@@ -3,6 +3,7 @@
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Queue;
+import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -29,6 +30,12 @@ public class TimeQueueHandler implements Listener {
private Queue timeChangeQueue = new ConcurrentLinkedQueue<>();
private VotingPluginMain plugin;
+ private final Object persistenceLock = new Object();
+ private boolean stopping;
+ private final AtomicBoolean processing = new AtomicBoolean();
+ private VoteTimeQueue dispatchingVote;
+ private UUID dispatchId;
+ private int persistedShutdownVotes;
private final AtomicBoolean retryPending = new AtomicBoolean();
private final AtomicInteger retryAttempts = new AtomicInteger();
@@ -49,8 +56,13 @@ public TimeQueueHandler(VotingPluginMain plugin) {
* @param voteSiteName the vote site name
*/
public void addVote(String voteUsername, String voteSiteName) {
- timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName,
- LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
+ synchronized (persistenceLock) {
+ timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName,
+ LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
+ // A vote task can outlive shutdownNow(). Persist its late admission
+ // through the same owner rather than appending to an already-saved queue.
+ if (stopping) persistPendingVotes();
+ }
}
/**
@@ -76,12 +88,8 @@ public void postTimeChange(DateChangedEvent event) {
}
private void scheduleQueueProcessing(long delay, TimeUnit unit) {
- boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), () -> {
- // Clear only after the bounded executor has admitted the task. If it is
- // rejected, shutdown persistence can still recover the in-memory queue.
- plugin.getServerData().clearTimedVoteCache();
- processQueue();
- }, delay, unit);
+ synchronized (persistenceLock) { if (stopping) return; }
+ boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), this::processQueue, delay, unit);
if (!admitted) {
plugin.getLogger().warning("Unable to schedule time-queue processing because vote processing is busy; queued votes were retained.");
scheduleRetry();
@@ -110,32 +118,93 @@ private void scheduleRetry() {
* Processes all votes in the queue.
*/
public void processQueue() {
- while (getTimeChangeQueue().size() > 0) {
- VoteTimeQueue vote = getTimeChangeQueue().remove();
- PlayerVoteEvent voteEvent = new PlayerVoteEvent(
- plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(),
- vote.getService(), true);
- voteEvent.setTime(vote.getTime());
- plugin.getServer().getPluginManager().callEvent(voteEvent);
-
- if (voteEvent.isCancelled()) {
- plugin.debug("Vote cancelled");
- return;
+ if (!processing.compareAndSet(false, true)) return;
+ try {
+ synchronized (persistenceLock) {
+ if (stopping || dispatchingVote != null) return;
+ // Every processing entry point retires the previous disk snapshot
+ // before handing any occurrence to event listeners.
+ plugin.getServerData().clearTimedVoteCache();
+ persistedShutdownVotes = 0;
+ }
+ while (true) {
+ VoteTimeQueue vote;
+ synchronized (persistenceLock) {
+ if (stopping || dispatchingVote != null) return;
+ vote = timeChangeQueue.peek();
+ }
+ if (vote == null) return;
+ PlayerVoteEvent voteEvent = new PlayerVoteEvent(
+ plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(),
+ vote.getService(), true);
+ voteEvent.setTime(vote.getTime());
+ synchronized (persistenceLock) {
+ if (stopping) return;
+ if (timeChangeQueue.peek() != vote) continue;
+ dispatchingVote = vote;
+ dispatchId = UUID.randomUUID();
+ }
+ try {
+ plugin.getServer().getPluginManager().callEvent(voteEvent);
+ } catch (RuntimeException | Error failure) {
+ synchronized (persistenceLock) {
+ try {
+ retainUncertainDispatch();
+ } catch (RuntimeException | Error persistenceFailure) {
+ if (failure != persistenceFailure) failure.addSuppressed(persistenceFailure);
+ }
+ }
+ throw failure;
+ }
+ synchronized (persistenceLock) {
+ timeChangeQueue.remove(vote);
+ dispatchingVote = null;
+ dispatchId = null;
+ }
+ if (voteEvent.isCancelled()) {
+ plugin.debug("Vote cancelled");
+ return;
+ }
}
+ } finally {
+ processing.set(false);
}
}
+ /** Uncertain event delivery is retained for review, never automatically replayed. */
+ private void retainUncertainDispatch() {
+ if (dispatchingVote == null || !timeChangeQueue.contains(dispatchingVote)) return;
+ plugin.getServerData().addUncertainTimedVote(dispatchId, dispatchingVote);
+ timeChangeQueue.remove(dispatchingVote);
+ plugin.getLogger().warning("Timed vote event delivery is uncertain; retained in ServerData.yml VotingPlugin.UncertainTimedVotes for manual review.");
+ }
+
/**
* Saves pending votes to server data.
*/
public void save() {
- if (!timeChangeQueue.isEmpty()) {
- int num = 0;
- for (VoteTimeQueue vote : timeChangeQueue) {
- plugin.getServerData().addTimeVoted(num, vote);
- num++;
+ synchronized (persistenceLock) { persistPendingVotes(); }
+ }
+
+ /** Seal processing and durably retain late admissions from tasks that ignore interruption. */
+ public void saveForShutdown() {
+ synchronized (persistenceLock) {
+ stopping = true;
+ persistPendingVotes();
+ }
+ }
+
+ /** Remove only a successfully saved prefix; failures retain the same next slot for retry. */
+ private void persistPendingVotes() {
+ VoteTimeQueue vote;
+ while ((vote = timeChangeQueue.peek()) != null) {
+ if (vote == dispatchingVote) {
+ retainUncertainDispatch();
+ } else {
+ plugin.getServerData().addTimeVoted(persistedShutdownVotes, vote);
+ persistedShutdownVotes++;
+ timeChangeQueue.remove(vote);
}
}
- timeChangeQueue.clear();
}
}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java
index 74b3c6169..1269d88e0 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainShutdownTest.java
@@ -68,7 +68,7 @@ void publicUnloadCleansInitializedQueueAndSignResources() throws Exception {
plugin.onUnLoad();
verify(signs).storeSigns();
- verify(timeQueue).save();
+ verify(timeQueue).saveForShutdown();
plugin.onUnLoad();
verify(signs).storeSigns();
}
@@ -84,7 +84,7 @@ void failedCleanupDoesNotSkipLaterResources() throws Exception {
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
VotingPluginWebhookManager webhook = mock(VotingPluginWebhookManager.class);
doThrow(new IllegalStateException("webhook close failed")).when(webhook).shutdown();
- doThrow(new IllegalStateException("queue save failed")).when(timeQueue).save();
+ doThrow(new IllegalStateException("queue save failed")).when(timeQueue).saveForShutdown();
setField(plugin, "webhookManager", webhook);
setField(plugin, "signs", signs);
setField(plugin, "timeQueueHandler", timeQueue);
@@ -92,7 +92,25 @@ void failedCleanupDoesNotSkipLaterResources() throws Exception {
assertDoesNotThrow(plugin::onUnLoad);
verify(webhook).shutdown();
- verify(timeQueue).save();
+ verify(timeQueue).saveForShutdown();
+ verify(signs).storeSigns();
+ }
+
+ @Test
+ void repeatedUnloadRetriesOnlyFailedTimedVotePersistence() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
+ setField(plugin, "unloadCompleted", new java.util.concurrent.atomic.AtomicBoolean());
+ setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
+ initializeEarlyLifecycle(plugin);
+ TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
+ Signs signs = mock(Signs.class);
+ org.mockito.Mockito.doThrow(new IllegalStateException("disk unavailable")).doNothing()
+ .when(timeQueue).saveForShutdown();
+ setField(plugin, "timeQueueHandler", timeQueue);
+ setField(plugin, "signs", signs);
+ assertDoesNotThrow(plugin::onUnLoad);
+ assertDoesNotThrow(plugin::onUnLoad);
+ verify(timeQueue, org.mockito.Mockito.times(2)).saveForShutdown();
verify(signs).storeSigns();
}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownPersistenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownPersistenceTest.java
new file mode 100644
index 000000000..4a80ea2ba
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownPersistenceTest.java
@@ -0,0 +1,187 @@
+package com.bencodez.votingplugin.tests.timequeue;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+import java.util.concurrent.*;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.bukkit.configuration.file.YamlConfiguration;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.data.ServerData;
+import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
+import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
+
+class TimeQueueShutdownPersistenceTest {
+ private record Fixture(VotingPluginMain plugin, ServerData data, TimeQueueHandler handler) {}
+ private Fixture fixture() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ ServerData data = mock(ServerData.class);
+ when(plugin.getServerData()).thenReturn(data);
+ when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ return new Fixture(plugin, data, new TimeQueueHandler(plugin));
+ }
+
+ @Test void failureRetainsOnlyUnsavedSuffixAndRetriesAtSameIndex() {
+ Fixture f = fixture();
+ Map saved = new TreeMap<>();
+ java.util.concurrent.atomic.AtomicBoolean fail = new java.util.concurrent.atomic.AtomicBoolean(true);
+ doAnswer(call -> {
+ int index = call.getArgument(0);
+ VoteTimeQueue vote = call.getArgument(1);
+ if (index == 1 && fail.getAndSet(false)) throw new IllegalStateException("disk unavailable");
+ saved.put(index, vote.getName()); return null;
+ }).when(f.data).addTimeVoted(anyInt(), any());
+ f.handler.addVote("First", "site"); f.handler.addVote("Second", "site");
+ assertThrows(IllegalStateException.class, f.handler::saveForShutdown);
+ assertEquals(Map.of(0, "First"), saved);
+ assertEquals("Second", f.handler.getTimeChangeQueue().peek().getName());
+ f.handler.saveForShutdown(); f.handler.saveForShutdown();
+ f.handler.addVote("Late", "site");
+ assertEquals(Map.of(0, "First", 1, "Second", 2, "Late"), saved);
+ assertTrue(f.handler.getTimeChangeQueue().isEmpty());
+ verify(f.data, times(1)).addTimeVoted(eq(0), any());
+ verify(f.data, times(2)).addTimeVoted(eq(1), any());
+ }
+
+ @Test void lateAdmissionDuringSaveIsAppendedWithoutBeingClearedOrOverwriting() throws Exception {
+ Fixture f = fixture();
+ var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
+ Map saved = new ConcurrentHashMap<>();
+ doAnswer(call -> {
+ int index = call.getArgument(0);
+ if (index == 0) { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); }
+ saved.put(index, ((VoteTimeQueue) call.getArgument(1)).getName()); return null;
+ }).when(f.data).addTimeVoted(anyInt(), any());
+ f.handler.addVote("First", "site");
+ ExecutorService workers = Executors.newFixedThreadPool(2);
+ try {
+ Future> save = workers.submit(f.handler::saveForShutdown);
+ assertTrue(entered.await(5, TimeUnit.SECONDS));
+ var adding = new CountDownLatch(1);
+ Future> late = workers.submit(() -> { adding.countDown(); f.handler.addVote("Late", "site"); });
+ assertTrue(adding.await(5, TimeUnit.SECONDS)); release.countDown();
+ save.get(5, TimeUnit.SECONDS); late.get(5, TimeUnit.SECONDS);
+ assertEquals(Map.of(0, "First", 1, "Late"), saved);
+ assertTrue(f.handler.getTimeChangeQueue().isEmpty());
+ } finally { release.countDown(); workers.shutdownNow(); assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); }
+ }
+
+ @Test void admittedProcessingCallbackCannotClearShutdownSnapshot() {
+ Fixture f = fixture();
+ var scheduled = org.mockito.ArgumentCaptor.forClass(Runnable.class);
+ verify(f.plugin.getVoteTimer()).schedule(scheduled.capture(), eq(120L), eq(TimeUnit.SECONDS));
+ f.handler.addVote("First", "site"); f.handler.saveForShutdown();
+ scheduled.getValue().run(); f.handler.processQueue();
+ verify(f.data, never()).clearTimedVoteCache();
+ verify(f.plugin.getServer().getPluginManager(), never()).callEvent(any());
+ }
+
+ @Test void ordinarySaveDoesNotSealFutureProcessing() {
+ Fixture f = fixture();
+ f.handler.addVote("Saved", "site"); f.handler.save();
+ f.handler.addVote("New", "site"); f.handler.processQueue();
+ verify(f.plugin.getServer().getPluginManager()).callEvent(any());
+ }
+
+ @Test void shutdownRetainsInFlightEventForReviewWithoutWaitingOrReplaying() throws Exception {
+ Fixture f = fixture();
+ var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
+ var events = f.plugin.getServer().getPluginManager();
+ doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; })
+ .when(events).callEvent(any());
+ f.handler.addVote("InFlight", "site");
+ ExecutorService worker = Executors.newSingleThreadExecutor();
+ try {
+ Future> delivery = worker.submit(f.handler::processQueue);
+ assertTrue(entered.await(5, TimeUnit.SECONDS));
+ f.handler.saveForShutdown(); f.handler.saveForShutdown();
+ f.handler.processQueue();
+ var vote = org.mockito.ArgumentCaptor.forClass(VoteTimeQueue.class);
+ verify(f.data, times(1)).addUncertainTimedVote(any(UUID.class), vote.capture());
+ assertEquals("InFlight", vote.getValue().getName());
+ verify(f.data, never()).addTimeVoted(anyInt(), any());
+ assertTrue(f.handler.getTimeChangeQueue().isEmpty());
+ release.countDown(); delivery.get(5, TimeUnit.SECONDS);
+ verify(f.plugin.getServer().getPluginManager(), times(1)).callEvent(any());
+ } finally { release.countDown(); worker.shutdownNow(); assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); }
+ }
+
+ @Test void shutdownBeforeEventDispatchPersistsVoteForNormalRecovery() throws Exception {
+ Fixture f = fixture();
+ var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
+ var sites = f.plugin.getVoteSiteManager();
+ doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; })
+ .when(sites).getVoteSite(any(), eq(true));
+ f.handler.addVote("NotDispatched", "site");
+ ExecutorService worker = Executors.newSingleThreadExecutor();
+ try {
+ Future> delivery = worker.submit(f.handler::processQueue);
+ assertTrue(entered.await(5, TimeUnit.SECONDS));
+ f.handler.saveForShutdown();
+ release.countDown(); delivery.get(5, TimeUnit.SECONDS);
+ verify(f.data).addTimeVoted(eq(0), any());
+ verify(f.data, never()).addUncertainTimedVote(any(), any());
+ verify(f.plugin.getServer().getPluginManager(), never()).callEvent(any());
+ } finally { release.countDown(); worker.shutdownNow(); assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); }
+ }
+
+ @Test void failedEventAndFailedQuarantineRemainRetryableWithoutRepeatingEvent() {
+ Fixture f = fixture();
+ RuntimeException original = new IllegalStateException("event failure");
+ var events = f.plugin.getServer().getPluginManager();
+ doThrow(original).when(events).callEvent(any());
+ doThrow(new IllegalStateException("disk failure")).doNothing()
+ .when(f.data).addUncertainTimedVote(any(), any());
+ f.handler.addVote("Ambiguous", "site");
+ assertSame(original, assertThrows(IllegalStateException.class, f.handler::processQueue));
+ assertEquals(1, original.getSuppressed().length);
+ assertEquals(1, f.handler.getTimeChangeQueue().size());
+ f.handler.processQueue();
+ f.handler.saveForShutdown(); f.handler.saveForShutdown();
+ var ids = org.mockito.ArgumentCaptor.forClass(UUID.class);
+ verify(f.data, times(2)).addUncertainTimedVote(ids.capture(), any());
+ assertEquals(ids.getAllValues().get(0), ids.getAllValues().get(1));
+ verify(f.plugin.getServer().getPluginManager(), times(1)).callEvent(any());
+ verify(f.data, never()).addTimeVoted(anyInt(), any());
+ }
+
+ @Test void directProcessingClearsPreviousCacheBeforeHandingOffRecoveredVote() {
+ Fixture f = fixture();
+ f.handler.addVote("Recovered", "site");
+ f.handler.processQueue();
+ var calls = inOrder(f.data, f.plugin.getServer().getPluginManager());
+ calls.verify(f.data).clearTimedVoteCache();
+ calls.verify(f.plugin.getServer().getPluginManager()).callEvent(any());
+ f.handler.addVote("New", "site");
+ f.handler.saveForShutdown();
+ verify(f.data).addTimeVoted(eq(0), any());
+ }
+
+ @TempDir java.nio.file.Path directory;
+ @Test void actualTimedCacheWriteReportsIoFailureAndSucceedsAfterRepair() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ var file = mock(com.bencodez.advancedcore.data.ServerData.class);
+ when(plugin.getServerDataFile()).thenReturn(file);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(file.getData()).thenReturn(yaml);
+ java.nio.file.Path target = directory.resolve("ServerData.yml");
+ java.nio.file.Files.createDirectory(target);
+ when(file.getdFile()).thenReturn(target.toFile());
+ ServerData data = new ServerData(plugin);
+ VoteTimeQueue vote = new VoteTimeQueue("First", "site", 123L);
+ assertThrows(java.io.UncheckedIOException.class, () -> data.addTimeVoted(0, vote));
+ java.nio.file.Files.delete(target);
+ data.addTimeVoted(0, vote);
+ YamlConfiguration stored = YamlConfiguration.loadConfiguration(target.toFile());
+ assertEquals("First", stored.getString("VotingPlugin.TimedVoteCache.0.Name"));
+ assertEquals(123L, stored.getLong("VotingPlugin.TimedVoteCache.0.Time"));
+ UUID uncertain = UUID.randomUUID();
+ data.addUncertainTimedVote(uncertain, vote);
+ stored = YamlConfiguration.loadConfiguration(target.toFile());
+ assertEquals("First", stored.getString("VotingPlugin.UncertainTimedVotes." + uncertain + ".Name"));
+ assertEquals(Set.of("0"), data.getTimedVoteCacheKeys());
+ }
+}
diff --git a/docs/startup-failure-shutdown.md b/docs/startup-failure-shutdown.md
index 80441898a..171c74b2d 100644
--- a/docs/startup-failure-shutdown.md
+++ b/docs/startup-failure-shutdown.md
@@ -9,19 +9,27 @@ the interrupt flag when shutdown is interrupted. Repeated unload callbacks are s
The shutdown change works independently, but the MySQL authentication-failure regression also exercises the corrected
SimpleAPI and AdvancedCore constructors. SimpleAPI PR #111 is merged; AdvancedCore PR #361 supplies the remaining SQL
-initialization fix. Build these dependencies in that order before building VotingPlugin. CI pins the merged SimpleAPI
-revision `fdbf26eec885ec3f1c1dbd937a243a0acfaa0a5e` and AdvancedCore revision
-`a68cc2dfe6e44c1c0854e659c614708492fcd58d` so an older published snapshot cannot silently replace the tested fixes.
+initialization fix. The POM pins published SimpleAPI `1.0.2-20261011.161601-40`, so no manual SimpleAPI source installation is needed. CI builds AdvancedCore revision
+`4ca3d3530e002bcafd0d815e752d4f61c9bd0a8f` while that PR remains under review.
-With those source revisions checked out in sibling directories, run:
+With that AdvancedCore revision checked out in a sibling directory, run:
```shell
-mvn -B -f ../SimpleAPI/SimpleAPI/pom.xml clean install
-mvn -B -nsu -f ../AdvancedCore/AdvancedCore/pom.xml clean install
+mvn -B -f ../AdvancedCore/AdvancedCore/pom.xml clean install
mvn -B -nsu -f VotingPlugin/pom.xml clean package
```
-`-nsu` preserves the locally installed snapshot dependencies for these builds. The CI pins can be changed deliberately
-once corrected dependencies are reliably published. This is a build dependency only: the distributable VotingPlugin JAR
-includes the required libraries, and server owners do not need to install separate SimpleAPI or AdvancedCore JARs.
+`-nsu` preserves the locally installed AdvancedCore snapshot for this build. The CI source pin can be removed once that
+fix is merged and published. This is a build dependency only: the distributable VotingPlugin JAR includes the required
+libraries, and server owners do not need to install separate SimpleAPI or AdvancedCore JARs.
Invalid MySQL credentials still abort startup; no storage fallback or configuration migration is introduced.
+
+Timed-vote shutdown publication shares an admission fence with queue additions and cache-clearing callbacks. A task that
+outlives the executor's bounded termination wait persists its late timed vote through that same owner. Successfully
+saved prefixes are retired; an unsuccessful write retains its vote and cache index for a later unload retry, without
+repeating unrelated cleanup. Timed-cache writes report I/O failures rather than relying on the legacy file helper's
+log-only error handling. Ordinary `TimeQueueHandler.save()` remains a checkpoint; `saveForShutdown()` seals processing.
+The existing ServerData file format and persistence model are retained: this is not a new transactional reward system or
+an exactly-once guarantee across arbitrary vote event handlers, chat, rewards and disk writes.
+
+A timed vote remains owned while its event is being prepared. If shutdown wins before event dispatch, it is saved for ordinary timed-cache recovery. Once event dispatch starts, external handlers may already have acted: shutdown or a thrown dispatch error retains that occurrence under `VotingPlugin.UncertainTimedVotes.` in `ServerData.yml` and logs a warning. These records contain the original name, service and timestamp and are **not automatically replayed**. An administrator must compare them with voting data/logs before deciding whether any recovery is needed; a completed event can still leave a conservative review record when shutdown raced it. Repeated persistence attempts use the same ID. Shutdown does not wait indefinitely for external event handlers.
From cf8def4c8a77068c7d1a7bd033a4a5ede94ea6b0 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 11 Oct 2026 11:06:37 -0600
Subject: [PATCH 5/8] Validate shutdown against latest AdvancedCore
initialization fix
---
.github/workflows/maven.yml | 2 +-
docs/startup-failure-shutdown.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index 9a0894776..7101cc5b4 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -27,7 +27,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: BenCodez/AdvancedCore
- ref: 4ca3d3530e002bcafd0d815e752d4f61c9bd0a8f
+ ref: d09f0d891127952f0787aef879380a0b8ee0dde8
path: .ci/advancedcore
persist-credentials: false
- name: Build and install AdvancedCore dependency
diff --git a/docs/startup-failure-shutdown.md b/docs/startup-failure-shutdown.md
index 171c74b2d..75fa2d332 100644
--- a/docs/startup-failure-shutdown.md
+++ b/docs/startup-failure-shutdown.md
@@ -10,7 +10,7 @@ the interrupt flag when shutdown is interrupted. Repeated unload callbacks are s
The shutdown change works independently, but the MySQL authentication-failure regression also exercises the corrected
SimpleAPI and AdvancedCore constructors. SimpleAPI PR #111 is merged; AdvancedCore PR #361 supplies the remaining SQL
initialization fix. The POM pins published SimpleAPI `1.0.2-20261011.161601-40`, so no manual SimpleAPI source installation is needed. CI builds AdvancedCore revision
-`4ca3d3530e002bcafd0d815e752d4f61c9bd0a8f` while that PR remains under review.
+`d09f0d891127952f0787aef879380a0b8ee0dde8` while that PR remains under review.
With that AdvancedCore revision checked out in a sibling directory, run:
From 3b4208c595526e57042065f05d8b8972719e9781 Mon Sep 17 00:00:00 2001
From: Ben
Date: Tue, 13 Oct 2026 23:15:15 -0600
Subject: [PATCH 6/8] Bound timed-vote shutdown persistence and retain
retryable recovery
---
.../votingplugin/data/ServerData.java | 18 ++
.../timequeue/TimeQueueHandler.java | 85 +++++++++-
.../TimeQueueRecoveryCheckpointTest.java | 68 ++++++++
.../timequeue/TimeQueueShutdownBoundTest.java | 156 ++++++++++++++++++
docs/startup-failure-shutdown.md | 8 +
5 files changed, 329 insertions(+), 6 deletions(-)
create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownBoundTest.java
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java
index 6bfa8f79c..7e4f4817c 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java
@@ -422,6 +422,24 @@ public void prepareTimedVoteDispatch(UUID id, VoteTimeQueue vote,
}
}
+ /** Restore an undispatched occurrence and its pending suffix for ordinary restart recovery. */
+ public void abortTimedVoteDispatch(UUID id, java.util.Collection pending) {
+ synchronized (plugin.getServerDataFile()) {
+ String path = "UncertainTimedVotes." + id;
+ Object previousCache = getData().get("TimedVoteCache");
+ Object previousRecord = getData().get(path);
+ try {
+ setTimedVoteCache(pending);
+ getData().set(path, null);
+ saveTimedVoteCache();
+ } catch (RuntimeException | Error failure) {
+ getData().set("TimedVoteCache", previousCache);
+ getData().set(path, previousRecord);
+ throw failure;
+ }
+ }
+ }
+
/** Clear only a confirmed dispatch record; failed writes retain its live representation. */
public void completeTimedVoteDispatch(UUID id) {
synchronized (plugin.getServerDataFile()) {
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java
index 7e817833b..bd59d2d9d 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java
@@ -5,9 +5,13 @@
import java.util.Queue;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.event.EventHandler;
@@ -31,11 +35,13 @@ public class TimeQueueHandler implements Listener {
private VotingPluginMain plugin;
private final Object persistenceLock = new Object();
- private boolean stopping;
+ private volatile boolean stopping;
+ private final AtomicReference> shutdownSave = new AtomicReference<>();
private final AtomicBoolean processing = new AtomicBoolean();
private VoteTimeQueue dispatchingVote;
private UUID dispatchId;
private boolean dispatchEnded;
+ private boolean dispatchStarted;
private int persistedShutdownVotes;
private final AtomicBoolean retryPending = new AtomicBoolean();
private final AtomicInteger retryAttempts = new AtomicInteger();
@@ -155,6 +161,9 @@ public void processQueue() {
clearDispatch();
throw failure;
}
+ // A preparation resumed after stop is still eligible for normal recovery.
+ if (stopping) return;
+ dispatchStarted = true;
}
try {
plugin.getServer().getPluginManager().callEvent(voteEvent);
@@ -192,6 +201,7 @@ private void clearDispatch() {
dispatchingVote = null;
dispatchId = null;
dispatchEnded = false;
+ dispatchStarted = false;
}
private void checkpointPendingVotes() {
@@ -212,17 +222,73 @@ private void retainUncertainDispatch() {
* Saves pending votes to server data.
*/
public void save() {
+ if (stopping) {
+ saveForShutdown();
+ return;
+ }
synchronized (persistenceLock) {
if (stopping) persistPendingVotes();
else checkpointPendingVotes();
}
}
- /** Seal processing and durably retain late admissions from tasks that ignore interruption. */
+ /** Seal processing and retain one persistence owner without an unbounded lifecycle wait. */
public void saveForShutdown() {
- synchronized (persistenceLock) {
- stopping = true;
- persistPendingVotes();
+ stopping = true;
+ FutureTask save;
+ while (true) {
+ save = shutdownSave.get();
+ if (save != null) {
+ if (!save.isDone()) break;
+ shutdownSave.compareAndSet(save, null);
+ continue;
+ }
+ FutureTask candidate = new FutureTask(() -> {
+ try {
+ synchronized (persistenceLock) { persistPendingVotes(); }
+ } catch (RuntimeException | Error failure) {
+ try {
+ plugin.getLogger().warning("Timed vote shutdown persistence failed; pending votes remain owned for retry. Recovery is not confirmed.");
+ } catch (RuntimeException | Error ignored) { }
+ throw failure;
+ }
+ return null;
+ }) {
+ @Override protected void done() {
+ // Late admission failures must be retryable after this owner completes.
+ shutdownSave.compareAndSet(this, null);
+ }
+ };
+ FutureTask existing = shutdownSave.compareAndExchange(null, candidate);
+ if (existing == null) {
+ save = candidate;
+ try {
+ Thread worker = new Thread(candidate, "VotingPlugin-timed-vote-shutdown");
+ worker.setDaemon(true);
+ worker.start();
+ }
+ catch (RuntimeException | Error failure) {
+ candidate.cancel(false);
+ shutdownSave.compareAndSet(candidate, null);
+ throw failure;
+ }
+ break;
+ }
+ }
+ try {
+ save.get(1, TimeUnit.SECONDS);
+ shutdownSave.compareAndSet(save, null);
+ } catch (TimeoutException pending) {
+ plugin.getLogger().warning("Timed vote shutdown persistence is still pending; its worker retains ownership. Recovery is not yet confirmed.");
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ plugin.getLogger().warning("Timed vote shutdown persistence wait was interrupted; its worker retains ownership.");
+ } catch (ExecutionException failure) {
+ shutdownSave.compareAndSet(save, null);
+ Throwable cause = failure.getCause();
+ if (cause instanceof Error error) throw error;
+ if (cause instanceof RuntimeException runtime) throw runtime;
+ throw new IllegalStateException("Timed vote shutdown persistence failed", cause);
}
}
@@ -231,7 +297,14 @@ private void persistPendingVotes() {
VoteTimeQueue vote;
while ((vote = timeChangeQueue.peek()) != null) {
if (vote == dispatchingVote) {
- retainUncertainDispatch();
+ if (dispatchStarted) retainUncertainDispatch();
+ else {
+ var pending = timeChangeQueue.stream().toList();
+ plugin.getServerData().abortTimedVoteDispatch(dispatchId, pending);
+ timeChangeQueue.removeAll(pending);
+ persistedShutdownVotes = pending.size();
+ clearDispatch();
+ }
} else {
plugin.getServerData().addTimeVoted(persistedShutdownVotes, vote);
persistedShutdownVotes++;
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueRecoveryCheckpointTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueRecoveryCheckpointTest.java
index c8187377a..3fea52f21 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueRecoveryCheckpointTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueRecoveryCheckpointTest.java
@@ -24,6 +24,74 @@
class TimeQueueRecoveryCheckpointTest {
@TempDir Path directory;
+ @Test
+ void stoppingDuringPreparationRestoresTheUndispatchedVoteForActualRestartRecovery() throws Exception {
+ Fixture fixture = new Fixture(directory.resolve("aborted-preparation.yml"));
+ fixture.handler.addVote("First", "site");
+ fixture.handler.addVote("Later", "site");
+ fixture.handler.save();
+ var entered = new java.util.concurrent.CountDownLatch(1);
+ var release = new java.util.concurrent.CountDownLatch(1);
+ doAnswer(call -> {
+ call.callRealMethod();
+ entered.countDown();
+ assertTrue(release.await(10, java.util.concurrent.TimeUnit.SECONDS));
+ return null;
+ }).when(fixture.yaml).save(fixture.path.toFile());
+ var callers = java.util.concurrent.Executors.newFixedThreadPool(2);
+ try {
+ var delivery = callers.submit(fixture.handler::processQueue);
+ assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
+ callers.submit(fixture.handler::saveForShutdown).get(2, java.util.concurrent.TimeUnit.SECONDS);
+ release.countDown();
+ delivery.get(5, java.util.concurrent.TimeUnit.SECONDS);
+ fixture.handler.saveForShutdown();
+ verify(fixture.plugin.getServer().getPluginManager(), never()).callEvent(any());
+ assertEquals(List.of("First", "Later"), new Fixture(fixture.path).names());
+ assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty());
+ assertEquals("keep", fixture.stored().getString("Adjacent.Value"));
+ } finally {
+ release.countDown();
+ callers.shutdownNow();
+ assertTrue(callers.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
+ fixture.handler.saveForShutdown();
+ }
+ }
+
+ @Test
+ void failedLateAdmissionRemainsRetryableAfterTheEarlierShutdownSaveSucceeded() throws Exception {
+ Fixture fixture = new Fixture(directory.resolve("late-admission.yml"));
+ fixture.handler.addVote("First", "site");
+ fixture.handler.saveForShutdown();
+ Path saved = fixture.blockWrites();
+ assertThrows(java.io.UncheckedIOException.class, () -> fixture.handler.addVote("Late", "site"));
+ assertEquals(List.of("Late"), fixture.names());
+ fixture.repair(saved);
+ fixture.handler.saveForShutdown();
+ assertTrue(fixture.names().isEmpty());
+ assertEquals(List.of("First", "Late"), new Fixture(fixture.path).names());
+ assertEquals("keep", fixture.stored().getString("Adjacent.Value"));
+ }
+
+ @Test
+ void abortedPreparationWriteFailurePreservesBothWrappersUntilRepairAndRetry() throws Exception {
+ Fixture fixture = new Fixture(directory.resolve("abort-retry.yml"));
+ VoteTimeQueue first = new VoteTimeQueue("First", "site", 123L);
+ VoteTimeQueue later = new VoteTimeQueue("Later", "site", 456L);
+ UUID id = new UUID(1, 2);
+ fixture.data.prepareTimedVoteDispatch(id, first, List.of(later));
+ Path saved = fixture.blockWrites();
+ assertThrows(java.io.UncheckedIOException.class,
+ () -> fixture.data.abortTimedVoteDispatch(id, List.of(first, later)));
+ assertEquals("Later", fixture.yaml.getString("VotingPlugin.TimedVoteCache.0.Name"));
+ assertEquals("First", fixture.yaml.getString("VotingPlugin.UncertainTimedVotes." + id + ".Name"));
+ fixture.repair(saved);
+ fixture.data.abortTimedVoteDispatch(id, List.of(first, later));
+ assertEquals(List.of("First", "Later"), new Fixture(fixture.path).names());
+ assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty());
+ assertEquals("keep", fixture.stored().getString("Adjacent.Value"));
+ }
+
@Test
void cancelledDispatchKeepsTheUndispatchedSuffixBothLiveAndRecoverable() throws Exception {
Fixture fixture = new Fixture(directory.resolve("ServerData.yml"));
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownBoundTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownBoundTest.java
new file mode 100644
index 000000000..6970e3102
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownBoundTest.java
@@ -0,0 +1,156 @@
+package com.bencodez.votingplugin.tests.timequeue;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.Set;
+import java.util.concurrent.*;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.data.ServerData;
+import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
+
+class TimeQueueShutdownBoundTest {
+ @Test void storageFailureAfterTimeoutIsReportedAndCanRetryAtTheSameIndex() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ ServerData data = mock(ServerData.class);
+ when(plugin.getServerData()).thenReturn(data);
+ when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1), failed = new CountDownLatch(1);
+ java.util.concurrent.atomic.AtomicBoolean fail = new java.util.concurrent.atomic.AtomicBoolean(true);
+ doAnswer(call -> {
+ if (fail.getAndSet(false)) {
+ entered.countDown();
+ assertTrue(release.await(10, TimeUnit.SECONDS));
+ failed.countDown();
+ throw new IllegalStateException("synthetic storage failure");
+ }
+ return null;
+ }).when(data).addTimeVoted(anyInt(), any());
+ handler.addVote("First", "site");
+ ExecutorService caller = Executors.newSingleThreadExecutor();
+ try {
+ Future> shutdown = caller.submit(handler::saveForShutdown);
+ assertTrue(entered.await(5, TimeUnit.SECONDS));
+ shutdown.get(2, TimeUnit.SECONDS);
+ release.countDown();
+ assertTrue(failed.await(5, TimeUnit.SECONDS));
+ verify(plugin.getLogger(), timeout(5000)).warning(
+ "Timed vote shutdown persistence failed; pending votes remain owned for retry. Recovery is not confirmed.");
+ assertEquals(1, handler.getTimeChangeQueue().size());
+ try { handler.saveForShutdown(); }
+ catch (IllegalStateException observedCompletion) {
+ assertEquals("synthetic storage failure", observedCompletion.getMessage());
+ handler.saveForShutdown();
+ }
+ assertTrue(handler.getTimeChangeQueue().isEmpty());
+ verify(data, times(2)).addTimeVoted(eq(0), any());
+ } finally {
+ release.countDown();
+ caller.shutdownNow();
+ assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test void interruptedWaitPreservesTheFlagAndLeavesPersistenceOwned() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ ServerData data = mock(ServerData.class);
+ when(plugin.getServerData()).thenReturn(data);
+ when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
+ doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; })
+ .when(data).addTimeVoted(anyInt(), any());
+ handler.addVote("First", "site");
+ try {
+ Thread.currentThread().interrupt();
+ handler.saveForShutdown();
+ assertTrue(Thread.currentThread().isInterrupted());
+ Thread.interrupted();
+ assertTrue(entered.await(5, TimeUnit.SECONDS));
+ assertEquals(1, handler.getTimeChangeQueue().size());
+ } finally {
+ Thread.interrupted();
+ release.countDown();
+ handler.saveForShutdown();
+ }
+ assertTrue(handler.getTimeChangeQueue().isEmpty());
+ verify(data, times(1)).addTimeVoted(eq(0), any());
+ }
+
+ @Test void stalledPreparationDoesNotBlockShutdownOrAuthorizeAnEventAfterStopping() throws Exception {
+ exerciseStalledWrite("prepare");
+ }
+
+ @Test void stalledCompletionDoesNotBlockShutdownOrRepeatTheCompletedEvent() throws Exception {
+ exerciseStalledWrite("complete");
+ }
+
+ @Test void stalledShutdownWriteRetainsOneOwnerAndDoesNotBlockTheLifecycleCaller() throws Exception {
+ exerciseStalledWrite("shutdown");
+ }
+
+ private void exerciseStalledWrite(String phase) throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ ServerData data = mock(ServerData.class);
+ when(plugin.getServerData()).thenReturn(data);
+ when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ org.mockito.stubbing.Answer