From 67028727aee75c81f06c260b8be603f142794c6b Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 14:32:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(velocity):=20=E4=BC=A0=E9=80=81=E6=B2=A1?= =?UTF-8?q?=E8=BF=9E=E4=B8=8A=E4=B8=94=E5=90=8E=E7=AB=AF=E6=B2=A1=E7=BB=99?= =?UTF-8?q?=E7=90=86=E7=94=B1=E6=97=B6=E9=87=8D=E6=96=B0=E6=8E=92=E9=98=9F?= =?UTF-8?q?=E9=87=8D=E6=8B=A8=EF=BC=8C=E6=9C=80=E5=A4=9A=E4=B8=89=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lolicon/felis/velocity/WaitingRouter.java | 80 +++++++++++----- .../best/lolicon/felis/velocity/Fakes.java | 7 ++ .../felis/velocity/WaitingRouterTest.java | 94 ++++++++++++++++--- 3 files changed, 146 insertions(+), 35 deletions(-) diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java index 944596b..5dcdf5a 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java @@ -46,7 +46,8 @@ import java.util.function.LongSupplier; * reports ready, transfers everyone waiting on it. A waiter stays as long as its * server is on the way up, through the operator's restart backoff, and drops out on a * successful transfer, when the player leaves the proxy, when the start is given up or - * the server stopped, or when felis-api stops answering for the wait window. + * the server stopped, or when felis-api stops answering for the wait window. A transfer + * that fails without the backend saying why queues the player again for another dial. * *

Every transition out of login is checked against felis-api's link status, and * command/menu queue entries are checked the same way. The wake is then gated @@ -79,6 +80,9 @@ public final class WaitingRouter { // The login gate re-sends its release with backoff (and during a felis-api outage // on every retry), so a denial line is shown at most once per this interval. private static final long GATE_NOTICE_INTERVAL_MILLIS = 15_000L; + // How many times a player is dialled into a server that fails the connect without + // saying why. The retries ride the queue, a tick apart, each after a fresh ready poll. + private static final int TRANSFER_ATTEMPTS = 3; private final ProxyServer proxy; private final Logger log; @@ -249,7 +253,8 @@ public final class WaitingRouter { NamedTextColor.RED)); return; } - transfer(player, lobbyServer, lobby.get()); + // One dial: the lobby is no queue target, and the player can simply ask again. + transfer(player, lobbyServer, lobby.get(), false, TRANSFER_ATTEMPTS); } /** @@ -505,7 +510,7 @@ public final class WaitingRouter { if (w.fromMenu && listener != null) { listener.onReady(player, w.serverName); } - transfer(player, w.serverName, backend.get()); + transfer(player, w.serverName, backend.get(), w.fromMenu, w.attempts + 1); } } @@ -644,7 +649,7 @@ public final class WaitingRouter { if (fromMenu && listener != null) { listener.onReady(player, serverName); } - transfer(player, serverName, backend.get()); + transfer(player, serverName, backend.get(), fromMenu, 1); return true; } @@ -724,7 +729,7 @@ public final class WaitingRouter { : "Starting « " + serverName + " » — you'll be moved in automatically.", NamedTextColor.GRAY)); } - waiting.put(id, new Waiter(serverName, clock.getAsLong(), fromMenu)); + waiting.put(id, new Waiter(serverName, clock.getAsLong(), fromMenu, 0)); } // The server's start failed and its automatic retries are spent: nothing more is @@ -745,26 +750,51 @@ public final class WaitingRouter { NamedTextColor.RED)); } - private void transfer(Player player, String serverName, RegisteredServer backend) { + /** + * transfer dials a player into a server; attempt numbers this dial among the ones + * made for the same move. A connect that fails without the backend saying why — the + * server went away between the ready poll and the dial, its port is not listening + * yet, the network dropped — puts the player back in the queue until + * {@link #TRANSFER_ATTEMPTS} dials are spent: a later tick polls the server again, + * registers the address it reports and dials once more. A refusal that carries a + * reason (ban, whitelist, full, mods) is final at once, and so is a dial another + * connection or a pre-connect listener pre-empted. + */ + private void transfer(Player player, String serverName, RegisteredServer backend, + boolean fromMenu, int attempt) { player.createConnectionRequest(backend).connect().whenComplete((result, err) -> { - if (err != null || (result != null && !result.isSuccessful())) { - plugin.stats().count(ProxyStats.Event.TRANSFER_FAILED); - log.warn("Felis: transfer of {} to {} failed: {}", player.getUniqueId(), serverName, - err != null ? err.toString() : result.getStatus()); - boolean zh = FelisVelocityPlugin.zh(player); - Component line = Component.text( - zh ? "无法把你连接到「" + serverName + "」。" - : "Couldn't connect you to « " + serverName + " ».", - NamedTextColor.RED); - // A backend that refuses the login says why (ban, whitelist, full, mods); - // "please try again" is wrong advice for all of those. - Optional reason = result == null ? Optional.empty() : result.getReasonComponent(); - line = reason.isPresent() - ? line.append(Component.text(zh ? " 原因:" : " Reason: ", NamedTextColor.RED)) - .append(reason.get()) - : line.append(Component.text(zh ? "请重试。" : " Please try again.", NamedTextColor.RED)); - player.sendMessage(line); + if (err == null && (result == null || result.isSuccessful())) { + return; } + plugin.stats().count(ProxyStats.Event.TRANSFER_FAILED); + log.warn("Felis: transfer of {} to {} failed (attempt {}/{}): {}", player.getUniqueId(), serverName, + attempt, TRANSFER_ATTEMPTS, err != null ? err.toString() : result.getStatus()); + boolean zh = FelisVelocityPlugin.zh(player); + boolean unexplained = err != null + || (result.getStatus() == ConnectionRequestBuilder.Status.SERVER_DISCONNECTED + && result.getReasonComponent().isEmpty()); + // putIfAbsent: a player who queued for another server meanwhile keeps that wait. + if (unexplained && attempt < TRANSFER_ATTEMPTS + && waiting.putIfAbsent(player.getUniqueId(), + new Waiter(serverName, clock.getAsLong(), fromMenu, attempt)) == null) { + player.sendMessage(Component.text( + zh ? "没能连上「" + serverName + "」,稍后自动重试……" + : "Couldn't reach « " + serverName + " »; trying again shortly…", + NamedTextColor.YELLOW)); + return; + } + Component line = Component.text( + zh ? "无法把你连接到「" + serverName + "」。" + : "Couldn't connect you to « " + serverName + " ».", + NamedTextColor.RED); + // A backend that refuses the login says why (ban, whitelist, full, mods); + // "please try again" is wrong advice for all of those. + Optional reason = result == null ? Optional.empty() : result.getReasonComponent(); + line = reason.isPresent() + ? line.append(Component.text(zh ? " 原因:" : " Reason: ", NamedTextColor.RED)) + .append(reason.get()) + : line.append(Component.text(zh ? "请重试。" : " Please try again.", NamedTextColor.RED)); + player.sendMessage(line); }); } @@ -786,6 +816,7 @@ public final class WaitingRouter { final String serverName; final boolean fromMenu; // true → notify the felis:control face on transfer final long sinceMillis; + final int attempts; // dials into serverName already made and failed // Only the drain touches these, one tick at a time (the ticking flag orders // the ticks), so they need no further synchronization. long deadlineMillis; @@ -793,9 +824,10 @@ public final class WaitingRouter { int restartsSeen; boolean stopSeen; - Waiter(String serverName, long nowMillis, boolean fromMenu) { + Waiter(String serverName, long nowMillis, boolean fromMenu, int attempts) { this.serverName = serverName; this.fromMenu = fromMenu; + this.attempts = attempts; this.sinceMillis = nowMillis; this.deadlineMillis = nowMillis + WAIT_TIMEOUT_MILLIS; this.noticedMillis = nowMillis; diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java b/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java index 6bb481c..e8f9d36 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.io.OutputStream; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Proxy; +import java.net.ConnectException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -239,6 +240,8 @@ final class Fakes { volatile String virtualHost; volatile RegisteredServer current; volatile boolean connectSucceeds = true; + /** connectThrows fails every connect the way an unreachable backend does, with no result. */ + volatile boolean connectThrows; volatile String disconnectedWith; final List messages = Collections.synchronizedList(new ArrayList<>()); /** @@ -331,6 +334,10 @@ final class Fakes { status = ConnectionRequestBuilder.Status.CONNECTION_CANCELLED; } else { connects.add(dest.getServerInfo().getName()); + if (connectThrows) { + return CompletableFuture.failedFuture( + new ConnectException("Connection refused")); + } status = connectSucceeds && refusal == null ? ConnectionRequestBuilder.Status.SUCCESS : ConnectionRequestBuilder.Status.SERVER_DISCONNECTED; diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java index 24febd0..19815d2 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java @@ -586,24 +586,87 @@ public final class WaitingRouterTest { () -> log.count("WARN", "join-event for " + p.id + " on beta failed (status=500)") == 1); api.joinStatus = 204; - // A transfer the backend refuses is counted, logged and told. - Fakes.FakePlayer refused = player(null, true); - refused.current = lobby; - refused.connectSucceeds = false; - router.enqueueFromInvite(refused.player, "beta"); - // The chat line is the last thing the failure path does; the count comes first. - Fakes.await("failed transfer: told", () -> refused.said("Couldn't connect you to « beta »")); - assertEq("failed transfer counted", 1L, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED)); - assertEq("failed transfer: logged", 1, log.count("WARN", "transfer of " + refused.id + " to beta failed")); + // A connect that fails without the backend saying why puts the player back in the + // queue; the next tick polls the server again and dials once more. + long failed = plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED); + int queued = router.waitingCount(); + Fakes.FakePlayer flaky = player(null, true); + flaky.current = lobby; + flaky.connectThrows = true; + router.enqueueFromInvite(flaky.player, "beta"); + // The chat line is the last thing the failure path does; the requeue comes first. + Fakes.await("unreachable: told it retries", () -> flaky.said("Couldn't reach « beta »; trying again shortly")); + assertEq("unreachable: queued again", queued + 1, router.waitingCount()); + assertEq("unreachable: counted", failed + 1, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED)); + assertEq("unreachable: logged", 1, + log.count("WARN", "transfer of " + flaky.id + " to beta failed (attempt 1/3)")); + assertEq("unreachable: no final word yet", false, flaky.said("Couldn't connect you")); + flaky.connectThrows = false; + int polls = api.count("GET " + SERVERS + "beta/status"); + router.tick(); + assertEq("retry: polled again", polls + 1, api.count("GET " + SERVERS + "beta/status")); + assertEq("retry: dialled again, and in", List.of("beta", "beta"), List.copyOf(flaky.connects)); + assertEq("retry: out of the queue", queued, router.waitingCount()); + assertEq("retry: nothing more counted", failed + 1, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED)); - // A backend that kicks the login says why; "please try again" is wrong for a ban. + // The dials are bounded: a backend that keeps dropping them gets the final word on + // the third. A menu wait stays one through its retries, so the lobby hears each. + List told = Collections.synchronizedList(new ArrayList<>()); + router.setMenuTransferListener((pl, server) -> told.add(pl.getUsername() + "@" + server)); + Fakes.FakePlayer dropped = player(null, true); + dropped.current = lobby; + dropped.connectSucceeds = false; + router.enqueueFromMenu(dropped.player, "beta"); + Fakes.await("dropped: queued again", () -> dropped.said("Couldn't reach « beta »; trying again shortly")); + router.tick(); + assertEq("dropped: second dial", 2, dropped.connects.size()); + assertEq("dropped: queued after the second", queued + 1, router.waitingCount()); + router.tick(); + assertEq("dropped: third dial", 3, dropped.connects.size()); + assertEq("dropped: given up", queued, router.waitingCount()); + assertEq("dropped: told to try again", true, dropped.said("Couldn't connect you to « beta ». Please try again.")); + assertEq("dropped: one final word", 1, count(dropped.messages, "Couldn't connect you")); + assertEq("dropped: every dial counted", failed + 4, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED)); + String at = dropped.name + "@beta"; + assertEq("dropped: the lobby told before every dial", List.of(at, at, at), List.copyOf(told)); + router.tick(); + assertEq("dropped: no fourth dial", 3, dropped.connects.size()); + + // A player already waiting for another server keeps that wait: the failed join is + // told as final instead of replacing it. + Fakes.FakePlayer busy = player(null, true); + busy.current = lobby; + router.enqueueFromCommand(busy.player, "zeta"); + Fakes.await("busy: waiting for zeta", () -> router.waitingCount() == queued + 1); + busy.connectThrows = true; + router.enqueueFromInvite(busy.player, "beta"); + Fakes.await("busy: told", () -> busy.said("Couldn't connect you to « beta ». Please try again.")); + router.tick(); + assertEq("busy: beta not dialled again", 1, busy.connects.size()); + assertEq("busy: still waiting for zeta", queued + 1, router.waitingCount()); + router.onDisconnect(new DisconnectEvent(busy.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)); + + // A pre-connect listener that cancels the move is final too. + Fakes.FakePlayer cancelled = player(null, true); + cancelled.current = lobby; + cancelled.preConnect = target -> null; + router.enqueueFromInvite(cancelled.player, "beta"); + Fakes.await("cancelled: told", () -> cancelled.said("Couldn't connect you to « beta »")); + assertEq("cancelled: not queued", queued, router.waitingCount()); + assertEq("failed transfers counted", failed + 6, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED)); + + // A backend that kicks the login says why; "please try again" is wrong for a ban, + // and so is dialling again. Fakes.FakePlayer full = player(null, true); full.current = lobby; full.refusals.put("beta", "The server is full"); router.enqueueFromInvite(full.player, "beta"); Fakes.await("refused transfer: told the reason", () -> full.said("Couldn't connect you to « beta ». Reason: The server is full")); - assertEq("refused transfer counted", 2L, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED)); + assertEq("refused transfer counted", failed + 7, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED)); assertEq("refused transfer: no retry advice", false, full.said("try again")); + assertEq("refused transfer: not queued", queued, router.waitingCount()); + router.tick(); + assertEq("refused transfer: dialled once", 1, full.connects.size()); } // /felis lobby and /felis go lobby: the lobby is a system server, so it never @@ -641,6 +704,15 @@ public final class WaitingRouterTest { full.refusals.put("lobby", "Server is full"); router.toLobby(full.player); Fakes.await("lobby refused: told", () -> full.said("Couldn't connect you to « lobby ». Reason: Server is full")); + + // The lobby gets one dial: it is no queue target, and the player can ask again. + Fakes.FakePlayer cut = player(null, true); + cut.current = beta; + cut.connectThrows = true; + int queued = router.waitingCount(); + router.toLobby(cut.player); + assertEq("lobby unreachable: told", true, cut.said("Couldn't connect you to « lobby ». Please try again.")); + assertEq("lobby unreachable: not queued", queued, router.waitingCount()); } private static void disconnectAndRelease() {