From 5080f11b97d15c7d35f9f7cc5d229ccdb27ecc78 Mon Sep 17 00:00:00 2001 From: ikoli Date: Fri, 29 May 2026 14:17:14 +0200 Subject: [PATCH 1/2] fix: clear session caches on player quit and world unload The bypass cache is keyed by WorldPlayerTuple, which holds strong references to both the world and the player (and thus the platform player object and its world). With expireAfterWrite the entries are only evicted lazily, so when the cache goes idle an entry can keep a world reachable long after its 2 second lifetime. The session cache value likewise retains the last world through Session#lastValid for up to the 10 minute access timeout. Add SessionManager#forget(LocalPlayer) and #forgetWorld(World) to explicitly drop these entries, call forget on player quit, forgetWorld on world unload, and force cache cleanup on each session tick so idle entries cannot pin a player or world indefinitely. --- .../bukkit/listener/PlayerMoveListener.java | 5 ++++ .../bukkit/session/BukkitSessionManager.java | 12 ++++++++ .../session/AbstractSessionManager.java | 29 +++++++++++++++++++ .../worldguard/session/SessionManager.java | 26 +++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/listener/PlayerMoveListener.java b/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/listener/PlayerMoveListener.java index 9d7051136..9e92b8eaa 100644 --- a/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/listener/PlayerMoveListener.java +++ b/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/listener/PlayerMoveListener.java @@ -169,6 +169,11 @@ public void onPlayerQuit(PlayerQuitEvent event) { } session.uninitialize(localPlayer); + + // Drop the cached session and bypass entries so the manager does not + // keep the (now offline) player and their last world reachable until + // the caches expire. + WorldGuard.getInstance().getPlatform().getSessionManager().forget(localPlayer); } /** diff --git a/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/session/BukkitSessionManager.java b/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/session/BukkitSessionManager.java index 2e64fb738..f270d9d4d 100644 --- a/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/session/BukkitSessionManager.java +++ b/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/session/BukkitSessionManager.java @@ -19,6 +19,7 @@ package com.sk89q.worldguard.bukkit.session; +import com.sk89q.worldedit.bukkit.BukkitAdapter; import com.sk89q.worldedit.world.World; import com.sk89q.worldguard.LocalPlayer; import com.sk89q.worldguard.WorldGuard; @@ -32,6 +33,7 @@ import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; +import org.bukkit.event.world.WorldUnloadEvent; import java.util.function.Consumer; @@ -96,6 +98,16 @@ public void accept(Object ignored) { task.run(); } } + + // Force eviction of expired bypass/session entries. Without this the + // caches only evict lazily, so an idle entry can keep a player (and + // their world) reachable long after its logical lifetime. + cleanUpCaches(); + } + + @EventHandler + public void onWorldUnload(WorldUnloadEvent event) { + forgetWorld(BukkitAdapter.adapt(event.getWorld())); } @Override diff --git a/worldguard-core/src/main/java/com/sk89q/worldguard/session/AbstractSessionManager.java b/worldguard-core/src/main/java/com/sk89q/worldguard/session/AbstractSessionManager.java index 0f9a05882..41226140c 100644 --- a/worldguard-core/src/main/java/com/sk89q/worldguard/session/AbstractSessionManager.java +++ b/worldguard-core/src/main/java/com/sk89q/worldguard/session/AbstractSessionManager.java @@ -164,6 +164,35 @@ public Session getIfPresent(LocalPlayer player) { return getIfPresentInternal(new CacheKey(player)); } + @Override + public void forget(LocalPlayer player) { + checkNotNull(player, "player"); + UUID uuid = player.getUniqueId(); + sessions.invalidate(new CacheKey(player)); + bypassCache.asMap().keySet().removeIf( + tuple -> tuple.getPlayer().getUniqueId().equals(uuid)); + } + + @Override + public void forgetWorld(World world) { + checkNotNull(world, "world"); + bypassCache.asMap().keySet().removeIf(tuple -> tuple.getWorld().equals(world)); + } + + /** + * Force the caches to evict any entries that have already expired. + * + *

The bypass cache uses {@code expireAfterWrite} and the session cache + * uses {@code expireAfterAccess}, but Guava only evicts expired entries + * lazily during cache operations. When the caches go idle, an expired + * entry can keep a strong reference to a player or world alive long after + * its logical lifetime. Calling this periodically bounds that retention.

+ */ + protected void cleanUpCaches() { + bypassCache.cleanUp(); + sessions.cleanUp(); + } + private Session getIfPresentInternal(CacheKey cacheKey) { @Nullable Session session = sessions.getIfPresent(cacheKey); if (session != null) { diff --git a/worldguard-core/src/main/java/com/sk89q/worldguard/session/SessionManager.java b/worldguard-core/src/main/java/com/sk89q/worldguard/session/SessionManager.java index fce2bf340..b25acae57 100644 --- a/worldguard-core/src/main/java/com/sk89q/worldguard/session/SessionManager.java +++ b/worldguard-core/src/main/java/com/sk89q/worldguard/session/SessionManager.java @@ -108,6 +108,32 @@ public interface SessionManager { */ @Nullable Session getIfPresent(LocalPlayer player); + /** + * Forget a player, evicting any cached session and bypass information. + * + *

This should be called when a player is no longer being tracked + * (for example, when they disconnect) so that the session manager does + * not retain strong references to the player or the world they were last + * in until the caches happen to expire. Failing to forget a player keeps + * the underlying platform player object (and therefore its world) + * reachable for the lifetime of the cache entries.

+ * + * @param player The player to forget + */ + void forget(LocalPlayer player); + + /** + * Forget any cached bypass information that references the given world. + * + *

This should be called when a world is unloaded so that the bypass + * cache does not retain a strong reference to the world (and the players + * that were last checked in it) until the cache entries happen to + * expire.

+ * + * @param world The world to forget + */ + void forgetWorld(World world); + /** * Get a player's session. A session will be created if there is no * existing session for the player. From 9dcdee04d345ba6d4d530e403b0703a94f4eee18 Mon Sep 17 00:00:00 2001 From: ikoli Date: Sat, 30 May 2026 13:58:46 +0200 Subject: [PATCH 2/2] fix: retain sessions after quit, drop only world references Instead of forgetting the player, Session#uninitialize now clears lastValid and lastRegionSet, so the lingering session no longer keeps its last world reachable. The session itself stays cached. Also removed the now-unused SessionManager#forget(LocalPlayer). World-level clearing with forgetWorld and WorldUnloadEvent, plus the periodic cache cleanup, are retained. I tested the changes and my tests still passed. SlimeInMemoryWorld instances no longer got stuck. --- .../bukkit/listener/PlayerMoveListener.java | 5 ----- .../worldguard/session/AbstractSessionManager.java | 9 --------- .../java/com/sk89q/worldguard/session/Session.java | 3 +++ .../sk89q/worldguard/session/SessionManager.java | 14 -------------- 4 files changed, 3 insertions(+), 28 deletions(-) diff --git a/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/listener/PlayerMoveListener.java b/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/listener/PlayerMoveListener.java index 9e92b8eaa..9d7051136 100644 --- a/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/listener/PlayerMoveListener.java +++ b/worldguard-bukkit/src/main/java/com/sk89q/worldguard/bukkit/listener/PlayerMoveListener.java @@ -169,11 +169,6 @@ public void onPlayerQuit(PlayerQuitEvent event) { } session.uninitialize(localPlayer); - - // Drop the cached session and bypass entries so the manager does not - // keep the (now offline) player and their last world reachable until - // the caches expire. - WorldGuard.getInstance().getPlatform().getSessionManager().forget(localPlayer); } /** diff --git a/worldguard-core/src/main/java/com/sk89q/worldguard/session/AbstractSessionManager.java b/worldguard-core/src/main/java/com/sk89q/worldguard/session/AbstractSessionManager.java index 41226140c..2dfea9f25 100644 --- a/worldguard-core/src/main/java/com/sk89q/worldguard/session/AbstractSessionManager.java +++ b/worldguard-core/src/main/java/com/sk89q/worldguard/session/AbstractSessionManager.java @@ -164,15 +164,6 @@ public Session getIfPresent(LocalPlayer player) { return getIfPresentInternal(new CacheKey(player)); } - @Override - public void forget(LocalPlayer player) { - checkNotNull(player, "player"); - UUID uuid = player.getUniqueId(); - sessions.invalidate(new CacheKey(player)); - bypassCache.asMap().keySet().removeIf( - tuple -> tuple.getPlayer().getUniqueId().equals(uuid)); - } - @Override public void forgetWorld(World world) { checkNotNull(world, "world"); diff --git a/worldguard-core/src/main/java/com/sk89q/worldguard/session/Session.java b/worldguard-core/src/main/java/com/sk89q/worldguard/session/Session.java index f52f00cc7..baa9fe84e 100644 --- a/worldguard-core/src/main/java/com/sk89q/worldguard/session/Session.java +++ b/worldguard-core/src/main/java/com/sk89q/worldguard/session/Session.java @@ -143,6 +143,9 @@ public void uninitialize(LocalPlayer player) { for (Handler handler : handlers.values()) { handler.uninitialize(player, location, set); } + + lastValid = null; + lastRegionSet = null; } /** diff --git a/worldguard-core/src/main/java/com/sk89q/worldguard/session/SessionManager.java b/worldguard-core/src/main/java/com/sk89q/worldguard/session/SessionManager.java index b25acae57..809bfaa84 100644 --- a/worldguard-core/src/main/java/com/sk89q/worldguard/session/SessionManager.java +++ b/worldguard-core/src/main/java/com/sk89q/worldguard/session/SessionManager.java @@ -108,20 +108,6 @@ public interface SessionManager { */ @Nullable Session getIfPresent(LocalPlayer player); - /** - * Forget a player, evicting any cached session and bypass information. - * - *

This should be called when a player is no longer being tracked - * (for example, when they disconnect) so that the session manager does - * not retain strong references to the player or the world they were last - * in until the caches happen to expire. Failing to forget a player keeps - * the underlying platform player object (and therefore its world) - * reachable for the lifetime of the cache entries.

- * - * @param player The player to forget - */ - void forget(LocalPlayer player); - /** * Forget any cached bypass information that references the given world. *