fix(velocity): 传送没连上且后端没给理由时重新排队重拨,最多三次

This commit is contained in:
Lemon-miaow committed 2026-09-27 14:32:30 +08:00
1 parent 56a4bbcf4c
commit 67028727ae
3 files changed
+133 -22

No files matched your search

@@ -46,7 +46,8 @@ import java.util.function.LongSupplier;
* reports ready, transfers everyone waiting on it. A waiter stays as long as its * 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 * 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 * 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.
* *
* <p>Every transition out of login is checked against felis-api's link status, and * <p>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 * 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 // 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. // 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; 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 ProxyServer proxy;
private final Logger log; private final Logger log;
@@ -249,7 +253,8 @@ public final class WaitingRouter {
NamedTextColor.RED)); NamedTextColor.RED));
return; 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) { if (w.fromMenu && listener != null) {
listener.onReady(player, w.serverName); 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) { if (fromMenu && listener != null) {
listener.onReady(player, serverName); listener.onReady(player, serverName);
} }
transfer(player, serverName, backend.get()); transfer(player, serverName, backend.get(), fromMenu, 1);
return true; return true;
} }
@@ -724,7 +729,7 @@ public final class WaitingRouter {
: "Starting « " + serverName + " » — you'll be moved in automatically.", : "Starting « " + serverName + " » — you'll be moved in automatically.",
NamedTextColor.GRAY)); 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 // The server's start failed and its automatic retries are spent: nothing more is
@@ -745,13 +750,39 @@ public final class WaitingRouter {
NamedTextColor.RED)); 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) -> { player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
if (err != null || (result != null && !result.isSuccessful())) { if (err == null && (result == null || result.isSuccessful())) {
return;
}
plugin.stats().count(ProxyStats.Event.TRANSFER_FAILED); plugin.stats().count(ProxyStats.Event.TRANSFER_FAILED);
log.warn("Felis: transfer of {} to {} failed: {}", player.getUniqueId(), serverName, log.warn("Felis: transfer of {} to {} failed (attempt {}/{}): {}", player.getUniqueId(), serverName,
err != null ? err.toString() : result.getStatus()); attempt, TRANSFER_ATTEMPTS, err != null ? err.toString() : result.getStatus());
boolean zh = FelisVelocityPlugin.zh(player); 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( Component line = Component.text(
zh ? "无法把你连接到「" + serverName + "」。" zh ? "无法把你连接到「" + serverName + "」。"
: "Couldn't connect you to « " + serverName + " ».", : "Couldn't connect you to « " + serverName + " ».",
@@ -764,7 +795,6 @@ public final class WaitingRouter {
.append(reason.get()) .append(reason.get())
: line.append(Component.text(zh ? "请重试。" : " Please try again.", NamedTextColor.RED)); : line.append(Component.text(zh ? "请重试。" : " Please try again.", NamedTextColor.RED));
player.sendMessage(line); player.sendMessage(line);
}
}); });
} }
@@ -786,6 +816,7 @@ public final class WaitingRouter {
final String serverName; final String serverName;
final boolean fromMenu; // true → notify the felis:control face on transfer final boolean fromMenu; // true → notify the felis:control face on transfer
final long sinceMillis; 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 // Only the drain touches these, one tick at a time (the ticking flag orders
// the ticks), so they need no further synchronization. // the ticks), so they need no further synchronization.
long deadlineMillis; long deadlineMillis;
@@ -793,9 +824,10 @@ public final class WaitingRouter {
int restartsSeen; int restartsSeen;
boolean stopSeen; boolean stopSeen;
Waiter(String serverName, long nowMillis, boolean fromMenu) { Waiter(String serverName, long nowMillis, boolean fromMenu, int attempts) {
this.serverName = serverName; this.serverName = serverName;
this.fromMenu = fromMenu; this.fromMenu = fromMenu;
this.attempts = attempts;
this.sinceMillis = nowMillis; this.sinceMillis = nowMillis;
this.deadlineMillis = nowMillis + WAIT_TIMEOUT_MILLIS; this.deadlineMillis = nowMillis + WAIT_TIMEOUT_MILLIS;
this.noticedMillis = nowMillis; this.noticedMillis = nowMillis;
@@ -19,6 +19,7 @@ import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy; import java.lang.reflect.Proxy;
import java.net.ConnectException;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
@@ -239,6 +240,8 @@ final class Fakes {
volatile String virtualHost; volatile String virtualHost;
volatile RegisteredServer current; volatile RegisteredServer current;
volatile boolean connectSucceeds = true; volatile boolean connectSucceeds = true;
/** connectThrows fails every connect the way an unreachable backend does, with no result. */
volatile boolean connectThrows;
volatile String disconnectedWith; volatile String disconnectedWith;
final List<String> messages = Collections.synchronizedList(new ArrayList<>()); final List<String> messages = Collections.synchronizedList(new ArrayList<>());
/** /**
@@ -331,6 +334,10 @@ final class Fakes {
status = ConnectionRequestBuilder.Status.CONNECTION_CANCELLED; status = ConnectionRequestBuilder.Status.CONNECTION_CANCELLED;
} else { } else {
connects.add(dest.getServerInfo().getName()); connects.add(dest.getServerInfo().getName());
if (connectThrows) {
return CompletableFuture.failedFuture(
new ConnectException("Connection refused"));
}
status = connectSucceeds && refusal == null status = connectSucceeds && refusal == null
? ConnectionRequestBuilder.Status.SUCCESS ? ConnectionRequestBuilder.Status.SUCCESS
: ConnectionRequestBuilder.Status.SERVER_DISCONNECTED; : ConnectionRequestBuilder.Status.SERVER_DISCONNECTED;
@@ -586,24 +586,87 @@ public final class WaitingRouterTest {
() -> log.count("WARN", "join-event for " + p.id + " on beta failed (status=500)") == 1); () -> log.count("WARN", "join-event for " + p.id + " on beta failed (status=500)") == 1);
api.joinStatus = 204; api.joinStatus = 204;
// A transfer the backend refuses is counted, logged and told. // A connect that fails without the backend saying why puts the player back in the
Fakes.FakePlayer refused = player(null, true); // queue; the next tick polls the server again and dials once more.
refused.current = lobby; long failed = plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED);
refused.connectSucceeds = false; int queued = router.waitingCount();
router.enqueueFromInvite(refused.player, "beta"); Fakes.FakePlayer flaky = player(null, true);
// The chat line is the last thing the failure path does; the count comes first. flaky.current = lobby;
Fakes.await("failed transfer: told", () -> refused.said("Couldn't connect you to « beta »")); flaky.connectThrows = true;
assertEq("failed transfer counted", 1L, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED)); router.enqueueFromInvite(flaky.player, "beta");
assertEq("failed transfer: logged", 1, log.count("WARN", "transfer of " + refused.id + " to beta failed")); // 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<String> 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); Fakes.FakePlayer full = player(null, true);
full.current = lobby; full.current = lobby;
full.refusals.put("beta", "The server is full"); full.refusals.put("beta", "The server is full");
router.enqueueFromInvite(full.player, "beta"); 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")); 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: 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 // /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"); full.refusals.put("lobby", "Server is full");
router.toLobby(full.player); router.toLobby(full.player);
Fakes.await("lobby refused: told", () -> full.said("Couldn't connect you to « lobby ». Reason: Server is full")); 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() { private static void disconnectAndRelease() {