fix(velocity): 传送没连上且后端没给理由时重新排队重拨,最多三次
This commit is contained in:
3 files changed
+146
-35
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
|
||||
* 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.
|
||||
*
|
||||
* <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
|
||||
@@ -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<Component> 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<Component> 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;
|
||||
|
||||
@@ -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<String> 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;
|
||||
|
||||
@@ -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<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);
|
||||
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() {
|
||||
|
||||
Reference in new issue
Block a user