From 6e459bbdad423cfe91c5c60d94336f93955ca156 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 23 Sep 2026 18:17:02 -0600 Subject: [PATCH 1/8] Separate background storage and platform work --- .../votingplugin/VotingPluginMain.java | 59 ++++++++++-- .../VotingPluginBackgroundTask.java | 91 +++++++++++++++---- .../votingplugin/user/VotingPluginUser.java | 23 +++-- .../VotingPluginBackgroundTaskTest.java | 29 +++++- 4 files changed, 163 insertions(+), 39 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index c21efc54ce..17cf976e1a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -340,15 +340,62 @@ public void addDirectlyDefinedRewards(DirectlyDefinedReward directlyDefinedRewar } - public void basicBungeeUpdate() { - for (Player player : Bukkit.getOnlinePlayers()) { - VotingPluginUser user = getVotingPluginUserManager().getVotingPluginUser(player); - user.cache(); - user.offVote(); - user.checkOfflineRewards(); + /** + * Capture live player state without crossing Folia ownership boundaries. + * Completion runs after every player owner callback has finished. + */ + public void captureOnlineTopVoterIgnore(java.util.function.Consumer> completion) { + java.util.Objects.requireNonNull(completion, "completion"); + try { + getBukkitScheduler().runTask(this, () -> { + java.util.List players = new java.util.ArrayList<>(Bukkit.getOnlinePlayers()); + if (players.isEmpty()) { + completion.accept(java.util.Map.of()); + return; + } + java.util.concurrent.ConcurrentHashMap captured = new java.util.concurrent.ConcurrentHashMap<>(); + java.util.concurrent.atomic.AtomicInteger remaining = new java.util.concurrent.atomic.AtomicInteger(players.size()); + Runnable maybeComplete = () -> { + if (remaining.decrementAndGet() == 0) completion.accept(java.util.Map.copyOf(captured)); + }; + for (Player player : players) { + try { + getBukkitScheduler().runTask(this, () -> { + try { + if (!player.isOnline()) return; + UUID storageUuid = getPlaceholderPlayerPresence().storageUuid(player); + if (storageUuid == null) storageUuid = placeholderStorageUuid(player, getOptions().isOnlineMode()); + if (storageUuid != null) captured.put(storageUuid, + player.hasPermission("VotingPlugin.TopVoter.Ignore")); + } finally { maybeComplete.run(); } + }, player); + } catch (RuntimeException failure) { + debug(failure); + maybeComplete.run(); + } + } + }); + } catch (RuntimeException failure) { + debug(failure); + completion.accept(java.util.Map.of()); } } + public void basicBungeeUpdate() { + captureOnlineTopVoterIgnore(online -> { + try { + getVoteTimer().execute(() -> { + for (java.util.Map.Entry entry : online.entrySet()) { + VotingPluginUser user = getVotingPluginUserManager().getVotingPluginUser(entry.getKey(), false); + if (user == null) continue; + user.cache(); + user.offVoteWithCapturedTopVoterIgnore(entry.getValue().booleanValue()); + user.checkOfflineRewards(); + } + }); + } catch (RuntimeException failure) { debug(failure); } + }); + } /** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java index 7437e92d42..73a1330f90 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java @@ -7,9 +7,14 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.Map.Entry; import org.bukkit.Bukkit; +import org.bukkit.entity.Player; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.simpleapi.skull.SkullCache; @@ -31,27 +36,66 @@ public VotingPluginBackgroundTask(VotingPluginMain plugin) { this.plugin = plugin; } - public synchronized void run() { - if (!(requested || plugin.getConfigFile().isAlwaysUpdate())) { - return; - } - if (!plugin.isEnabled() || running) { - return; + public void run() { + CompletableFuture completion = new CompletableFuture<>(); + synchronized (this) { + if (!(requested || plugin.getConfigFile().isAlwaysUpdate()) || !plugin.isEnabled() || running) return; + running = true; } - if (plugin.getConfigFile().isUpdateWithPlayersOnlineOnly() && Bukkit.getOnlinePlayers().isEmpty()) { - return; + try { + plugin.captureOnlineTopVoterIgnore(online -> { + if (plugin.getConfigFile().isUpdateWithPlayersOnlineOnly() && online.isEmpty()) { + finishRun(completion); + return; + } + synchronized (VotingPluginBackgroundTask.this) { requested = false; } + try { + plugin.getVoteTimer().execute(() -> { + try { + runRefresh(online); + completion.complete(null); + } catch (Throwable failure) { + plugin.debug(failure); + setRequested(true); + completion.complete(null); + } finally { finishRun(completion); } + }); + } catch (RuntimeException failure) { + plugin.debug(failure); + setRequested(true); + completion.complete(null); + finishRun(completion); + } + }); + } catch (RuntimeException failure) { + plugin.debug(failure); + setRequested(true); + completion.complete(null); + finishRun(completion); } + if (!isPlatformOwnedThread()) completion.join(); + } - running = true; - requested = false; + private synchronized void finishRun(CompletableFuture completion) { + running = false; + if (!completion.isDone()) completion.complete(null); + } + + private boolean isPlatformOwnedThread() { + if (Bukkit.getServer() == null) return false; + try { if (Bukkit.isPrimaryThread()) return true; } catch (RuntimeException ignored) { } try { - runRefresh(); - } finally { - running = false; - } + java.lang.reflect.Method method = Bukkit.getServer().getClass().getMethod("isGlobalTickThread"); + if (Boolean.TRUE.equals(method.invoke(Bukkit.getServer()))) return true; + } catch (ReflectiveOperationException | RuntimeException ignored) { } + try { + Class tickThread = Class.forName("ca.spottedleaf.moonrise.common.util.TickThread"); + if (Boolean.TRUE.equals(tickThread.getMethod("isTickThread").invoke(null))) return true; + } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { } + return false; } - private void runRefresh() { + private void runRefresh(Map onlineUsers) { synchronized (plugin) { try { if (!plugin.isEnabled()) { @@ -123,10 +167,13 @@ private void runRefresh() { } } - if (extraBackgroundUpdate && user.isOnline()) { - user.offVote(); + Boolean topVoterIgnore = onlineUsers.get(uuid); + boolean online = topVoterIgnore != null; + if (extraBackgroundUpdate && online) { + user.offVoteWithCapturedTopVoterIgnore(topVoterIgnore.booleanValue()); + user.checkOfflineRewards(); } - if (!plugin.getPlaceholders().getCacheLevel().onlineOnly() || user.isOnline()) { + if (!plugin.getPlaceholders().getCacheLevel().onlineOnly() || online) { plugin.getPlaceholders().onUpdate(user, false); } } finally { @@ -139,7 +186,11 @@ private void runRefresh() { }); plugin.getTopVoterHandler().updateTopVoters(tempTopVoter); - plugin.getPlaceholders().onUpdate(); + plugin.getPlaceholders().checkNonCachedPlaceholders(); + for (UUID onlineUuid : onlineUsers.keySet()) { + VotingPluginUser onlineUser = plugin.getVotingPluginUserManager().getVotingPluginUser(onlineUuid, false); + if (onlineUser != null) plugin.getPlaceholders().onUpdate(onlineUser, true); + } plugin.setVoteToday(voteToday); plugin.getServerData().updateValues(); plugin.getSigns().updateSigns(); @@ -152,7 +203,6 @@ private void runRefresh() { } } - plugin.getUserManager().getDataManager().clearNonNeededCachedUsers(); plugin.extraDebug("Current cached users: " + plugin.getUserManager().getDataManager().getUserDataCache().keySet().size()); @@ -167,6 +217,7 @@ private void runRefresh() { } } + public boolean isRequested() { return requested; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 406df59581..a1b0f8e790 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1930,20 +1930,19 @@ public void offVote() { } Player player = getPlayer(); - if (!plugin.getOptions().isOnlineMode()) { - player = Bukkit.getPlayer(getPlayerName()); - } - if (player == null) { - return; - } - - plugin.extraDebug("Checking offline votes for " + player.getName() + "/" + getUUID()); + if (!plugin.getOptions().isOnlineMode()) player = Bukkit.getPlayer(getPlayerName()); + if (player == null) return; + offVoteWithCapturedTopVoterIgnore(player.hasPermission("VotingPlugin.TopVoter.Ignore")); + } - // Update top voter ignore flag if needed. - boolean currentTopVoterIgnore = player.hasPermission("VotingPlugin.TopVoter.Ignore"); - if (isTopVoterIgnore() != currentTopVoterIgnore) { - setTopVoterIgnore(currentTopVoterIgnore); + /** Process offline-vote storage/rewards after platform-owned permission state was captured. */ + public void offVoteWithCapturedTopVoterIgnore(boolean currentTopVoterIgnore) { + if (!plugin.getOptions().isProcessRewards()) { + plugin.debug("Processing rewards is disabled"); + return; } + plugin.extraDebug("Checking offline votes for " + getPlayerName() + "/" + getUUID()); + if (isTopVoterIgnore() != currentTopVoterIgnore) setTopVoterIgnore(currentTopVoterIgnore); ArrayList offlineVotes = getOfflineVotes(); if (offlineVotes.isEmpty()) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java index 61a63b59fb..cd2736745f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java @@ -2,11 +2,15 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.*; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; import com.bencodez.votingplugin.backgroundtask.VotingPluginBackgroundTask; public class VotingPluginBackgroundTaskTest { @@ -20,4 +24,27 @@ public void requestedStateCanBeDelegated() { task.setRequested(false); assertFalse(task.isRequested()); } + @Test + public void runRequestsPlatformSnapshotBeforeStartingRefresh() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + Config config = mock(Config.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getConfigFile()).thenReturn(config); + when(config.isUpdateWithPlayersOnlineOnly()).thenReturn(true); + java.util.concurrent.atomic.AtomicReference>> callback = + new java.util.concurrent.atomic.AtomicReference<>(); + org.mockito.Mockito.doAnswer(call -> { callback.set(call.getArgument(0)); return null; }) + .when(plugin).captureOnlineTopVoterIgnore(any()); + VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(plugin); + task.setRequested(true); + + task.run(); + + verify(plugin).captureOnlineTopVoterIgnore(any()); + assertTrue(task.isRunning()); + callback.get().accept(java.util.Map.of()); + assertFalse(task.isRunning()); + assertTrue(task.isRequested(), "online-only skip must preserve the pending request"); + } + } From 1d778fa8971d60e975cc8a2c20ed08904056d6c8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:38:37 -0600 Subject: [PATCH 2/8] Finish background refresh thread boundaries --- .../votingplugin/VotingPluginMain.java | 19 +++++++------ .../VotingPluginBackgroundTask.java | 2 +- .../VotingPluginBackgroundTaskTest.java | 27 ++++++++++++++----- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 17cf976e1a..e88c390aea 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -307,7 +307,7 @@ public File getLoadedPluginJarFile() { @Getter private DiscordHandler discordHandler; - private VotingPluginBackgroundTask backgroundTask; + private volatile VotingPluginBackgroundTask backgroundTask; private VotingPluginVersionInfo versionInfo; private VotingPluginConfigHealth configHealth; private VotifierIntegration votifierIntegration; @@ -321,10 +321,12 @@ public boolean isUpdate() { } public void setUpdate(boolean update) { - if (backgroundTask == null) { - backgroundTask = new VotingPluginBackgroundTask(this); - } - backgroundTask.setRequested(update); + backgroundTask().setRequested(update); + } + + private synchronized VotingPluginBackgroundTask backgroundTask() { + if (backgroundTask == null) backgroundTask = new VotingPluginBackgroundTask(this); + return backgroundTask; } public boolean isUpdateStarted() { @@ -2114,11 +2116,8 @@ private void setupFiles() { plugin.debug("Loaded Files"); } - public synchronized void update() { - if (backgroundTask == null) { - backgroundTask = new VotingPluginBackgroundTask(this); - } - backgroundTask.run(); + public void update() { + backgroundTask().run(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java index 73a1330f90..efb67d142d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; -import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -203,6 +202,7 @@ private void runRefresh(Map onlineUsers) { } } + plugin.getUserManager().getDataManager().clearNonNeededCachedUsers(); plugin.extraDebug("Current cached users: " + plugin.getUserManager().getDataManager().getUserDataCache().keySet().size()); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java index cd2736745f..34018c3dc8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java @@ -6,6 +6,10 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import org.bukkit.Bukkit; +import org.bukkit.Server; import com.bencodez.simpleapi.scheduler.BukkitScheduler; @@ -14,6 +18,11 @@ import com.bencodez.votingplugin.backgroundtask.VotingPluginBackgroundTask; public class VotingPluginBackgroundTaskTest { + @Test + public void updateDoesNotHoldPluginMonitorWhileRefreshWaits() throws Exception { + assertFalse(java.lang.reflect.Modifier.isSynchronized( + VotingPluginMain.class.getMethod("update").getModifiers())); + } @Test public void requestedStateCanBeDelegated() { @@ -38,13 +47,17 @@ public void runRequestsPlatformSnapshotBeforeStartingRefresh() { VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(plugin); task.setRequested(true); - task.run(); - - verify(plugin).captureOnlineTopVoterIgnore(any()); - assertTrue(task.isRunning()); - callback.get().accept(java.util.Map.of()); - assertFalse(task.isRunning()); - assertTrue(task.isRequested(), "online-only skip must preserve the pending request"); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + task.run(); + + verify(plugin).captureOnlineTopVoterIgnore(any()); + assertTrue(task.isRunning()); + callback.get().accept(java.util.Map.of()); + assertFalse(task.isRunning()); + assertTrue(task.isRequested(), "online-only skip must preserve the pending request"); + } } } From 535eff5b882e904f0bc3c70e4ec2f863d6b4ed11 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:46:05 -0600 Subject: [PATCH 3/8] Keep background scans off vote executor --- .../VotingPluginBackgroundTask.java | 2 +- .../VotingPluginBackgroundTaskTest.java | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java index efb67d142d..ce3cac3db8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java @@ -49,7 +49,7 @@ public void run() { } synchronized (VotingPluginBackgroundTask.this) { requested = false; } try { - plugin.getVoteTimer().execute(() -> { + plugin.getUserManager().getDataManager().getTimer().execute(() -> { try { runRefresh(online); completion.complete(null); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java index 34018c3dc8..35a108403f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java @@ -60,4 +60,30 @@ public void runRequestsPlatformSnapshotBeforeStartingRefresh() { } } + @Test + public void refreshUsesUserStorageWorkerInsteadOfVoteExecutor() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + Config config = mock(Config.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getConfigFile()).thenReturn(config); + java.util.concurrent.atomic.AtomicReference>> callback = + new java.util.concurrent.atomic.AtomicReference<>(); + doAnswer(call -> { callback.set(call.getArgument(0)); return null; }) + .when(plugin).captureOnlineTopVoterIgnore(any()); + java.util.concurrent.ScheduledExecutorService storage = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(storage); + VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(plugin); + task.setRequested(true); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + task.run(); + callback.get().accept(java.util.Map.of(java.util.UUID.randomUUID(), Boolean.FALSE)); + } + + verify(storage).execute(any(Runnable.class)); + verify(plugin.getVoteTimer(), never()).execute(any(Runnable.class)); + } + } From 8c8d118095558b90e306594c3610e084eedba648 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:00:27 -0600 Subject: [PATCH 4/8] Complete refresh snapshots after entity retirement --- .../votingplugin/VotingPluginMain.java | 12 +++----- .../VotingPluginBackgroundTaskTest.java | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index e88c390aea..74e9b0662b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -101,8 +101,9 @@ import com.bencodez.votingplugin.placeholders.VotingPluginExpansion; import com.bencodez.votingplugin.presets.VoteSitePresetSetupHandler; import com.bencodez.votingplugin.proxy.control.HostedControlManager; -import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; import com.bencodez.votingplugin.util.BoundedScheduledExecutor; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; +import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; import com.bencodez.votingplugin.rewards.VotingPluginRewardRegistrar; import com.bencodez.votingplugin.servicesites.ServiceSiteHandler; import com.bencodez.votingplugin.signs.Signs; @@ -361,8 +362,7 @@ public void captureOnlineTopVoterIgnore(java.util.function.Consumer { + BukkitCompletionScheduler.run(this, player, () -> { try { if (!player.isOnline()) return; UUID storageUuid = getPlaceholderPlayerPresence().storageUuid(player); @@ -370,11 +370,7 @@ public void captureOnlineTopVoterIgnore(java.util.function.Consumer { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + java.util.concurrent.atomic.AtomicReference> result = + new java.util.concurrent.atomic.AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(java.util.List.of(player)); + plugin.captureOnlineTopVoterIgnore(result::set); + } + + assertEquals(java.util.Map.of(), result.get()); + verify(scheduler, times(2)).runTask(eq(plugin), any(Runnable.class)); + } + } From dcd9a8ecda7177e7f60518d2d38a6c51f0e3377e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:20:17 -0600 Subject: [PATCH 5/8] Bound cancelled background snapshot waits --- .../VotingPluginBackgroundTask.java | 37 ++++++++++++++++++- .../VotingPluginBackgroundTaskTest.java | 24 ++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java index ce3cac3db8..a921885d53 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java @@ -10,6 +10,9 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.Map.Entry; import org.bukkit.Bukkit; @@ -27,22 +30,32 @@ public final class VotingPluginBackgroundTask { private final VotingPluginMain plugin; + private final long snapshotWaitMillis; private volatile boolean requested; private volatile boolean running; private volatile long lastRunTimeSeconds = -1; public VotingPluginBackgroundTask(VotingPluginMain plugin) { + this(plugin, 5_000L); + } + + VotingPluginBackgroundTask(VotingPluginMain plugin, long snapshotWaitMillis) { this.plugin = plugin; + this.snapshotWaitMillis = snapshotWaitMillis; } public void run() { CompletableFuture completion = new CompletableFuture<>(); + CompletableFuture snapshotStarted = new CompletableFuture<>(); + AtomicBoolean snapshotPending = new AtomicBoolean(true); synchronized (this) { if (!(requested || plugin.getConfigFile().isAlwaysUpdate()) || !plugin.isEnabled() || running) return; running = true; } try { plugin.captureOnlineTopVoterIgnore(online -> { + if (!snapshotPending.compareAndSet(true, false)) return; + snapshotStarted.complete(null); if (plugin.getConfigFile().isUpdateWithPlayersOnlineOnly() && online.isEmpty()) { finishRun(completion); return; @@ -69,10 +82,32 @@ public void run() { } catch (RuntimeException failure) { plugin.debug(failure); setRequested(true); + snapshotPending.set(false); + snapshotStarted.complete(null); completion.complete(null); finishRun(completion); } - if (!isPlatformOwnedThread()) completion.join(); + if (!isPlatformOwnedThread()) { + try { + snapshotStarted.get(snapshotWaitMillis, TimeUnit.MILLISECONDS); + } catch (TimeoutException failure) { + if (snapshotPending.compareAndSet(true, false)) { + setRequested(true); + finishRun(completion); + return; + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + if (snapshotPending.compareAndSet(true, false)) { + setRequested(true); + finishRun(completion); + return; + } + } catch (java.util.concurrent.ExecutionException impossible) { + throw new IllegalStateException(impossible); + } + completion.join(); + } } private synchronized void finishRun(CompletableFuture completion) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java index c0e512075e..c29c122b0c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java @@ -115,4 +115,28 @@ public void retiredFoliaPlayerStillCompletesOnlineSnapshot() { verify(scheduler, times(2)).runTask(eq(plugin), any(Runnable.class)); } + @Test + public void cancelledInitialSnapshotDoesNotBlockStorageWorkerForever() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + Config config = mock(Config.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getConfigFile()).thenReturn(config); + java.lang.reflect.Constructor constructor = + VotingPluginBackgroundTask.class.getDeclaredConstructor(VotingPluginMain.class, long.class); + constructor.setAccessible(true); + VotingPluginBackgroundTask task = constructor.newInstance(plugin, 10L); + task.setRequested(true); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(null); + long started = System.nanoTime(); + task.run(); + assertTrue(java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) < 1_000); + } + + assertFalse(task.isRunning()); + assertTrue(task.isRequested()); + verify(plugin).captureOnlineTopVoterIgnore(any()); + } + } From 0a509b5fb82e28d0811b59ff2d0a4bab983d8e40 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:35:11 -0600 Subject: [PATCH 6/8] Keep periodic user refresh off vote executor --- .../votingplugin/VotingPluginMain.java | 2 +- ...tingPluginMainPlaceholderPresenceTest.java | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 74e9b0662b..43e883ee2b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -382,7 +382,7 @@ public void captureOnlineTopVoterIgnore(java.util.function.Consumer { try { - getVoteTimer().execute(() -> { + getUserManager().getDataManager().getTimer().execute(() -> { for (java.util.Map.Entry entry : online.entrySet()) { VotingPluginUser user = getVotingPluginUserManager().getVotingPluginUser(entry.getKey(), false); if (user == null) continue; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java index c30acd3e5b..1f1421e952 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java @@ -1,9 +1,7 @@ package com.bencodez.votingplugin; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import java.util.UUID; @@ -11,8 +9,31 @@ import org.junit.jupiter.api.Test; import com.bencodez.advancedcore.api.player.UuidLookup; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; class VotingPluginMainPlaceholderPresenceTest { + @Test + void periodicUserRefreshUsesStorageWorkerInsteadOfVoteAdmissionExecutor() { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + java.util.concurrent.ScheduledExecutorService storage = mock(java.util.concurrent.ScheduledExecutorService.class); + java.util.concurrent.ScheduledExecutorService votes = mock(java.util.concurrent.ScheduledExecutorService.class); + UserManager userManager = mock(UserManager.class); + UserDataManager dataManager = mock(UserDataManager.class); + doReturn(userManager).when(plugin).getUserManager(); + when(userManager.getDataManager()).thenReturn(dataManager); + when(dataManager.getTimer()).thenReturn(storage); + doReturn(votes).when(plugin).getVoteTimer(); + doAnswer(call -> { + call.getArgument(0, java.util.function.Consumer.class).accept(java.util.Map.of()); + return null; + }).when(plugin).captureOnlineTopVoterIgnore(any()); + + plugin.basicBungeeUpdate(); + + verify(storage).execute(any(Runnable.class)); + verify(votes, never()).execute(any(Runnable.class)); + } @Test void cachedStorageIdentityWinsDuringColdPresenceRefresh() { Player player = mock(Player.class); From 2d047c76a643049fd011021c1f0683d3a63c414f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:38:46 -0600 Subject: [PATCH 7/8] Preserve refresh requests after snapshot failure --- .../votingplugin/VotingPluginMain.java | 8 ++++- .../VotingPluginBackgroundTask.java | 5 ++++ .../VotingPluginBackgroundTaskTest.java | 29 ++++++++++++++++--- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 43e883ee2b..d7d2053108 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -348,7 +348,13 @@ public void addDirectlyDefinedRewards(DirectlyDefinedReward directlyDefinedRewar * Completion runs after every player owner callback has finished. */ public void captureOnlineTopVoterIgnore(java.util.function.Consumer> completion) { + captureOnlineTopVoterIgnore(completion, () -> completion.accept(java.util.Map.of())); + } + + public void captureOnlineTopVoterIgnore(java.util.function.Consumer> completion, + Runnable failureCompletion) { java.util.Objects.requireNonNull(completion, "completion"); + java.util.Objects.requireNonNull(failureCompletion, "failureCompletion"); try { getBukkitScheduler().runTask(this, () -> { java.util.List players = new java.util.ArrayList<>(Bukkit.getOnlinePlayers()); @@ -375,7 +381,7 @@ public void captureOnlineTopVoterIgnore(java.util.function.Consumer { + setRequested(true); + snapshotPending.set(false); + snapshotStarted.complete(null); + finishRun(completion); }); } catch (RuntimeException failure) { plugin.debug(failure); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java index c29c122b0c..9559670525 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java @@ -44,7 +44,7 @@ public void runRequestsPlatformSnapshotBeforeStartingRefresh() { java.util.concurrent.atomic.AtomicReference>> callback = new java.util.concurrent.atomic.AtomicReference<>(); org.mockito.Mockito.doAnswer(call -> { callback.set(call.getArgument(0)); return null; }) - .when(plugin).captureOnlineTopVoterIgnore(any()); + .when(plugin).captureOnlineTopVoterIgnore(any(), any()); VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(plugin); task.setRequested(true); @@ -53,7 +53,7 @@ public void runRequestsPlatformSnapshotBeforeStartingRefresh() { bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); task.run(); - verify(plugin).captureOnlineTopVoterIgnore(any()); + verify(plugin).captureOnlineTopVoterIgnore(any(), any()); assertTrue(task.isRunning()); callback.get().accept(java.util.Map.of()); assertFalse(task.isRunning()); @@ -70,7 +70,7 @@ public void refreshUsesUserStorageWorkerInsteadOfVoteExecutor() { java.util.concurrent.atomic.AtomicReference>> callback = new java.util.concurrent.atomic.AtomicReference<>(); doAnswer(call -> { callback.set(call.getArgument(0)); return null; }) - .when(plugin).captureOnlineTopVoterIgnore(any()); + .when(plugin).captureOnlineTopVoterIgnore(any(), any()); java.util.concurrent.ScheduledExecutorService storage = mock(java.util.concurrent.ScheduledExecutorService.class); when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(storage); VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(plugin); @@ -136,7 +136,28 @@ public void cancelledInitialSnapshotDoesNotBlockStorageWorkerForever() throws Ex assertFalse(task.isRunning()); assertTrue(task.isRequested()); - verify(plugin).captureOnlineTopVoterIgnore(any()); + verify(plugin).captureOnlineTopVoterIgnore(any(), any()); + } + + @Test + public void snapshotAdmissionFailureKeepsRefreshPending() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + Config config = mock(Config.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getConfigFile()).thenReturn(config); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(plugin).captureOnlineTopVoterIgnore(any(), any()); + VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(plugin); + task.setRequested(true); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(null); + task.run(); + } + + assertFalse(task.isRunning()); + assertTrue(task.isRequested()); + verify(plugin.getUserManager().getDataManager().getTimer(), never()).execute(any(Runnable.class)); } } From a03ee6643af28c832ba29185f550ddde474deb11 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:30:25 -0600 Subject: [PATCH 8/8] Bound background storage refresh admission --- .../votingplugin/VotingPluginMain.java | 32 +++++++++++++------ .../VotingPluginBackgroundTask.java | 26 ++++++++++++++- ...tingPluginMainPlaceholderPresenceTest.java | 28 +++++++++++++++- .../VotingPluginBackgroundTaskTest.java | 29 +++++++++++++++++ 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index d7d2053108..35d578604a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -17,6 +17,7 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -309,6 +310,12 @@ public File getLoadedPluginJarFile() { private DiscordHandler discordHandler; private volatile VotingPluginBackgroundTask backgroundTask; + private volatile AtomicBoolean basicBungeeUpdateRunning; + + private synchronized AtomicBoolean basicBungeeUpdateAdmission() { + if (basicBungeeUpdateRunning == null) basicBungeeUpdateRunning = new AtomicBoolean(); + return basicBungeeUpdateRunning; + } private VotingPluginVersionInfo versionInfo; private VotingPluginConfigHealth configHealth; private VotifierIntegration votifierIntegration; @@ -386,19 +393,26 @@ public void captureOnlineTopVoterIgnore(java.util.function.Consumer { try { getUserManager().getDataManager().getTimer().execute(() -> { - for (java.util.Map.Entry entry : online.entrySet()) { - VotingPluginUser user = getVotingPluginUserManager().getVotingPluginUser(entry.getKey(), false); - if (user == null) continue; - user.cache(); - user.offVoteWithCapturedTopVoterIgnore(entry.getValue().booleanValue()); - user.checkOfflineRewards(); - } + try { + for (java.util.Map.Entry entry : online.entrySet()) { + VotingPluginUser user = getVotingPluginUserManager().getVotingPluginUser(entry.getKey(), false); + if (user == null) continue; + user.cache(); + user.offVoteWithCapturedTopVoterIgnore(entry.getValue().booleanValue()); + user.checkOfflineRewards(); + } + } finally { admission.set(false); } }); - } catch (RuntimeException failure) { debug(failure); } - }); + } catch (RuntimeException failure) { + admission.set(false); + debug(failure); + } + }, () -> admission.set(false)); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java index 6f1313fa77..5c06c1775b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backgroundtask/VotingPluginBackgroundTask.java @@ -47,7 +47,9 @@ public VotingPluginBackgroundTask(VotingPluginMain plugin) { public void run() { CompletableFuture completion = new CompletableFuture<>(); CompletableFuture snapshotStarted = new CompletableFuture<>(); + CompletableFuture storageStarted = new CompletableFuture<>(); AtomicBoolean snapshotPending = new AtomicBoolean(true); + AtomicBoolean storagePending = new AtomicBoolean(false); synchronized (this) { if (!(requested || plugin.getConfigFile().isAlwaysUpdate()) || !plugin.isEnabled() || running) return; running = true; @@ -57,12 +59,16 @@ public void run() { if (!snapshotPending.compareAndSet(true, false)) return; snapshotStarted.complete(null); if (plugin.getConfigFile().isUpdateWithPlayersOnlineOnly() && online.isEmpty()) { + storageStarted.complete(null); finishRun(completion); return; } synchronized (VotingPluginBackgroundTask.this) { requested = false; } + storagePending.set(true); try { plugin.getUserManager().getDataManager().getTimer().execute(() -> { + if (!storagePending.compareAndSet(true, false)) return; + storageStarted.complete(null); try { runRefresh(online); completion.complete(null); @@ -75,6 +81,8 @@ public void run() { } catch (RuntimeException failure) { plugin.debug(failure); setRequested(true); + storagePending.set(false); + storageStarted.complete(null); completion.complete(null); finishRun(completion); } @@ -82,6 +90,7 @@ public void run() { setRequested(true); snapshotPending.set(false); snapshotStarted.complete(null); + storageStarted.complete(null); finishRun(completion); }); } catch (RuntimeException failure) { @@ -89,6 +98,7 @@ public void run() { setRequested(true); snapshotPending.set(false); snapshotStarted.complete(null); + storageStarted.complete(null); completion.complete(null); finishRun(completion); } @@ -111,7 +121,21 @@ public void run() { } catch (java.util.concurrent.ExecutionException impossible) { throw new IllegalStateException(impossible); } - completion.join(); + if (completion.isDone()) return; + try { + storageStarted.get(snapshotWaitMillis, TimeUnit.MILLISECONDS); + } catch (TimeoutException failure) { + if (storagePending.compareAndSet(true, false)) { + setRequested(true); + finishRun(completion); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + if (storagePending.compareAndSet(true, false)) finishRun(completion); + setRequested(true); + } catch (java.util.concurrent.ExecutionException impossible) { + throw new IllegalStateException(impossible); + } } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java index 1f1421e952..09f73188c6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java @@ -7,6 +7,7 @@ import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import com.bencodez.advancedcore.api.player.UuidLookup; import com.bencodez.advancedcore.api.user.UserManager; @@ -27,13 +28,38 @@ void periodicUserRefreshUsesStorageWorkerInsteadOfVoteAdmissionExecutor() { doAnswer(call -> { call.getArgument(0, java.util.function.Consumer.class).accept(java.util.Map.of()); return null; - }).when(plugin).captureOnlineTopVoterIgnore(any()); + }).when(plugin).captureOnlineTopVoterIgnore(any(), any()); plugin.basicBungeeUpdate(); verify(storage).execute(any(Runnable.class)); verify(votes, never()).execute(any(Runnable.class)); } + + @Test + void periodicUserRefreshKeepsOnlyOneStorageJobInFlight() { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + java.util.concurrent.ScheduledExecutorService storage = mock(java.util.concurrent.ScheduledExecutorService.class); + UserManager userManager = mock(UserManager.class); + UserDataManager dataManager = mock(UserDataManager.class); + doReturn(userManager).when(plugin).getUserManager(); + when(userManager.getDataManager()).thenReturn(dataManager); + when(dataManager.getTimer()).thenReturn(storage); + doAnswer(call -> { + call.getArgument(0, java.util.function.Consumer.class).accept(java.util.Map.of()); + return null; + }).when(plugin).captureOnlineTopVoterIgnore(any(), any()); + ArgumentCaptor refresh = ArgumentCaptor.forClass(Runnable.class); + + plugin.basicBungeeUpdate(); + plugin.basicBungeeUpdate(); + verify(plugin, times(1)).captureOnlineTopVoterIgnore(any(), any()); + verify(storage).execute(refresh.capture()); + + refresh.getValue().run(); + plugin.basicBungeeUpdate(); + verify(plugin, times(2)).captureOnlineTopVoterIgnore(any(), any()); + } @Test void cachedStorageIdentityWinsDuringColdPresenceRefresh() { Player player = mock(Player.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java index 9559670525..a56cd286e7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backgroundtask/VotingPluginBackgroundTaskTest.java @@ -160,4 +160,33 @@ public void snapshotAdmissionFailureKeepsRefreshPending() { verify(plugin.getUserManager().getDataManager().getTimer(), never()).execute(any(Runnable.class)); } + @Test + public void acceptedStorageTaskThatNeverStartsDoesNotBlockForever() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + Config config = mock(Config.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getConfigFile()).thenReturn(config); + doAnswer(call -> { + call.getArgument(0, java.util.function.Consumer.class) + .accept(java.util.Map.of(java.util.UUID.randomUUID(), Boolean.FALSE)); + return null; + }).when(plugin).captureOnlineTopVoterIgnore(any(), any()); + java.lang.reflect.Constructor constructor = + VotingPluginBackgroundTask.class.getDeclaredConstructor(VotingPluginMain.class, long.class); + constructor.setAccessible(true); + VotingPluginBackgroundTask task = constructor.newInstance(plugin, 10L); + task.setRequested(true); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(null); + long started = System.nanoTime(); + task.run(); + assertTrue(java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) < 1_000); + } + + assertFalse(task.isRunning()); + assertTrue(task.isRequested()); + verify(plugin.getUserManager().getDataManager().getTimer()).execute(any(Runnable.class)); + } + }