Unverified Commit c15eec6c authored by Lemon-miaow's avatar Lemon-miaow
Browse files

test(velocity): 路由核心类接真 velocity-api 自测,修正子域名改名后旧域名仍路由

parent b65c2c00
Loading
Loading
Loading
Loading
+4 −2
Changes for plugins/README.md: 4 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -312,8 +312,10 @@ plugins/neoforge/gradlew -p plugins/neoforge build

The first build of each mod downloads and remaps/decompiles Minecraft, so it takes a few
minutes; subsequent builds are fast. Jars land in each module's `build/libs`. CI runs
both gates: `bash plugins/test.sh` (JDK 25 — the install-time plugins plus the
codec/invite/server-list tests) and `bash plugins/test-mods.sh` (JDK 17 — the three
both gates: `bash plugins/test.sh` (JDK 25 — the install-time plugins, the
codec/invite/server-list tests, and the proxy routing tests that drive ServerRegistry,
WaitingRouter and ControlChannel against the real velocity-api; run those alone with
`plugins/velocity/gradlew -p plugins/velocity routingTest`) and `bash plugins/test-mods.sh` (JDK 17 — the three
loader mods, via the wrappers above).

### Dependency verification
+13 −1
Changes for plugins/test.sh: 13 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@
#
#     bash plugins/test.sh
#
# Two gates, both runnable on any machine with a JDK 25 (Gradle comes from each
# Three gates, all runnable on any machine with a JDK 25 (Gradle comes from each
# module's wrapper, sha256-pinned):
#
#   1. The hand-written, framework-free test mains under shared/test and
@@ -37,6 +37,15 @@
#      install-time surprise. limbo compiles against the API release the login gate
#      bundles: deploy/game-stack.lock's LIMBO_VERSION, which bootstrap passes too.
#
#   3. The velocity routing self-tests (`./gradlew routingTest`): ServerRegistry,
#      WaitingRouter and ControlChannel run against the real velocity-api with a
#      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
#      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
#      frame budget. They ride the module's verified dependency set, which is why
#      they live in Gradle rather than in the javac mains above.
#
# No test framework: the mains are the same javac one-liners their javadocs document,
# so a local run and CI run the same bytes.
set -euo pipefail
@@ -168,6 +177,9 @@ for module in velocity paper; do
  ( cd "plugins/$module" && ./gradlew --no-daemon build )
done

echo "==> plugins/velocity: ./gradlew --no-daemon routingTest"
( cd plugins/velocity && ./gradlew --no-daemon routingTest )

limbo_version="$(sed -n 's/^LIMBO_VERSION=//p' deploy/game-stack.lock)"
[ -n "$limbo_version" ] || { echo "deploy/game-stack.lock sets no LIMBO_VERSION" >&2; exit 1; }
echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build"
+36 −0
Changes for plugins/velocity/build.gradle: 36 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -68,6 +68,42 @@ tasks.withType(JavaCompile).configureEach {
    options.encoding = 'UTF-8'
}

// Routing self-tests: ServerRegistry and WaitingRouter run unchanged against the real
// velocity-api types (faked with java.lang.reflect.Proxy, test/.../Fakes.java) and a
// stub felis-api on a loopback port. They reuse the compile classpath, which dependency
// verification has already checked, so they add no artifact. Kept out of `build` on
// purpose: bootstrap runs `gradle clean build` on every install, and a test there would
// only slow the install down. plugins/test.sh runs `./gradlew routingTest`.
sourceSets {
    routingTest {
        java {
            srcDir 'test'
            include 'best/lolicon/felis/velocity/Fakes.java'
            include 'best/lolicon/felis/velocity/ServerRegistryTest.java'
            include 'best/lolicon/felis/velocity/WaitingRouterTest.java'
            include 'best/lolicon/felis/velocity/ControlChannelTest.java'
        }
        compileClasspath += sourceSets.main.output + configurations.compileClasspath
        runtimeClasspath += output + compileClasspath
    }
}

def routingMains = ['ServerRegistryTest', 'WaitingRouterTest', 'ControlChannelTest']
routingMains.each { name ->
    tasks.register(name, JavaExec) {
        group = 'verification'
        description = "Runs the ${name} self-test main."
        classpath = sourceSets.routingTest.runtimeClasspath
        mainClass = "best.lolicon.felis.velocity.${name}"
    }
}

tasks.register('routingTest') {
    group = 'verification'
    description = 'Runs the routing self-tests (ServerRegistry, WaitingRouter, ControlChannel).'
    dependsOn routingMains
}

// Byte-identical jars from identical sources. deploy/bootstrap.sh restarts the proxy only when
// a file it runs changed (velocity_fingerprint), and build-time timestamps in the jar would
// make every rebuild look like a change and disconnect every player on each rerun.
+8 −2
Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java: 8 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@ import org.slf4j.Logger;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
@@ -55,6 +56,7 @@ final class ServerRegistry {
    /** refresh reconciles registrations against a freshly fetched server list. */
    void refresh(Collection<ServerView> servers) {
        Set<String> seen = new HashSet<>();
        Map<String, String> subdomains = new HashMap<>();
        for (ServerView v : servers) {
            String name = v.name();
            if (name == null || name.isEmpty()) {
@@ -64,7 +66,7 @@ final class ServerRegistry {
            byName.put(name, v);
            String sub = v.subdomain();
            if (sub != null && !sub.isEmpty()) {
                subdomainToName.put(sub.toLowerCase(Locale.ROOT), name);
                subdomains.put(sub.toLowerCase(Locale.ROOT), name);
            }
            ensureRegistered(v);
        }
@@ -75,7 +77,11 @@ final class ServerRegistry {
                deregister(name);
            }
        }
        subdomainToName.values().removeIf(n -> !seen.contains(n));
        // The fetch is the whole truth for host routing too: a subdomain a server gave
        // up (renamed, or gone with the server) stops routing, not only one another
        // server took over.
        subdomainToName.putAll(subdomains);
        subdomainToName.keySet().retainAll(subdomains.keySet());
    }

    private void ensureRegistered(ServerView v) {
+307 −0
Changes for plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java: 307 added lines, 0 removed lines.
Original line number Diff line number Diff line
package best.lolicon.felis.velocity;

import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.ServerView;

import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.proxy.ServerConnection;
import com.velocitypowered.api.proxy.messages.ChannelIdentifier;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import com.velocitypowered.api.proxy.server.RegisteredServer;

import java.net.InetAddress;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;

/**
 * ControlChannelTest drives the real ControlChannel (with the real WaitingRouter,
 * ServerRegistry, FelisApiClient and call pool behind it) through Velocity's
 * PluginMessageEvent, against a fake proxy and a stub felis-api: which frames are
 * consumed, which sources may act and as whom, what each lobby frame answers,
 * that status answers are shared across players, that a claim refusal never wakes,
 * and that the per-player frame budget holds. Framework free: a failed assertion
 * throws.
 *
 * <p>Run: {@code ./gradlew routingTest} in plugins/velocity.
 */
public final class ControlChannelTest {

    private static final String SERVERS = "/api/v1/internal/servers/";

    private static int checks;
    private static int nextPlayer = 1;

    private static Fakes.Net net;
    private static Fakes.Log log;
    private static Fakes.Api api;
    private static WaitingRouter router;
    private static ControlChannel channel;
    private static RegisteredServer login;
    private static RegisteredServer lobby;
    private static RegisteredServer beta;

    public static void main(String[] args) throws Exception {
        net = new Fakes.Net();
        log = new Fakes.Log();
        api = new Fakes.Api();
        try {
            FelisApiClient client = new FelisApiClient(new LinkConfig(api.url(), "t"));
            ServerRegistry reg = new ServerRegistry(net.proxy, log.logger, "mc.test");
            // Deliberately unsorted: the tile list must come back sorted.
            reg.refresh(List.of(
                    view("gamma", false, "10.43.0.5:25565"),
                    view("login", true, "10.43.0.1:25565"),
                    view("beta", true, "10.43.0.4:25565"),
                    view("lobby", true, "10.43.0.2:25565"),
                    view("alpha", false, "10.43.0.3:25565")));
            FelisVelocityPlugin plugin =
                    new FelisVelocityPlugin(net.proxy, log.logger, Files.createTempDirectory("felis-control"));
            router = new WaitingRouter(net.proxy, log.logger, client, reg, plugin, "login", "lobby");
            channel = new ControlChannel(net.proxy, log.logger, client, router, reg, plugin, "login", "lobby");
            login = net.proxy.getServer("login").orElseThrow();
            lobby = net.proxy.getServer("lobby").orElseThrow();
            beta = net.proxy.getServer("beta").orElseThrow();

            channel.register();
            assertEq("register opens felis:control", List.of("felis:control"), List.copyOf(net.channels));

            consumption();
            sources();
            statusQueries(reg, plugin);
            claims();
            wakeAndTransfer();
            budget();
        } finally {
            api.close();
        }
        System.out.println("ControlChannelTest OK (" + checks + " checks)");
    }

    private static void consumption() {
        Fakes.FakePlayer p = player(true);
        PluginMessageEvent other = message(p.on(lobby), MinecraftChannelIdentifier.create("other", "x"),
                Control.encode(ControlFrame.statusQuery("alpha")));
        assertEq("another channel is left alone", true, other.getResult().isAllowed());

        PluginMessageEvent fromClient = new PluginMessageEvent(p.player, p.on(lobby), ControlChannel.CHANNEL,
                Control.encode(ControlFrame.statusQuery("alpha")));
        channel.onPluginMessage(fromClient);
        assertEq("a client's frame is consumed", false, fromClient.getResult().isAllowed());

        PluginMessageEvent junk = message(p.on(lobby), ControlChannel.CHANNEL,
                "not a frame".getBytes(StandardCharsets.UTF_8));
        assertEq("a malformed frame is consumed", false, junk.getResult().isAllowed());
        assertEq("... and neither is answered", 0, p.pluginMessages.size());
        assertEq("... nor reaches felis-api", 0, api.count("GET " + SERVERS + "alpha/menu"));
    }

    private static void sources() {
        // A user backend is running its owner's plugins: refused, logged once a minute.
        Fakes.FakePlayer p = player(true);
        PluginMessageEvent e = message(p.on(beta), ControlChannel.CHANNEL,
                Control.encode(ControlFrame.wakeRequest(p.name, "alpha")));
        assertEq("user backend: consumed", false, e.getResult().isAllowed());
        message(p.on(beta), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest(p.name, "alpha")));
        assertEq("user backend: logged once", 1, log.count("WARN", "refused felis:control 'WakeRequest' from backend beta"));
        assertEq("user backend: not woken", 0, api.count("POST " + SERVERS + "alpha/wake"));

        // LoginRelease belongs to the login gate alone.
        message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.loginRelease(p.name)));
        assertEq("lobby's LoginRelease: nothing moved", 0, p.connects.size());
        assertEq("lobby's LoginRelease: logged", 1, log.count("WARN", "refused felis:control 'LoginRelease' from backend lobby"));
        message(p.on(login), ControlChannel.CHANNEL, Control.encode(ControlFrame.loginRelease(p.name)));
        assertEq("login's LoginRelease: moved to the lobby", List.of("lobby"), List.copyOf(p.connects));

        // The lobby may only name managed user servers; a StatusQuery gets an answer
        // anyway so the tile stops loading, without echoing a malformed name.
        Fakes.FakePlayer q = player(true);
        message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.statusQuery("nope")));
        message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.statusQuery("lobby")));
        message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.statusQuery("../x")));
        message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest(q.name, "nope")));
        List<ControlFrame> r = frames(q);
        assertEq("bad server: three answers, none for the wake", 3, r.size());
        assertEq("unknown server", "Error not_found nope", brief(r.get(0)));
        assertEq("system server", "Error not_found lobby", brief(r.get(1)));
        assertEq("malformed name not echoed", "Error not_found null", brief(r.get(2)));
        assertEq("bad server: no felis-api call", 0, api.count("POST " + SERVERS + "nope/wake"));

        // The tile list: user servers only, sorted.
        message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
        List<ControlFrame> all = frames(q);
        ControlFrame list = all.get(all.size() - 1);
        assertEq("list type", ControlFrame.LIST_UPDATE, list.type());
        assertEq("list: user servers, sorted", List.of("alpha", "beta", "gamma"), list.servers());
    }

    private static void statusQueries(ServerRegistry reg, FelisVelocityPlugin plugin) throws Exception {
        api.claimable.add("alpha");
        Fakes.FakePlayer p = player(true);
        message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.statusQuery("alpha")));
        Fakes.await("status answered", () -> p.pluginMessages.size() == 1);
        ControlFrame s = frames(p).get(0);
        assertEq("status type", ControlFrame.STATUS_UPDATE, s.type());
        assertEq("status fields", "alpha Stopped false 3/20 claimable=true",
                s.server() + " " + s.phase() + " " + s.ready() + " " + s.playersOnline() + "/" + s.playersMax()
                        + " claimable=" + s.claimable());

        // A second player within the TTL is answered from the shared projection, at once.
        Fakes.FakePlayer q = player(true);
        message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.statusQuery("alpha")));
        assertEq("shared answer is immediate", 1, q.pluginMessages.size());
        assertEq("shared answer is the same", "alpha Stopped false", brief3(frames(q).get(0)));
        assertEq("felis-api read once", 1, api.count("GET " + SERVERS + "alpha/menu"));

        // felis-api's own refusal comes back as its code and message.
        api.menuError.put("gamma", "404 not_found");
        message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.statusQuery("gamma")));
        Fakes.await("refusal answered", () -> p.pluginMessages.size() == 2);
        ControlFrame refused = frames(p).get(1);
        assertEq("refusal", "Error not_found gamma", brief(refused));
        assertEq("refusal message", "stub not_found", refused.message());

        // A transport failure never shows its internals (host, port) to the player.
        int closed;
        try (ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
            closed = probe.getLocalPort();
        }
        FelisApiClient dead = new FelisApiClient(new LinkConfig("http://127.0.0.1:" + closed, "t"));
        ControlChannel cut = new ControlChannel(net.proxy, log.logger, dead, router, reg, plugin, "login", "lobby");
        Fakes.FakePlayer t = player(true);
        cut.onPluginMessage(new PluginMessageEvent(t.on(lobby), t.player, ControlChannel.CHANNEL,
                Control.encode(ControlFrame.statusQuery("beta"))));
        Fakes.await("transport failure answered", () -> t.pluginMessages.size() == 1);
        ControlFrame down = frames(t).get(0);
        assertEq("transport failure code", "Error transport_error beta", brief(down));
        assertEq("transport failure message",
                "Felis 暂时不可用,请稍后再试 / Felis is temporarily unavailable — please try again.", down.message());
    }

    private static void claims() {
        // A refused claim answers with the refusal and never wakes.
        api.claimError.put("gamma", "403 quota_exceeded");
        Fakes.FakePlayer p = player(true);
        p.current = lobby;
        message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.claimRequest("someone-else", "gamma")));
        Fakes.await("claim refusal answered", () -> p.pluginMessages.size() == 1);
        assertEq("claim refused", "Error quota_exceeded gamma", brief(frames(p).get(0)));
        assertEq("claim refused: not woken", 0, api.count("POST " + SERVERS + "gamma/wake"));
        assertEq("the claim acted as the connection's player",
                true, api.bodies.get("POST " + SERVERS + "gamma/claim").contains(p.id.toString()));

        // A granted claim wakes and parks, and drops the stale "claimable" answer. Warm
        // the shared answer right before, so only the claim can have dropped it.
        Fakes.FakePlayer w = player(true);
        int reads = api.count("GET " + SERVERS + "alpha/menu");
        message(w.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.statusQuery("alpha")));
        Fakes.await("warm status", () -> w.pluginMessages.size() == 1);
        int warm = api.count("GET " + SERVERS + "alpha/menu");
        assertEq("warmed (read at most once more)", true, warm == reads || warm == reads + 1);
        message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.claimRequest(p.name, "alpha")));
        Fakes.await("claimed server woken", () -> api.count("POST " + SERVERS + "alpha/wake") == 1);
        Fakes.await("claimer queued", () -> router.waitingCount() == 1);
        assertEq("the wake acted as the connection's player",
                true, api.bodies.get("POST " + SERVERS + "alpha/wake").contains(p.id.toString()));
        Fakes.FakePlayer q = player(true);
        message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.statusQuery("alpha")));
        Fakes.await("fresh status after the claim", () -> q.pluginMessages.size() == 1);
        assertEq("status re-read after the claim", warm + 1, api.count("GET " + SERVERS + "alpha/menu"));
    }

    private static void wakeAndTransfer() {
        // A WakeRequest parks the player; when the backend is ready the lobby hears
        // TransferReady just before the proxy moves them.
        Fakes.FakePlayer p = player(true);
        p.current = lobby;
        message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest("forged-name", "beta")));
        Fakes.await("beta woken", () -> api.count("POST " + SERVERS + "beta/wake") == 1);
        Fakes.await("waker queued", () -> router.waitingCount() == 2);
        api.ready.put("beta", true);
        router.tick();
        List<ControlFrame> r = frames(p);
        assertEq("one frame to the lobby", 1, r.size());
        assertEq("transfer ready", ControlFrame.TRANSFER_READY + " " + p.name + " beta",
                r.get(0).type() + " " + r.get(0).player() + " " + r.get(0).server());
        assertEq("then moved", List.of("beta"), List.copyOf(p.connects));
    }

    private static void budget() {
        // 96 frames in a burst, then 10 a second: a flood is cut off, the connection
        // is not. ListRequest is answered inline, so every accepted frame shows at once.
        Fakes.FakePlayer p = player(true);
        for (int i = 0; i < 150; i++) {
            message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
        }
        int answered = p.pluginMessages.size();
        assertEq("flood cut at the burst (plus what refilled meanwhile): " + answered,
                true, answered >= 96 && answered <= 99);

        // Another player has a budget of their own.
        Fakes.FakePlayer q = player(true);
        message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
        assertEq("other player unaffected", 1, q.pluginMessages.size());

        // Leaving the proxy forgets the budget: a fresh burst, far more than a refill.
        channel.onDisconnect(new DisconnectEvent(p.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN));
        p.pluginMessages.clear();
        for (int i = 0; i < 50; i++) {
            message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
        }
        assertEq("budget forgotten on disconnect", 50, p.pluginMessages.size());
    }

    // ---- helpers ----

    private static Fakes.FakePlayer player(boolean linked) {
        int n = nextPlayer++;
        Fakes.FakePlayer p = new Fakes.FakePlayer(net, "player" + n, n);
        if (linked) {
            api.linked.add(p.id);
        }
        return p;
    }

    // message delivers a frame a backend sent up its connection toward its player.
    private static PluginMessageEvent message(ServerConnection source, ChannelIdentifier id, byte[] data) {
        PluginMessageEvent e = new PluginMessageEvent(source, source.getPlayer(), id, data);
        channel.onPluginMessage(e);
        return e;
    }

    private static List<ControlFrame> frames(Fakes.FakePlayer p) {
        List<ControlFrame> out = new ArrayList<>();
        synchronized (p.pluginMessages) {
            for (byte[] b : p.pluginMessages) {
                out.add(Control.decode(b));
            }
        }
        return out;
    }

    private static String brief(ControlFrame f) {
        return f.type() + " " + f.code() + " " + f.server();
    }

    private static String brief3(ControlFrame f) {
        return f.server() + " " + f.phase() + " " + f.ready();
    }

    private static ServerView view(String name, boolean ready, String addr) {
        return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "ownerOnly",
                "Running", "ClusterIP", addr, 0, 20);
    }

    private static void assertEq(String what, Object want, Object got) {
        if (want == null ? got != null : !want.equals(got)) {
            throw new AssertionError(what + ": got " + got + ", want " + want);
        }
        checks++;
    }
}
Loading