Skip to content

Fix SessionManager world retention after player quit and world unload#2281

Open
ikoliHU wants to merge 2 commits into
EngineHub:version/7.0.xfrom
ikoliHU:fix/session-cache-world-retention
Open

Fix SessionManager world retention after player quit and world unload#2281
ikoliHU wants to merge 2 commits into
EngineHub:version/7.0.xfrom
ikoliHU:fix/session-cache-world-retention

Conversation

@ikoliHU
Copy link
Copy Markdown

@ikoliHU ikoliHU commented May 29, 2026

Problem

SessionManager can keep unloaded worlds reachable through cached player/session state.

This is visible with island/Slime worlds. After a player leaves and the world is unloaded, a heap dump can still show the world retained through:

SlimeInMemoryWorld
-> SlimeLevelInstance
-> ServerPlayer
-> CraftPlayer
-> WorldPlayer (LocalPlayer)
-> WorldPlayerTuple
-> com.google.common.cache.LocalCache$StrongAccessWriteEntry
-> com.sk89q.worldguard.bukkit.session.SessionManager

There are two relevant cache paths:

  1. The bypass cache uses LoadingCache<WorldPlayerTuple, Boolean>. WorldPlayerTuple strongly references both the World and the LocalPlayer, which also keeps the platform player object reachable. The cache uses expireAfterWrite(2s), but Guava removes expired entries lazily during cache operations. If the cache is idle, an expired entry can remain reachable longer than intended.

  2. The session cache uses Cache<CacheKey, Session>. The key weakly references the player, but the Session value can still retain the last world through Session#lastValid. On quit the session is uninitialized, but the cache entry is not removed.

Changes

  • Add SessionManager#forget(LocalPlayer).
  • Add SessionManager#forgetWorld(World).
  • Invalidate matching session and bypass-cache entries from those methods.
  • Add cache cleanup in AbstractSessionManager.
  • Call forget(player) after session uninitialization on PlayerQuitEvent.
  • Call forgetWorld(world) on WorldUnloadEvent.
  • Run cache cleanup on each session tick.

Result

  • Player quit removes the related session and bypass-cache entries.
  • World unload removes bypass-cache entries for that world.
  • Expired bypass-cache entries are cleaned up on the next session tick instead of waiting for another unrelated cache operation.
  • Plugins using short-lived worlds can call forget or forgetWorld directly instead of accessing WorldGuard internals.

Testing

  • :worldguard-core:compileJava passed.
  • :worldguard-bukkit:compileJava passed.
  • :worldguard-core:checkstyleMain passed.
  • :worldguard-bukkit:checkstyleMain passed.
  • Repeated player enter, leave/disconnect, and world unload cycles no longer leave SlimeInMemoryWorld or SlimeLevelInstance retained through SessionManager in heap dumps.

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.
@content

This comment was marked as low quality.

@me4502
Copy link
Copy Markdown
Member

me4502 commented May 30, 2026

Thanks for the PR, I haven't had a chance to look through it properly, but from a quick look it seems to break some functionality. Sessions are meant to stay around for a while after the player leaves, so clearing them from the cache breaks some functionality there. Clearing the world itself is likely fine, but not removing the session itself. That should likely instead be handled by setting lastValid to null in the uninitialize function, rather than clearing the player from the cache.

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.
@ikoliHU
Copy link
Copy Markdown
Author

ikoliHU commented May 30, 2026

Thanks for the review, you were right. I pushed a follow-up with the suggested approach.

Updated from the previous version:

  • Removed the forget(localPlayer) call on player quit, so sessions stay cached as intended.
  • Removed the now-unused SessionManager#forget(LocalPlayer).
  • Session#uninitialize now clears lastValid and lastRegionSet, so the lingering session no longer holds world/region references.

I kept the world-level cleanup:

  • forgetWorld(World) + WorldUnloadEvent listener for the bypass cache.
  • Periodic cache cleanup for idle expired bypass-cache entries.

I tested these changes as well, and my tests still passed. SlimeInMemoryWorld instances no longer got stuck.

I hope this is the change you had in mind.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants