diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index c21efc54ce..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; @@ -101,8 +102,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; @@ -307,7 +309,13 @@ public File getLoadedPluginJarFile() { @Getter private DiscordHandler discordHandler; - private VotingPluginBackgroundTask backgroundTask; + 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; @@ -321,10 +329,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() { @@ -340,15 +350,70 @@ 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) { + 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()); + 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) { + BukkitCompletionScheduler.run(this, player, () -> { + 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(); } + }, maybeComplete, maybeComplete); + } + }); + } catch (RuntimeException failure) { + debug(failure); + failureCompletion.run(); } } + public void basicBungeeUpdate() { + AtomicBoolean admission = basicBungeeUpdateAdmission(); + if (!admission.compareAndSet(false, true)) return; + captureOnlineTopVoterIgnore(online -> { + try { + getUserManager().getDataManager().getTimer().execute(() -> { + 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) { + admission.set(false); + debug(failure); + } + }, () -> admission.set(false)); + } /** @@ -2067,11 +2132,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 7437e92d42..5c06c1775b 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,16 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +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; +import org.bukkit.entity.Player; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.simpleapi.skull.SkullCache; @@ -23,35 +30,135 @@ 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 synchronized void run() { - if (!(requested || plugin.getConfigFile().isAlwaysUpdate())) { - return; + 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; } - if (!plugin.isEnabled() || running) { - return; + try { + plugin.captureOnlineTopVoterIgnore(online -> { + 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); + } catch (Throwable failure) { + plugin.debug(failure); + setRequested(true); + completion.complete(null); + } finally { finishRun(completion); } + }); + } catch (RuntimeException failure) { + plugin.debug(failure); + setRequested(true); + storagePending.set(false); + storageStarted.complete(null); + completion.complete(null); + finishRun(completion); + } + }, () -> { + setRequested(true); + snapshotPending.set(false); + snapshotStarted.complete(null); + storageStarted.complete(null); + finishRun(completion); + }); + } catch (RuntimeException failure) { + plugin.debug(failure); + setRequested(true); + snapshotPending.set(false); + snapshotStarted.complete(null); + storageStarted.complete(null); + completion.complete(null); + finishRun(completion); } - if (plugin.getConfigFile().isUpdateWithPlayersOnlineOnly() && Bukkit.getOnlinePlayers().isEmpty()) { - return; + 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); + } + 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); + } } + } - 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 +230,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 +249,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(); @@ -167,6 +281,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/VotingPluginMainPlaceholderPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java index c30acd3e5b..09f73188c6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java @@ -1,18 +1,65 @@ 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; 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; +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(), 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 61a63b59fb..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 @@ -1,15 +1,29 @@ package com.bencodez.votingplugin.tests.backgroundtask; +import static org.junit.jupiter.api.Assertions.assertEquals; 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 org.mockito.MockedStatic; + +import org.bukkit.Bukkit; +import org.bukkit.Server; + +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 { + @Test + public void updateDoesNotHoldPluginMonitorWhileRefreshWaits() throws Exception { + assertFalse(java.lang.reflect.Modifier.isSynchronized( + VotingPluginMain.class.getMethod("update").getModifiers())); + } @Test public void requestedStateCanBeDelegated() { @@ -20,4 +34,159 @@ 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(), any()); + 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(); + + verify(plugin).captureOnlineTopVoterIgnore(any(), 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"); + } + } + + @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(), 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)); + } + + @Test + public void retiredFoliaPlayerStillCompletesOnlineSnapshot() { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation implementation = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(implementation); + when(implementation.runAtEntityWithFallback(eq(player), any(), any(Runnable.class))) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture( + com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SCHEDULER_RETIRED)); + doAnswer(call -> { 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)); + } + + @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(), 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)); + } + + @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)); + } + }