From 59b6e6534966146d9a9d49055cde7edbac98fc48 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 15:44:02 +0200 Subject: [PATCH 01/14] Run unban suggestions off the command thread --- .../sanctions/command/UnbanCommand.java | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java index e87318c..947af03 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java @@ -81,11 +81,26 @@ public String[] getAliases() { public CompletableFuture> suggestAsync(Invocation invocation) { - String[] a = invocation.arguments(); - String prefix = (a.length >= 1) ? a[0] : ""; - List names = feature.getService() - .suggestActiveTargetNames(nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType.BAN, prefix, 20); - return CompletableFuture.completedFuture(names); + String[] arguments = invocation.arguments(); + if (arguments.length > 1) { + return CompletableFuture.completedFuture(List.of()); + } + + String prefix = arguments.length == 1 ? arguments[0] : ""; + CompletableFuture> suggestions = new CompletableFuture<>(); + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + suggestions.complete(feature.getService().suggestActiveTargetNames( + nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType.BAN, + prefix, + 20 + )); + } catch (RuntimeException exception) { + feature.getLogger().error("[Sanctions] Failed to provide /unban suggestions: " + exception.getMessage()); + suggestions.complete(List.of()); + } + }); + return suggestions; } // helpers From 413f4263fb7a587ce34d8d012df325ad2d5033a3 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 15:44:25 +0200 Subject: [PATCH 02/14] Run unmute suggestions off the command thread --- .../sanctions/command/UnmuteCommand.java | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java index 92dc369..319a74c 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java @@ -82,17 +82,29 @@ public String getName() { return "unmute"; } + public String[] getAliases() { return new String[0]; } public CompletableFuture> suggestAsync(Invocation invocation) { - String[] a = invocation.arguments(); - String prefix = (a.length >= 1) ? a[0] : ""; - List names = feature.getService() - .suggestActiveTargetNames(SanctionType.MUTE, prefix, 20); - return CompletableFuture.completedFuture(names); + String[] arguments = invocation.arguments(); + if (arguments.length > 1) { + return CompletableFuture.completedFuture(List.of()); + } + + String prefix = arguments.length == 1 ? arguments[0] : ""; + CompletableFuture> suggestions = new CompletableFuture<>(); + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + suggestions.complete(feature.getService().suggestActiveTargetNames(SanctionType.MUTE, prefix, 20)); + } catch (RuntimeException exception) { + feature.getLogger().error("[Sanctions] Failed to provide /unmute suggestions: " + exception.getMessage()); + suggestions.complete(List.of()); + } + }); + return suggestions; } // helpers From d1a7486552dcddb890c9c1d2ad8b9afe250ab45c Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 15:44:46 +0200 Subject: [PATCH 03/14] Run IP unban suggestions off the command thread --- .../sanctions/command/UnbanIpCommand.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java index ce99a15..a44921d 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java @@ -68,10 +68,22 @@ public String[] getAliases() { public CompletableFuture> suggestAsync(Invocation invocation) { - String[] a = invocation.arguments(); - String prefix = (a.length >= 1) ? a[0] : ""; - List ips = feature.getService().suggestActiveBannedIps(prefix, 20); - return CompletableFuture.completedFuture(ips); + String[] arguments = invocation.arguments(); + if (arguments.length > 1) { + return CompletableFuture.completedFuture(List.of()); + } + + String prefix = arguments.length == 1 ? arguments[0] : ""; + CompletableFuture> suggestions = new CompletableFuture<>(); + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + suggestions.complete(feature.getService().suggestActiveBannedIps(prefix, 20)); + } catch (RuntimeException exception) { + feature.getLogger().error("[Sanctions] Failed to provide /unbanip suggestions: " + exception.getMessage()); + suggestions.complete(List.of()); + } + }); + return suggestions; } // helpers From 16349f81405c2cb3c84880efca94f77b56b729c3 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 15:46:09 +0200 Subject: [PATCH 04/14] Move friend database commands off Velocity event threads --- .../friends/command/AsyncFriendCommand.java | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java new file mode 100644 index 0000000..1625289 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java @@ -0,0 +1,153 @@ +package nl.hauntedmc.proxyfeatures.features.friends.command; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.api.proxy.Player; +import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.features.friends.Friends; + +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +/** + * Keeps database-backed friend command work away from Velocity's command and Netty threads. + */ +public final class AsyncFriendCommand implements FeatureCommand { + + private static final Set NO_ARGUMENT_SUBCOMMANDS = Set.of( + "acceptall", "denyall", "disable", "enable" + ); + private static final Set SINGLE_ARGUMENT_SUBCOMMANDS = Set.of( + "add", "remove", "accept", "deny", "cancel", "server", "block", "unblock" + ); + private static final Set OPTIONAL_PAGE_SUBCOMMANDS = Set.of("list", "requests"); + + private final Friends feature; + private final FriendCommand delegate; + + public AsyncFriendCommand(Friends feature) { + this.feature = feature; + this.delegate = new FriendCommand(feature); + } + + @Override + public void execute(Invocation invocation) { + CommandSource source = invocation.source(); + if (!(source instanceof Player)) { + delegate.execute(invocation); + return; + } + + String alias = invocation.alias(); + String[] arguments = invocation.arguments().clone(); + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + delegate.execute(capturedInvocation(source, alias, arguments)); + } catch (RuntimeException exception) { + feature.getLogger().error("[Friends] Command execution failed: " + exception.getMessage()); + source.sendMessage(feature.getLocalizationHandler() + .getMessage("friend.data_unavailable") + .forAudience(source) + .build()); + } + }); + } + + @Override + public boolean hasPermission(Invocation invocation) { + return delegate.hasPermission(invocation); + } + + @Override + public CompletableFuture> suggestAsync(Invocation invocation) { + CommandSource source = invocation.source(); + if (!(source instanceof Player)) { + return CompletableFuture.completedFuture(List.of()); + } + + String[] arguments = invocation.arguments().clone(); + if (hasNoFurtherArguments(arguments)) { + return CompletableFuture.completedFuture(List.of()); + } + + // Subcommand completion itself never touches persistence. + if (arguments.length <= 1) { + return delegate.suggestAsync(invocation); + } + + String alias = invocation.alias(); + CompletableFuture> result = new CompletableFuture<>(); + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + delegate.suggestAsync(capturedInvocation(source, alias, arguments)) + .whenComplete((suggestions, failure) -> { + if (failure != null || suggestions == null) { + if (failure != null) { + feature.getLogger().error("[Friends] Tab completion failed: " + failure.getMessage()); + } + result.complete(List.of()); + } else { + result.complete(suggestions); + } + }); + } catch (RuntimeException exception) { + feature.getLogger().error("[Friends] Tab completion failed: " + exception.getMessage()); + result.complete(List.of()); + } + }); + return result; + } + + static boolean hasNoFurtherArguments(String[] arguments) { + if (arguments.length == 0) { + return false; + } + + String subcommand = arguments[0].toLowerCase(Locale.ROOT); + if (NO_ARGUMENT_SUBCOMMANDS.contains(subcommand)) { + return arguments.length >= 2; + } + if (SINGLE_ARGUMENT_SUBCOMMANDS.contains(subcommand)) { + return arguments.length > 2; + } + if (OPTIONAL_PAGE_SUBCOMMANDS.contains(subcommand)) { + return arguments.length > 2; + } + return false; + } + + private static SimpleCommand.Invocation capturedInvocation( + CommandSource source, + String alias, + String[] arguments + ) { + return new SimpleCommand.Invocation() { + @Override + public CommandSource source() { + return source; + } + + @Override + public String[] arguments() { + return arguments; + } + + @Override + public String alias() { + return alias; + } + }; + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public String[] getAliases() { + return delegate.getAliases(); + } +} From 6dbff33171690dc53cd4eaed7632038bbc2dd311 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 15:46:58 +0200 Subject: [PATCH 05/14] Register the asynchronous friend command adapter --- .../nl/hauntedmc/proxyfeatures/features/friends/Friends.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java index 46b96d8..bbf174a 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java @@ -5,7 +5,7 @@ import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; -import nl.hauntedmc.proxyfeatures.features.friends.command.FriendCommand; +import nl.hauntedmc.proxyfeatures.features.friends.command.AsyncFriendCommand; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendRelationEntity; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendSettingsEntity; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendsService; @@ -118,7 +118,7 @@ public void initialize() { service = new FriendsService(this, cache); getLifecycleManager().getCommandManager() - .registerFeatureCommand(new FriendCommand(this)); + .registerFeatureCommand(new AsyncFriendCommand(this)); getLifecycleManager().getListenerManager().registerListener(new FriendActivityListener(this)); } @@ -138,5 +138,4 @@ public FriendsService getService() { public FriendsCache getCache() { return cache; } - } From f1902ca942b8ff67ce436245dbf2ba3a277ad989 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 15:47:21 +0200 Subject: [PATCH 06/14] Resolve sanction suggestions without nullable map entries --- .../service/SanctionSuggestionLookup.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java new file mode 100644 index 0000000..8ae23fb --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java @@ -0,0 +1,56 @@ +package nl.hauntedmc.proxyfeatures.features.sanctions.service; + +import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; +import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** + * Null-safe, persisted identity lookup for database-backed sanction suggestions. + */ +public final class SanctionSuggestionLookup { + + private SanctionSuggestionLookup() { + } + + public static List activeTargetNames( + Sanctions feature, + SanctionType type, + String startsWith, + int limit + ) { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(type, "type"); + int resultLimit = Math.max(0, limit); + if (resultLimit == 0) { + return List.of(); + } + + String prefix = startsWith == null ? "" : startsWith.toLowerCase(Locale.ROOT); + PlayerReferenceResolver resolver = new PlayerReferenceResolver(feature.getPlugin().getDataRegistry() + .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for sanction suggestions."))); + + return feature.getOrm().runInTransaction(session -> + session.createQuery( + "select distinct s.targetPlayerId " + + "from SanctionEntity s " + + "where s.active = true and s.type = :type and s.targetPlayerId is not null " + + "order by s.targetPlayerId asc", + Long.class) + .setParameter("type", type) + .list().stream() + .map(resolver::findActiveIdentityById) + .flatMap(java.util.Optional::stream) + .map(identity -> identity.username()) + .filter(Objects::nonNull) + .filter(username -> username.toLowerCase(Locale.ROOT).startsWith(prefix)) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .limit(resultLimit) + .toList() + ); + } +} From d7fadc6cb102b634eb72b79b7af5ea2b42672c5c Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 15:48:02 +0200 Subject: [PATCH 07/14] Use null-safe persisted identities for unban suggestions --- .../sanctions/command/UnbanCommand.java | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java index 947af03..67e730e 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java @@ -6,6 +6,8 @@ import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; +import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; +import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionSuggestionLookup; import java.util.List; import java.util.Map; @@ -19,7 +21,6 @@ public UnbanCommand(Sanctions feature) { this.feature = feature; } - public void execute(Invocation inv) { CommandSource src = inv.source(); String[] a = inv.arguments(); @@ -52,21 +53,13 @@ public void execute(Invocation inv) { } String actorName = (src instanceof Player pl) ? pl.getUsername() : "CONSOLE"; - - // Feedback to executor sendMsg(src, "sanctions.unbanned", Map.of("target", target.getUsername())); - // Broadcast to staff Map ph = Map.of("target", target.getUsername(), "actor", actorName); - feature.getService().broadcastToStaff( - "sanctions.announce.unban", - MessagePlaceholders.of(ph) - ); - + feature.getService().broadcastToStaff("sanctions.announce.unban", MessagePlaceholders.of(ph)); feature.getDiscordService().sendUnban(target, actorName); } - public boolean hasPermission(Invocation inv) { return inv.source().hasPermission("proxyfeatures.feature.sanctions.command.unban"); } @@ -79,7 +72,6 @@ public String[] getAliases() { return new String[0]; } - public CompletableFuture> suggestAsync(Invocation invocation) { String[] arguments = invocation.arguments(); if (arguments.length > 1) { @@ -90,11 +82,7 @@ public CompletableFuture> suggestAsync(Invocation invocation) { CompletableFuture> suggestions = new CompletableFuture<>(); feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { try { - suggestions.complete(feature.getService().suggestActiveTargetNames( - nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType.BAN, - prefix, - 20 - )); + suggestions.complete(SanctionSuggestionLookup.activeTargetNames(feature, SanctionType.BAN, prefix, 20)); } catch (RuntimeException exception) { feature.getLogger().error("[Sanctions] Failed to provide /unban suggestions: " + exception.getMessage()); suggestions.complete(List.of()); @@ -103,7 +91,6 @@ public CompletableFuture> suggestAsync(Invocation invocation) { return suggestions; } - // helpers private void sendMsg(CommandSource src, String key) { src.sendMessage(feature.getLocalizationHandler().getMessage(key).forAudience(src).build()); } From 8a4bd9eaedadddff6dbb74a303101e1db3325ba7 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 15:48:39 +0200 Subject: [PATCH 08/14] Use null-safe persisted identities for unmute suggestions --- .../sanctions/command/UnmuteCommand.java | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java index 319a74c..7721fcb 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java @@ -7,6 +7,7 @@ import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; +import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionSuggestionLookup; import java.util.List; import java.util.Map; @@ -20,7 +21,6 @@ public UnmuteCommand(Sanctions feature) { this.feature = feature; } - public void execute(Invocation inv) { CommandSource src = inv.source(); String[] a = inv.arguments(); @@ -53,19 +53,11 @@ public void execute(Invocation inv) { } String actorName = (src instanceof Player pl) ? pl.getUsername() : "CONSOLE"; - - // Feedback to executor sendMsg(src, "sanctions.unmuted", Map.of("target", target.getUsername())); - - // Announce to staff Map ph = Map.of("target", target.getUsername(), "actor", actorName); - feature.getService().broadcastToStaff( - "sanctions.announce.unmute", - MessagePlaceholders.of(ph) - ); + feature.getService().broadcastToStaff("sanctions.announce.unmute", MessagePlaceholders.of(ph)); - // Notify player if online feature.getPlugin().getProxy().getPlayer(java.util.UUID.fromString(target.getUuid())) .ifPresent(pl -> pl.sendMessage(feature.getLocalizationHandler() .getMessage("sanctions.notify.unmuted") @@ -73,7 +65,6 @@ public void execute(Invocation inv) { feature.getDiscordService().sendUnmute(target, actorName); } - public boolean hasPermission(Invocation inv) { return inv.source().hasPermission("proxyfeatures.feature.sanctions.command.unmute"); } @@ -82,12 +73,10 @@ public String getName() { return "unmute"; } - public String[] getAliases() { return new String[0]; } - public CompletableFuture> suggestAsync(Invocation invocation) { String[] arguments = invocation.arguments(); if (arguments.length > 1) { @@ -98,7 +87,7 @@ public CompletableFuture> suggestAsync(Invocation invocation) { CompletableFuture> suggestions = new CompletableFuture<>(); feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { try { - suggestions.complete(feature.getService().suggestActiveTargetNames(SanctionType.MUTE, prefix, 20)); + suggestions.complete(SanctionSuggestionLookup.activeTargetNames(feature, SanctionType.MUTE, prefix, 20)); } catch (RuntimeException exception) { feature.getLogger().error("[Sanctions] Failed to provide /unmute suggestions: " + exception.getMessage()); suggestions.complete(List.of()); @@ -107,7 +96,6 @@ public CompletableFuture> suggestAsync(Invocation invocation) { return suggestions; } - // helpers private void sendMsg(CommandSource src, String key) { src.sendMessage(feature.getLocalizationHandler().getMessage(key).forAudience(src).build()); } From ecb83030f139c7fc153a3c5b39f0edf308cf4503 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 16:00:14 +0200 Subject: [PATCH 09/14] Run 2FA reset suggestions off the command thread --- .../twofactor/command/TwoFactorCommand.java | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java index 25cb0ab..e66c9e2 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.concurrent.CompletableFuture; /** @@ -18,6 +19,8 @@ */ public class TwoFactorCommand implements FeatureCommand { + private static final int MAX_USERNAME_SUGGESTIONS = 50; + private final TwoFactor feature; public TwoFactorCommand(TwoFactor feature) { @@ -82,11 +85,39 @@ public CompletableFuture> suggestAsync(Invocation invocation) { return CompletableFuture.completedFuture(suggestions); } - if (args.length == 2 && "reset".equalsIgnoreCase(args[0]) && (!(source instanceof Player) || source.hasPermission(TwoFactorPermissions.RESET))) { - return CompletableFuture.completedFuture(feature.getService().knownUsernames()); + if (args.length != 2 + || !"reset".equalsIgnoreCase(args[0]) + || (source instanceof Player && !source.hasPermission(TwoFactorPermissions.RESET))) { + return CompletableFuture.completedFuture(List.of()); } - return CompletableFuture.completedFuture(List.of()); + String prefix = args[1] == null ? "" : args[1].toLowerCase(Locale.ROOT); + CompletableFuture> suggestions = new CompletableFuture<>(); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + List usernames = feature.getService().knownUsernames().stream() + .filter(Objects::nonNull) + .filter(username -> username.toLowerCase(Locale.ROOT).startsWith(prefix)) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .limit(MAX_USERNAME_SUGGESTIONS) + .toList(); + suggestions.complete(usernames); + } catch (RuntimeException exception) { + feature.getLogger().warn( + "Could not provide 2FA reset suggestions: " + safeMessage(exception) + ); + suggestions.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + feature.getLogger().warn( + "Could not schedule 2FA reset suggestions: " + safeMessage(exception) + ); + suggestions.complete(List.of()); + } + return suggestions; } @Override @@ -259,4 +290,10 @@ private static String describeSource(CommandSource source) { } return source.getClass().getSimpleName(); } + + private static String safeMessage(Throwable throwable) { + return throwable == null || throwable.getMessage() == null + ? throwable == null ? "unknown" : throwable.getClass().getSimpleName() + : throwable.getMessage(); + } } From 73461c9e7eacfcc717017e877a55952277ff7d01 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 16:01:04 +0200 Subject: [PATCH 10/14] Cover asynchronous 2FA reset suggestions --- .../command/TwoFactorCommandTest.java | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommandTest.java index 75790bd..0ea0010 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommandTest.java @@ -8,7 +8,10 @@ import nl.hauntedmc.proxyfeatures.features.twofactor.audit.TwoFactorAuditLogService; import nl.hauntedmc.proxyfeatures.features.twofactor.policy.TwoFactorPermissions; import nl.hauntedmc.proxyfeatures.features.twofactor.service.TwoFactorService; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; +import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; import org.junit.jupiter.api.Test; import java.util.List; @@ -36,17 +39,41 @@ void suggestAsyncOnlyReturnsSubcommandsAllowedForPlayer() { } @Test - void suggestAsyncIncludesResetForAuthorizedSources() { + void resetSuggestionsRunThroughFeatureTaskManagerAndFilterPrefix() { TwoFactor feature = mock(TwoFactor.class); TwoFactorService service = mock(TwoFactorService.class); + FeatureLifecycleManager lifecycle = mock(FeatureLifecycleManager.class); + FeatureTaskManager tasks = mock(FeatureTaskManager.class); + FeatureLogger logger = mock(FeatureLogger.class); when(feature.getAuditLogService()).thenReturn(mock(TwoFactorAuditLogService.class)); when(feature.getService()).thenReturn(service); - when(service.knownUsernames()).thenReturn(List.of("Remy")); + when(feature.getLifecycleManager()).thenReturn(lifecycle); + when(feature.getLogger()).thenReturn(logger); + when(lifecycle.getTaskManager()).thenReturn(tasks); + when(tasks.scheduleTask(any())).thenAnswer(invocation -> { + Runnable task = invocation.getArgument(0); + task.run(); + return null; + }); + when(service.knownUsernames()).thenReturn(List.of("Remy", "Rens", "Alice", "remy")); TwoFactorCommand command = new TwoFactorCommand(feature); assertEquals(List.of("setup", "reset"), command.suggestAsync(invocation(console(), "")).join()); - assertEquals(List.of("Remy"), command.suggestAsync(invocation(console(), "reset", "R")).join()); + assertEquals(List.of("Remy", "remy", "Rens"), command.suggestAsync(invocation(console(), "reset", "R")).join()); + verify(tasks).scheduleTask(any()); + } + + @Test + void resetSuggestionsDoNotQueryAfterFinalArgument() { + TwoFactor feature = mock(TwoFactor.class); + TwoFactorService service = mock(TwoFactorService.class); + when(feature.getService()).thenReturn(service); + + TwoFactorCommand command = new TwoFactorCommand(feature); + + assertEquals(List.of(), command.suggestAsync(invocation(console(), "reset", "Remy", "extra")).join()); + verifyNoInteractions(service); } @Test From 120d1e614fb2f0f5305df64ab86357bbd28a6bd2 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 16:47:39 +0200 Subject: [PATCH 11/14] Harden asynchronous friend command dispatch --- .../friends/command/AsyncFriendCommand.java | 108 ++++++++++++------ 1 file changed, 76 insertions(+), 32 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java index 1625289..8dd5643 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java @@ -42,17 +42,19 @@ public void execute(Invocation invocation) { String alias = invocation.alias(); String[] arguments = invocation.arguments().clone(); - feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { - try { - delegate.execute(capturedInvocation(source, alias, arguments)); - } catch (RuntimeException exception) { - feature.getLogger().error("[Friends] Command execution failed: " + exception.getMessage()); - source.sendMessage(feature.getLocalizationHandler() - .getMessage("friend.data_unavailable") - .forAudience(source) - .build()); - } - }); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + delegate.execute(capturedInvocation(source, alias, arguments)); + } catch (RuntimeException exception) { + logFailure("Command execution", exception); + sendDataUnavailable(source); + } + }); + } catch (RuntimeException exception) { + logFailure("Command scheduling", exception); + sendDataUnavailable(source); + } } @Override @@ -72,31 +74,47 @@ public CompletableFuture> suggestAsync(Invocation invocation) { return CompletableFuture.completedFuture(List.of()); } - // Subcommand completion itself never touches persistence. - if (arguments.length <= 1) { - return delegate.suggestAsync(invocation); + // Pure subcommand filtering is safe synchronously. Numeric first arguments query friend data + // for page counts and therefore must use the background path as well. + if (arguments.length == 0 + || (arguments.length == 1 && !isPositiveInteger(arguments[0]))) { + try { + CompletableFuture> result = delegate.suggestAsync(invocation); + return result == null ? CompletableFuture.completedFuture(List.of()) : result; + } catch (RuntimeException exception) { + logFailure("Tab completion", exception); + return CompletableFuture.completedFuture(List.of()); + } } String alias = invocation.alias(); CompletableFuture> result = new CompletableFuture<>(); - feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { - try { - delegate.suggestAsync(capturedInvocation(source, alias, arguments)) - .whenComplete((suggestions, failure) -> { - if (failure != null || suggestions == null) { - if (failure != null) { - feature.getLogger().error("[Friends] Tab completion failed: " + failure.getMessage()); - } - result.complete(List.of()); - } else { - result.complete(suggestions); - } - }); - } catch (RuntimeException exception) { - feature.getLogger().error("[Friends] Tab completion failed: " + exception.getMessage()); - result.complete(List.of()); - } - }); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + CompletableFuture> delegated = + delegate.suggestAsync(capturedInvocation(source, alias, arguments)); + if (delegated == null) { + result.complete(List.of()); + return; + } + delegated.whenComplete((suggestions, failure) -> { + if (failure != null) { + logFailure("Tab completion", failure); + result.complete(List.of()); + } else { + result.complete(suggestions == null ? List.of() : suggestions); + } + }); + } catch (RuntimeException exception) { + logFailure("Tab completion", exception); + result.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + logFailure("Tab-completion scheduling", exception); + result.complete(List.of()); + } return result; } @@ -118,6 +136,18 @@ static boolean hasNoFurtherArguments(String[] arguments) { return false; } + private static boolean isPositiveInteger(String value) { + if (value == null || value.isEmpty()) { + return false; + } + for (int i = 0; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) { + return false; + } + } + return true; + } + private static SimpleCommand.Invocation capturedInvocation( CommandSource source, String alias, @@ -141,6 +171,20 @@ public String alias() { }; } + private void sendDataUnavailable(CommandSource source) { + source.sendMessage(feature.getLocalizationHandler() + .getMessage("friend.data_unavailable") + .forAudience(source) + .build()); + } + + private void logFailure(String operation, Throwable failure) { + String message = failure == null || failure.getMessage() == null + ? failure == null ? "unknown" : failure.getClass().getSimpleName() + : failure.getMessage(); + feature.getLogger().error("[Friends] " + operation + " failed: " + message); + } + @Override public String getName() { return delegate.getName(); From 774cc72700121af766938fbe0d6f9a4ec6b492b8 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 16:48:19 +0200 Subject: [PATCH 12/14] Complete unban suggestions when scheduling fails --- .../sanctions/command/UnbanCommand.java | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java index 67e730e..0c89c95 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java @@ -80,17 +80,29 @@ public CompletableFuture> suggestAsync(Invocation invocation) { String prefix = arguments.length == 1 ? arguments[0] : ""; CompletableFuture> suggestions = new CompletableFuture<>(); - feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { - try { - suggestions.complete(SanctionSuggestionLookup.activeTargetNames(feature, SanctionType.BAN, prefix, 20)); - } catch (RuntimeException exception) { - feature.getLogger().error("[Sanctions] Failed to provide /unban suggestions: " + exception.getMessage()); - suggestions.complete(List.of()); - } - }); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + suggestions.complete(SanctionSuggestionLookup.activeTargetNames(feature, SanctionType.BAN, prefix, 20)); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } return suggestions; } + private void logSuggestionFailure(Throwable failure) { + String message = failure == null || failure.getMessage() == null + ? failure == null ? "unknown" : failure.getClass().getSimpleName() + : failure.getMessage(); + feature.getLogger().error("[Sanctions] Failed to provide /unban suggestions: " + message); + } + private void sendMsg(CommandSource src, String key) { src.sendMessage(feature.getLocalizationHandler().getMessage(key).forAudience(src).build()); } From 2ad816377d1bc3ea5f9987b5fc4cdd41770baf27 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 16:48:53 +0200 Subject: [PATCH 13/14] Complete unmute suggestions when scheduling fails --- .../sanctions/command/UnmuteCommand.java | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java index 7721fcb..76b6481 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java @@ -85,17 +85,29 @@ public CompletableFuture> suggestAsync(Invocation invocation) { String prefix = arguments.length == 1 ? arguments[0] : ""; CompletableFuture> suggestions = new CompletableFuture<>(); - feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { - try { - suggestions.complete(SanctionSuggestionLookup.activeTargetNames(feature, SanctionType.MUTE, prefix, 20)); - } catch (RuntimeException exception) { - feature.getLogger().error("[Sanctions] Failed to provide /unmute suggestions: " + exception.getMessage()); - suggestions.complete(List.of()); - } - }); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + suggestions.complete(SanctionSuggestionLookup.activeTargetNames(feature, SanctionType.MUTE, prefix, 20)); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } return suggestions; } + private void logSuggestionFailure(Throwable failure) { + String message = failure == null || failure.getMessage() == null + ? failure == null ? "unknown" : failure.getClass().getSimpleName() + : failure.getMessage(); + feature.getLogger().error("[Sanctions] Failed to provide /unmute suggestions: " + message); + } + private void sendMsg(CommandSource src, String key) { src.sendMessage(feature.getLocalizationHandler().getMessage(key).forAudience(src).build()); } From 19d241e6ae18729eb31422c0d2f814770387df21 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Mon, 27 Jul 2026 16:49:22 +0200 Subject: [PATCH 14/14] Complete IP unban suggestions when scheduling fails --- .../sanctions/command/UnbanIpCommand.java | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java index a44921d..bdcbc60 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java @@ -18,7 +18,6 @@ public UnbanIpCommand(Sanctions feature) { this.feature = feature; } - public void execute(Invocation inv) { CommandSource src = inv.source(); String[] a = inv.arguments(); @@ -49,11 +48,9 @@ public void execute(Invocation inv) { return; } - // Feedback to executor sendMsg(src, "sanctions.unbanned_ip", Map.of("ip", ip)); } - public boolean hasPermission(Invocation inv) { return inv.source().hasPermission("proxyfeatures.feature.sanctions.command.unbanip"); } @@ -66,7 +63,6 @@ public String[] getAliases() { return new String[0]; } - public CompletableFuture> suggestAsync(Invocation invocation) { String[] arguments = invocation.arguments(); if (arguments.length > 1) { @@ -75,18 +71,29 @@ public CompletableFuture> suggestAsync(Invocation invocation) { String prefix = arguments.length == 1 ? arguments[0] : ""; CompletableFuture> suggestions = new CompletableFuture<>(); - feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { - try { - suggestions.complete(feature.getService().suggestActiveBannedIps(prefix, 20)); - } catch (RuntimeException exception) { - feature.getLogger().error("[Sanctions] Failed to provide /unbanip suggestions: " + exception.getMessage()); - suggestions.complete(List.of()); - } - }); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + suggestions.complete(feature.getService().suggestActiveBannedIps(prefix, 20)); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } return suggestions; } - // helpers + private void logSuggestionFailure(Throwable failure) { + String message = failure == null || failure.getMessage() == null + ? failure == null ? "unknown" : failure.getClass().getSimpleName() + : failure.getMessage(); + feature.getLogger().error("[Sanctions] Failed to provide /unbanip suggestions: " + message); + } + private String normalizeIp(String ip) { try { return InetAddress.getByName(ip).getHostAddress();