From 86064791ef814e9628b4fccb0549e10ab87760fe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 03:03:16 +0000 Subject: [PATCH 1/4] fix: prevent heartbeat drops from transient API lag The Request library uses HttpClient with no timeout configured, so any network hiccup causes execute() to block indefinitely and eventually throw, which previously kicked all players on the very first failure. Two fixes applied to both velocity and waterfall Heartbeat: - Wrap req.execute() in a CompletableFuture with a 10s timeout so a slow/hung request fails fast rather than blocking forever - Track consecutive failures with AtomicInteger; only kick all players after 3 consecutive failures, not on the first transient error https://claude.ai/code/session_01SRyGmN3Re6fQsQDMxeL9Z3 --- .../zander/velocity/util/api/Heartbeat.java | 59 +++++++++++-------- .../zander/waterfall/util/api/Heartbeat.java | 54 ++++++++++------- 2 files changed, 67 insertions(+), 46 deletions(-) diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/api/Heartbeat.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/api/Heartbeat.java index 263f10c..bd4e975 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/api/Heartbeat.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/api/Heartbeat.java @@ -8,49 +8,60 @@ import net.kyori.adventure.text.format.NamedTextColor; import org.modularsoft.zander.velocity.ZanderVelocityMain; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; public class Heartbeat { + private static final int MAX_CONSECUTIVE_FAILURES = 3; + private static final int REQUEST_TIMEOUT_SECONDS = 10; + public static void startHeartbeatTask() { String BaseAPIURL = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); String APIKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); + AtomicInteger consecutiveFailures = new AtomicInteger(0); + ZanderVelocityMain.getProxy().getScheduler().buildTask(ZanderVelocityMain.getInstance(), () -> { try { - // Your existing code here - // GET request to link to rules. Request req = Request.builder() .setURL(BaseAPIURL + "/heartbeat") .setMethod(Request.Method.GET) .addHeader("x-access-token", APIKey) .build(); - Response res = req.execute(); + Response res = CompletableFuture.supplyAsync(req::execute) + .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + String json = res.getBody(); Boolean heartbeat = JsonPath.read(json, "$.success"); - System.out.println("API Heartbeat Success"); - - // Check if the heartbeat is not successful - if (!heartbeat) { - // Kick all players - ZanderVelocityMain.getProxy().getAllPlayers().forEach(player -> { - Component message = Component.text("API Heartbeat Failed, the server is temporarily offline.") - .color(NamedTextColor.RED); - player.disconnect(message); - }); + + if (heartbeat) { + consecutiveFailures.set(0); + System.out.println("API Heartbeat Success"); + } else { + int failures = consecutiveFailures.incrementAndGet(); + ZanderVelocityMain.getLogger().warn("API Heartbeat returned failure ({}/{})", failures, MAX_CONSECUTIVE_FAILURES); + if (failures >= MAX_CONSECUTIVE_FAILURES) { + kickAllPlayers(); + } } } catch (Exception e) { - // Handle exceptions here - e.printStackTrace(); - ZanderVelocityMain.getLogger().error("API Heartbeat Failed, kicking all players until back online."); - - // Kick all players - ZanderVelocityMain.getProxy().getAllPlayers().forEach(player -> { - Component message = Component.text("API Heartbeat Failed, the server is temporarily offline.") - .color(NamedTextColor.RED); - player.disconnect(message); - }); + int failures = consecutiveFailures.incrementAndGet(); + ZanderVelocityMain.getLogger().error("API Heartbeat error ({}/{}): {}", failures, MAX_CONSECUTIVE_FAILURES, e.getMessage()); + if (failures >= MAX_CONSECUTIVE_FAILURES) { + ZanderVelocityMain.getLogger().error("API Heartbeat failed {} consecutive times, kicking all players.", MAX_CONSECUTIVE_FAILURES); + kickAllPlayers(); + } } }).repeat(60, TimeUnit.SECONDS).schedule(); } -} \ No newline at end of file + + private static void kickAllPlayers() { + ZanderVelocityMain.getProxy().getAllPlayers().forEach(player -> { + Component message = Component.text("API Heartbeat Failed, the server is temporarily offline.") + .color(NamedTextColor.RED); + player.disconnect(message); + }); + } +} diff --git a/zander-waterfall/src/main/java/org/modularsoft/zander/waterfall/util/api/Heartbeat.java b/zander-waterfall/src/main/java/org/modularsoft/zander/waterfall/util/api/Heartbeat.java index e31dbba..49121cc 100644 --- a/zander-waterfall/src/main/java/org/modularsoft/zander/waterfall/util/api/Heartbeat.java +++ b/zander-waterfall/src/main/java/org/modularsoft/zander/waterfall/util/api/Heartbeat.java @@ -6,49 +6,59 @@ import net.md_5.bungee.api.ProxyServer; import org.modularsoft.zander.waterfall.ConfigurationManager; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; public class Heartbeat { + private static final int MAX_CONSECUTIVE_FAILURES = 3; + private static final int REQUEST_TIMEOUT_SECONDS = 10; + public static void startHeartbeatTask() { - // Create a ScheduledExecutorService with a single thread ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); - // Schedule the task to run every 60 seconds + AtomicInteger consecutiveFailures = new AtomicInteger(0); + scheduler.scheduleAtFixedRate(() -> { try { - // Your existing code here - // GET request to link to rules. Request req = Request.builder() .setURL(ConfigurationManager.getConfig().get("BaseAPIURL") + "/heartbeat") .setMethod(Request.Method.GET) .addHeader("x-access-token", String.valueOf(ConfigurationManager.getConfig().get("APIKey"))) .build(); - Response res = req.execute(); + Response res = CompletableFuture.supplyAsync(req::execute) + .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + String json = res.getBody(); Boolean heartbeat = JsonPath.read(json, "$.success"); - System.out.println("API Heartbeat Success"); - - // Check if the heartbeat is not successful - if (!heartbeat) { - // Kick all players - ProxyServer.getInstance().getPlayers().forEach(player -> { - player.disconnect("API Heartbeat Failed, the server is temporarily offline."); - }); + + if (heartbeat) { + consecutiveFailures.set(0); + System.out.println("API Heartbeat Success"); + } else { + int failures = consecutiveFailures.incrementAndGet(); + System.out.println("API Heartbeat returned failure (" + failures + "/" + MAX_CONSECUTIVE_FAILURES + ")"); + if (failures >= MAX_CONSECUTIVE_FAILURES) { + kickAllPlayers(); + } } } catch (Exception e) { - // Handle exceptions here - e.printStackTrace(); - - System.out.println("API Heartbeat Failed, kicking all players until back online."); - - // Kick all players - ProxyServer.getInstance().getPlayers().forEach(player -> { - player.disconnect("API Heartbeat Failed, the server is temporarily offline."); - }); + int failures = consecutiveFailures.incrementAndGet(); + System.out.println("API Heartbeat error (" + failures + "/" + MAX_CONSECUTIVE_FAILURES + "): " + e.getMessage()); + if (failures >= MAX_CONSECUTIVE_FAILURES) { + System.out.println("API Heartbeat failed " + MAX_CONSECUTIVE_FAILURES + " consecutive times, kicking all players."); + kickAllPlayers(); + } } }, 0, 60, TimeUnit.SECONDS); } + + private static void kickAllPlayers() { + ProxyServer.getInstance().getPlayers().forEach(player -> { + player.disconnect("API Heartbeat Failed, the server is temporarily offline."); + }); + } } From cff3105f9f0f4a9d77f7695b30a3a4a8fd3aaef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 13:43:04 +0000 Subject: [PATCH 2/4] fix: remove duplicate build tag in zander-hub pom.xml https://claude.ai/code/session_01SRyGmN3Re6fQsQDMxeL9Z3 --- zander-hub/pom.xml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/zander-hub/pom.xml b/zander-hub/pom.xml index 30d959b..fba0a52 100644 --- a/zander-hub/pom.xml +++ b/zander-hub/pom.xml @@ -11,15 +11,6 @@ zander-hub 1.0 - - - - src/main/resources - true - - - - papermc From f3e2b321e066ddedba16a498b29d6e474696168a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 13:45:25 +0000 Subject: [PATCH 3/4] fix: remove duplicate variable declarations in UserOnLogin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseAPIURL and APIKey were declared twice — once in the outer method scope and again inside the lambda, causing a compilation error. Removed the redundant inner declarations; the lambda captures them from the outer scope. https://claude.ai/code/session_01SRyGmN3Re6fQsQDMxeL9Z3 --- .../zander/velocity/events/session/UserOnLogin.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/session/UserOnLogin.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/session/UserOnLogin.java index cb70099..e65ec1b 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/session/UserOnLogin.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/session/UserOnLogin.java @@ -20,9 +20,6 @@ public void UserLoginEvent (PostLoginEvent event) { ZanderVelocityMain.getPrivateMessageService().updateNameCache(player.getUniqueId(), player.getUsername()); ZanderVelocityMain.getProxy().getScheduler().buildTask(ZanderVelocityMain.getInstance(), () -> { - String BaseAPIURL = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); - String APIKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); - try { // // Send User Creation API POST for new user From fb2720c01ededa595c089d34552a93620735aa3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 00:33:14 +0000 Subject: [PATCH 4/4] fix: stop double-sending chat messages The Velocity plugin was manually formatting and broadcasting chat to players, then calling ChatResult.denied() to suppress the default forward. However, SignedVelocity (a required dependency) intercepts signed chat packets at the network level and forwards them to the backend before Velocity's event result is evaluated, causing both the proxy-formatted message and the backend's chat plugin output to appear simultaneously. Reverted to the same approach used in the Waterfall version: let chat pass through to the backend normally, only deny when the filter blocks content. The backend's chat plugin handles formatting. https://claude.ai/code/session_01SRyGmN3Re6fQsQDMxeL9Z3 --- .../zander/velocity/events/UserChatEvent.java | 182 ------------------ 1 file changed, 182 deletions(-) diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java index ebcc06c..2359822 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java @@ -8,28 +8,17 @@ import io.github.ModularEnigma.Request; import io.github.ModularEnigma.Response; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.modularsoft.zander.velocity.ZanderVelocityMain; import org.modularsoft.zander.velocity.model.Filter; import org.modularsoft.zander.velocity.model.discord.DiscordChat; -import java.lang.reflect.Method; -import java.util.List; -import java.util.Map; -import java.util.Optional; - public class UserChatEvent { - private final MiniMessage miniMessage = MiniMessage.miniMessage(); - @Subscribe public void UserChatEvent(PlayerChatEvent event) { Player player = event.getPlayer(); String rawMessage = event.getMessage(); - Component originalMessage = Component.text(rawMessage); String BaseAPIURL = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); String APIKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); @@ -79,15 +68,6 @@ public void UserChatEvent(PlayerChatEvent event) { Response discordChatReqRes = discordChatReq.execute(); ZanderVelocityMain.getLogger().info("Response (" + discordChatReqRes.getStatusCode() + "): " + discordChatReqRes.getBody()); } - - Component formattedMessage = formatChatMessage(player, originalMessage); - player.getCurrentServer() - .map(serverConnection -> serverConnection.getServer()) - .ifPresentOrElse( - server -> server.getPlayersConnected().forEach(target -> target.sendMessage(formattedMessage)), - () -> player.sendMessage(formattedMessage) - ); - event.setResult(PlayerChatEvent.ChatResult.denied()); } catch (Exception e) { Component builder = Component.text("The chat filter could not be reached at this time, there maybe an issue with the API.").color(NamedTextColor.YELLOW); player.sendMessage(builder); @@ -95,166 +75,4 @@ public void UserChatEvent(PlayerChatEvent event) { ZanderVelocityMain.getLogger().error("Chat filter error for player {}", player.getUsername(), e); } } - - private Component formatChatMessage(Player player, Component originalMessage) { - LuckPermsMeta metaData = resolveLuckPermsMeta(player).orElse(null); - Component rankPrefix = buildRankPrefix(metaData); - - return Component.text() - .append(rankPrefix) - .append(Component.space()) - .append(Component.text(player.getUsername())) - .append(Component.text(": ")) - .append(originalMessage) - .build(); - } - - private Component buildRankPrefix(LuckPermsMeta metaData) { - String prefix = metaData != null ? metaData.prefix : null; - String rankNameMeta = getMetaValue(metaData, "displayname"); - String rankDescriptionMeta = getMetaValue(metaData, "rank_description"); - - String rankName = (rankNameMeta != null && !rankNameMeta.isBlank()) - ? rankNameMeta - : (prefix != null && !prefix.isBlank() ? prefix : "Member"); - String rankDescription = (rankDescriptionMeta != null && !rankDescriptionMeta.isBlank()) - ? rankDescriptionMeta - : "No description set for this rank."; - - rankName = stripLegacy(rankName); - rankDescription = stripLegacy(rankDescription); - - Component prefixComponent = buildPrefixComponent(prefix, rankName); - Component hoverText = Component.text() - .append(prefixComponent) - .append(Component.space()) - .append(Component.text(rankName).color(NamedTextColor.GOLD)) - .append(Component.newline()) - .append(Component.text(rankDescription).color(NamedTextColor.GRAY)) - .build(); - - return prefixComponent.hoverEvent(HoverEvent.showText(hoverText)); - } - - private String getMetaValue(LuckPermsMeta metaData, String baseKey) { - if (metaData == null) { - return null; - } - String scopedKey = null; - if (metaData.primaryGroup != null && !metaData.primaryGroup.isBlank()) { - scopedKey = baseKey + "." + metaData.primaryGroup; - } - if (scopedKey != null) { - String scopedValue = metaData.metaValues.get(scopedKey); - if (scopedValue != null && !scopedValue.isBlank()) { - return scopedValue; - } - } - String directValue = metaData.metaValues.get(baseKey); - if (directValue != null && !directValue.isBlank()) { - return directValue; - } - return null; - } - - private Component buildPrefixComponent(String prefix, String rankName) { - if (prefix != null && !prefix.isBlank()) { - return LegacyComponentSerializer.legacyAmpersand().deserialize(prefix); - } - - String miniMessagePrefix = "[" - + escapeMiniMessageContent(rankName) - + "]"; - return miniMessage.deserialize(miniMessagePrefix); - } - - private String escapeMiniMessageContent(String input) { - return input.replace("<", "\\<").replace(">", "\\>"); - } - - private String stripLegacy(String input) { - return input.replaceAll("§.", "").replaceAll("&.", ""); - } - - private Optional resolveLuckPermsMeta(Player player) { - try { - Class providerClass = Class.forName("net.luckperms.api.LuckPermsProvider"); - Object luckPerms = providerClass.getMethod("get").invoke(null); - Object playerAdapter = luckPerms.getClass().getMethod("getPlayerAdapter", Class.class) - .invoke(luckPerms, Player.class); - Object user = playerAdapter.getClass().getMethod("getUser", Player.class).invoke(playerAdapter, player); - Object metaData = playerAdapter.getClass().getMethod("getMetaData", Player.class).invoke(playerAdapter, player); - - String prefix = null; - String primaryGroup = null; - Map> metaMap = Map.of(); - - if (user != null) { - Method primaryGroupMethod = user.getClass().getMethod("getPrimaryGroup"); - Object primaryGroupResult = primaryGroupMethod.invoke(user); - if (primaryGroupResult instanceof String) { - primaryGroup = (String) primaryGroupResult; - } - } - - if (metaData != null) { - Method prefixMethod = metaData.getClass().getMethod("getPrefix"); - Object prefixResult = prefixMethod.invoke(metaData); - if (prefixResult instanceof String) { - prefix = (String) prefixResult; - } - Method metaMethod = metaData.getClass().getMethod("getMeta"); - Object metaResult = metaMethod.invoke(metaData); - if (metaResult instanceof Map rawMeta) { - metaMap = castMetaMap(rawMeta); - } - } - - return Optional.of(new LuckPermsMeta(prefix, primaryGroup, flattenMeta(metaMap))); - } catch (ReflectiveOperationException | LinkageError ignored) { - return Optional.empty(); - } - } - - private Map flattenMeta(Map> metaMap) { - Map flattened = new java.util.HashMap<>(); - for (Map.Entry> entry : metaMap.entrySet()) { - List values = entry.getValue(); - if (values == null) { - continue; - } - for (String value : values) { - if (value != null && !value.isBlank()) { - flattened.put(entry.getKey(), value); - break; - } - } - } - return flattened; - } - - @SuppressWarnings("unchecked") - private Map> castMetaMap(Map rawMeta) { - Map> casted = new java.util.HashMap<>(); - for (Map.Entry entry : rawMeta.entrySet()) { - Object key = entry.getKey(); - Object value = entry.getValue(); - if (key instanceof String && value instanceof List listValue) { - casted.put((String) key, (List) listValue); - } - } - return casted; - } - - private static class LuckPermsMeta { - private final String prefix; - private final String primaryGroup; - private final Map metaValues; - - private LuckPermsMeta(String prefix, String primaryGroup, Map metaValues) { - this.prefix = prefix; - this.primaryGroup = primaryGroup; - this.metaValues = metaValues; - } - } }