feat: name backend host and verify real Uranium lifecycle

This commit is contained in:
layue13 committed 2026-09-27 22:16:21 +08:00
1 parent cc817c057c
commit 8c2d35b8be
19 files changed
+937 -18

No files matched your search

+2
View File
@@ -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.")
}
}
@@ -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
@@ -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,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
@@ -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
View File
@@ -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")
}
+3 -3
View File
@@ -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()
+14
View File
@@ -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)。
+1 -1
View File
@@ -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
View File
@@ -29,5 +29,5 @@ include(
"backend-channel-client",
"messaging-api",
"messaging-protocol",
"backend-channel-bukkit",
"backend-bukkit",
)
+168
View File
@@ -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());
}
}
+49
View File
@@ -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");
}
}
+410
View File
@@ -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();
}
}
+234
View File
@@ -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" }
}
+42
View File
@@ -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#后端宿主真实服务端验收)。
这次验收覆盖后端注册和消息服务;不包含富文本的真实玩家视觉/鼠标交互、完整整合包兼容性、跨物理主机网络或生产负载容量测量。