test(velocity): 测试桩按真实 wake 的顺序从服的状态推导回复,运行中的服对谁都 202 就绪,403 和 start_failed 不再由用例手配,停着的服一律报 fallback
This commit is contained in:
6 files changed
+183
-37
No files matched your search
+3
-1
@@ -43,7 +43,9 @@
|
|||||||
# fake proxy and a stub felis-api — a refresh registers, moves and drops
|
# fake proxy and a stub felis-api — a refresh registers, moves and drops
|
||||||
# backends and lets go of a renamed subdomain, the login gate and host routing
|
# backends and lets go of a renamed subdomain, the login gate and host routing
|
||||||
# admit only linked players, each wake refusal reaches the player as its own
|
# admit only linked players, each wake refusal reaches the player as its own
|
||||||
# message, felis:control acts only for the connection's player and holds its
|
# message (the stub answers a wake from the server's state in felis-api's own
|
||||||
|
# order, so it cannot hand the router an answer the real API never gives),
|
||||||
|
# felis:control acts only for the connection's player and holds its
|
||||||
# frame budget. They ride the module's verified dependency set, which is why
|
# frame budget. They ride the module's verified dependency set, which is why
|
||||||
# they live in Gradle rather than in the javac mains above.
|
# they live in Gradle rather than in the javac mains above.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -216,20 +216,32 @@ public final class ControlChannelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void wakeAndTransfer() {
|
private static void wakeAndTransfer() {
|
||||||
// A WakeRequest parks the player; when the backend is ready the lobby hears
|
// A WakeRequest for a stopped server parks the player; when the backend is ready
|
||||||
// TransferReady just before the proxy moves them.
|
// the lobby hears TransferReady just before the proxy moves them.
|
||||||
Fakes.FakePlayer p = player(true);
|
Fakes.FakePlayer p = player(true);
|
||||||
p.current = lobby;
|
p.current = lobby;
|
||||||
message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest("forged-name", "beta")));
|
message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest("forged-name", "gamma")));
|
||||||
Fakes.await("beta woken", () -> api.count("POST " + SERVERS + "beta/wake") == 1);
|
Fakes.await("gamma woken", () -> api.count("POST " + SERVERS + "gamma/wake") == 1);
|
||||||
Fakes.await("waker queued", () -> router.waitingCount() == 2);
|
Fakes.await("waker queued", () -> router.waitingCount() == 2);
|
||||||
api.ready.put("beta", true);
|
api.ready.put("gamma", true);
|
||||||
router.tick();
|
router.tick();
|
||||||
List<ControlFrame> r = frames(p);
|
List<ControlFrame> r = frames(p);
|
||||||
assertEq("one frame to the lobby", 1, r.size());
|
assertEq("one frame to the lobby", 1, r.size());
|
||||||
assertEq("transfer ready", ControlFrame.TRANSFER_READY + " " + p.name + " beta",
|
assertEq("transfer ready", ControlFrame.TRANSFER_READY + " " + p.name + " gamma",
|
||||||
r.get(0).type() + " " + r.get(0).player() + " " + r.get(0).server());
|
r.get(0).type() + " " + r.get(0).player() + " " + r.get(0).server());
|
||||||
assertEq("then moved", List.of("beta"), List.copyOf(p.connects));
|
assertEq("then moved", List.of("gamma"), List.copyOf(p.connects));
|
||||||
|
|
||||||
|
// For a running one it is a join: TransferReady and the move at once, no wake.
|
||||||
|
Fakes.FakePlayer j = player(true);
|
||||||
|
j.current = lobby;
|
||||||
|
message(j.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest(j.name, "beta")));
|
||||||
|
Fakes.await("running server joined", () -> j.connects.size() == 1);
|
||||||
|
List<ControlFrame> jr = frames(j);
|
||||||
|
assertEq("running server: one frame to the lobby", 1, jr.size());
|
||||||
|
assertEq("running server: transfer ready", ControlFrame.TRANSFER_READY + " " + j.name + " beta",
|
||||||
|
jr.get(0).type() + " " + jr.get(0).player() + " " + jr.get(0).server());
|
||||||
|
assertEq("running server: moved", List.of("beta"), List.copyOf(j.connects));
|
||||||
|
assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void budget() {
|
private static void budget() {
|
||||||
@@ -295,8 +307,12 @@ public final class ControlChannelTest {
|
|||||||
|
|
||||||
// Shaped like the operator's status: up is direct at addr; down is the fallback,
|
// Shaped like the operator's status: up is direct at addr; down is the fallback,
|
||||||
// whose name ("login") sits in the address field.
|
// whose name ("login") sits in the address field.
|
||||||
|
// view is a server as GET /servers lists it, and the stub API's status, menu and
|
||||||
|
// wake answer for the same state: up at addr, or down on the "login" fallback.
|
||||||
private static ServerView view(String name, boolean ready, String addr) {
|
private static ServerView view(String name, boolean ready, String addr) {
|
||||||
return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "ownerOnly",
|
api.ready.put(name, ready);
|
||||||
|
api.address.put(name, addr);
|
||||||
|
return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "public",
|
||||||
"Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20);
|
"Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import java.util.concurrent.CompletableFuture;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.function.BooleanSupplier;
|
import java.util.function.BooleanSupplier;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fakes are the test doubles the routing tests run the real proxy classes against:
|
* Fakes are the test doubles the routing tests run the real proxy classes against:
|
||||||
@@ -432,13 +434,18 @@ final class Fakes {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Api is a stub felis-api internal face: link status from {@link #linked}, server
|
* Api is a stub felis-api internal face: link status from {@link #linked}, server
|
||||||
* status from {@link #ready}, wake answering 202 unless {@link #wakeError} holds an
|
* status, menu and wake from one server state ({@link #ready}, {@link #phase},
|
||||||
* answer for the server, and join-events recorded. {@link #linkDown} makes the
|
* {@link #desired}, {@link #gaveUp}, {@link #policy}, {@link #owner}), and
|
||||||
* link-status route answer 500.
|
* join-events recorded. {@link #linkDown} makes the link-status route answer 500.
|
||||||
*
|
*
|
||||||
* <p>Status replies carry the endpoint the way the operator writes it: a server that
|
* <p>Status replies carry the endpoint the way the operator writes it: a server that
|
||||||
* is up reports {@code direct} and its {@link #address}; one that is not reports
|
* is up reports {@code direct} and its {@link #address}; one that is not reports
|
||||||
* {@code fallback} with the fallback server's NAME, "login", in the address field.
|
* {@code fallback} with the fallback server's NAME, "login", in the address field.
|
||||||
|
*
|
||||||
|
* <p>The wake follows handleInternalWake step for step, so a test cannot give the
|
||||||
|
* router an answer the real API never gives: an up server is 202 ready for anyone,
|
||||||
|
* 403 and 409 start_failed come from the state, and {@link #wakeError} only holds
|
||||||
|
* what the state cannot say (a cooldown, the running cap, maintenance, a fault).
|
||||||
*/
|
*/
|
||||||
static final class Api implements AutoCloseable {
|
static final class Api implements AutoCloseable {
|
||||||
final Set<UUID> linked = ConcurrentHashMap.newKeySet();
|
final Set<UUID> linked = ConcurrentHashMap.newKeySet();
|
||||||
@@ -455,9 +462,19 @@ final class Fakes {
|
|||||||
final Map<String, String> desired = new ConcurrentHashMap<>();
|
final Map<String, String> desired = new ConcurrentHashMap<>();
|
||||||
final Map<String, Integer> restarts = new ConcurrentHashMap<>();
|
final Map<String, Integer> restarts = new ConcurrentHashMap<>();
|
||||||
final Set<String> gaveUp = ConcurrentHashMap.newKeySet();
|
final Set<String> gaveUp = ConcurrentHashMap.newKeySet();
|
||||||
|
/**
|
||||||
|
* policy is a server's autostartPolicy, public unless set, and owner the player
|
||||||
|
* who owns it. Only public or the owner passes the wake's gate: ownerOnly, and an
|
||||||
|
* allowlist the stub keeps empty, refuse everyone else.
|
||||||
|
*/
|
||||||
|
final Map<String, String> policy = new ConcurrentHashMap<>();
|
||||||
|
final Map<String, UUID> owner = new ConcurrentHashMap<>();
|
||||||
/** statusDown makes the status route answer 500. */
|
/** statusDown makes the status route answer 500. */
|
||||||
volatile boolean statusDown;
|
volatile boolean statusDown;
|
||||||
/** wakeError maps a server to "status code" (e.g. "403 forbidden"). */
|
/**
|
||||||
|
* wakeError maps a server to "status code" (e.g. "429 cooldown"), answered where
|
||||||
|
* the real wake reaches its cooldown: after the gate and the start_failed check.
|
||||||
|
*/
|
||||||
final Map<String, String> wakeError = new ConcurrentHashMap<>();
|
final Map<String, String> wakeError = new ConcurrentHashMap<>();
|
||||||
volatile int joinStatus = 204;
|
volatile int joinStatus = 204;
|
||||||
/** claimable names the servers the menu route reports as claimable. */
|
/** claimable names the servers the menu route reports as claimable. */
|
||||||
@@ -511,16 +528,13 @@ final class Fakes {
|
|||||||
reply(ex, 200, status(name, ready.getOrDefault(name, false)));
|
reply(ex, 200, status(name, ready.getOrDefault(name, false)));
|
||||||
return;
|
return;
|
||||||
case "POST wake":
|
case "POST wake":
|
||||||
if (!error(ex, wakeError.get(name))) {
|
wake(ex, name, bodies.get(method + " " + path));
|
||||||
// The real wake reply is this subset of the view: no endpoint.
|
|
||||||
reply(ex, 202, "{\"name\":\"" + name + "\",\"desiredState\":\"Running\","
|
|
||||||
+ "\"phase\":\"Stopped\",\"ready\":false}");
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
case "GET menu":
|
case "GET menu":
|
||||||
if (!error(ex, menuError.get(name))) {
|
if (!error(ex, menuError.get(name))) {
|
||||||
reply(ex, 200, "{\"name\":\"" + name + "\",\"phase\":\"Stopped\",\"ready\":false,"
|
reply(ex, 200, "{\"name\":\"" + name + "\",\"phase\":\"" + phaseOf(name)
|
||||||
+ "\"playersOnline\":3,\"playersMax\":20,\"claimable\":" + claimable.contains(name) + "}");
|
+ "\",\"ready\":" + ready.getOrDefault(name, false)
|
||||||
|
+ ",\"playersOnline\":3,\"playersMax\":20,\"claimable\":" + claimable.contains(name) + "}");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case "POST claim":
|
case "POST claim":
|
||||||
@@ -548,6 +562,52 @@ final class Fakes {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wake answers the way handleInternalWake does, in its order: a server that is up
|
||||||
|
// and meant to stay up is 202 ready with no gate, nothing flipped; then the
|
||||||
|
// autostartPolicy gate on the body's mc_uuid; then 409 start_failed for a start
|
||||||
|
// whose retries are spent; then the cooldown and the rest of wakeError; then the
|
||||||
|
// flip to Running and a 202 with the phase as it stands.
|
||||||
|
private void wake(HttpExchange ex, String name, String body) throws IOException {
|
||||||
|
boolean up = ready.getOrDefault(name, false);
|
||||||
|
String want = desired.getOrDefault(name, "Running");
|
||||||
|
if (up && "Running".equals(want)) {
|
||||||
|
reply(ex, 202, wakeReply(name, want, true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UUID waker = mcUuid(body);
|
||||||
|
if (!"public".equals(policy.getOrDefault(name, "public"))
|
||||||
|
&& (waker == null || !waker.equals(owner.get(name)))) {
|
||||||
|
error(ex, "403 forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gaveUp.contains(name) && "Running".equals(want)) {
|
||||||
|
error(ex, "409 start_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error(ex, wakeError.get(name))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
desired.put(name, "Running");
|
||||||
|
reply(ex, 202, wakeReply(name, "Running", up));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real wake reply is this subset of the view: no endpoint.
|
||||||
|
private String wakeReply(String name, String desiredState, boolean up) {
|
||||||
|
return "{\"name\":\"" + name + "\",\"desiredState\":\"" + desiredState + "\",\"phase\":\""
|
||||||
|
+ phaseOf(name) + "\",\"ready\":" + up + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Pattern MC_UUID = Pattern.compile("\"mc_uuid\"\\s*:\\s*\"([^\"]+)\"");
|
||||||
|
|
||||||
|
private static UUID mcUuid(String body) {
|
||||||
|
Matcher m = MC_UUID.matcher(body == null ? "" : body);
|
||||||
|
return m.find() ? UUID.fromString(m.group(1)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String phaseOf(String name) {
|
||||||
|
return ready.getOrDefault(name, false) ? "Running" : phase.getOrDefault(name, "Stopped");
|
||||||
|
}
|
||||||
|
|
||||||
private String status(String name, boolean up) {
|
private String status(String name, boolean up) {
|
||||||
String addr = address.get(name);
|
String addr = address.get(name);
|
||||||
String endpoint = up
|
String endpoint = up
|
||||||
@@ -556,7 +616,8 @@ final class Fakes {
|
|||||||
// Like the real ServerInfo, autoRestarts and startGaveUp are left out at 0/false.
|
// Like the real ServerInfo, autoRestarts and startGaveUp are left out at 0/false.
|
||||||
int restarts = this.restarts.getOrDefault(name, 0);
|
int restarts = this.restarts.getOrDefault(name, 0);
|
||||||
return "{\"name\":\"" + name + "\",\"subdomain\":\"" + name + "\",\"phase\":\""
|
return "{\"name\":\"" + name + "\",\"subdomain\":\"" + name + "\",\"phase\":\""
|
||||||
+ (up ? "Running" : phase.getOrDefault(name, "Stopped")) + "\",\"ready\":" + up
|
+ phaseOf(name) + "\",\"ready\":" + up
|
||||||
|
+ ",\"autostartPolicy\":\"" + policy.getOrDefault(name, "public") + "\""
|
||||||
+ ",\"desiredState\":\"" + desired.getOrDefault(name, "Running") + "\""
|
+ ",\"desiredState\":\"" + desired.getOrDefault(name, "Running") + "\""
|
||||||
+ (restarts == 0 ? "" : ",\"autoRestarts\":" + restarts)
|
+ (restarts == 0 ? "" : ",\"autoRestarts\":" + restarts)
|
||||||
+ (gaveUp.contains(name) ? ",\"startGaveUp\":true" : "")
|
+ (gaveUp.contains(name) ? ",\"startGaveUp\":true" : "")
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ public final class LegacyForwardingTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ServerView view(String name, boolean legacy) {
|
private static ServerView view(String name, boolean legacy) {
|
||||||
return new ServerView(name, name, "Running", true, null, "Running", "ClusterIP", "10.43.0.9:25565", 0, 20, legacy);
|
return new ServerView(name, name, "Running", true, null, "Running", "direct", "10.43.0.9:25565", 0, 20, legacy);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertEq(String what, Object want, Object got) {
|
private static void assertEq(String what, Object want, Object got) {
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ public final class ServerListSourceTest {
|
|||||||
private static volatile List<ServerView> answer;
|
private static volatile List<ServerView> answer;
|
||||||
|
|
||||||
private static final ServerView LOGIN = new ServerView("login", null, "Running", true,
|
private static final ServerView LOGIN = new ServerView("login", null, "Running", true,
|
||||||
null, "Running", "ClusterIP", "10.43.0.17:25565", 0, 0);
|
null, "Running", "direct", "10.43.0.17:25565", 0, 0);
|
||||||
private static final ServerView SURVIVAL = new ServerView("survival", "Survival", "Stopped", false,
|
private static final ServerView SURVIVAL = new ServerView("survival", "Survival", "Stopped", false,
|
||||||
"Anyone", "Stopped", "ClusterIP", "10.43.9.2:25565", 3, 20);
|
"public", "Stopped", "fallback", "login", 3, 20);
|
||||||
|
|
||||||
public static void main(String[] args) throws Exception {
|
public static void main(String[] args) throws Exception {
|
||||||
Path dir = Files.createTempDirectory("felis-server-list");
|
Path dir = Files.createTempDirectory("felis-server-list");
|
||||||
@@ -84,10 +84,10 @@ public final class ServerListSourceTest {
|
|||||||
assertEq("survival subdomain", "Survival", s.subdomain());
|
assertEq("survival subdomain", "Survival", s.subdomain());
|
||||||
assertEq("survival phase", "Stopped", s.phase());
|
assertEq("survival phase", "Stopped", s.phase());
|
||||||
assertEq("survival ready", false, s.ready());
|
assertEq("survival ready", false, s.ready());
|
||||||
assertEq("survival policy", "Anyone", s.autostartPolicy());
|
assertEq("survival policy", "public", s.autostartPolicy());
|
||||||
assertEq("survival desired", "Stopped", s.desiredState());
|
assertEq("survival desired", "Stopped", s.desiredState());
|
||||||
assertEq("survival mode", "ClusterIP", s.endpointMode());
|
assertEq("survival mode", "fallback", s.endpointMode());
|
||||||
assertEq("survival address", "10.43.9.2:25565", s.endpointAddress());
|
assertEq("survival address", "login", s.endpointAddress());
|
||||||
assertEq("survival online", 3, s.playersOnline());
|
assertEq("survival online", 3, s.playersOnline());
|
||||||
assertEq("survival max", 20, s.playersMax());
|
assertEq("survival max", 20, s.playersMax());
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ public final class ServerListSourceTest {
|
|||||||
private static void theLegacyForwardingMarkIsSaved(Path dir) throws Exception {
|
private static void theLegacyForwardingMarkIsSaved(Path dir) throws Exception {
|
||||||
Path file = fresh(dir);
|
Path file = fresh(dir);
|
||||||
ServerView legacy = new ServerView("legacy18", "old", "Running", true,
|
ServerView legacy = new ServerView("legacy18", "old", "Running", true,
|
||||||
null, "Running", "ClusterIP", "10.43.7.1:25565", 0, 20, true);
|
null, "Running", "direct", "10.43.7.1:25565", 0, 20, true);
|
||||||
answer = List.of(legacy, SURVIVAL);
|
answer = List.of(legacy, SURVIVAL);
|
||||||
new ServerListSource(ServerListSourceTest::fetch, file).next();
|
new ServerListSource(ServerListSourceTest::fetch, file).next();
|
||||||
answer = null;
|
answer = null;
|
||||||
@@ -167,7 +167,7 @@ public final class ServerListSourceTest {
|
|||||||
// The exact shape GET /api/v1/servers answers with, extra fields included.
|
// The exact shape GET /api/v1/servers answers with, extra fields included.
|
||||||
private static void theApiEnvelopeParses() {
|
private static void theApiEnvelopeParses() {
|
||||||
List<ServerView> v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\","
|
List<ServerView> v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\","
|
||||||
+ "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"ClusterIP\","
|
+ "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"direct\","
|
||||||
+ "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100,"
|
+ "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100,"
|
||||||
+ "\"owner\":\"x\"},\"junk\",{\"name\":\"legacy18\",\"legacyForwarding\":true}]}");
|
+ "\"owner\":\"x\"},\"junk\",{\"name\":\"legacy18\",\"legacyForwarding\":true}]}");
|
||||||
assertEq("non-object entries skipped", 2, v.size());
|
assertEq("non-object entries skipped", 2, v.size());
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public final class WaitingRouterTest {
|
|||||||
loginGate();
|
loginGate();
|
||||||
wakeRefusals();
|
wakeRefusals();
|
||||||
queue();
|
queue();
|
||||||
|
wakeAnswers();
|
||||||
longStart();
|
longStart();
|
||||||
menuAndCommands();
|
menuAndCommands();
|
||||||
joins();
|
joins();
|
||||||
@@ -97,6 +98,9 @@ public final class WaitingRouterTest {
|
|||||||
view("theta", false, "10.43.0.22:25565"),
|
view("theta", false, "10.43.0.22:25565"),
|
||||||
view("iota", false, "10.43.0.23:25565"),
|
view("iota", false, "10.43.0.23:25565"),
|
||||||
view("kappa", false, "10.43.0.24:25565"),
|
view("kappa", false, "10.43.0.24:25565"),
|
||||||
|
view("lambda", false, "10.43.0.25:25565"),
|
||||||
|
view("omicron", false, "10.43.0.26:25565"),
|
||||||
|
view("sigma", false, "10.43.0.27:25565"),
|
||||||
view("fresh", false, null)));
|
view("fresh", false, null)));
|
||||||
if (withGone) {
|
if (withGone) {
|
||||||
list.add(view("gone", false, "10.43.0.9:25565"));
|
list.add(view("gone", false, "10.43.0.9:25565"));
|
||||||
@@ -216,17 +220,27 @@ public final class WaitingRouterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void wakeRefusals() {
|
private static void wakeRefusals() {
|
||||||
|
// The first two come from the server's state, as on the real API: a stranger's
|
||||||
|
// wake of an ownerOnly server, and a start whose automatic retries are spent.
|
||||||
String[][] cases = {
|
String[][] cases = {
|
||||||
{"403 forbidden", "You're not allowed to start « gamma »", null},
|
{"ownerOnly, not the owner", "You're not allowed to start « gamma »", null},
|
||||||
|
{"retries spent", "« gamma » failed to start and its automatic retries are spent", null},
|
||||||
{"409 maintenance_in_progress", "« gamma » is under maintenance", null},
|
{"409 maintenance_in_progress", "« gamma » is under maintenance", null},
|
||||||
{"409 start_failed", "« gamma » failed to start and its automatic retries are spent", null},
|
|
||||||
{"409 conflict", "Couldn't start « gamma » right now", "wake gamma failed (status=409)"},
|
{"409 conflict", "Couldn't start « gamma » right now", "wake gamma failed (status=409)"},
|
||||||
{"503 at_capacity", "The cluster is at capacity right now", null},
|
{"503 at_capacity", "The cluster is at capacity right now", null},
|
||||||
{"503 unavailable", "Couldn't start « gamma » right now", "wake gamma failed (status=503)"},
|
{"503 unavailable", "Couldn't start « gamma » right now", "wake gamma failed (status=503)"},
|
||||||
{"500 internal", "Couldn't start « gamma » right now", "wake gamma failed (status=500)"},
|
{"500 internal", "Couldn't start « gamma » right now", "wake gamma failed (status=500)"},
|
||||||
};
|
};
|
||||||
for (String[] c : cases) {
|
for (String[] c : cases) {
|
||||||
api.wakeError.put("gamma", c[0]);
|
switch (c[0]) {
|
||||||
|
case "ownerOnly, not the owner" -> api.policy.put("gamma", "ownerOnly");
|
||||||
|
case "retries spent" -> {
|
||||||
|
api.phase.put("gamma", "Failed");
|
||||||
|
api.restarts.put("gamma", 3);
|
||||||
|
api.gaveUp.add("gamma");
|
||||||
|
}
|
||||||
|
default -> api.wakeError.put("gamma", c[0]);
|
||||||
|
}
|
||||||
int before = router.waitingCount();
|
int before = router.waitingCount();
|
||||||
int warned = c[2] == null ? 0 : log.count("WARN", c[2]);
|
int warned = c[2] == null ? 0 : log.count("WARN", c[2]);
|
||||||
Fakes.FakePlayer p = player("gamma.mc.test", true);
|
Fakes.FakePlayer p = player("gamma.mc.test", true);
|
||||||
@@ -238,6 +252,11 @@ public final class WaitingRouterTest {
|
|||||||
if (c[2] != null) {
|
if (c[2] != null) {
|
||||||
assertEq(c[0] + ": logged", warned + 1, log.count("WARN", c[2]));
|
assertEq(c[0] + ": logged", warned + 1, log.count("WARN", c[2]));
|
||||||
}
|
}
|
||||||
|
api.policy.remove("gamma");
|
||||||
|
api.phase.remove("gamma");
|
||||||
|
api.restarts.remove("gamma");
|
||||||
|
api.gaveUp.remove("gamma");
|
||||||
|
api.wakeError.remove("gamma");
|
||||||
}
|
}
|
||||||
// 429: a wake is already in flight, so join the wait.
|
// 429: a wake is already in flight, so join the wait.
|
||||||
api.wakeError.put("gamma", "429 cooldown");
|
api.wakeError.put("gamma", "429 cooldown");
|
||||||
@@ -331,6 +350,53 @@ public final class WaitingRouterTest {
|
|||||||
assertEq("registered: queue empty", 0, router.waitingCount());
|
assertEq("registered: queue empty", 0, router.waitingCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The wakes that are not refused, each answered from the server's state the way the
|
||||||
|
// real API answers it.
|
||||||
|
private static void wakeAnswers() {
|
||||||
|
assertEq("wake answers: queue empty to begin with", 0, router.waitingCount());
|
||||||
|
|
||||||
|
// The owner of an ownerOnly server may wake it, and the wake names the player
|
||||||
|
// who asked: the gate reads nothing else.
|
||||||
|
api.policy.put("lambda", "ownerOnly");
|
||||||
|
Fakes.FakePlayer owner = player("lambda.mc.test", true);
|
||||||
|
api.owner.put("lambda", owner.id);
|
||||||
|
choose(owner);
|
||||||
|
assertEq("owner: released to the lobby", "lobby", allowedTo(release(owner)));
|
||||||
|
assertEq("owner: told it is starting", true, owner.said("Starting « lambda »"));
|
||||||
|
assertEq("owner: queued", 1, router.waitingCount());
|
||||||
|
assertEq("the wake carried the joining player", true,
|
||||||
|
api.bodies.get("POST " + SERVERS + "lambda/wake").contains(owner.id.toString()));
|
||||||
|
|
||||||
|
// Up since the last refresh: the host path still wakes, and the API answers 202
|
||||||
|
// ready without the gate. Nobody is refused or told a start is under way, and
|
||||||
|
// the next drain moves them in.
|
||||||
|
api.policy.put("omicron", "ownerOnly");
|
||||||
|
api.ready.put("omicron", true);
|
||||||
|
Fakes.FakePlayer friend = player("omicron.mc.test", true);
|
||||||
|
choose(friend);
|
||||||
|
assertEq("up behind a stale list: released to the lobby", "lobby", allowedTo(release(friend)));
|
||||||
|
assertEq("up behind a stale list: not refused", false, friend.said("not allowed"));
|
||||||
|
assertEq("up behind a stale list: no promise of a start", false, friend.said("Starting « omicron »"));
|
||||||
|
assertEq("up behind a stale list: queued", 2, router.waitingCount());
|
||||||
|
router.tick();
|
||||||
|
assertEq("up behind a stale list: moved at the next drain", List.of("omicron"), List.copyOf(friend.connects));
|
||||||
|
assertEq("up behind a stale list: only the owner's wait is left", 1, router.waitingCount());
|
||||||
|
|
||||||
|
// Failed, but inside its restart backoff: the next attempt is coming, so the
|
||||||
|
// wake is a 202 and the player waits for it.
|
||||||
|
api.phase.put("sigma", "Failed");
|
||||||
|
api.restarts.put("sigma", 1);
|
||||||
|
Fakes.FakePlayer patient = player("sigma.mc.test", true);
|
||||||
|
choose(patient);
|
||||||
|
assertEq("in the backoff: released to the lobby", "lobby", allowedTo(release(patient)));
|
||||||
|
assertEq("in the backoff: told it is starting", true, patient.said("Starting « sigma »"));
|
||||||
|
assertEq("in the backoff: queued", 2, router.waitingCount());
|
||||||
|
|
||||||
|
net.players.clear();
|
||||||
|
router.tick();
|
||||||
|
assertEq("wake answers: queue empty again", 0, router.waitingCount());
|
||||||
|
}
|
||||||
|
|
||||||
// A modpack's cold start outlasts any fixed wait: the operator gives a start 300 s,
|
// A modpack's cold start outlasts any fixed wait: the operator gives a start 300 s,
|
||||||
// then recreates the pod up to three times with a 1, 2, 4 min backoff. The waiter
|
// then recreates the pod up to three times with a 1, 2, 4 min backoff. The waiter
|
||||||
// follows the server's own progress instead of a clock, and hears how it is going.
|
// follows the server's own progress instead of a clock, and hears how it is going.
|
||||||
@@ -446,9 +512,10 @@ public final class WaitingRouterTest {
|
|||||||
assertEq("only the menu entry tells the lobby", List.of(menu.name + "@epsilon"), List.copyOf(notified));
|
assertEq("only the menu entry tells the lobby", List.of(menu.name + "@epsilon"), List.copyOf(notified));
|
||||||
|
|
||||||
// A friend's running server: the menu and /felis go join it without waking it.
|
// A friend's running server: the menu and /felis go join it without waking it.
|
||||||
// The API refuses a non-owner's wake of an ownerOnly server, and that refusal
|
// The API used to refuse a non-owner's wake of a running ownerOnly server, and
|
||||||
// was all a friend got from the green tile while every entry woke first.
|
// that refusal was all a friend got from the green tile while every entry woke
|
||||||
api.wakeError.put("beta", "403 forbidden");
|
// first.
|
||||||
|
api.policy.put("beta", "ownerOnly");
|
||||||
Fakes.FakePlayer friend = player(null, true);
|
Fakes.FakePlayer friend = player(null, true);
|
||||||
friend.current = lobby;
|
friend.current = lobby;
|
||||||
router.enqueueFromMenu(friend.player, "beta");
|
router.enqueueFromMenu(friend.player, "beta");
|
||||||
@@ -462,7 +529,7 @@ public final class WaitingRouterTest {
|
|||||||
assertEq("running server via /felis go: joined", List.of("beta"), List.copyOf(friend2.connects));
|
assertEq("running server via /felis go: joined", List.of("beta"), List.copyOf(friend2.connects));
|
||||||
assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake"));
|
assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake"));
|
||||||
assertEq("running server: nobody refused", false, friend.said("not allowed") || friend2.said("not allowed"));
|
assertEq("running server: nobody refused", false, friend.said("not allowed") || friend2.said("not allowed"));
|
||||||
api.wakeError.remove("beta");
|
api.policy.remove("beta");
|
||||||
|
|
||||||
// Asking for the server you stand on is answered at once, case-insensitively.
|
// Asking for the server you stand on is answered at once, case-insensitively.
|
||||||
Fakes.FakePlayer there = player(null, true);
|
Fakes.FakePlayer there = player(null, true);
|
||||||
@@ -674,7 +741,7 @@ public final class WaitingRouterTest {
|
|||||||
if (addr != null) {
|
if (addr != null) {
|
||||||
api.address.put(name, addr);
|
api.address.put(name, addr);
|
||||||
}
|
}
|
||||||
return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "ownerOnly",
|
return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "public",
|
||||||
"Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20);
|
"Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user