From a036a0a34b1ceacdaf62b73728dbe2f3bcfe410a Mon Sep 17 00:00:00 2001 From: JRoy <10731363+JRoy@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:33:28 -0700 Subject: [PATCH] Fix Discord Link RoleSyncManager thread leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues causing unbounded thread growth: 1. The repeating sync task was never cancelled on plugin disable, leaking the timer itself. 2. Each sync() call spawns an async task that calls acquireUninterruptibly() on a 5-permit semaphore. The timer fires up to 50 sync calls per cycle, so 45 threads block indefinitely waiting for permits. Before they drain, the next cycle spawns 50 more. Threads accumulate until OOM. Fix: cancel the task on disable, and replace acquireUninterruptibly() with tryAcquire(5s timeout) in both sync() and unSync() so threads don't block indefinitely — skipped syncs retry on the next cycle. Fixes #6381 --- .../discordlink/EssentialsDiscordLink.java | 3 +++ .../discordlink/rolesync/RoleSyncManager.java | 27 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/EssentialsDiscordLink.java b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/EssentialsDiscordLink.java index 0d30cd92bf9..9da2bbef893 100644 --- a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/EssentialsDiscordLink.java +++ b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/EssentialsDiscordLink.java @@ -87,6 +87,9 @@ public void onEnable() { @Override public void onDisable() { + if (roleSyncManager != null) { + roleSyncManager.shutdown(); + } if (accounts != null) { accounts.shutdown(); } diff --git a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/rolesync/RoleSyncManager.java b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/rolesync/RoleSyncManager.java index 5a72ae4345a..72353afb735 100644 --- a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/rolesync/RoleSyncManager.java +++ b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/rolesync/RoleSyncManager.java @@ -10,6 +10,7 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.scheduler.BukkitTask; import java.util.ArrayList; import java.util.Collections; @@ -19,6 +20,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; import java.util.logging.Level; import static com.earth2me.essentials.I18n.tlLiteral; @@ -28,13 +30,14 @@ public class RoleSyncManager implements Listener { private final Map groupToRoleMap = new HashMap<>(); private final Map roleIdToGroupMap = new HashMap<>(); private final Semaphore syncSemaphore = new Semaphore(5); + private BukkitTask syncTask; private int syncCursor = 0; public RoleSyncManager(final EssentialsDiscordLink ess) { this.ess = ess; Bukkit.getPluginManager().registerEvents(this, ess); onReload(); - this.ess.getEss().runTaskTimerAsynchronously(() -> { + this.syncTask = this.ess.getEss().runTaskTimerAsynchronously(() -> { if (groupToRoleMap.isEmpty() && roleIdToGroupMap.isEmpty()) { return; } @@ -67,6 +70,12 @@ public RoleSyncManager(final EssentialsDiscordLink ess) { }, 0, ess.getSettings().getRoleSyncResyncDelay() * 1200L); } + public void shutdown() { + if (syncTask != null) { + syncTask.cancel(); + } + } + public void sync(final UUID uuid, final String discordId) { final Map groupToRoleMapCopy = new HashMap<>(groupToRoleMap); final Map roleIdToGroupMapCopy = new HashMap<>(roleIdToGroupMap); @@ -81,7 +90,13 @@ public void sync(final Player player, final String discordId, final Map groups = primaryOnly ? Collections.singletonList(ess.getEss().getPermissionsHandler().getGroup(player)) : ess.getEss().getPermissionsHandler().getGroups(player); ess.getEss().runTaskAsynchronously(() -> { - syncSemaphore.acquireUninterruptibly(); + try { + if (!syncSemaphore.tryAcquire(5, TimeUnit.SECONDS)) { + return; + } + } catch (final InterruptedException e) { + return; + } ess.getApi().getMemberById(discordId).thenCompose(member -> { if (member == null) { if (ess.getSettings().isUnlinkOnLeave()) { @@ -151,7 +166,13 @@ public void unSync(final UUID uuid, final String discordId) { } ess.getEss().runTaskAsynchronously(() -> { - syncSemaphore.acquireUninterruptibly(); + try { + if (!syncSemaphore.tryAcquire(5, TimeUnit.SECONDS)) { + return; + } + } catch (final InterruptedException e) { + return; + } ess.getApi().getMemberById(discordId).thenCompose(member -> { // Check if the member is no longer in the guild (null), they don't have any roles anyway. if (member == null) {