mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-25 18:46:18 +00:00
feat: name backend host and verify real Uranium lifecycle
This commit is contained in:
1 parent
cc817c057c
commit
8c2d35b8be
19 files changed
+937
-18
No files matched your search
@@ -24,4 +24,6 @@
|
||||
|
||||
## 验证状态
|
||||
|
||||
`StrataProxyBackend` 后端宿主已通过两个真实 Uranium / Java 8 服务端的注册、通信、主线程回调、插件停用、重连及注销/租约验收,见[后端宿主验收记录](smoke/results/2026-09-27-backend-host.md)。这是无玩家、隔离服务端上的功能验收,不代表生产容量测试。
|
||||
|
||||
已完成当前 Prism 整合包的真实 Mojang 认证、连续 20 次转服、历史 DNS/Agent 发行包烟测及同条件流量对照,见[验收记录](benchmarks/results/2026-09-26-prism-pack.md)。验收覆盖单玩家与本机后端;生产容量需按部署环境测量。已知 Uranium 候选服登录停滞的调查见[记录](smoke/results/2026-09-26-uranium-login-stalls.md)及 [Uranium #585](https://git.nest.potatolab.uk:8443/TDLM/Uranium/issues/585)。
|
||||
@@ -34,7 +34,7 @@ tasks.withType<Test>().configureEach { useJUnitPlatform() }
|
||||
// One provider owns the API classes, codec and transport. Consumers use compileOnly
|
||||
// and plugin.yml depend, so Bukkit resolves the same service class identity.
|
||||
tasks.jar {
|
||||
archiveBaseName.set("strataproxy-backend-channel-bukkit")
|
||||
archiveBaseName.set("strataproxy-backend-bukkit")
|
||||
dependsOn(configurations.runtimeClasspath)
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
from({ configurations.runtimeClasspath.get().filter { it.extension == "jar" }.map { zipTree(it) } })
|
||||
@@ -76,9 +76,9 @@ publishing {
|
||||
publications {
|
||||
create<MavenPublication>("bukkitHost") {
|
||||
from(components["java"])
|
||||
artifactId = "backend-channel-bukkit"
|
||||
artifactId = "backend-bukkit"
|
||||
pom {
|
||||
name.set("StrataProxy Bukkit Channel Host")
|
||||
name.set("StrataProxy Backend Host")
|
||||
description.set("Java 8 shared channel messaging service for Bukkit and Uranium plugins.")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ package dev.strataproxy.bukkit;
|
||||
import dev.strataproxy.messaging.Messaging;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Shared service provided by the StrataProxyChannels Bukkit plugin. */
|
||||
/** Shared service provided by the StrataProxyBackend Bukkit plugin. */
|
||||
public interface BukkitMessagingService {
|
||||
/**
|
||||
* Returns this enabled plugin's owned messaging scope. Repeated lookups for
|
||||
File renamed without changes.
+1
-1
@@ -15,7 +15,7 @@ import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
/** One process-level authenticated transport shared by all dependent plugins. */
|
||||
public final class StrataProxyChannelsPlugin extends JavaPlugin implements Listener {
|
||||
public final class StrataProxyBackendPlugin extends JavaPlugin implements Listener {
|
||||
private volatile boolean stopping;
|
||||
private BackendChannelClient client;
|
||||
private OwnedMessagingService service;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Install exactly one host plugin per backend. Consumer plugins depend on
|
||||
# StrataProxyChannels and obtain BukkitMessagingService from ServicesManager.
|
||||
# StrataProxyBackend and obtain BukkitMessagingService from ServicesManager.
|
||||
proxyHost: "127.0.0.1"
|
||||
proxyPort: 28081
|
||||
instanceId: "island-a"
|
||||
@@ -0,0 +1,5 @@
|
||||
name: StrataProxyBackend
|
||||
main: dev.strataproxy.bukkit.StrataProxyBackendPlugin
|
||||
version: 0.1.0
|
||||
description: Shared backend host for StrataProxy
|
||||
load: STARTUP
|
||||
File renamed without changes.
@@ -1,5 +0,0 @@
|
||||
name: StrataProxyChannels
|
||||
main: dev.strataproxy.bukkit.StrataProxyChannelsPlugin
|
||||
version: 0.1.0
|
||||
description: Shared channel messaging service for StrataProxy backends
|
||||
load: STARTUP
|
||||
+2
-2
@@ -11,11 +11,11 @@ allprojects {
|
||||
tasks.register("check") {
|
||||
group = "verification"
|
||||
dependsOn(":proxy-plugin-api:check", ":messaging-api:check", ":messaging-protocol:check",
|
||||
":backend-channel-client:check", ":backend-channel-bukkit:check", ":proxy-core:check")
|
||||
":backend-channel-client:check", ":backend-bukkit:check", ":proxy-core:check")
|
||||
}
|
||||
|
||||
tasks.register("publish") {
|
||||
group = "publishing"
|
||||
dependsOn(":proxy-plugin-api:publish", ":messaging-api:publish", ":messaging-protocol:publish",
|
||||
":backend-channel-client:publish", ":backend-channel-bukkit:publish")
|
||||
":backend-channel-client:publish", ":backend-bukkit:publish")
|
||||
}
|
||||
@@ -25,15 +25,15 @@ flowchart LR
|
||||
|
||||
## 后端插件接入
|
||||
|
||||
在 Bukkit/Uranium 后端安装一次 `backend-channel-bukkit` 生成的 `strataproxy-backend-channel-bukkit` JAR,插件名为 `StrataProxyChannels`。配置唯一的实例 ID、后端名称、代理控制地址、游戏地址与凭据。宿主异步建立连接,不在启动主线程等待网络。
|
||||
在 Bukkit/Uranium 后端安装一次 `backend-bukkit` 生成的 `strataproxy-backend-bukkit` JAR,插件名为 `StrataProxyBackend`。配置唯一的实例 ID、后端名称、代理控制地址、游戏地址与凭据。宿主异步建立连接,不在启动主线程等待网络。
|
||||
|
||||
消费者 `plugin.yml` 声明:
|
||||
|
||||
```yaml
|
||||
depend: [StrataProxyChannels]
|
||||
depend: [StrataProxyBackend]
|
||||
```
|
||||
|
||||
消费者仅将 `uk.potatolab:backend-channel-bukkit` 作为 `compileOnly` / `provided` 依赖,不将 API、SDK 或宿主实现重新打包进自己的 JAR,确保各插件使用同一服务类。
|
||||
消费者仅将 `uk.potatolab:backend-bukkit` 作为 `compileOnly` / `provided` 依赖,不将 API、SDK 或宿主实现重新打包进自己的 JAR,确保各插件使用同一服务类。
|
||||
|
||||
```java
|
||||
BukkitMessagingService service = getServer().getServicesManager()
|
||||
|
||||
@@ -110,3 +110,17 @@ PR 的 `.gitea/workflows/verify.yml` 独立运行 `gradlew check`,不使用 Ma
|
||||
新增验证包含组件构造与旧版 JSON 字段、模板变量作为文字插入、消息深度/节点/字节限制;根命令与后端候选合并、参数补全、原始后端回复透传;超时、迟到回复隔离、队列上限、转服代次切换、插件注销/关闭与取消回调线程。真实 TCP 测试同时核对富文本命令回复和补全,转服测试核对切换前后的补全上下文。
|
||||
|
||||
此外用 Java 8 加载本机 Uranium 的实际组件解析器读取当前发行包生成的 JSON,核对正文、点击命令和悬浮文字通过。未启动真实客户端进行视觉/鼠标交互验收;没有测量本次改动的吞吐收益。最终远端 CI 按对应 PR 检查项记录。
|
||||
|
||||
## 后端宿主真实服务端验收
|
||||
|
||||
`smoke/backend-uranium.ps1` 将已经接受 EULA 的 Uranium 发行包中的服务端 JAR、运行库和 EULA 复制到全新隔离目录,启动两个 Java 8 后端及实际 Java 25 Proxy。它安装 `StrataProxyBackend`、两个独立的 Bukkit 业务插件和一个外部 Proxy 验收插件,通过正式插件 API 检查注册与消息服务;不会复制或修改原有世界、模组、插件及凭据。
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat check :proxy-core:installDist
|
||||
.\smoke\backend-uranium.ps1 -BundlePath C:\path\to\accepted-uranium-bundle `
|
||||
-BukkitApiJar C:\path\to\accepted-uranium-bundle\Uranium-server.jar
|
||||
```
|
||||
|
||||
脚本默认使用本机 Zulu Java 8 和 Java 25,可通过 `-Java8Home`、`-Java25Home` 指定其他路径。完整成功输出 `BACKEND_URANIUM_PASS`;任一阶段失败会返回非零退出码并保留日志。后台进程有启动、请求、停机期限,退出时只清理本次创建的进程。
|
||||
|
||||
验收覆盖 Proxy→后端、后端→Proxy、后端→后端请求,UUID/replyTo、多个独立订阅者接收同一广播、Bukkit 主线程与零在线玩家、业务插件停用隔离、Proxy 重启后的自动重新注册、正常停服快速注销、异常退出后的租约清理和后端重启。具体构件哈希和运行结果见[2026-09-27 后端宿主记录](../smoke/results/2026-09-27-backend-host.md)。
|
||||
@@ -99,7 +99,7 @@ distributions {
|
||||
from(project(":messaging-protocol").tasks.named("jar")) {
|
||||
into("backend-client")
|
||||
}
|
||||
from(project(":backend-channel-bukkit").tasks.named("jar")) {
|
||||
from(project(":backend-bukkit").tasks.named("jar")) {
|
||||
into("backend-host")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,5 +29,5 @@ include(
|
||||
"backend-channel-client",
|
||||
"messaging-api",
|
||||
"messaging-protocol",
|
||||
"backend-channel-bukkit",
|
||||
"backend-bukkit",
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
package dev.strataproxy.smoke;
|
||||
|
||||
import dev.strataproxy.bukkit.BukkitMessagingService;
|
||||
import dev.strataproxy.messaging.Endpoint;
|
||||
import dev.strataproxy.messaging.Message;
|
||||
import dev.strataproxy.messaging.MessageChannel;
|
||||
import dev.strataproxy.messaging.MessageKind;
|
||||
import dev.strataproxy.messaging.Messaging;
|
||||
import dev.strataproxy.messaging.Subscription;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Installed only by the real-Uranium backend acceptance smoke. */
|
||||
public class BackendAcceptancePlugin extends JavaPlugin {
|
||||
private final AtomicInteger eventCount = new AtomicInteger();
|
||||
private final AtomicInteger requestCount = new AtomicInteger();
|
||||
private volatile String lastEventId = "none";
|
||||
private volatile boolean stopping;
|
||||
private boolean observer;
|
||||
private String backendName;
|
||||
private Messaging messaging;
|
||||
private Subscription requestSubscription;
|
||||
private Subscription eventSubscription;
|
||||
|
||||
@Override public void onEnable() {
|
||||
observer = getName().toLowerCase(Locale.ROOT).contains("observer");
|
||||
backendName = getConfig().getString("backendName", "").trim();
|
||||
if (backendName.isEmpty()) {
|
||||
fail("missing backendName configuration");
|
||||
return;
|
||||
}
|
||||
BukkitMessagingService service = Bukkit.getServicesManager().load(BukkitMessagingService.class);
|
||||
if (service == null) {
|
||||
fail("BukkitMessagingService is unavailable");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The host supplies the owner-bound scope and dispatches both callbacks on the server thread.
|
||||
messaging = service.forPlugin(this);
|
||||
String requestChannel = observer ? "accept:observer" : "accept:rpc";
|
||||
MessageChannel requests = messaging.channel(requestChannel);
|
||||
requestSubscription = requests.onRequest(this::handleRequest);
|
||||
eventSubscription = messaging.channel("accept:events").subscribe(this::handleEvent);
|
||||
getLogger().info("ACCEPTANCE_PLUGIN_READY node=" + backendName + " role=" + role());
|
||||
} catch (RuntimeException failure) {
|
||||
fail("could not register acceptance channels: " + failure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
private CompletionStage<byte[]> handleRequest(Message request) {
|
||||
assertCallback("request", request);
|
||||
if (request.kind() != MessageKind.REQUEST) {
|
||||
fail("request handler received " + request.kind());
|
||||
throw new IllegalStateException("request handler received a non-request message");
|
||||
}
|
||||
requestCount.incrementAndGet();
|
||||
String command = new String(request.payload(), StandardCharsets.UTF_8);
|
||||
if (observer) {
|
||||
if (command.equals("stats") || command.equals("echo")) {
|
||||
return completed(summary(request));
|
||||
}
|
||||
return completed("unknown-observer-command=" + command);
|
||||
}
|
||||
if (command.equals("echo") || command.equals("stats")) {
|
||||
return completed(summary(request));
|
||||
}
|
||||
if (command.equals("cross")) {
|
||||
// Exercise backend-to-backend routing without blocking Bukkit's primary thread.
|
||||
return messaging.channel("accept:rpc").request(Endpoint.backend(otherBackend()),
|
||||
bytes("echo")).thenApply(reply -> bytes("cross=" + new String(reply.payload(), StandardCharsets.UTF_8)
|
||||
+ ";correlation=" + (reply.kind() == MessageKind.REPLY && reply.replyTo() != null
|
||||
&& reply.replyTo().toString().equals(field(new String(reply.payload(), StandardCharsets.UTF_8), "id"))
|
||||
&& Endpoint.backend(otherBackend()).equals(reply.source())
|
||||
&& Endpoint.backend(backendName).equals(reply.target()) ? "valid" : "invalid")
|
||||
+ ";originRequestId=" + request.id()));
|
||||
}
|
||||
if (command.equals("proxy")) {
|
||||
return messaging.channel("accept:rpc").request(Endpoint.proxy(), bytes("proxy"))
|
||||
.thenApply(reply -> bytes("proxy=" + new String(reply.payload(), StandardCharsets.UTF_8)
|
||||
+ ";correlation=" + (reply.kind() == MessageKind.REPLY && reply.replyTo() != null
|
||||
&& reply.replyTo().toString().equals(field(new String(reply.payload(), StandardCharsets.UTF_8), "id"))
|
||||
&& reply.source().isProxy() && Endpoint.backend(backendName).equals(reply.target())
|
||||
? "valid" : "invalid") + ";originRequestId=" + request.id()));
|
||||
}
|
||||
if (command.equals("disable")) {
|
||||
// Reply first, then let the host observe normal Bukkit owner-disable cleanup.
|
||||
Bukkit.getScheduler().runTaskLater(this, () -> Bukkit.getPluginManager().disablePlugin(this), 20L);
|
||||
return completed("disable-scheduled;node=" + backendName + ";role=primary;id=" + request.id());
|
||||
}
|
||||
return completed("unknown-command=" + command);
|
||||
}
|
||||
|
||||
private void handleEvent(Message message) {
|
||||
assertCallback("event", message);
|
||||
if (message.kind() != MessageKind.EVENT) {
|
||||
fail("event subscriber received " + message.kind());
|
||||
throw new IllegalStateException("event subscriber received a non-event message");
|
||||
}
|
||||
lastEventId = message.id().toString();
|
||||
eventCount.incrementAndGet();
|
||||
}
|
||||
|
||||
private void assertCallback(String kind, Message message) {
|
||||
if (!Bukkit.isPrimaryThread()) {
|
||||
fail(kind + " callback did not run on Bukkit primary thread");
|
||||
throw new IllegalStateException("acceptance callback ran off the Bukkit primary thread");
|
||||
}
|
||||
boolean playersOnline = Bukkit.getWorlds().stream().anyMatch(world -> !world.getPlayers().isEmpty());
|
||||
if (playersOnline) {
|
||||
fail(kind + " callback ran while players were online");
|
||||
throw new IllegalStateException("acceptance smoke requires zero online players");
|
||||
}
|
||||
if (stopping) throw new IllegalStateException("plugin is stopping");
|
||||
if (message.id() == null || message.kind() == null || message.replyTo() != null) {
|
||||
fail(kind + " received an invalid request/event envelope");
|
||||
throw new IllegalStateException("unexpected request/event envelope");
|
||||
}
|
||||
}
|
||||
|
||||
private String summary(Message request) {
|
||||
return "node=" + backendName + ";role=" + role()
|
||||
+ ";main=" + Bukkit.isPrimaryThread()
|
||||
+ ";events=" + eventCount.get()
|
||||
+ ";lastEvent=" + lastEventId
|
||||
+ ";requests=" + requestCount.get()
|
||||
+ ";id=" + request.id()
|
||||
+ ";replyTo=none"
|
||||
+ ";source=" + request.source();
|
||||
}
|
||||
|
||||
private static String field(String text, String name) {
|
||||
String marker = ";" + name + "=";
|
||||
int start = text.indexOf(marker);
|
||||
if (start < 0) return "";
|
||||
start += marker.length();
|
||||
int end = text.indexOf(';', start);
|
||||
return text.substring(start, end < 0 ? text.length() : end);
|
||||
}
|
||||
|
||||
private String otherBackend() {
|
||||
return backendName.equals("a") ? "b" : "a";
|
||||
}
|
||||
|
||||
private String role() { return observer ? "observer" : "primary"; }
|
||||
|
||||
private static CompletionStage<byte[]> completed(String value) {
|
||||
return CompletableFuture.completedFuture(bytes(value));
|
||||
}
|
||||
|
||||
private static byte[] bytes(String value) { return value.getBytes(StandardCharsets.UTF_8); }
|
||||
|
||||
private void fail(String reason) {
|
||||
getLogger().severe("ACCEPTANCE_PLUGIN_FAIL node=" + backendName + " plugin=" + getName() + " reason=" + reason);
|
||||
}
|
||||
|
||||
@Override public void onDisable() {
|
||||
stopping = true;
|
||||
// Leave the owner scope open here. The service's PluginDisableEvent listener must release it;
|
||||
// automatic owner cleanup is the lifecycle behavior under test.
|
||||
getLogger().info("ACCEPTANCE_PLUGIN_DISABLED node=" + backendName + " role=" + role());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package dev.strataproxy.smoke;
|
||||
|
||||
import dev.strataproxy.bukkit.BukkitMessagingService;
|
||||
import dev.strataproxy.messaging.Message;
|
||||
import dev.strataproxy.messaging.MessageKind;
|
||||
import dev.strataproxy.messaging.Messaging;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/** A separate plugin class/loader; no JavaPlugin implementation is shared between owners. */
|
||||
public final class BackendObserverPlugin extends JavaPlugin {
|
||||
private String backendName;
|
||||
private int events;
|
||||
private String lastEvent = "";
|
||||
|
||||
@Override public void onEnable() {
|
||||
backendName = getConfig().getString("backendName", "");
|
||||
BukkitMessagingService service = Bukkit.getServicesManager().load(BukkitMessagingService.class);
|
||||
if (service == null || backendName.isEmpty()) throw new IllegalStateException("observer configuration unavailable");
|
||||
Messaging messaging = service.forPlugin(this);
|
||||
messaging.channel("accept:events").subscribe(message -> {
|
||||
check(message, MessageKind.EVENT);
|
||||
events++;
|
||||
lastEvent = message.id().toString();
|
||||
});
|
||||
messaging.channel("accept:observer").onRequest(message -> {
|
||||
check(message, MessageKind.REQUEST);
|
||||
String response = "node=" + backendName + ";role=observer;main=true;events=" + events
|
||||
+ ";lastEvent=" + lastEvent + ";id=" + message.id();
|
||||
return CompletableFuture.completedFuture(response.getBytes(StandardCharsets.UTF_8));
|
||||
});
|
||||
getLogger().info("ACCEPTANCE_PLUGIN_READY node=" + backendName + " role=observer");
|
||||
}
|
||||
|
||||
private void check(Message message, MessageKind kind) {
|
||||
if (!Bukkit.isPrimaryThread() || !isEnabled() || message.kind() != kind || message.replyTo() != null
|
||||
|| Bukkit.getWorlds().stream().anyMatch(world -> !world.getPlayers().isEmpty())) {
|
||||
throw new IllegalStateException("observer callback violated thread, player or envelope contract");
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onDisable() {
|
||||
// Deliberately rely on the host's owner-scope cleanup.
|
||||
getLogger().info("ACCEPTANCE_PLUGIN_DISABLED node=" + backendName + " role=observer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package dev.strataproxy.smoke;
|
||||
|
||||
import dev.strataproxy.api.Plugin;
|
||||
import dev.strataproxy.api.PluginContext;
|
||||
import dev.strataproxy.api.ServerView;
|
||||
import dev.strataproxy.messaging.Endpoint;
|
||||
import dev.strataproxy.messaging.Message;
|
||||
import dev.strataproxy.messaging.MessageKind;
|
||||
import dev.strataproxy.messaging.MessageChannel;
|
||||
import dev.strataproxy.messaging.Messaging;
|
||||
import dev.strataproxy.messaging.MessagingException;
|
||||
import dev.strataproxy.messaging.PublishResult;
|
||||
import dev.strataproxy.messaging.SendResult;
|
||||
import dev.strataproxy.messaging.Subscription;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** External proxy plugin driven by the isolated real-Uranium acceptance orchestrator. */
|
||||
public final class ProxyBackendAcceptancePlugin implements Plugin {
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(8);
|
||||
private PluginContext context;
|
||||
private Messaging messaging;
|
||||
private Path controlDir;
|
||||
private int aPort;
|
||||
private int bPort;
|
||||
private volatile boolean stopping;
|
||||
private Thread worker;
|
||||
private final AtomicInteger eventCount = new AtomicInteger();
|
||||
private Subscription rpcSubscription;
|
||||
private Subscription eventSubscription;
|
||||
|
||||
@Override public void onLoad(PluginContext loadedContext) {
|
||||
context = loadedContext;
|
||||
Map<String, String> settings = loadedContext.settings();
|
||||
controlDir = Paths.get(required(settings, "controlDir")).toAbsolutePath().normalize();
|
||||
aPort = parsePort(settings, "aPort");
|
||||
bPort = parsePort(settings, "bPort");
|
||||
messaging = loadedContext.messaging();
|
||||
}
|
||||
|
||||
@Override public void onEnable() {
|
||||
MessageChannel rpc = messaging.channel("accept:rpc");
|
||||
rpcSubscription = rpc.onRequest(request -> {
|
||||
assertEnvelope(request, "proxy request");
|
||||
String payload = text(request);
|
||||
if (!payload.equals("proxy")) {
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(bytes("unexpected=" + payload));
|
||||
}
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(bytes(
|
||||
"proxy-ok;main=false;events=" + eventCount.get() + ";id=" + request.id()
|
||||
+ ";replyTo=none;source=" + request.source()));
|
||||
});
|
||||
eventSubscription = messaging.channel("accept:events").subscribe(message -> {
|
||||
assertEnvelope(message, "proxy event");
|
||||
eventCount.incrementAndGet();
|
||||
});
|
||||
worker = Thread.ofVirtual().name("backend-acceptance-control").start(this::watchControlFile);
|
||||
context.logger().info("Backend acceptance plugin ready; control directory configured");
|
||||
}
|
||||
|
||||
private void watchControlFile() {
|
||||
Path commandFile = controlDir.resolve("command.txt");
|
||||
String lastId = "";
|
||||
while (!stopping) {
|
||||
try {
|
||||
if (Files.isRegularFile(commandFile)) {
|
||||
String[] parts = Files.readString(commandFile, StandardCharsets.UTF_8).trim().split("\\s+", 2);
|
||||
if (parts.length == 2 && !parts[0].equals(lastId)) {
|
||||
lastId = parts[0];
|
||||
try {
|
||||
String result = runPhase(parts[1]);
|
||||
writeResult(lastId, true, result);
|
||||
} catch (Throwable failure) {
|
||||
context.logger().error("Backend acceptance phase failed: " + parts[1], failure);
|
||||
writeResult(lastId, false, parts[1] + ": " + failure.getClass().getSimpleName()
|
||||
+ ": " + safeMessage(failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException interrupted) {
|
||||
if (!stopping) Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (IOException failure) {
|
||||
context.logger().error("Cannot read acceptance control file", failure);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String runPhase(String phase) throws Exception {
|
||||
switch (phase) {
|
||||
case "baseline":
|
||||
case "reconnect":
|
||||
return baseline(phase);
|
||||
case "disable":
|
||||
return disablePrimaryA();
|
||||
case "b-absent-fast":
|
||||
return absentB(5, phase);
|
||||
case "b-absent":
|
||||
return absentB(45, phase);
|
||||
case "b-present":
|
||||
return presentB();
|
||||
default:
|
||||
throw new IllegalArgumentException("unknown acceptance phase " + phase);
|
||||
}
|
||||
}
|
||||
|
||||
private String baseline(String phase) throws Exception {
|
||||
waitForBackend("a", aPort, 45);
|
||||
waitForBackend("b", bPort, 45);
|
||||
String a = request(Endpoint.backend("a"), "accept:rpc", "echo");
|
||||
requireContains(a, "node=a;role=primary;main=true", "backend a primary response");
|
||||
String b = request(Endpoint.backend("b"), "accept:rpc", "echo");
|
||||
requireContains(b, "node=b;role=primary;main=true", "backend b primary response");
|
||||
String cross = request(Endpoint.backend("a"), "accept:rpc", "cross");
|
||||
requireContains(cross, "cross=node=b;role=primary;main=true", "backend-to-backend request");
|
||||
requireContains(cross, ";correlation=valid", "backend request correlation");
|
||||
String proxyReply = request(Endpoint.backend("a"), "accept:rpc", "proxy");
|
||||
requireContains(proxyReply, "proxy=proxy-ok", "backend-to-proxy request");
|
||||
requireContains(proxyReply, ";correlation=valid", "proxy request correlation");
|
||||
PublishResult published = messaging.channel("accept:events").publish(bytes("acceptance-event-" + phase))
|
||||
.toCompletableFuture().get(10, TimeUnit.SECONDS);
|
||||
requireAccepted(published, Endpoint.proxy(), Endpoint.backend("a"), Endpoint.backend("b"));
|
||||
waitForEvents(phase, published.messageId().toString(), 10);
|
||||
String aObserver = request(Endpoint.backend("a"), "accept:observer", "stats");
|
||||
String bObserver = request(Endpoint.backend("b"), "accept:observer", "stats");
|
||||
String aPrimary = request(Endpoint.backend("a"), "accept:rpc", "stats");
|
||||
String bPrimary = request(Endpoint.backend("b"), "accept:rpc", "stats");
|
||||
requireContains(aObserver, "node=a;role=observer;main=true;events=", "backend a observer");
|
||||
requirePositiveEvents(aObserver, "backend a observer");
|
||||
requireContains(bObserver, "node=b;role=observer;main=true;events=", "backend b observer");
|
||||
requirePositiveEvents(bObserver, "backend b observer");
|
||||
requireSameEvent(aObserver, published.messageId().toString());
|
||||
requireSameEvent(bObserver, published.messageId().toString());
|
||||
requireSameEvent(aPrimary, published.messageId().toString());
|
||||
requireSameEvent(bPrimary, published.messageId().toString());
|
||||
if (phase.equals("baseline")) {
|
||||
writeBaselineEventCounts(aPrimary, aObserver, bPrimary, bObserver);
|
||||
} else {
|
||||
requireCountsAdvanced(aPrimary, aObserver, bPrimary, bObserver);
|
||||
}
|
||||
return "BACKEND_PHASE_PASS phase=" + phase + " aPort=" + aPort + " bPort=" + bPort
|
||||
+ " eventId=" + published.messageId() + " proxyEvents=" + eventCount.get();
|
||||
}
|
||||
|
||||
private String disablePrimaryA() throws Exception {
|
||||
String response = request(Endpoint.backend("a"), "accept:rpc", "disable");
|
||||
requireContains(response, "disable-scheduled;node=a;role=primary", "disable acknowledgement");
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(12);
|
||||
while (System.nanoTime() < deadline) {
|
||||
requireBackend("a", aPort);
|
||||
try {
|
||||
String observer = request(Endpoint.backend("a"), "accept:observer", "stats");
|
||||
requireContains(observer, "node=a;role=observer;main=true", "surviving observer");
|
||||
requirePositiveEvents(observer, "surviving observer");
|
||||
if (primaryRequestFails()) {
|
||||
return "BACKEND_PHASE_PASS phase=disable backendARegistered=true observer=responsive primary=NO_HANDLER";
|
||||
}
|
||||
} catch (java.util.concurrent.ExecutionException expectedDuringDisable) {
|
||||
// Owner disable is asynchronous; retry the observer and primary probes below.
|
||||
}
|
||||
Thread.sleep(200);
|
||||
}
|
||||
throw new IllegalStateException("backend a primary handler remained registered after disable deadline");
|
||||
}
|
||||
|
||||
private boolean primaryRequestFails() throws Exception {
|
||||
try {
|
||||
request(Endpoint.backend("a"), "accept:rpc", "echo");
|
||||
return false;
|
||||
} catch (java.util.concurrent.ExecutionException failure) {
|
||||
Throwable cause = failure.getCause();
|
||||
if (cause instanceof MessagingException
|
||||
&& ((MessagingException) cause).code() == MessagingException.Code.NO_HANDLER) return true;
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private String absentB(int seconds, String phase) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
|
||||
while (System.nanoTime() < deadline && context.servers().find("b").isPresent()) Thread.sleep(100);
|
||||
if (context.servers().find("b").isPresent()) {
|
||||
throw new IllegalStateException("backend b remained registered after " + seconds + " seconds");
|
||||
}
|
||||
String observer = request(Endpoint.backend("a"), "accept:observer", "stats");
|
||||
requireContains(observer, "node=a;role=observer;main=true", "backend a observer during " + phase);
|
||||
requirePositiveEvents(observer, "backend a observer during " + phase);
|
||||
return "BACKEND_PHASE_PASS phase=" + phase + " backendBAbsent=true backendAObserver=responsive";
|
||||
}
|
||||
|
||||
private String presentB() throws Exception {
|
||||
waitForBackend("b", bPort, 45);
|
||||
PublishResult published = messaging.channel("accept:events").publish(bytes("acceptance-event-b-present"))
|
||||
.toCompletableFuture().get(10, TimeUnit.SECONDS);
|
||||
requireAccepted(published, Endpoint.proxy(), Endpoint.backend("a"), Endpoint.backend("b"));
|
||||
waitForBackendEventB(published.messageId().toString(), 10);
|
||||
String primary = request(Endpoint.backend("b"), "accept:rpc", "echo");
|
||||
requireContains(primary, "node=b;role=primary;main=true", "reconnected backend b primary");
|
||||
String observer = request(Endpoint.backend("b"), "accept:observer", "stats");
|
||||
requireContains(observer, "node=b;role=observer;main=true", "reconnected backend b observer");
|
||||
requirePositiveEvents(observer, "reconnected backend b observer");
|
||||
requireSameEvent(primary, published.messageId().toString());
|
||||
requireSameEvent(observer, published.messageId().toString());
|
||||
return "BACKEND_PHASE_PASS phase=b-present backendBRegistered=true primary=responsive observer=responsive";
|
||||
}
|
||||
|
||||
private String request(Endpoint target, String channel, String payload) throws Exception {
|
||||
Message reply = messaging.channel(channel).request(target, bytes(payload), REQUEST_TIMEOUT)
|
||||
.toCompletableFuture().get(10, TimeUnit.SECONDS);
|
||||
if (reply.kind() != MessageKind.REPLY || reply.replyTo() == null || reply.id().equals(reply.replyTo())
|
||||
|| !target.equals(reply.source()) || !Endpoint.proxy().equals(reply.target())
|
||||
|| !channel.equals(reply.channel())) {
|
||||
throw new IllegalStateException("request reply did not carry replyTo correlation");
|
||||
}
|
||||
String result = text(reply);
|
||||
String requestId = field(result, "originRequestId");
|
||||
if (requestId.isEmpty()) requestId = field(result, "id");
|
||||
if (!reply.replyTo().toString().equals(requestId)) {
|
||||
throw new IllegalStateException("replyTo did not match the originating request id: " + result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void waitForBackend(String name, int port, int seconds) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (context.servers().find(name).isPresent()) {
|
||||
requireBackend(name, port);
|
||||
return;
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
throw new IllegalStateException("backend " + name + " did not register within " + seconds + " seconds");
|
||||
}
|
||||
|
||||
private void requireBackend(String name, int port) {
|
||||
ServerView backend = context.servers().find(name)
|
||||
.orElseThrow(() -> new IllegalStateException("backend " + name + " is not registered"));
|
||||
URI address = backend.address();
|
||||
if (!"tcp".equalsIgnoreCase(address.getScheme()) || !"127.0.0.1".equals(address.getHost())
|
||||
|| address.getPort() != port) {
|
||||
throw new IllegalStateException("backend " + name + " has unexpected gameAddress " + address);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitForEvents(String phase, String expectedEventId, int seconds) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
|
||||
while (System.nanoTime() < deadline) {
|
||||
String a = request(Endpoint.backend("a"), "accept:rpc", "stats");
|
||||
String b = request(Endpoint.backend("b"), "accept:rpc", "stats");
|
||||
if (eventCount.get() > 0 && hasPositiveEvents(a) && hasPositiveEvents(b)
|
||||
&& expectedEventId.equals(lastEventId(a)) && expectedEventId.equals(lastEventId(b))) return;
|
||||
Thread.sleep(100);
|
||||
}
|
||||
throw new IllegalStateException("event subscribers did not observe the published event during " + phase);
|
||||
}
|
||||
|
||||
private void waitForBackendEventB(String expectedEventId, int seconds) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
|
||||
while (System.nanoTime() < deadline) {
|
||||
String primary = request(Endpoint.backend("b"), "accept:rpc", "stats");
|
||||
String observer = request(Endpoint.backend("b"), "accept:observer", "stats");
|
||||
if (expectedEventId.equals(lastEventId(primary)) && expectedEventId.equals(lastEventId(observer))) return;
|
||||
Thread.sleep(100);
|
||||
}
|
||||
throw new IllegalStateException("backend b subscribers did not observe the b-present event");
|
||||
}
|
||||
|
||||
private void writeBaselineEventCounts(String aPrimary, String aObserver, String bPrimary, String bObserver)
|
||||
throws IOException {
|
||||
String baseline = eventCount(aPrimary) + " " + eventCount(aObserver) + " "
|
||||
+ eventCount(bPrimary) + " " + eventCount(bObserver) + System.lineSeparator();
|
||||
Files.writeString(controlDir.resolve("baseline-events.txt"), baseline, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void requireCountsAdvanced(String aPrimary, String aObserver, String bPrimary, String bObserver)
|
||||
throws IOException {
|
||||
Path saved = controlDir.resolve("baseline-events.txt");
|
||||
if (!Files.isRegularFile(saved)) throw new IllegalStateException("baseline event counts are missing");
|
||||
String[] before = Files.readString(saved, StandardCharsets.UTF_8).trim().split("\\s+");
|
||||
String[] now = { eventCount(aPrimary), eventCount(aObserver), eventCount(bPrimary), eventCount(bObserver) };
|
||||
if (before.length != now.length) throw new IllegalStateException("baseline event count file is invalid");
|
||||
for (int i = 0; i < now.length; i++) {
|
||||
if (Integer.parseInt(now[i]) <= Integer.parseInt(before[i])) {
|
||||
throw new IllegalStateException("event subscriber " + i + " did not receive the reconnect broadcast");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String eventCount(String summary) {
|
||||
int start = summary.indexOf(";events=");
|
||||
if (start < 0) throw new IllegalStateException("event count missing from response: " + summary);
|
||||
start += ";events=".length();
|
||||
int end = summary.indexOf(';', start);
|
||||
return summary.substring(start, end < 0 ? summary.length() : end);
|
||||
}
|
||||
|
||||
private static String lastEventId(String summary) {
|
||||
int start = summary.indexOf(";lastEvent=");
|
||||
if (start < 0) return "";
|
||||
start += ";lastEvent=".length();
|
||||
int end = summary.indexOf(';', start);
|
||||
return summary.substring(start, end < 0 ? summary.length() : end);
|
||||
}
|
||||
|
||||
private static String field(String text, String name) {
|
||||
String marker = ";" + name + "=";
|
||||
int start = text.indexOf(marker);
|
||||
if (start < 0) return "";
|
||||
start += marker.length();
|
||||
int end = text.indexOf(';', start);
|
||||
return text.substring(start, end < 0 ? text.length() : end);
|
||||
}
|
||||
|
||||
private static void requireSameEvent(String summary, String expectedId) {
|
||||
if (!expectedId.equals(lastEventId(summary))) {
|
||||
throw new IllegalStateException("subscriber saw a different event id: " + summary);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAccepted(PublishResult result, Endpoint... endpoints) {
|
||||
for (Endpoint endpoint : endpoints) {
|
||||
if (result.results().get(endpoint) != SendResult.ACCEPTED) {
|
||||
throw new IllegalStateException("event was not accepted by " + endpoint + ": "
|
||||
+ result.results().get(endpoint));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasPositiveEvents(String summary) {
|
||||
int start = summary.indexOf(";events=");
|
||||
if (start < 0) return false;
|
||||
start += ";events=".length();
|
||||
int end = summary.indexOf(';', start);
|
||||
try { return Integer.parseInt(summary.substring(start, end < 0 ? summary.length() : end)) > 0; }
|
||||
catch (NumberFormatException invalid) { return false; }
|
||||
}
|
||||
|
||||
private void requirePositiveEvents(String summary, String description) {
|
||||
if (!hasPositiveEvents(summary)) throw new IllegalStateException(description + " has no event count");
|
||||
}
|
||||
|
||||
private static void requireContains(String value, String expected, String description) {
|
||||
if (!value.contains(expected)) throw new IllegalStateException(description + " failed: " + value);
|
||||
}
|
||||
|
||||
private void assertEnvelope(Message message, String description) {
|
||||
if (message == null || message.id() == null || message.kind() == null || message.replyTo() != null) {
|
||||
throw new IllegalStateException(description + " has invalid message envelope");
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(Message message) { return new String(message.payload(), StandardCharsets.UTF_8); }
|
||||
private static byte[] bytes(String text) { return text.getBytes(StandardCharsets.UTF_8); }
|
||||
|
||||
private static String required(Map<String, String> settings, String name) {
|
||||
String value = settings.get(name);
|
||||
if (value == null || value.isBlank()) throw new IllegalArgumentException("missing setting " + name);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int parsePort(Map<String, String> settings, String name) {
|
||||
int port = Integer.parseInt(required(settings, name));
|
||||
if (port < 1 || port > 65535) throw new IllegalArgumentException(name + " is outside the TCP port range");
|
||||
return port;
|
||||
}
|
||||
|
||||
private void writeResult(String id, boolean success, String content) {
|
||||
Path target = controlDir.resolve(id + (success ? ".pass" : ".fail"));
|
||||
Path temporary = controlDir.resolve(id + ".result.tmp");
|
||||
try {
|
||||
Files.createDirectories(controlDir);
|
||||
Files.writeString(temporary, content + System.lineSeparator(), StandardCharsets.UTF_8);
|
||||
try { Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
|
||||
catch (AtomicMoveNotSupportedException unsupported) {
|
||||
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
context.logger().error("Could not publish acceptance phase result", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeMessage(Throwable failure) {
|
||||
String message = failure.getMessage();
|
||||
return message == null ? "no detail" : message.replace('\n', ' ').replace('\r', ' ');
|
||||
}
|
||||
|
||||
@Override public void onDisable() {
|
||||
stopping = true;
|
||||
if (worker != null) {
|
||||
worker.interrupt();
|
||||
try { worker.join(2000); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
if (rpcSubscription != null) rpcSubscription.close();
|
||||
if (eventSubscription != null) eventSubscription.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
#Requires -Version 7.0
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$BundlePath,
|
||||
[Parameter(Mandatory)][string]$BukkitApiJar,
|
||||
[string]$Java8Home = 'C:\Program Files\Zulu\zulu-8',
|
||||
[string]$Java25Home = 'C:\Program Files\Zulu\zulu-25'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = Split-Path -Parent $PSScriptRoot
|
||||
$bundle = (Resolve-Path -LiteralPath $BundlePath).Path
|
||||
if ((Get-Content (Join-Path $bundle 'eula.txt') -Raw).Trim() -ne 'eula=true') {
|
||||
throw 'Supply a Uranium bundle whose EULA has already been accepted.'
|
||||
}
|
||||
$serverJars = @(Get-ChildItem -LiteralPath $bundle -Filter '*-server.jar')
|
||||
if ($serverJars.Count -ne 1) { throw 'Expected exactly one Uranium server JAR.' }
|
||||
$serverJar = $serverJars[0]
|
||||
$serverHash = (Get-FileHash -LiteralPath $serverJar.FullName -Algorithm SHA256).Hash
|
||||
$dist = Join-Path $repo 'proxy-core/build/install/strataproxy'
|
||||
$hostJars = @(Get-ChildItem (Join-Path $dist 'backend-host') -Filter 'strataproxy-backend-bukkit-*.jar')
|
||||
if ($hostJars.Count -ne 1) { throw 'Build the renamed host and installDist first.' }
|
||||
$run = Join-Path $repo ('build/backend-uranium-' + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Force $run | Out-Null
|
||||
Write-Output "ACCEPTANCE_DIRECTORY=$run"
|
||||
$utf8 = [Text.UTF8Encoding]::new($false)
|
||||
function Write-Utf8([string]$Path, [string]$Value) { [IO.File]::WriteAllText($Path, $Value, $utf8) }
|
||||
function New-Port {
|
||||
do {
|
||||
$socket = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
$socket.Start()
|
||||
$port = $socket.LocalEndpoint.Port
|
||||
$socket.Stop()
|
||||
} until ($ports.Add($port))
|
||||
return $port
|
||||
}
|
||||
$ports = [Collections.Generic.HashSet[int]]::new()
|
||||
$aPort = New-Port
|
||||
$bPort = New-Port
|
||||
$controlPort = New-Port
|
||||
$proxyPort = New-Port
|
||||
$controlDir = Join-Path $run 'control'
|
||||
$proxyPlugins = Join-Path $run 'proxy-plugins'
|
||||
$probeClasses = Join-Path $run 'probe-classes'
|
||||
$proxyClasses = Join-Path $run 'proxy-classes'
|
||||
New-Item -ItemType Directory -Force $controlDir, $proxyPlugins, $probeClasses, $proxyClasses | Out-Null
|
||||
$proxyClasspath = Join-Path $dist 'lib/*'
|
||||
& (Join-Path $Java8Home 'bin/javac.exe') -cp "$($hostJars[0].FullName);$BukkitApiJar" -d $probeClasses `
|
||||
(Join-Path $PSScriptRoot 'BackendAcceptancePlugin.java') (Join-Path $PSScriptRoot 'BackendObserverPlugin.java')
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bukkit probe compilation failed.' }
|
||||
& (Join-Path $Java25Home 'bin/javac.exe') -cp $proxyClasspath -d $proxyClasses `
|
||||
(Join-Path $PSScriptRoot 'ProxyBackendAcceptancePlugin.java')
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Proxy probe compilation failed.' }
|
||||
$services = Join-Path $proxyClasses 'META-INF/services'
|
||||
New-Item -ItemType Directory -Force $services | Out-Null
|
||||
Write-Utf8 (Join-Path $services 'dev.strataproxy.api.Plugin') 'dev.strataproxy.smoke.ProxyBackendAcceptancePlugin'
|
||||
& (Join-Path $Java25Home 'bin/jar.exe') cf (Join-Path $proxyPlugins 'acceptance.jar') -C $proxyClasses .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Proxy probe packaging failed.' }
|
||||
foreach ($role in @('Probe', 'Observer')) {
|
||||
$main = if ($role -eq 'Probe') { 'BackendAcceptancePlugin' } else { 'BackendObserverPlugin' }
|
||||
Write-Utf8 (Join-Path $probeClasses 'plugin.yml') "name: Acceptance$role`nmain: dev.strataproxy.smoke.$main`nversion: 1.0`ndepend: [StrataProxyBackend]`n"
|
||||
& (Join-Path $Java8Home 'bin/jar.exe') cf (Join-Path $run "Acceptance$role.jar") `
|
||||
-C $probeClasses "dev/strataproxy/smoke/$main.class" -C $probeClasses plugin.yml
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bukkit probe packaging failed.' }
|
||||
}
|
||||
# Each run is isolated. Copy runtime libraries, the server and the already accepted
|
||||
# EULA; do not copy or alter live worlds, mods, credentials or plugin directories.
|
||||
foreach ($node in @('a', 'b')) {
|
||||
$directory = Join-Path $run $node
|
||||
$pluginDir = Join-Path $directory 'plugins'
|
||||
$hostConfigDir = Join-Path $pluginDir 'StrataProxyBackend'
|
||||
New-Item -ItemType Directory -Force $directory, $pluginDir, $hostConfigDir | Out-Null
|
||||
Copy-Item -LiteralPath $serverJar.FullName -Destination $directory
|
||||
Copy-Item -LiteralPath (Join-Path $bundle 'libraries') -Destination $directory -Recurse
|
||||
Copy-Item -LiteralPath (Join-Path $bundle 'eula.txt') -Destination $directory
|
||||
if ((Get-FileHash (Join-Path $directory $serverJar.Name)).Hash -ne $serverHash) {
|
||||
throw 'Source server artifact changed while creating the isolated runtime.'
|
||||
}
|
||||
Copy-Item -LiteralPath $hostJars[0].FullName -Destination $pluginDir
|
||||
foreach ($role in @('Probe', 'Observer')) {
|
||||
Copy-Item -LiteralPath (Join-Path $run "Acceptance$role.jar") -Destination $pluginDir
|
||||
$configDir = Join-Path $pluginDir "Acceptance$role"
|
||||
New-Item -ItemType Directory -Force $configDir | Out-Null
|
||||
Write-Utf8 (Join-Path $configDir 'config.yml') "backendName: $node`n"
|
||||
}
|
||||
$port = if ($node -eq 'a') { $aPort } else { $bPort }
|
||||
Write-Utf8 (Join-Path $directory 'server.properties') "server-ip=127.0.0.1`nserver-port=$port`nonline-mode=false`nlevel-type=FLAT`ngenerate-structures=false`nview-distance=2`nspawn-protection=0`nmax-players=1`n"
|
||||
Write-Utf8 (Join-Path $hostConfigDir 'config.yml') @"
|
||||
proxyHost: 127.0.0.1
|
||||
proxyPort: $controlPort
|
||||
instanceId: probe-$node
|
||||
backendName: $node
|
||||
gameAddress: "tcp://127.0.0.1:$port"
|
||||
keyId: primary
|
||||
secret: isolated-acceptance-secret-at-least-32-bytes
|
||||
"@
|
||||
}
|
||||
$controlYaml = $controlDir.Replace('\', '/')
|
||||
$pluginYaml = $proxyPlugins.Replace('\', '/')
|
||||
$config = Join-Path $run 'strataproxy.yml'
|
||||
Write-Utf8 $config @"
|
||||
listen: "127.0.0.1:$proxyPort"
|
||||
authentication: OFFLINE
|
||||
backends: []
|
||||
plugins:
|
||||
directory: "$pluginYaml"
|
||||
enabled:
|
||||
dev.strataproxy.smoke.ProxyBackendAcceptancePlugin:
|
||||
controlDir: "$controlYaml"
|
||||
aPort: "$aPort"
|
||||
bPort: "$bPort"
|
||||
backendChannel:
|
||||
listen: "127.0.0.1:$controlPort"
|
||||
leaseSeconds: 30
|
||||
maxConnections: 8
|
||||
clients:
|
||||
probe-a:
|
||||
backendName: a
|
||||
keyId: primary
|
||||
secret: isolated-acceptance-secret-at-least-32-bytes
|
||||
allowedHosts: [127.0.0.1]
|
||||
allowedNamespaces: [accept]
|
||||
probe-b:
|
||||
backendName: b
|
||||
keyId: primary
|
||||
secret: isolated-acceptance-secret-at-least-32-bytes
|
||||
allowedHosts: [127.0.0.1]
|
||||
allowedNamespaces: [accept]
|
||||
"@
|
||||
$owned = [Collections.Generic.List[object]]::new()
|
||||
function Start-Owned([string]$Name, [string]$Executable, [string]$Directory, [string[]]$Arguments) {
|
||||
$info = [Diagnostics.ProcessStartInfo]::new()
|
||||
$info.FileName = $Executable
|
||||
$info.WorkingDirectory = $Directory
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
$info.RedirectStandardInput = $true
|
||||
$info.RedirectStandardOutput = $true
|
||||
$info.RedirectStandardError = $true
|
||||
foreach ($argument in $Arguments) { $info.ArgumentList.Add($argument) }
|
||||
$process = [Diagnostics.Process]::Start($info)
|
||||
$entry = [pscustomobject]@{ Name=$Name; Process=$process; Out=$process.StandardOutput.ReadToEndAsync(); Err=$process.StandardError.ReadToEndAsync() }
|
||||
$owned.Add($entry)
|
||||
return $entry
|
||||
}
|
||||
function Start-Proxy([string]$Name) {
|
||||
return Start-Owned $Name (Join-Path $Java25Home 'bin/java.exe') $run @('-cp', $proxyClasspath, 'dev.strataproxy.app.ProxyMain', '--config', $config)
|
||||
}
|
||||
function Start-Backend([string]$Node, [string]$Name) {
|
||||
$log = Join-Path $run "$Node/logs/latest.log"
|
||||
if (Test-Path -LiteralPath $log) {
|
||||
Move-Item -LiteralPath $log -Destination (Join-Path $run "$Name.previous-server.log")
|
||||
}
|
||||
$entry = Start-Owned $Name (Join-Path $Java8Home 'bin/java.exe') (Join-Path $run $Node) @('-Xms256m', '-Xmx1024m', '-jar', $serverJar.Name, 'nogui')
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(180)
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
if ($entry.Process.HasExited) { throw "$Name exited before readiness: $($entry.Err.Result)" }
|
||||
if ((Test-Path $log) -and (Select-String -LiteralPath $log -Pattern 'Done \(' -Quiet)) { return $entry }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
throw "$Name startup deadline exceeded; inspect $log"
|
||||
}
|
||||
function Stop-Backend($Entry, [switch]$NoWait) {
|
||||
$Entry.Process.StandardInput.WriteLine('stop')
|
||||
$Entry.Process.StandardInput.Flush()
|
||||
if ($NoWait) { return }
|
||||
if (-not $Entry.Process.WaitForExit(45000)) { throw "$($Entry.Name) did not stop normally." }
|
||||
if ($Entry.Process.ExitCode -ne 0) { throw "$($Entry.Name) exited with $($Entry.Process.ExitCode)." }
|
||||
}
|
||||
function Kill-Owned($Entry) {
|
||||
if (-not $Entry.Process.HasExited) { $Entry.Process.Kill() }
|
||||
if (-not $Entry.Process.WaitForExit(5000) -or -not $Entry.Process.HasExited) {
|
||||
throw "$($Entry.Name) process $($Entry.Process.Id) did not exit after termination."
|
||||
}
|
||||
}
|
||||
function Invoke-Phase([string]$Phase) {
|
||||
$id = [guid]::NewGuid().ToString('N')
|
||||
$temporary = Join-Path $controlDir "$id.tmp"
|
||||
Write-Utf8 $temporary "$id $Phase"
|
||||
Move-Item -LiteralPath $temporary -Destination (Join-Path $controlDir 'command.txt') -Force
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(90)
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
if ($proxy.Process.HasExited) { throw 'Proxy exited during acceptance.' }
|
||||
$failure = Join-Path $controlDir "$id.fail"
|
||||
if (Test-Path $failure) { throw (Get-Content $failure -Raw) }
|
||||
$success = Join-Path $controlDir "$id.pass"
|
||||
if (Test-Path $success) {
|
||||
Write-Output (Get-Content $success -Raw)
|
||||
return
|
||||
}
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
throw "Acceptance phase $Phase exceeded its deadline."
|
||||
}
|
||||
try {
|
||||
$proxy = Start-Proxy 'proxy-initial'
|
||||
$a = Start-Backend a 'backend-a'
|
||||
$b = Start-Backend b 'backend-b'
|
||||
Invoke-Phase baseline
|
||||
Kill-Owned $proxy
|
||||
Remove-Item -LiteralPath (Join-Path $controlDir 'command.txt')
|
||||
$proxy = Start-Proxy 'proxy-restarted'
|
||||
Invoke-Phase reconnect
|
||||
Invoke-Phase disable
|
||||
Stop-Backend $b -NoWait
|
||||
Invoke-Phase b-absent-fast
|
||||
if (-not $b.Process.WaitForExit(45000) -or $b.Process.ExitCode -ne 0) {
|
||||
throw 'Backend B failed to stop cleanly after its registration was removed.'
|
||||
}
|
||||
$b = Start-Backend b 'backend-b-restarted'
|
||||
Invoke-Phase b-present
|
||||
Kill-Owned $b
|
||||
Invoke-Phase b-absent
|
||||
$b = Start-Backend b 'backend-b-after-crash'
|
||||
Invoke-Phase b-present
|
||||
Stop-Backend $b
|
||||
Stop-Backend $a
|
||||
Write-Utf8 (Join-Path $run 'acceptance-pass.txt') "BACKEND_URANIUM_PASS`nserver=$($serverJar.Name)`nserverSHA256=$serverHash`nhostSHA256=$((Get-FileHash $hostJars[0].FullName).Hash)`n"
|
||||
Write-Output "BACKEND_URANIUM_PASS directory=$run"
|
||||
} finally {
|
||||
$cleanupFailed = $false
|
||||
foreach ($entry in $owned) {
|
||||
try { Kill-Owned $entry }
|
||||
catch { $cleanupFailed = $true; Write-Warning $_ }
|
||||
foreach ($stream in @(@{ Task=$entry.Out; Suffix='stdout' }, @{ Task=$entry.Err; Suffix='stderr' })) {
|
||||
if ($stream.Task.Wait(5000)) {
|
||||
Write-Utf8 (Join-Path $run "$($entry.Name).$($stream.Suffix).log") $stream.Task.GetAwaiter().GetResult()
|
||||
} else {
|
||||
$cleanupFailed = $true
|
||||
Write-Warning "Timed out draining $($entry.Name) $($stream.Suffix); runtime evidence remains in $run"
|
||||
}
|
||||
}
|
||||
$entry.Process.Dispose()
|
||||
}
|
||||
if ($cleanupFailed) { throw "Acceptance process cleanup failed; inspect $run" }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# StrataProxyBackend 命名与真实 Uranium 验收
|
||||
|
||||
时间:2026-09-27 22:13–22:14(Asia/Shanghai)。源码对应本记录所在提交,基于 `cc817c0`。
|
||||
|
||||
## 命名及构件
|
||||
|
||||
后端宿主插件名改为 `StrataProxyBackend`,入口类为 `dev.strataproxy.bukkit.StrataProxyBackendPlugin`,Gradle/Maven 模块为 `backend-bukkit`,发行包为 `strataproxy-backend-bukkit-0.1.0-SNAPSHOT.jar`。宿主负责后端身份、注册和连接生命周期,并向业务插件提供消息服务;原 `StrataProxyChannels` 名称不足以描述这些职责。当前接入文档与 `depend` 示例同步更新,没有保留旧插件名别名。
|
||||
|
||||
传输 SDK `backend-channel-client` 保持其名称。历史验收记录中的旧构件名和哈希保持原样,不能当作本次构件。
|
||||
|
||||
## 环境与结果
|
||||
|
||||
- Windows 本机;Proxy 使用 Zulu Java 25,两个隔离的 Uranium 实例使用 Zulu Java 8.0_492。
|
||||
- 实际服务端为 `Uranium-1710-rfg-bridge-fea65fe4d9-server.jar`,使用其 Bukkit API 编译验收业务插件。
|
||||
- 每个后端安装实际宿主 JAR,以及分别打包的 `AcceptanceProbe` 和 `AcceptanceObserver`,由真实 Bukkit 插件加载器装载。Proxy 运行发行包的 `ProxyMain`,通过正式插件 API 观察目录和发收消息。
|
||||
- 后端只复制服务端与运行库,使用全新世界、无额外整合包模组、零在线玩家。全部监听地址限定为本机环回地址。
|
||||
- `gradlew --no-daemon check` 成功:244 项测试,失败、错误和跳过均为 0,包含宿主 Java 8 字节码/打包边界和安装发行包烟测。
|
||||
|
||||
| 阶段 | 实际检查 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| baseline | 两后端自动注册及准确的游戏地址;三个方向的请求;独立回复 UUID、replyTo 和端点;Proxy 与四个后端业务插件订阅者收到同一消息 ID;Bukkit 回调主线程与零玩家 | PASS |
|
||||
| reconnect | 终止并重新启动 Proxy,后端自动重连、重新注册,重做请求与广播;订阅者计数增加,排除旧消息造成误判 | PASS |
|
||||
| disable | 停用 A 的主业务插件,不主动撤销其订阅;宿主自动清理后请求返回 NO_HANDLER,观察插件仍响应、A 仍注册 | PASS |
|
||||
| b-absent-fast | 向 B 发送正常 stop,在等待进程退出前检查目录于 5 秒内移除,早于 30 秒租约到期;A 仍响应 | PASS |
|
||||
| b-present | 重新启动 B,自动注册,两个业务插件恢复请求和广播 | PASS |
|
||||
| b-absent | 强制终止 B,检查租约到期后移除,A 在此期间保持连接和响应 | PASS |
|
||||
| b-present | 再次启动 B,重新验证目录、请求和广播;最后两后端正常停机 | PASS |
|
||||
|
||||
最终输出为 `BACKEND_URANIUM_PASS`,脚本退出码 0,所有本次创建的进程均已结束。实际日志中没有宿主/业务插件装载错误或验收回调错误。
|
||||
|
||||
首轮验收辅助插件共用 JavaPlugin 基类,被旧 Bukkit 的全局类缓存重复引用,导致 `Plugin already initialized`。已将它们改为独立入口实现且分别打包,随后以上全部阶段通过;宿主没有因为此问题修改业务逻辑。全新服务端首次缺少封禁/白名单 JSON 的警告不属于插件加载失败。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| 构件 | SHA-256 |
|
||||
| --- | --- |
|
||||
| Uranium 服务端 | `8AC63D595166720E163B07047C76C903ACB673254F3FD473DC122CE98A9517E5` |
|
||||
| StrataProxyBackend 宿主 | `6C1768407C91019637F0010842F8865938C394EDED76C4ACB8E8B5E048FBF614` |
|
||||
|
||||
运行目录:`build/backend-uranium-3f2ac0ae9e9548c2bdd48563c0a63302`。原始进程日志、各阶段结果、测试汇总和已测宿主另存于同工作区的 `StrataProxy-Acceptance/2026-09-27-backend-host`。复现入口见 [测试说明](../../docs/testing.md#后端宿主真实服务端验收)。
|
||||
|
||||
这次验收覆盖后端注册和消息服务;不包含富文本的真实玩家视觉/鼠标交互、完整整合包兼容性、跨物理主机网络或生产负载容量测量。
|
||||
Reference in new issue
Block a user