diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java
index 14ab45b03c..e5502f55e4 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java
@@ -6,6 +6,7 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
import org.bukkit.Bukkit;
@@ -49,9 +50,12 @@ public class PermissionHandler {
@Getter
private final ScheduledExecutorService timer = Executors.newScheduledThreadPool(1);
+ private final AtomicBoolean acceptingExpirations = new AtomicBoolean(true);
+ private final Object[] stateLocks = new Object[64];
public PermissionHandler(AdvancedCorePlugin plugin) {
this.plugin = plugin;
+ for (int i = 0; i < stateLocks.length; i++) stateLocks[i] = new Object();
// Restore timed permissions from previous shutdown (stored as expireAtMillis)
if (plugin.getServerDataFile().getData() != null
@@ -88,6 +92,63 @@ public PermissionHandler(AdvancedCorePlugin plugin) {
}
}
+
+ void scheduleExpiration(PlayerPermissionHandler handle, String permission, long expectedExpireAt, long delayMillis) {
+ if (!acceptingExpirations.get()) return;
+ try {
+ timer.schedule(() -> dispatchExpiration(handle, permission, expectedExpireAt),
+ Math.max(0L, delayMillis), java.util.concurrent.TimeUnit.MILLISECONDS);
+ } catch (RuntimeException failure) {
+ plugin.debug(failure);
+ throw failure;
+ }
+ }
+
+ void dispatchExpiration(PlayerPermissionHandler handle, String permission, long expectedExpireAt) {
+ if (!acceptingExpirations.get() || !handle.isExpirationCurrent(permission, expectedExpireAt)) return;
+ try {
+ plugin.getBukkitScheduler().runTask(plugin, () -> {
+ if (!acceptingExpirations.get() || !handle.isExpirationCurrent(permission, expectedExpireAt)) return;
+ Player player = Bukkit.getPlayer(handle.getUuid());
+ if (player == null) {
+ expireOfflineOrRetry(handle, permission, expectedExpireAt);
+ return;
+ }
+ try {
+ plugin.getBukkitScheduler().runTask(plugin, () -> {
+ if (!acceptingExpirations.get()) return;
+ handle.expirePermission(permission, expectedExpireAt, true);
+ }, player);
+ } catch (RuntimeException failure) {
+ plugin.debug(failure);
+ retryExpiration(handle, permission, expectedExpireAt);
+ }
+ });
+ } catch (RuntimeException failure) {
+ plugin.debug(failure);
+ retryExpiration(handle, permission, expectedExpireAt);
+ }
+ }
+
+ private void expireOfflineOrRetry(PlayerPermissionHandler handle, String permission, long expectedExpireAt) {
+ boolean active;
+ UUID uuid = handle.getUuid();
+ synchronized (stateLock(uuid)) {
+ if (permsToAdd.get(uuid) == handle) {
+ handle.expirePermission(permission, expectedExpireAt, false);
+ return;
+ }
+ active = perms.get(uuid) == handle;
+ }
+ if (active) retryExpiration(handle, permission, expectedExpireAt);
+ }
+
+ private void retryExpiration(PlayerPermissionHandler handle, String permission, long expectedExpireAt) {
+ if (!acceptingExpirations.get() || !handle.isExpirationCurrent(permission, expectedExpireAt)) return;
+ try { scheduleExpiration(handle, permission, expectedExpireAt, 1_000L); }
+ catch (RuntimeException ignored) { /* scheduleExpiration already reported the rejection */ }
+ }
+
public void addPermission(Player player, String permission) {
addPermission(player.getUniqueId(), permission);
}
@@ -115,22 +176,32 @@ public void addPermission(UUID uuid, String permission) {
return;
}
- for (String perm : permission.split(Pattern.quote("|"))) {
- PlayerPermissionHandler handle = perms.get(uuid);
+ synchronized (stateLock(uuid)) {
+ for (String perm : permission.split(Pattern.quote("|"))) {
+ PlayerPermissionHandler handle = perms.get(uuid);
- if (handle != null) {
- handle.addPerm(perm);
- continue;
- }
+ if (handle != null) {
+ handle.addPerm(perm);
+ continue;
+ }
+ PlayerPermissionHandler pending = permsToAdd.get(uuid);
+ if (pending != null) {
+ pending.addOfflinePerm(perm, ParsedDuration.empty());
+ continue;
+ }
- Player p = Bukkit.getPlayer(uuid);
- if (p != null) {
- PermissionAttachment attachment = p.addAttachment(plugin);
- PlayerPermissionHandler newHandle = new PlayerPermissionHandler(uuid, attachment, this).addPerm(perm);
- perms.put(uuid, newHandle);
- } else {
- permsToAdd.put(uuid,
- new PlayerPermissionHandler(uuid, null, this).addOfflinePerm(perm, ParsedDuration.empty()));
+ Player p = Bukkit.getPlayer(uuid);
+ if (p != null) {
+ PermissionAttachment attachment = p.addAttachment(plugin);
+ PlayerPermissionHandler newHandle = new PlayerPermissionHandler(uuid, attachment, this).addPerm(perm);
+ perms.put(uuid, newHandle);
+ } else {
+ permsToAdd.compute(uuid, (ignored, existing) -> {
+ PlayerPermissionHandler target = existing == null
+ ? new PlayerPermissionHandler(uuid, null, this) : existing;
+ return target.addOfflinePerm(perm, ParsedDuration.empty());
+ });
+ }
}
}
}
@@ -152,22 +223,33 @@ public void addPermission(UUID uuid, String permission, ParsedDuration duration)
return;
}
- for (String perm : permission.split(Pattern.quote("|"))) {
- PlayerPermissionHandler handle = perms.get(uuid);
+ synchronized (stateLock(uuid)) {
+ for (String perm : permission.split(Pattern.quote("|"))) {
+ PlayerPermissionHandler handle = perms.get(uuid);
- if (handle != null) {
- handle.addExpiration(perm, duration);
- continue;
- }
+ if (handle != null) {
+ handle.addExpiration(perm, duration);
+ continue;
+ }
+ PlayerPermissionHandler pending = permsToAdd.get(uuid);
+ if (pending != null) {
+ pending.addOfflinePerm(perm, duration);
+ continue;
+ }
- Player p = Bukkit.getPlayer(uuid);
- if (p != null) {
- PermissionAttachment attachment = p.addAttachment(plugin);
- PlayerPermissionHandler newHandle = new PlayerPermissionHandler(uuid, attachment, this)
- .addExpiration(perm, duration);
- perms.put(uuid, newHandle);
- } else {
- permsToAdd.put(uuid, new PlayerPermissionHandler(uuid, null, this).addOfflinePerm(perm, duration));
+ Player p = Bukkit.getPlayer(uuid);
+ if (p != null) {
+ PermissionAttachment attachment = p.addAttachment(plugin);
+ PlayerPermissionHandler newHandle = new PlayerPermissionHandler(uuid, attachment, this)
+ .addExpiration(perm, duration);
+ perms.put(uuid, newHandle);
+ } else {
+ permsToAdd.compute(uuid, (ignored, existing) -> {
+ PlayerPermissionHandler target = existing == null
+ ? new PlayerPermissionHandler(uuid, null, this) : existing;
+ return target.addOfflinePerm(perm, duration);
+ });
+ }
}
}
}
@@ -188,19 +270,20 @@ public void addPermission(UUID uuid, String permission, long seconds) {
*/
public void login(Player player) {
UUID uuid = player.getUniqueId();
+ synchronized (stateLock(uuid)) {
+ PlayerPermissionHandler handle = perms.get(uuid);
+ if (handle != null) {
+ handle.setAttachment(player.addAttachment(plugin));
+ handle.onLogin(player);
+ return;
+ }
- PlayerPermissionHandler handle = perms.get(uuid);
- if (handle != null) {
- handle.setAttachment(player.addAttachment(plugin));
- handle.onLogin(player);
- return;
- }
-
- PlayerPermissionHandler pending = permsToAdd.remove(uuid);
- if (pending != null) {
- pending.setAttachment(player.addAttachment(plugin));
- pending.onLogin(player);
- perms.put(uuid, pending);
+ PlayerPermissionHandler pending = permsToAdd.remove(uuid);
+ if (pending != null) {
+ pending.setAttachment(player.addAttachment(plugin));
+ pending.onLogin(player);
+ perms.put(uuid, pending);
+ }
}
}
@@ -213,26 +296,46 @@ public void login(Player player) {
*
*/
public void logout(Player player) {
- PlayerPermissionHandler handle = perms.remove(player.getUniqueId());
- if (handle == null) {
- return;
- }
+ UUID uuid = player.getUniqueId();
+ synchronized (stateLock(uuid)) {
+ PlayerPermissionHandler handle = perms.remove(uuid);
+ if (handle == null) return;
- try {
- if (handle.getAttachment() != null) {
- player.removeAttachment(handle.getAttachment());
+ try {
+ if (handle.getAttachment() != null) player.removeAttachment(handle.getAttachment());
+ } catch (Throwable ignored) {
}
- } catch (Throwable ignored) {
- }
- handle.setAttachment(null);
- handle.onLogout(player);
- permsToAdd.put(player.getUniqueId(), handle);
+ handle.setAttachment(null);
+ handle.onLogout(player);
+ permsToAdd.merge(uuid, handle, (pending, moved) -> {
+ java.util.Map queued = pending.offlinePermissionSnapshot();
+ moved.mergeOfflinePermissions(queued);
+ return moved;
+ });
+ }
}
public void removePermission(UUID uuid) {
- perms.remove(uuid);
- permsToAdd.remove(uuid);
+ synchronized (stateLock(uuid)) {
+ perms.remove(uuid);
+ permsToAdd.remove(uuid);
+ }
+ }
+
+ void removePermission(UUID uuid, PlayerPermissionHandler expected) {
+ synchronized (stateLock(uuid)) {
+ perms.remove(uuid, expected);
+ permsToAdd.remove(uuid, expected);
+ }
+ }
+
+ void removePermissionIfEmpty(UUID uuid, PlayerPermissionHandler expected, boolean attachmentIsOffline) {
+ synchronized (stateLock(uuid)) {
+ if (!expected.isHandlerEmpty(attachmentIsOffline)) return;
+ perms.remove(uuid, expected);
+ permsToAdd.remove(uuid, expected);
+ }
}
/**
@@ -251,28 +354,34 @@ public void removePermission(UUID uuid, String playerName, String permission) {
return;
}
- PlayerPermissionHandler handle = perms.get(uuid);
- if (handle == null) {
- handle = permsToAdd.get(uuid);
- }
- if (handle == null) {
- return;
- }
-
- for (String perm : permission.split(Pattern.quote("|"))) {
- handle.removePermission(perm);
- if (playerName != null && !playerName.isEmpty()) {
- plugin.debug("Removing temp permission " + perm + " from " + playerName);
- } else {
- plugin.debug("Removing temp permission " + perm + " from " + uuid);
+ synchronized (stateLock(uuid)) {
+ PlayerPermissionHandler handle = perms.get(uuid);
+ if (handle == null) handle = permsToAdd.get(uuid);
+ if (handle == null) return;
+
+ for (String perm : permission.split(Pattern.quote("|"))) {
+ handle.removePermission(perm);
+ if (playerName != null && !playerName.isEmpty()) {
+ plugin.debug("Removing temp permission " + perm + " from " + playerName);
+ } else {
+ plugin.debug("Removing temp permission " + perm + " from " + uuid);
+ }
}
}
}
+ private Object stateLock(UUID uuid) {
+ return stateLocks[(uuid.hashCode() & Integer.MAX_VALUE) % stateLocks.length];
+ }
+
/**
* Persists timed permissions for both online + offline handlers.
*/
public void shutDown() {
+ // Fence every timer/global/entity callback before taking persistence snapshots.
+ // A callback already inside a handler monitor finishes before timedPermissionSnapshot().
+ acceptingExpirations.set(false);
+ timer.shutdownNow();
saveTimedPerms(perms);
saveTimedPerms(permsToAdd);
plugin.getServerDataFile().saveData();
@@ -280,16 +389,13 @@ public void shutDown() {
private void saveTimedPerms(ConcurrentHashMap map) {
for (PlayerPermissionHandler handle : map.values()) {
- if (handle.getTimedPermissions() == null || handle.getTimedPermissions().isEmpty()) {
- continue;
- }
+ java.util.Map snapshot = handle.timedPermissionSnapshot();
+ if (snapshot.isEmpty()) continue;
ArrayList list = new ArrayList<>();
- for (Entry entry : handle.getTimedPermissions().entrySet()) {
- // Store absolute expireAtMillis
+ for (Entry entry : snapshot.entrySet()) {
list.add(entry.getKey() + "%line%" + entry.getValue());
}
-
plugin.getServerDataFile().getData().set("TimedPermissions." + handle.getUuid(), list);
}
}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandler.java
index b1c5a52dea..658d7a71b3 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandler.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandler.java
@@ -5,7 +5,6 @@
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;
-import java.util.concurrent.TimeUnit;
import org.bukkit.entity.Player;
import org.bukkit.permissions.PermissionAttachment;
@@ -70,7 +69,7 @@ public PlayerPermissionHandler(UUID uuid, PermissionAttachment attachment, Permi
* @param duration How long the permission should last
* @return this
*/
- public PlayerPermissionHandler addExpiration(String perm, ParsedDuration duration) {
+ public synchronized PlayerPermissionHandler addExpiration(String perm, ParsedDuration duration) {
if (duration == null || duration.isEmpty()) {
return addPerm(perm);
}
@@ -87,7 +86,7 @@ public PlayerPermissionHandler addExpiration(String perm, ParsedDuration duratio
}
long delayMillis = duration.delayMillisFromNow();
- handler.getTimer().schedule(() -> removePermission(perm), delayMillis, TimeUnit.MILLISECONDS);
+ handler.scheduleExpiration(this, perm, expireAt, delayMillis);
return this;
}
@@ -106,7 +105,7 @@ public PlayerPermissionHandler addExpiration(String perm, long seconds) {
* @param duration Duration; empty means permanent
* @return this
*/
- public PlayerPermissionHandler addOfflinePerm(String perm, ParsedDuration duration) {
+ public synchronized PlayerPermissionHandler addOfflinePerm(String perm, ParsedDuration duration) {
if (permsToAdd == null) {
permsToAdd = new HashMap<>();
}
@@ -115,6 +114,16 @@ public PlayerPermissionHandler addOfflinePerm(String perm, ParsedDuration durati
return this;
}
+ synchronized void mergeOfflinePermissions(java.util.Map queued) {
+ if (queued.isEmpty()) return;
+ if (permsToAdd == null) permsToAdd = new HashMap<>();
+ permsToAdd.putAll(queued);
+ }
+
+ synchronized java.util.Map offlinePermissionSnapshot() {
+ return permsToAdd == null ? java.util.Map.of() : new HashMap<>(permsToAdd);
+ }
+
/**
* Backwards-compatible offline queue.
*/
@@ -128,7 +137,7 @@ public PlayerPermissionHandler addOfflinePerm(String perm, long seconds) {
* @param perm Permission node
* @return this
*/
- public PlayerPermissionHandler addPerm(String perm) {
+ public synchronized PlayerPermissionHandler addPerm(String perm) {
persistentPermissions.add(perm);
if (attachment != null) {
@@ -141,7 +150,7 @@ public PlayerPermissionHandler addPerm(String perm) {
/**
* Re-applies stored permissions after an attachment is created on login.
*/
- public void onLogin(Player player) {
+ public synchronized void onLogin(Player player) {
if (attachment == null) {
return;
}
@@ -184,30 +193,67 @@ public void onLogout(Player player) {
// intentionally empty
}
+
+ /** True only while this exact scheduled expiration still owns the timed grant. */
+ synchronized java.util.Map timedPermissionSnapshot() {
+ return timedPermissions == null ? java.util.Map.of() : new java.util.HashMap<>(timedPermissions);
+ }
+
+ synchronized boolean isExpirationCurrent(String perm, long expectedExpireAt) {
+ if (timedPermissions == null) return false;
+ Long current = timedPermissions.get(perm);
+ return current != null && current.longValue() == expectedExpireAt;
+ }
+
/**
- * Removes a permission from both internal tracking and the live attachment (if online).
+ * Expire only the scheduled grant that still owns this permission. The
+ * attachment flag is true only from the player's owning platform scheduler.
*/
- public void removePermission(String perm) {
- persistentPermissions.remove(perm);
- if (timedPermissions != null) {
- timedPermissions.remove(perm);
- }
- if (permsToAdd != null) {
- permsToAdd.remove(perm);
- }
-
- if (attachment != null) {
- attachment.setPermission(perm, false);
- attachment.getPermissions().remove(perm);
+ void expirePermission(String perm, long expectedExpireAt, boolean updateAttachment) {
+ long remaining;
+ boolean removeHandler;
+ synchronized (this) {
+ if (timedPermissions == null) return;
+ Long current = timedPermissions.get(perm);
+ if (current == null || current.longValue() != expectedExpireAt) return;
+ remaining = current.longValue() - System.currentTimeMillis();
+ if (remaining > 0L) {
+ removeHandler = false;
+ } else {
+ if (!timedPermissions.remove(perm, current)) return;
+ persistentPermissions.remove(perm);
+ if (updateAttachment && attachment != null) attachment.unsetPermission(perm);
+ removeHandler = isHandlerEmpty(!updateAttachment);
+ }
}
+ if (remaining > 0L) handler.scheduleExpiration(this, perm, expectedExpireAt, remaining);
+ else if (removeHandler) handler.removePermissionIfEmpty(uuid, this, !updateAttachment);
+ }
- // If nothing tracked and attachment has nothing, drop handler state entirely
+ synchronized boolean isHandlerEmpty(boolean attachmentIsOffline) {
boolean noTracked = persistentPermissions.isEmpty()
&& (timedPermissions == null || timedPermissions.isEmpty())
&& (permsToAdd == null || permsToAdd.isEmpty());
+ return noTracked && (attachmentIsOffline || attachment == null || attachment.getPermissions().isEmpty());
+ }
+
+ /**
+ * Removes a permission from both internal tracking and the live attachment (if online).
+ */
+ public void removePermission(String perm) {
+ boolean removeHandler;
+ synchronized (this) {
+ persistentPermissions.remove(perm);
+ if (timedPermissions != null) timedPermissions.remove(perm);
+ if (permsToAdd != null) permsToAdd.remove(perm);
+
+ if (attachment != null) {
+ attachment.setPermission(perm, false);
+ attachment.getPermissions().remove(perm);
+ }
- if (noTracked && attachment != null && attachment.getPermissions().isEmpty()) {
- handler.removePermission(uuid);
+ removeHandler = isHandlerEmpty(false);
}
+ if (removeHandler) handler.removePermissionIfEmpty(uuid, this, false);
}
}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java
index 0f5c094fa5..6e102c45fa 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java
@@ -1,6 +1,7 @@
package com.bencodez.advancedcore.listeners;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
@@ -17,7 +18,14 @@
public class PlayerJoinEvent implements Listener {
- private final AdvancedCorePlugin plugin;
+ private final AdvancedCorePlugin plugin;
+ private final ConcurrentHashMap pendingLoginSessions = new ConcurrentHashMap<>();
+ private final Object[] loginSessionLocks = java.util.stream.IntStream.range(0, 64)
+ .mapToObj(ignored -> new Object()).toArray();
+
+ private Object loginSessionLock(java.util.UUID uuid) {
+ return loginSessionLocks[(uuid.hashCode() & Integer.MAX_VALUE) % loginSessionLocks.length];
+ }
public PlayerJoinEvent(AdvancedCorePlugin plugin) {
this.plugin = plugin;
@@ -58,7 +66,7 @@ public void onJoin(AdvancedCoreLoginEvent event) {
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
- public void onPlayerLogin(final org.bukkit.event.player.PlayerJoinEvent event) {
+ public void onPlayerLogin(final org.bukkit.event.player.PlayerJoinEvent event) {
if (plugin == null || !plugin.isEnabled() || !plugin.isLoadUserData()) {
return;
}
@@ -70,7 +78,11 @@ public void onPlayerLogin(final org.bukkit.event.player.PlayerJoinEvent event) {
plugin.debug("Login: " + event.getPlayer().getName() + " (" + event.getPlayer().getUniqueId() + ")");
}
- plugin.getLoginTimer().schedule(new Runnable() {
+ Player joiningPlayer = event.getPlayer();
+ synchronized (loginSessionLock(joiningPlayer.getUniqueId())) {
+ pendingLoginSessions.put(joiningPlayer.getUniqueId(), joiningPlayer);
+ }
+ plugin.getLoginTimer().schedule(new Runnable() {
@Override
public void run() {
@@ -86,10 +98,10 @@ public void run() {
return;
}
- Player player = event.getPlayer();
- if (player == null) {
- return;
- }
+ Player player = event.getPlayer();
+ if (player == null || pendingLoginSessions.get(player.getUniqueId()) != player) {
+ return;
+ }
// Vanish metadata support
for (MetadataValue meta : player.getMetadata("vanished")) {
@@ -115,9 +127,10 @@ public void run() {
plugin.debug("Login: " + player.getName() + " (" + player.getUniqueId() + ")");
- if (plugin.getPermissionHandler() != null) {
- plugin.getPermissionHandler().login(player);
- }
+ synchronized (loginSessionLock(player.getUniqueId())) {
+ if (pendingLoginSessions.get(player.getUniqueId()) != player) return;
+ if (plugin.getPermissionHandler() != null) plugin.getPermissionHandler().login(player);
+ }
// Resolve UUID BEFORE constructing/getting the user
final String resolvedUuid = plugin.getOptions().isOnlineMode() ? player.getUniqueId().toString()
@@ -142,9 +155,9 @@ public void run() {
return;
}
- } catch (Exception e) {
- e.printStackTrace();
- }
+ } catch (Exception e) {
+ e.printStackTrace();
+ } finally { pendingLoginSessions.remove(joiningPlayer.getUniqueId(), joiningPlayer); }
}
}, 1500 + plugin.getOptions().getDelayLoginEventMs(), TimeUnit.MILLISECONDS);
}
@@ -155,12 +168,13 @@ public void onPlayerQuit(PlayerQuitEvent event) {
return;
}
- Player player = event.getPlayer();
- plugin.debug("Logout: " + player.getName() + " (" + player.getUniqueId() + ")");
-
- if (plugin.getPermissionHandler() != null) {
- plugin.getPermissionHandler().login(player);
- }
+ Player player = event.getPlayer();
+ plugin.debug("Logout: " + player.getName() + " (" + player.getUniqueId() + ")");
+
+ synchronized (loginSessionLock(player.getUniqueId())) {
+ pendingLoginSessions.remove(player.getUniqueId(), player);
+ if (plugin.getPermissionHandler() != null) plugin.getPermissionHandler().logout(player);
+ }
plugin.getLoginTimer().execute(new Runnable() {
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandlerThreadingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandlerThreadingTest.java
new file mode 100644
index 0000000000..fdd035cf48
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandlerThreadingTest.java
@@ -0,0 +1,256 @@
+package com.bencodez.advancedcore.api.permissions;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Server;
+import org.bukkit.entity.Player;
+import org.bukkit.permissions.PermissionAttachment;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import com.bencodez.advancedcore.AdvancedCorePlugin;
+import com.bencodez.simpleapi.scheduler.BukkitScheduler;
+import com.bencodez.simpleapi.time.ParsedDuration;
+
+class PlayerPermissionHandlerThreadingTest {
+ @Test
+ void offlineGrantsReuseTheHandlerPreservedByLogout() {
+ AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS);
+ when(plugin.getServerDataFile().getData()).thenReturn(null);
+ PermissionHandler manager = new PermissionHandler(plugin);
+ UUID uuid = UUID.randomUUID();
+ PlayerPermissionHandler preserved = new PlayerPermissionHandler(uuid, null, manager)
+ .addOfflinePerm("existing.use", ParsedDuration.empty());
+ manager.getPermsToAdd().put(uuid, preserved);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null);
+ manager.addPermission(uuid, "new.use");
+ assertSame(preserved, manager.getPermsToAdd().get(uuid));
+ PermissionAttachment attachment = mock(PermissionAttachment.class);
+ preserved.setAttachment(attachment);
+ preserved.onLogin(mock(Player.class));
+ verify(attachment).setPermission("existing.use", true);
+ verify(attachment).setPermission("new.use", true);
+ } finally { manager.getTimer().shutdownNow(); }
+ }
+
+ @Test
+ void entitySchedulerRejectionRetriesCurrentExpiration() {
+ AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS);
+ when(plugin.getServerDataFile().getData()).thenReturn(null);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ PermissionHandler manager = spy(new PermissionHandler(plugin));
+ PlayerPermissionHandler handle = mock(PlayerPermissionHandler.class);
+ UUID uuid = UUID.randomUUID();
+ long expiry = System.currentTimeMillis();
+ when(handle.getUuid()).thenReturn(uuid);
+ when(handle.isExpirationCurrent("example.use", expiry)).thenReturn(true);
+ Player player = mock(Player.class);
+ doAnswer(call -> { ((Runnable) call.getArgument(1)).run(); return null; })
+ .when(scheduler).runTask(eq(plugin), any(Runnable.class));
+ doThrow(new IllegalStateException("entity retired"))
+ .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player));
+ doNothing().when(manager).scheduleExpiration(handle, "example.use", expiry, 1_000L);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class));
+ bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player);
+ manager.dispatchExpiration(handle, "example.use", expiry);
+ verify(manager).scheduleExpiration(handle, "example.use", expiry, 1_000L);
+ verify(handle, never()).expirePermission(anyString(), anyLong(), anyBoolean());
+ } finally { manager.getTimer().shutdownNow(); }
+ }
+
+ @Test
+ void staleExpirationCannotRevokeExtendedPermission() {
+ PermissionHandler manager = mock(PermissionHandler.class);
+ PermissionAttachment attachment = mock(PermissionAttachment.class);
+ when(attachment.getPermissions()).thenReturn(new java.util.LinkedHashMap<>());
+ PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), attachment, manager);
+
+ handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000));
+ long firstExpiry = handler.getTimedPermissions().get("example.use");
+ handler.addExpiration("example.use", ParsedDuration.ofMillis(120_000));
+ long extendedExpiry = handler.getTimedPermissions().get("example.use");
+ assertTrue(extendedExpiry >= firstExpiry);
+ clearInvocations(attachment);
+
+ handler.expirePermission("example.use", firstExpiry, true);
+
+ assertEquals(extendedExpiry, handler.getTimedPermissions().get("example.use"));
+ verify(attachment, never()).unsetPermission("example.use");
+ }
+
+ @Test
+ void offOwnerExpirationUpdatesStateWithoutTouchingAttachment() {
+ PermissionHandler manager = mock(PermissionHandler.class);
+ PermissionAttachment attachment = mock(PermissionAttachment.class);
+ when(attachment.getPermissions()).thenReturn(new java.util.LinkedHashMap<>());
+ PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), attachment, manager);
+
+ handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000));
+ long expiry = System.currentTimeMillis() - 1L;
+ handler.getTimedPermissions().put("example.use", expiry);
+ clearInvocations(attachment);
+ handler.expirePermission("example.use", expiry, false);
+
+ assertFalse(handler.getTimedPermissions().containsKey("example.use"));
+ verify(attachment, never()).unsetPermission(anyString());
+ verify(attachment, never()).setPermission(anyString(), anyBoolean());
+ }
+
+ @Test
+ void offlineExpirationDropsEmptyHandlerDespiteStaleAttachmentState() {
+ PermissionHandler manager = mock(PermissionHandler.class);
+ PermissionAttachment attachment = mock(PermissionAttachment.class);
+ when(attachment.getPermissions()).thenReturn(java.util.Map.of("example.use", true));
+ UUID uuid = UUID.randomUUID();
+ PlayerPermissionHandler handler = new PlayerPermissionHandler(uuid, attachment, manager);
+ handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000));
+ long expiry = System.currentTimeMillis() - 1L;
+ handler.getTimedPermissions().put("example.use", expiry);
+ clearInvocations(attachment);
+
+ handler.expirePermission("example.use", expiry, false);
+
+ verify(manager).removePermissionIfEmpty(uuid, handler, true);
+ verify(attachment, never()).unsetPermission(anyString());
+ verify(attachment, never()).setPermission(anyString(), anyBoolean());
+ }
+
+ @Test
+ void expirationClearsAttachmentEntryInsteadOfInstallingDenial() {
+ PermissionHandler manager = mock(PermissionHandler.class);
+ PermissionAttachment attachment = mock(PermissionAttachment.class);
+ when(attachment.getPermissions()).thenReturn(new java.util.LinkedHashMap<>());
+ PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), attachment, manager);
+ handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000));
+ long expiry = System.currentTimeMillis() - 1L;
+ handler.getTimedPermissions().put("example.use", expiry);
+ clearInvocations(attachment);
+ handler.expirePermission("example.use", expiry, true);
+ verify(attachment).unsetPermission("example.use");
+ verify(attachment, never()).setPermission("example.use", false);
+ }
+
+ @Test
+ void earlyTimerReschedulesInsteadOfRevokingPermission() {
+ PermissionHandler manager = mock(PermissionHandler.class);
+ PermissionAttachment attachment = mock(PermissionAttachment.class);
+ PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), attachment, manager);
+ handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000));
+ long expiry = handler.getTimedPermissions().get("example.use");
+ clearInvocations(manager, attachment);
+
+ handler.expirePermission("example.use", expiry, true);
+
+ assertTrue(handler.isExpirationCurrent("example.use", expiry));
+ verify(manager).scheduleExpiration(eq(handler), eq("example.use"), eq(expiry), longThat(delay -> delay > 0));
+ verify(attachment, never()).unsetPermission(anyString());
+ }
+
+ @Test
+ void oldTimedExpirationPreservesQueuedRegrant() {
+ PermissionHandler manager = mock(PermissionHandler.class);
+ PermissionAttachment attachment = mock(PermissionAttachment.class);
+ PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), null, manager);
+ handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000));
+ long expiry = System.currentTimeMillis() - 1L;
+ handler.getTimedPermissions().put("example.use", expiry);
+ handler.addOfflinePerm("example.use", ParsedDuration.empty());
+
+ handler.expirePermission("example.use", expiry, false);
+ handler.setAttachment(attachment);
+ handler.onLogin(mock(Player.class));
+
+ verify(attachment).setPermission("example.use", true);
+ }
+
+ @Test
+ void logoutHandoffIsAtomicWithOfflineGrant() throws Exception {
+ AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS);
+ when(plugin.getServerDataFile().getData()).thenReturn(null);
+ PermissionHandler manager = new PermissionHandler(plugin);
+ UUID uuid = UUID.randomUUID();
+ Player player = mock(Player.class);
+ PermissionAttachment oldAttachment = mock(PermissionAttachment.class);
+ when(player.getUniqueId()).thenReturn(uuid);
+ PlayerPermissionHandler active = new PlayerPermissionHandler(uuid, oldAttachment, manager).addPerm("existing.use");
+ manager.getPerms().put(uuid, active);
+ java.util.concurrent.CountDownLatch logoutEntered = new java.util.concurrent.CountDownLatch(1);
+ java.util.concurrent.CountDownLatch releaseLogout = new java.util.concurrent.CountDownLatch(1);
+ doAnswer(call -> {
+ logoutEntered.countDown();
+ releaseLogout.await();
+ return null;
+ }).when(player).removeAttachment(oldAttachment);
+ java.util.concurrent.atomic.AtomicBoolean grantFinished = new java.util.concurrent.atomic.AtomicBoolean();
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null);
+ Thread logout = new Thread(() -> manager.logout(player));
+ Thread grant = new Thread(() -> {
+ manager.addPermission(uuid, "new.use");
+ grantFinished.set(true);
+ });
+ logout.start();
+ assertTrue(logoutEntered.await(1, java.util.concurrent.TimeUnit.SECONDS));
+ grant.start();
+ Thread.sleep(50L);
+ assertFalse(grantFinished.get(), "grant must wait for the map handoff");
+ releaseLogout.countDown();
+ logout.join(1_000L);
+ grant.join(1_000L);
+ assertFalse(logout.isAlive());
+ assertFalse(grant.isAlive());
+ assertSame(active, manager.getPermsToAdd().get(uuid));
+ PermissionAttachment newAttachment = mock(PermissionAttachment.class);
+ active.setAttachment(newAttachment);
+ active.onLogin(player);
+ verify(newAttachment).setPermission("existing.use", true);
+ verify(newAttachment).setPermission("new.use", true);
+ } finally {
+ releaseLogout.countDown();
+ manager.getTimer().shutdownNow();
+ }
+ }
+
+ @Test
+ void offlineExpirationCannotRaceACompletedLogin() {
+ AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS);
+ when(plugin.getServerDataFile().getData()).thenReturn(null);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ PermissionHandler manager = spy(new PermissionHandler(plugin));
+ UUID uuid = UUID.randomUUID();
+ PlayerPermissionHandler handle = mock(PlayerPermissionHandler.class);
+ when(handle.getUuid()).thenReturn(uuid);
+ long expiry = System.currentTimeMillis() - 1L;
+ when(handle.isExpirationCurrent("example.use", expiry)).thenReturn(true);
+ manager.getPermsToAdd().put(uuid, handle);
+ java.util.concurrent.atomic.AtomicReference global = new java.util.concurrent.atomic.AtomicReference<>();
+ doAnswer(call -> { global.set(call.getArgument(1)); return null; })
+ .when(scheduler).runTask(eq(plugin), any(Runnable.class));
+ doNothing().when(manager).scheduleExpiration(handle, "example.use", expiry, 1_000L);
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(uuid);
+ when(player.addAttachment(plugin)).thenReturn(mock(PermissionAttachment.class));
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null);
+ manager.dispatchExpiration(handle, "example.use", expiry);
+ manager.login(player);
+ global.get().run();
+ verify(handle, never()).expirePermission("example.use", expiry, false);
+ verify(manager).scheduleExpiration(handle, "example.use", expiry, 1_000L);
+ } finally { manager.getTimer().shutdownNow(); }
+ }
+
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java
new file mode 100644
index 0000000000..a019453273
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java
@@ -0,0 +1,46 @@
+package com.bencodez.advancedcore.listeners;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.UUID;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import com.bencodez.advancedcore.AdvancedCorePlugin;
+import com.bencodez.advancedcore.api.permissions.PermissionHandler;
+
+class PlayerJoinEventSessionTest {
+ @Test
+ void delayedLoginDoesNotRestorePermissionsAfterThePlayerQuit() {
+ AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS);
+ ScheduledExecutorService loginTimer = mock(ScheduledExecutorService.class);
+ PermissionHandler permissions = mock(PermissionHandler.class);
+ when(plugin.isEnabled()).thenReturn(true);
+ when(plugin.isLoadUserData()).thenReturn(true);
+ when(plugin.getOptions().isHideLoginMessage()).thenReturn(true);
+ when(plugin.getLoginTimer()).thenReturn(loginTimer);
+ when(plugin.getPermissionHandler()).thenReturn(permissions);
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+ when(player.getName()).thenReturn("Player");
+ org.bukkit.event.player.PlayerJoinEvent join = mock(org.bukkit.event.player.PlayerJoinEvent.class);
+ when(join.getPlayer()).thenReturn(player);
+ org.bukkit.event.player.PlayerQuitEvent quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
+ when(quit.getPlayer()).thenReturn(player);
+ ArgumentCaptor delayed = ArgumentCaptor.forClass(Runnable.class);
+
+ PlayerJoinEvent listener = new PlayerJoinEvent(plugin);
+ listener.onPlayerLogin(join);
+ verify(loginTimer).schedule(delayed.capture(), anyLong(), eq(TimeUnit.MILLISECONDS));
+ listener.onPlayerQuit(quit);
+ delayed.getValue().run();
+
+ verify(permissions).logout(player);
+ verify(permissions, never()).login(any());
+ }
+}