Merge pull request 'Add rich text and unified backend registration and messaging' (#9) from feature/messages-completion into main

This commit is contained in:
layue13 committed 2026-09-27 14:24:12 +00:00
commit 21011d51bd
93 files changed
+7413 -1105

No files matched your search

+4 -1
View File
@@ -1,6 +1,6 @@
# StrataProxy
面向 Minecraft 1.7.10 Forge 的玩家会话代理:登录认证、协议转发、跨后端转服,以及供插件使用的玩家、服务器目录、命令、类型化事件和后端控制通道 API。空岛分配、实例唤醒和 Ban 数据存储由插件实现。
面向 Minecraft 1.7.10 Forge 的玩家会话代理:登录认证、协议转发、跨后端转服,以及供插件使用的玩家、服务器目录、富文本、命令补全、类型化事件和 Channel 消息服务。后端插件共享认证连接,通过 channel 与 proxy 或其他 backend 通信。空岛分配、实例唤醒和 Ban 数据存储由插件实现。
## 文档
@@ -8,6 +8,7 @@
- [架构与边界](docs/architecture.md):会话、协议、线程模型、转服、后端目录。
- [插件开发](docs/plugins.md):生命周期、ServerView、选服、Command 和统一类型化事件 API。
- [运行配置](docs/operations.md)与[后端控制通道](docs/backend-channel-design.md)。
- [消息服务设计契约](docs/channel-messaging-v2.md):消息身份、路由、插件生命周期及失败语义。
- [测试与性能验证](docs/testing.md):构建、烟测、真实客户端记录和基准方法。
## 本机运行
@@ -23,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)。
+97
View File
@@ -0,0 +1,97 @@
import java.io.DataInputStream
import java.util.jar.JarFile
plugins {
`java-library`
`maven-publish`
}
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
withSourcesJar()
withJavadocJar()
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(8)
options.encoding = "UTF-8"
}
dependencies {
api(project(":messaging-api"))
implementation(project(":backend-channel-client"))
compileOnly("org.spigotmc:spigot-api:1.8-R0.1-SNAPSHOT") { isTransitive = false }
testImplementation("org.spigotmc:spigot-api:1.8-R0.1-SNAPSHOT") { isTransitive = false }
// Plugin's legacy API signature references Ebean; needed by the test proxy only.
testRuntimeOnly("org.avaje:ebean:2.8.1") { isTransitive = false }
testImplementation(platform("org.junit:junit-bom:5.14.1"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
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-bukkit")
dependsOn(configurations.runtimeClasspath)
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from({ configurations.runtimeClasspath.get().filter { it.extension == "jar" }.map { zipTree(it) } })
exclude("META-INF/*.SF", "META-INF/*.RSA", "META-INF/*.DSA")
}
val verifyHostJar = tasks.register("verifyHostJar") {
group = "verification"
dependsOn(tasks.jar)
val hostJar = tasks.jar.flatMap { it.archiveFile }
inputs.file(hostJar)
doLast {
JarFile(hostJar.get().asFile).use { archive ->
val entries = archive.entries().asSequence().toList()
val names = entries.map { it.name }
listOf("plugin.yml", "config.yml",
"dev/strataproxy/bukkit/BukkitMessagingService.class",
"dev/strataproxy/messaging/Message.class",
"dev/strataproxy/messaging/internal/LocalMessaging.class",
"dev/strataproxy/messaging/protocol/MessageCodec.class",
"dev/strataproxy/backendchannel/BackendChannelClient.class").forEach {
check(names.count { name -> name == it } == 1) { "Host jar must contain exactly one $it" }
}
check(names.none { it.startsWith("org/bukkit/") }) { "Bukkit API must remain server-provided" }
entries.filter { it.name.endsWith(".class") }.forEach { entry ->
DataInputStream(archive.getInputStream(entry)).use { input ->
input.readInt()
input.readUnsignedShort()
check(input.readUnsignedShort() <= 52) { "Host class is newer than Java 8: ${entry.name}" }
}
}
}
}
}
tasks.named("check") { dependsOn(verifyHostJar) }
publishing {
publications {
create<MavenPublication>("bukkitHost") {
from(components["java"])
artifactId = "backend-bukkit"
pom {
name.set("StrataProxy Backend Host")
description.set("Java 8 shared channel messaging service for Bukkit and Uranium plugins.")
}
}
}
repositories {
maven {
name = "giteaPackages"
url = uri(providers.environmentVariable("MAVEN_URL")
.orElse("https://git.nest.potatolab.uk:8443/api/packages/layue13/maven").get())
credentials {
username = providers.environmentVariable("MAVEN_USER").orElse("NONE").get()
password = providers.environmentVariable("MAVEN_PASSWORD").orElse("NONE").get()
}
}
}
}
@@ -0,0 +1,15 @@
package dev.strataproxy.bukkit;
import dev.strataproxy.messaging.Messaging;
import org.bukkit.plugin.Plugin;
/** Shared service provided by the StrataProxyBackend Bukkit plugin. */
public interface BukkitMessagingService {
/**
* Returns this enabled plugin's owned messaging scope. Repeated lookups for
* the same plugin return the same scope. Message/request handlers execute on
* the Bukkit main thread. Never block that thread waiting for a remote reply.
* The host closes the scope automatically when the owner is disabled.
*/
Messaging forPlugin(Plugin owner);
}
@@ -0,0 +1,54 @@
package dev.strataproxy.bukkit;
import dev.strataproxy.messaging.Messaging;
import org.bukkit.plugin.Plugin;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.function.Function;
/** Bukkit ownership adapter; the injected factory always uses the host's one client. */
final class OwnedMessagingService implements BukkitMessagingService, AutoCloseable {
interface ScopeFactory { Messaging open(String owner, Executor executor); }
private final ScopeFactory scopes;
private final Function<Plugin, Executor> executors;
private final Map<Plugin, Messaging> owners = new IdentityHashMap<Plugin, Messaging>();
private boolean closed;
OwnedMessagingService(ScopeFactory scopes, Function<Plugin, Executor> executors) {
this.scopes = Objects.requireNonNull(scopes, "scopes");
this.executors = Objects.requireNonNull(executors, "executors");
}
@Override public synchronized Messaging forPlugin(Plugin owner) {
Objects.requireNonNull(owner, "owner");
if (closed) throw new IllegalStateException("StrataProxy messaging host is disabled");
if (!owner.isEnabled()) throw new IllegalStateException("Message owner is not enabled");
Messaging existing = owners.get(owner);
if (existing != null) return existing;
Messaging scope = scopes.open("bukkit:" + owner.getName(), executors.apply(owner));
owners.put(owner, scope);
return scope;
}
void release(Plugin owner) {
Messaging scope;
synchronized (this) { scope = owners.remove(owner); }
if (scope != null) scope.close();
}
@Override public void close() {
ArrayList<Messaging> snapshot;
synchronized (this) {
if (closed) return;
closed = true;
snapshot = new ArrayList<Messaging>(owners.values());
owners.clear();
}
for (Messaging scope : snapshot) scope.close();
}
}
@@ -0,0 +1,82 @@
package dev.strataproxy.bukkit;
import dev.strataproxy.backendchannel.BackendChannelClient;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.server.PluginDisableEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.ServicePriority;
import org.bukkit.plugin.java.JavaPlugin;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
/** One process-level authenticated transport shared by all dependent plugins. */
public final class StrataProxyBackendPlugin extends JavaPlugin implements Listener {
private volatile boolean stopping;
private BackendChannelClient client;
private OwnedMessagingService service;
@Override public void onEnable() {
stopping = false;
saveDefaultConfig();
byte[] secret = null;
try {
String secretText = getConfig().getString("secret", "");
if (secretText.startsWith("replace-") || secretText.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalArgumentException("Configure a unique secret of at least 32 UTF-8 bytes");
}
secret = secretText.getBytes(StandardCharsets.UTF_8);
client = new BackendChannelClient(
required("proxyHost"), getConfig().getInt("proxyPort", 28081),
required("instanceId"), required("backendName"), required("gameAddress"),
UUID.randomUUID().toString(), required("keyId"), secret);
service = new OwnedMessagingService(client::messaging, this::mainThreadExecutor);
getServer().getServicesManager().register(BukkitMessagingService.class, service, this, ServicePriority.Normal);
getServer().getPluginManager().registerEvents(this, this);
getLogger().info("Shared StrataProxy messaging service started; registration runs asynchronously");
} catch (RuntimeException failure) {
// Do not include configuration values or authentication peer data.
getLogger().severe("Cannot start StrataProxy messaging: " + failure.getClass().getSimpleName());
getServer().getPluginManager().disablePlugin(this);
} finally {
if (secret != null) Arrays.fill(secret, (byte) 0);
}
}
private String required(String key) {
String value = getConfig().getString(key, "").trim();
if (value.isEmpty()) throw new IllegalArgumentException("Missing configuration key: " + key);
return value;
}
private Executor mainThreadExecutor(final Plugin owner) {
return command -> {
if (stopping || !owner.isEnabled()) throw new RejectedExecutionException("Message owner is disabled");
try {
getServer().getScheduler().runTask(owner, () -> {
if (!stopping && owner.isEnabled()) command.run();
});
} catch (RuntimeException rejected) {
// A disable can race the enabled check. Preserve the Executor
// rejection contract so the dispatcher releases its admission slot.
throw new RejectedExecutionException("Bukkit scheduler rejected the message owner", rejected);
}
};
}
@EventHandler public void onPluginDisable(PluginDisableEvent event) {
OwnedMessagingService active = service;
if (active != null) active.release(event.getPlugin());
}
@Override public void onDisable() {
stopping = true;
getServer().getServicesManager().unregisterAll(this);
if (service != null) { service.close(); service = null; }
if (client != null) { client.close(); client = null; }
}
}
@@ -0,0 +1,9 @@
# Install exactly one host plugin per backend. Consumer plugins depend on
# StrataProxyBackend and obtain BukkitMessagingService from ServicesManager.
proxyHost: "127.0.0.1"
proxyPort: 28081
instanceId: "island-a"
backendName: "island-a"
gameAddress: "tcp://127.0.0.1:25565"
keyId: "primary"
secret: "replace-with-a-unique-secret-of-32-bytes-or-more"
@@ -0,0 +1,5 @@
name: StrataProxyBackend
main: dev.strataproxy.bukkit.StrataProxyBackendPlugin
version: 0.1.0
description: Shared backend host for StrataProxy
load: STARTUP
@@ -0,0 +1,84 @@
package dev.strataproxy.bukkit;
import dev.strataproxy.messaging.MessageChannel;
import dev.strataproxy.messaging.Messaging;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
class OwnedMessagingServiceTest {
@Test void consumersShareProviderButOwnIndependentScopesAndExecutors() {
List<Scope> opened = new ArrayList<Scope>();
List<Plugin> scheduledOwners = new ArrayList<Plugin>();
OwnedMessagingService service = new OwnedMessagingService((owner, executor) -> {
Scope scope = new Scope(executor);
opened.add(scope);
return scope;
}, owner -> task -> { scheduledOwners.add(owner); task.run(); });
Plugin a = plugin("A", new AtomicBoolean(true));
Plugin b = plugin("B", new AtomicBoolean(true));
Messaging first = service.forPlugin(a);
assertSame(first, service.forPlugin(a));
Messaging second = service.forPlugin(b);
assertNotSame(first, second);
assertEquals(2, opened.size());
opened.get(0).executor.execute(() -> { });
opened.get(1).executor.execute(() -> { });
assertSame(a, scheduledOwners.get(0));
assertSame(b, scheduledOwners.get(1));
service.release(a);
assertTrue(opened.get(0).closed);
assertFalse(opened.get(1).closed);
assertSame(second, service.forPlugin(b));
service.close();
assertTrue(opened.get(1).closed);
assertThrows(IllegalStateException.class, () -> service.forPlugin(b));
}
@Test void disabledOwnerCannotReopenScopeAndPluginReloadGetsFreshScope() {
List<Scope> opened = new ArrayList<Scope>();
OwnedMessagingService service = new OwnedMessagingService((owner, executor) -> {
Scope scope = new Scope(executor);
opened.add(scope);
return scope;
}, owner -> Runnable::run);
AtomicBoolean enabled = new AtomicBoolean(true);
Plugin beforeReload = plugin("A", enabled);
Messaging old = service.forPlugin(beforeReload);
enabled.set(false);
service.release(beforeReload);
assertThrows(IllegalStateException.class, () -> service.forPlugin(beforeReload));
Plugin afterReload = plugin("A", new AtomicBoolean(true));
assertNotSame(old, service.forPlugin(afterReload));
assertTrue(opened.get(0).closed);
assertFalse(opened.get(1).closed);
service.close();
}
private static Plugin plugin(String name, AtomicBoolean enabled) {
return (Plugin) Proxy.newProxyInstance(Plugin.class.getClassLoader(), new Class<?>[]{Plugin.class},
(proxy, method, arguments) -> {
if (method.getName().equals("getName")) return name;
if (method.getName().equals("isEnabled")) return enabled.get();
if (method.getName().equals("toString")) return name;
if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
if (method.getName().equals("equals")) return proxy == arguments[0];
return null;
});
}
private static final class Scope implements Messaging {
final Executor executor;
boolean closed;
Scope(Executor executor) { this.executor = executor; }
public MessageChannel channel(String name) { throw new UnsupportedOperationException(); }
public void close() { closed = true; }
}
}
+2
View File
@@ -22,6 +22,8 @@ tasks.withType<Test>().configureEach {
}
dependencies {
api(project(":messaging-api"))
api(project(":messaging-protocol"))
testImplementation(platform("org.junit:junit-bom:5.14.1"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
@@ -1,8 +0,0 @@
package dev.strataproxy.backendchannel;
import java.util.concurrent.CompletionStage;
/** Handles an inbound request or one-way message for a registered channel name. */
public interface BackendChannelHandler {
CompletionStage<byte[]> handle(byte[] payload) throws Exception;
}
@@ -7,25 +7,18 @@ import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/** Wire constants and codecs shared by the backend SDK and proxy. */
/** Authentication and control-frame codecs for the backend connection. Business messages use MessageCodec. */
public final class Wire {
public static final int VERSION = 1;
public static final int VERSION = 2;
public static final int HELLO = 1;
public static final int REGISTER = 2;
public static final int REGISTERED = 3;
public static final int HEARTBEAT = 4;
public static final int PONG = 5;
public static final int GOODBYE = 6;
public static final int MESSAGE = 7;
public static final int RESPONSE = 8;
public static final int STATUS_OK = 0;
public static final int STATUS_NO_HANDLER = 1;
public static final int STATUS_ERROR = 2;
public static final int NONCE_BYTES = 32;
public static final int MAX_FRAME_BYTES = 66 * 1024;
public static final int MAX_PAYLOAD_BYTES = 64 * 1024;
public static final int MAX_STRING_BYTES = 1024;
private Wire() { }
@@ -64,7 +57,7 @@ public final class Wire {
return bounded(bytes.toByteArray());
}
/** Canonical HMAC input after the server nonce: five unsigned-short-length-prefixed UTF-8 strings. */
/** Canonical HMAC input after the server nonce: five length-prefixed UTF-8 strings. */
public static byte[] canonicalRegistration(String instanceId, String name, String address,
String generation, String keyId) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
@@ -115,52 +108,6 @@ public final class Wire {
requireEnd(in);
}
public static byte[] message(String channel, long requestId, byte[] payload) throws IOException {
requirePayload(payload);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(bytes);
out.writeByte(MESSAGE);
writeString(out, channel);
out.writeLong(requestId);
out.writeInt(payload.length);
out.write(payload);
return bounded(bytes.toByteArray());
}
public static Message decodeMessage(byte[] frame) throws IOException {
DataInputStream in = body(frame, MESSAGE);
String channel = readString(in);
long requestId = in.readLong();
if (requestId < 0) throw new IOException("negative request ID");
byte[] payload = readPayload(in);
requireEnd(in);
return new Message(channel, requestId, payload);
}
public static byte[] response(long requestId, int status, byte[] payload) throws IOException {
if (requestId <= 0) throw new IllegalArgumentException("requestId must be positive");
if (status < STATUS_OK || status > STATUS_ERROR) throw new IllegalArgumentException("invalid response status");
requirePayload(payload);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(bytes);
out.writeByte(RESPONSE);
out.writeLong(requestId);
out.writeByte(status);
out.writeInt(payload.length);
out.write(payload);
return bounded(bytes.toByteArray());
}
public static Response decodeResponse(byte[] frame) throws IOException {
DataInputStream in = body(frame, RESPONSE);
long requestId = in.readLong();
int status = in.readUnsignedByte();
if (requestId <= 0 || status > STATUS_ERROR) throw new IOException("invalid response header");
byte[] payload = readPayload(in);
requireEnd(in);
return new Response(requestId, status, payload);
}
static DataInputStream body(byte[] frame, int type) throws IOException {
if (frame == null || frame.length < 1 || frame.length > MAX_FRAME_BYTES || (frame[0] & 0xff) != type) {
throw new IOException("invalid frame type or size");
@@ -189,18 +136,6 @@ public final class Wire {
return new String(encoded, StandardCharsets.UTF_8);
}
private static byte[] readPayload(DataInputStream in) throws IOException {
int length = in.readInt();
if (length < 0 || length > MAX_PAYLOAD_BYTES) throw new IOException("payload length out of bounds");
byte[] payload = new byte[length];
in.readFully(payload);
return payload;
}
private static void requirePayload(byte[] payload) {
if (payload == null || payload.length > MAX_PAYLOAD_BYTES) throw new IllegalArgumentException("payload length out of bounds");
}
private static void requireEnd(DataInputStream in) throws IOException {
if (in.read() != -1) throw new IOException("trailing frame data");
}
@@ -219,18 +154,4 @@ public final class Wire {
this.keyId = keyId; this.signature = signature;
}
}
public static final class Message {
public final String channel;
public final long requestId;
public final byte[] payload;
Message(String channel, long requestId, byte[] payload) { this.channel = channel; this.requestId = requestId; this.payload = payload; }
}
public static final class Response {
public final long requestId;
public final int status;
public final byte[] payload;
Response(long requestId, int status, byte[] payload) { this.requestId = requestId; this.status = status; this.payload = payload; }
}
}
@@ -1,5 +1,10 @@
package dev.strataproxy.backendchannel;
import dev.strataproxy.messaging.Endpoint;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.MessageKind;
import dev.strataproxy.messaging.MessageChannel;
import dev.strataproxy.messaging.protocol.MessageCodec;
import org.junit.jupiter.api.Test;
import javax.crypto.Mac;
@@ -8,30 +13,33 @@ import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
class BackendChannelClientTest {
@Test void frameCodecRoundTripsAndRejectsOversize() throws Exception {
byte[] frame = Wire.message("demo:echo", 9L, new byte[] { 1, 2, 3 });
java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(bytes);
FrameCodec.write(out, frame);
byte[] encoded = bytes.toByteArray();
assertEquals(frame.length, ByteBuffer.wrap(encoded).getInt());
Wire.Message decoded = Wire.decodeMessage(FrameCodec.read(new DataInputStream(new java.io.ByteArrayInputStream(encoded))));
assertEquals("demo:echo", decoded.channel);
assertEquals(9L, decoded.requestId);
assertArrayEquals(new byte[] { 1, 2, 3 }, decoded.payload);
assertThrows(java.io.IOException.class, () -> FrameCodec.read(new DataInputStream(new java.io.ByteArrayInputStream(new byte[] { 0, 1, 0, 1 }))));
@Test void messageCodecRoundTripsAndRejectsMalformedFrames() throws Exception {
Message message = Message.request("demo:echo", Endpoint.backend("island-a"), Endpoint.proxy(),
new byte[] { 1, 2, 3 });
byte[] frame = MessageCodec.message(9L, 1000L, message);
MessageCodec.IncomingMessage decoded = MessageCodec.decodeMessage(frame);
assertEquals(9L, decoded.operationId);
assertEquals(1000L, decoded.timeoutMillis);
assertEquals(message.id(), decoded.message.id());
assertArrayEquals(message.payload(), decoded.message.payload());
byte[] trailing = Arrays.copyOf(frame, frame.length + 1);
assertThrows(java.io.IOException.class, () -> MessageCodec.decodeMessage(trailing));
assertThrows(IllegalArgumentException.class, () -> MessageCodec.message(0L, 1000L, message));
assertThrows(IllegalArgumentException.class, () -> MessageCodec.message(1L, 1000L,
Message.reply(message, Endpoint.proxy(), new byte[0])));
}
@Test void authenticatesRegistersAndExchangesRequestsInBothDirections() throws Exception {
@Test void authenticatesAndUsesUnifiedMessagingInBothDirections() throws Exception {
final byte[] secret = new byte[32];
Arrays.fill(secret, (byte) 0x6a);
final ServerSocket server = new ServerSocket(0);
@@ -53,16 +61,21 @@ class BackendChannelClientTest {
assertArrayEquals(mac.doFinal(signed), registration.signature);
FrameCodec.write(out, Wire.registered(77L));
Wire.Message request = Wire.decodeMessage(FrameCodec.read(in));
assertEquals("proxy:query", request.channel);
assertEquals(1L, request.requestId);
FrameCodec.write(out, Wire.response(request.requestId, Wire.STATUS_OK, "answer".getBytes(StandardCharsets.UTF_8)));
MessageCodec.IncomingMessage outgoing = MessageCodec.decodeMessage(FrameCodec.read(in));
assertEquals(MessageKind.REQUEST, outgoing.message.kind());
assertEquals(Endpoint.backend("island-a"), outgoing.message.source());
assertEquals(Endpoint.proxy(), outgoing.message.target());
Message outgoingReply = Message.reply(outgoing.message, Endpoint.proxy(), "answer".getBytes(StandardCharsets.UTF_8));
FrameCodec.write(out, MessageCodec.reply(outgoing.operationId, outgoingReply));
FrameCodec.write(out, Wire.message("backend:echo", 42L, "hello".getBytes(StandardCharsets.UTF_8)));
Wire.Response response = Wire.decodeResponse(FrameCodec.read(in));
assertEquals(42L, response.requestId);
assertEquals(Wire.STATUS_OK, response.status);
assertArrayEquals("hello!".getBytes(StandardCharsets.UTF_8), response.payload);
Message inbound = Message.request("backend:echo", Endpoint.proxy(), Endpoint.backend("island-a"),
"hello".getBytes(StandardCharsets.UTF_8));
FrameCodec.write(out, MessageCodec.message(42L, 2000L, inbound));
MessageCodec.Response response = MessageCodec.decodeResponse(FrameCodec.read(in));
assertEquals(42L, response.operationId);
assertEquals(MessageCodec.Response.Type.REPLY, response.type);
assertEquals(inbound.id(), response.message.replyTo());
assertArrayEquals("hello!".getBytes(StandardCharsets.UTF_8), response.message.payload());
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
@@ -71,99 +84,214 @@ class BackendChannelClientTest {
});
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "backend-1", "island-a",
"tcp://127.0.0.1:25565", "gen-1", "key-1", secret, 10_000L, 2_000L, 100L, 500L);
client.registerHandler("backend:echo", payload -> CompletableFuture.completedFuture(
(new String(payload, StandardCharsets.UTF_8) + "!").getBytes(StandardCharsets.UTF_8)));
"tcp://127.0.0.1:25565", "gen-1", "key-1", secret, 10_000L, 100L, 500L);
dev.strataproxy.messaging.Messaging messaging = client.messaging("test-plugin");
MessageChannel channel = messaging.channel("backend:echo");
channel.onRequest(message -> CompletableFuture.completedFuture(
(new String(message.payload(), StandardCharsets.UTF_8) + "!").getBytes(StandardCharsets.UTF_8)));
try {
assertTrue(client.awaitRegistered(5, TimeUnit.SECONDS));
assertEquals(77L, client.getEpoch());
byte[] answer = client.request("proxy:query", new byte[] { 7 }).get(5, TimeUnit.SECONDS);
assertArrayEquals("answer".getBytes(StandardCharsets.UTF_8), answer);
Message answer = messaging.channel("proxy:query").request(Endpoint.proxy(), new byte[] { 7 })
.toCompletableFuture().get(5, TimeUnit.SECONDS);
assertArrayEquals("answer".getBytes(StandardCharsets.UTF_8), answer.payload());
peer.get(5, TimeUnit.SECONDS);
} finally {
messaging.close();
client.close();
}
}
@Test void reconnectsAndRegistersAgainAfterConnectionLoss() throws Exception {
final byte[] secret = new byte[32];
Arrays.fill(secret, (byte) 0x2d);
final ServerSocket server = new ServerSocket(0);
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
try {
for (int connection = 1; connection <= 2; connection++) {
try (Socket socket = server.accept()) {
DataInputStream in = new DataInputStream(socket.getInputStream());
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
byte[] nonce = new byte[Wire.NONCE_BYTES];
Arrays.fill(nonce, (byte) connection);
FrameCodec.write(out, Wire.hello(Wire.VERSION, nonce));
Wire.Registration registration = Wire.decodeRegister(FrameCodec.read(in));
assertEquals("retry-backend", registration.instanceId);
FrameCodec.write(out, Wire.registered(connection));
if (connection == 2) {
Wire.Message request = Wire.decodeMessage(FrameCodec.read(in));
FrameCodec.write(out, Wire.response(request.requestId, Wire.STATUS_OK, request.payload));
}
}
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try { server.close(); } catch (Exception ignored) { }
}
});
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "retry-backend", "island-b",
"tcp://127.0.0.1:25566", "gen-retry", "key-1", secret, 10_000L, 2_000L, 100L, 500L);
try {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (client.getEpoch() < 2L && System.nanoTime() < deadline) Thread.sleep(10L);
assertEquals(2L, client.getEpoch());
assertTrue(client.isRegistered());
byte[] payload = new byte[] { 8, 6, 7 };
assertArrayEquals(payload, client.request("proxy:retry", payload).get(5, TimeUnit.SECONDS));
peer.get(5, TimeUnit.SECONDS);
} finally {
client.close();
}
@Test void frameCodecRejectsOversizeLength() throws Exception {
assertThrows(java.io.IOException.class, () -> FrameCodec.read(new DataInputStream(
new java.io.ByteArrayInputStream(new byte[] { 0, 1, 0, 1 }))));
}
@Test void disconnectsWhenProxyStopsSendingPongsAndFailsPendingRequest() throws Exception {
final byte[] secret = new byte[32];
Arrays.fill(secret, (byte) 0x44);
final ServerSocket server = new ServerSocket(0);
@Test void slowPeerDoesNotBlockSendCaller() throws Exception {
byte[] secret = new byte[32];
Arrays.fill(secret, (byte) 0x29);
ServerSocket server = new ServerSocket(0);
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
try (Socket socket = server.accept()) {
DataInputStream in = new DataInputStream(socket.getInputStream());
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
byte[] nonce = new byte[Wire.NONCE_BYTES];
Arrays.fill(nonce, (byte) 0x61);
FrameCodec.write(out, Wire.hello(Wire.VERSION, nonce));
Wire.decodeRegister(FrameCodec.read(in));
FrameCodec.write(out, Wire.registered(1L));
Wire.decodeMessage(FrameCodec.read(in));
while (true) FrameCodec.read(in); // Deliberately consume heartbeats without replying PONG.
} catch (Exception ignored) {
// The client should close this socket after its PONG watchdog expires.
Thread.sleep(1200L); // Do not read messaging frames while the caller submits them.
} catch (Exception failure) {
throw new RuntimeException(failure);
} finally {
try { server.close(); } catch (Exception ignored) { }
}
});
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "slow-peer", "island-slow",
"tcp://127.0.0.1:25570", "gen-slow", "key-1", secret);
dev.strataproxy.messaging.Messaging messaging = client.messaging("slow-test");
try {
assertTrue(client.awaitRegistered(5, TimeUnit.SECONDS));
byte[] payload = new byte[Message.MAX_PAYLOAD_BYTES];
long started = System.nanoTime();
for (int i = 0; i < 24; i++) messaging.channel("test:slow").send(Endpoint.proxy(), payload);
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
assertTrue(elapsedMillis < 1000L, "send API must not wait for the peer socket write");
} finally {
messaging.close();
client.close();
peer.get(3, TimeUnit.SECONDS);
}
}
@Test void neverCompletingHandlersExpireAndReleaseInboundCapacity() throws Exception {
byte[] secret = new byte[32];
Arrays.fill(secret, (byte) 0x35);
ServerSocket server = new ServerSocket(0);
CountDownLatch handlerInstalled = new CountDownLatch(1);
CountDownLatch firstBatchDispatched = new CountDownLatch(128);
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
try (Socket socket = server.accept()) {
DataInputStream in = new DataInputStream(socket.getInputStream());
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
FrameCodec.write(out, Wire.hello(Wire.VERSION, new byte[Wire.NONCE_BYTES]));
Wire.decodeRegister(FrameCodec.read(in));
FrameCodec.write(out, Wire.registered(1L));
if (!handlerInstalled.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("handler was not installed");
for (int i = 1; i <= 128; i++) {
Message request = Message.request("test:hang", Endpoint.proxy(), Endpoint.backend("island-hang"), new byte[0]);
FrameCodec.write(out, MessageCodec.message(i, 1000L, request));
}
if (!firstBatchDispatched.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("request handlers were not dispatched");
Thread.sleep(1200L); // Let all incomplete handlers time out and release their bounded slots.
Message last = Message.request("test:hang", Endpoint.proxy(), Endpoint.backend("island-hang"), new byte[0]);
FrameCodec.write(out, MessageCodec.message(129L, 3000L, last));
MessageCodec.Response afterExpiry;
do {
afterExpiry = MessageCodec.decodeResponse(FrameCodec.read(in));
if (afterExpiry.operationId != 129L) {
assertTrue(afterExpiry.operationId >= 1L && afterExpiry.operationId <= 128L);
assertEquals(MessageCodec.Response.Type.ERROR, afterExpiry.type);
assertEquals(dev.strataproxy.messaging.MessagingException.Code.TIMED_OUT, afterExpiry.errorCode);
}
} while (afterExpiry.operationId != 129L);
assertEquals(MessageCodec.Response.Type.REPLY, afterExpiry.type);
assertArrayEquals(new byte[] { 1 }, afterExpiry.message.payload());
} catch (Exception failure) {
throw new RuntimeException(failure);
} finally {
try { server.close(); } catch (Exception ignored) { }
}
});
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "hang-backend", "island-hang",
"tcp://127.0.0.1:25571", "gen-hang", "key-1", secret);
dev.strataproxy.messaging.Messaging messaging = client.messaging("hang-test");
java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();
messaging.channel("test:hang").onRequest(message -> {
int call = calls.incrementAndGet();
if (call <= 128) {
firstBatchDispatched.countDown();
return new CompletableFuture<byte[]>();
}
return CompletableFuture.completedFuture(new byte[] { 1 });
});
handlerInstalled.countDown();
try {
assertTrue(client.awaitRegistered(5, TimeUnit.SECONDS));
peer.get(10, TimeUnit.SECONDS);
} finally {
messaging.close();
client.close();
}
}
@Test void reconnectFailsOldOperationAndUsesOnlyNewSessionResponse() throws Exception {
byte[] secret = new byte[32];
Arrays.fill(secret, (byte) 0x48);
ServerSocket server = new ServerSocket(0);
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
try {
try (Socket first = server.accept()) {
DataInputStream in = new DataInputStream(first.getInputStream());
DataOutputStream out = new DataOutputStream(first.getOutputStream());
FrameCodec.write(out, Wire.hello(Wire.VERSION, new byte[Wire.NONCE_BYTES]));
Wire.decodeRegister(FrameCodec.read(in));
FrameCodec.write(out, Wire.registered(1L));
MessageCodec.IncomingMessage old = MessageCodec.decodeMessage(FrameCodec.read(in));
assertEquals(1L, old.operationId);
// Closing this session must fail its in-flight operation before a new session is installed.
}
try (Socket second = server.accept()) {
DataInputStream in = new DataInputStream(second.getInputStream());
DataOutputStream out = new DataOutputStream(second.getOutputStream());
FrameCodec.write(out, Wire.hello(Wire.VERSION, new byte[Wire.NONCE_BYTES]));
Wire.decodeRegister(FrameCodec.read(in));
FrameCodec.write(out, Wire.registered(2L));
MessageCodec.IncomingMessage current = MessageCodec.decodeMessage(FrameCodec.read(in));
assertEquals(1L, current.operationId, "operation IDs are scoped to the new socket session");
Message reply = Message.reply(current.message, Endpoint.proxy(), new byte[] { 2 });
FrameCodec.write(out, MessageCodec.reply(current.operationId, reply));
}
} catch (Exception failure) {
throw new RuntimeException(failure);
} finally {
try { server.close(); } catch (Exception ignored) { }
}
});
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "reconnect-backend", "island-reconnect",
"tcp://127.0.0.1:25572", "gen-reconnect", "key-1", secret, 10_000L, 25L, 100L);
dev.strataproxy.messaging.Messaging messaging = client.messaging("reconnect-test");
try {
assertTrue(client.awaitRegistered(5, TimeUnit.SECONDS));
CompletableFuture<Message> old = messaging.channel("test:reconnect")
.request(Endpoint.proxy(), new byte[] { 1 }).toCompletableFuture();
assertThrows(java.util.concurrent.ExecutionException.class, () -> old.get(5, TimeUnit.SECONDS));
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while ((client.getEpoch() != 2L || !client.isRegistered()) && System.nanoTime() < deadline) Thread.sleep(10L);
assertEquals(2L, client.getEpoch());
Message fresh = messaging.channel("test:reconnect").request(Endpoint.proxy(), new byte[] { 2 })
.toCompletableFuture().get(5, TimeUnit.SECONDS);
assertArrayEquals(new byte[] { 2 }, fresh.payload());
assertTrue(old.isCompletedExceptionally());
peer.get(5, TimeUnit.SECONDS);
} finally {
messaging.close();
client.close();
}
}
@Test void immediateCloseAfterRegistrationAlwaysFlushesGoodbye() throws Exception {
final int iterations = 20;
byte[] secret = new byte[32];
Arrays.fill(secret, (byte) 0x71);
ServerSocket server = new ServerSocket(0);
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
try {
for (int i = 0; i < iterations; i++) {
try (Socket socket = server.accept()) {
DataInputStream in = new DataInputStream(socket.getInputStream());
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
FrameCodec.write(out, Wire.hello(Wire.VERSION, new byte[Wire.NONCE_BYTES]));
Wire.decodeRegister(FrameCodec.read(in));
FrameCodec.write(out, Wire.registered(i + 1L));
byte[] goodbye = FrameCodec.read(in);
assertEquals(Wire.GOODBYE, goodbye[0] & 0xff);
Wire.validateEmpty(goodbye, Wire.GOODBYE);
}
}
} catch (Exception failure) {
throw new RuntimeException(failure);
} finally {
try { server.close(); } catch (Exception ignored) { }
}
});
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "silent-backend", "island-c",
"tcp://127.0.0.1:25567", "gen-silent", "key-1", secret, 1_000L, 60_000L, 100L, 500L);
try {
for (int i = 0; i < iterations; i++) {
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(),
"close-backend-" + i, "island-close", "tcp://127.0.0.1:25580", "gen-close", "key-1", secret);
assertTrue(client.awaitRegistered(5, TimeUnit.SECONDS));
CompletableFuture<byte[]> pending = client.request("proxy:slow", new byte[] { 1 }, 60_000L, TimeUnit.MILLISECONDS);
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
while (client.isRegistered() && System.nanoTime() < deadline) Thread.sleep(25L);
assertFalse(client.isRegistered(), "client must expire a connection without PONGs");
assertThrows(java.util.concurrent.ExecutionException.class, () -> pending.get(2, TimeUnit.SECONDS));
peer.get(2, TimeUnit.SECONDS);
} finally {
client.close();
server.close();
}
peer.get(10, TimeUnit.SECONDS);
}
}
+4 -2
View File
@@ -10,10 +10,12 @@ allprojects {
tasks.register("check") {
group = "verification"
dependsOn(":proxy-plugin-api:check", ":backend-channel-client:check", ":proxy-core:check")
dependsOn(":proxy-plugin-api:check", ":messaging-api:check", ":messaging-protocol:check",
":backend-channel-client:check", ":backend-bukkit:check", ":proxy-core:check")
}
tasks.register("publish") {
group = "publishing"
dependsOn(":proxy-plugin-api:publish", ":backend-channel-client:publish")
dependsOn(":proxy-plugin-api:publish", ":messaging-api:publish", ":messaging-protocol:publish",
":backend-channel-client:publish", ":backend-bukkit:publish")
}
+3 -1
View File
@@ -5,10 +5,12 @@
| 文档 | 内容 |
| --- | --- |
| [架构与边界](architecture.md) | 模块职责、会话状态、线程模型、转服、目录所有权 |
| [消息与命令补全](messages-and-completion.md) | 富文本、模板、异步参数补全及协议边界 |
| [插件开发](plugins.md) | 生命周期、玩家/服务器视图、选服、命令与统一类型化事件 API |
| [玩家操作与部署完善设计](player-operations.md) | 消息、主动断开、服务器列表配置与验收边界 |
| [运行配置](operations.md) | 认证、接入限制、超时、启动和部署边界 |
| [后端控制通道](backend-channel-design.md) | 独立连接、注册与双向消息 |
| [后端 Channel 消息服务](backend-channel-design.md) | 共享宿主、跨后端路由、消息身份与交付语义 |
| [Channel 消息设计契约](channel-messaging-v2.md) | 从参与者、寻址、响应与失败语义推导 API 和验收条件 |
| [验证方法](testing.md) | 自动化测试、烟测、真实客户端与合成性能测量 |
设计约束:核心管理连接和协议;插件负责玩法与访问策略。`ServerView` 是后端目录快照,发现方式是目录的提供者,不引入核心负载观测。准入与会话通知使用同一事件类型、监听器和注册表;不同事件在派发策略上处理结果。常规 PLAY 转发没有通用事件派发。
+74 -34
View File
@@ -1,63 +1,103 @@
# 后端控制通道
# 后端 Channel 消息服务
控制通道已经提供第一版实现:每个后端实例主动连接代理的独立端口,在没有在线玩家时注册,并与代理插件双向交换消息。它不使用 Minecraft PLAY 插件消息,也不使用 HTTP 注册或 DNS 发现。
业务插件通过命名 channel 与 proxy 或指定 backend 通信。每个后端由一个宿主持有认证控制连接,多个插件共享;proxy 内置路由,后端之间通信不需要另写代理转发插件,也不依赖在线玩家。
## 组成与部署
模型推导、失败语义及验收条件见 [设计契约](channel-messaging-v2.md)。消息服务与 Minecraft PLAY 转发独立;现有后端注册、心跳租约和游戏地址目录继续由控制服务维护。
```mermaid
flowchart LR
BP[后端插件] --> SDK[Java 8 后端 SDK]
SDK -->|每实例一条控制连接| C[代理控制服务]
C --> R[后端目录]
C --> D[有界消息派发]
D --> PP[代理插件 API]
P[玩家连接] -->|独立的游戏链路| R
A[Backend A 业务插件] <--> HA[共享消息宿主]
HA <-->|单条认证连接| R[Proxy 内置路由]
P[Proxy 业务插件] <--> R
R <-->|单条认证连接| HB[共享消息宿主]
HB <--> B[Backend B 业务插件]
```
代理在 `backendChannel.listen` 启动第二个监听器。每个实例用独立的 `instanceId`、`backendName`、凭据和游戏端口注册;同一 IP 可以承载多个不同端口的实例。代理侧不需要为每个实例启动单独的插件或 HTTP 端点。控制通道只创建动态目录条目,不能覆盖静态后端或其他实例拥有的名称。
## 参与者、Channel 与消息
示例配置见 [`strataproxy-channel.example.yml`](../proxy-core/src/main/resources/config/strataproxy-channel.example.yml)。`clients` 中每个实例的 `secret` 至少 32 个 UTF-8 字节,`allowedHosts` 限制它可广告的游戏主机名或 IP,`allowedNamespaces` 限制它可以发往代理插件的消息命名空间。后端注册地址必须是代理实际可达的 `tcp://host:port`;只有代理与后端同机时,才能使用 `127.0.0.1` 作为游戏地址。不要把示例密钥用于实际部署。
- `Endpoint.proxy()`、`Endpoint.backend("island-b")` 表示目标节点。
- `islands:control` 这样的 channel 表示业务域及权限边界;目标 backend 名称不编码进 channel。
- `Message` 包含 UUID `id`、`kind`、`channel`、认证 `source`、`target`、`replyTo` 和复制后的二进制 `payload`。普通调用由框架生成这些元数据;业务消息类型与数据版本由 payload 表达。
- 通知使用 `subscribe`,同一节点同一 channel 可有多个订阅者。请求使用 `onRequest`,同一节点同一 channel 只能有一个处理者;重复注册会失败。
- `request` 返回完整回复消息。回复有自己的 `id`,`replyTo` 等于请求的 `id`;经 proxy 转发保持原始消息身份。来源由认证连接确定,不能相信 payload 中自称的发送者。
后端插件只依赖 `backend-channel-client`,该模块按 Java 8 字节码编译,不依赖 JDK 25 的 `proxy-plugin-api` 或代理核心。创建一个 `BackendChannelClient` 后,它会连接、注册、发送心跳并在断线后重连。构造器中的 `generation` 应在进程启动时生成一次 UUID,重连期间保持不变。
后端共享一个认证主体,代理不能独立认证其中每个业务插件。本地插件 owner 用于生命周期清理,不是新的跨进程安全身份。连接代次留在传输实现中。
### Docker 部署
## 后端插件接入
代理容器运行 JDK 25,后端容器按服务端要求运行 Java 8;两者加入同一个私有 Docker 网络。代理配置中的 `listen` 和 `backendChannel.listen` 在容器内绑定 `0.0.0.0`,分别监听玩家端口和控制端口。仅将玩家端口发布到宿主机;控制端口只需在容器网络内可达。后端 SDK 的代理地址填 Docker 网络中的代理服务名(例如 `proxy:28081`),游戏地址填代理可达的后端容器名和实际端口(例如 `tcp://island-a:25565`)。`allowedHosts` 应列出这个游戏地址的主机名。这里的容器 DNS 只用于连接已配置的服务名,不承担后端发现或注册。
在 Bukkit/Uranium 后端安装一次 `backend-bukkit` 生成的 `strataproxy-backend-bukkit` JAR,插件名为 `StrataProxyBackend`。配置唯一的实例 ID、后端名称、代理控制地址、游戏地址与凭据。宿主异步建立连接,不在启动主线程等待网络。
每个后端实例使用独立的 `instanceId`、`backendName`、游戏地址和密钥;同一台宿主机上的不同容器不能把 `127.0.0.1` 当作彼此的地址。不要把真实密钥写入镜像或提交到仓库;启动时挂载由部署系统生成的代理配置,并向后端插件注入对应密钥。跨宿主机部署还需要私有加密链路或 TLS 终止器。可运行 `sh smoke/container-channel.sh`,在 Docker bridge 中验证注册、登录、注销和已有玩家连接继续转发。
消费者 `plugin.yml` 声明:
```yaml
depend: [StrataProxyBackend]
```
消费者仅将 `uk.potatolab:backend-bukkit` 作为 `compileOnly` / `provided` 依赖,不将 API、SDK 或宿主实现重新打包进自己的 JAR,确保各插件使用同一服务类。
```java
BackendChannelClient channel = new BackendChannelClient(
"127.0.0.1", 28081, "island-a", "island-a", "tcp://127.0.0.1:25565",
UUID.randomUUID().toString(), "primary", secretBytes);
channel.registerHandler("islands:echo", payload ->
CompletableFuture.completedFuture(payload));
// 服务端插件关闭时调用 channel.close()。
BukkitMessagingService service = getServer().getServicesManager()
.load(BukkitMessagingService.class);
Messaging messaging = service.forPlugin(this);
MessageChannel channel = messaging.channel("islands:control");
channel.subscribe(message -> handleNotice(message));
channel.onRequest(message -> CompletableFuture.completedFuture(query(message.payload())));
channel.send(Endpoint.proxy(), noticeBytes);
channel.send(Endpoint.backend("island-b"), noticeBytes);
channel.request(Endpoint.backend("island-b"), queryBytes, Duration.ofSeconds(3))
.thenAccept(reply -> logReply(reply.id(), reply.replyTo(), reply.payload()));
```
代理插件通过 `PluginContext.backendChannels()` 使用通道。处理器在 `onLoad` 或 `onEnable` 注册,同一通道只能有一个处理器;插件关闭时自动撤销。`BackendMessage` 含代理认证过的 `backendName`、`instanceId` 和连接代次,处理器应使用这些字段判断来源,不应信任负载里自称的身份。
宿主在游戏主线程调用通知/请求处理器;业务应快速返回,耗时操作返回异步 stage。不要在游戏主线程对网络 future 调用 `get` / `join`。Future 的后续回调不保证在游戏主线程,操作 Bukkit 游戏对象前应显式安排主线程任务。
`forPlugin` 对同一个启用中的插件返回同一 scope。消费者停用时,宿主自动关闭它的 scope,撤销注册并结束未决调用;其他插件继续工作。宿主停用则关闭唯一的连接。需要提前停止服务时,也可主动 `messaging.close()`。
非 Bukkit 宿主可以创建一次 Java 8 `BackendChannelClient`,通过 `client.messaging(owner, executor)` 给各插件提供 scope,并负责调用 scope/client 的 `close`。普通业务插件不持有凭据或直接构造客户端。
## Proxy 插件接入
```java
context.backendChannels().subscribe("islands:prepare", message ->
prepare(message).thenApply(ignored -> new byte[0]));
context.backendChannels().send("island-a", "islands:notice", bytes);
context.backendChannels().request("island-a", "islands:echo", bytes);
MessageChannel channel = context.messaging().channel("islands:control");
channel.onRequest(message -> prepare(message.payload()));
channel.subscribe(message -> audit(message.id(), message.source()));
channel.send(Endpoint.backend("island-a"), bytes);
channel.request(Endpoint.backend("island-b"), bytes);
```
## 注册、断线与安全
Proxy 为每个插件管理独立的 scope,在停用时自动撤销。项目处于开发阶段,旧 `backendChannels()`、裸字节处理器及直接客户端消息方法已经移除;两端插件统一使用 `messaging()` API。
代理先发送协议版本和随机挑战。后端以实例 ID、名称、游戏地址、启动代次、密钥 ID 和覆盖这些字段的 HMAC-SHA256 签名注册;验证成功且目录写入完成后,代理返回连接代次。认证失败、名称冲突或广告主机不在允许列表时,不会创建目录条目。重复连接同一实例会接管旧连接,旧连接的回调和清理不能修改新条目。
## 交付、发布与故障
SDK 默认每 10 秒发一次心跳,代理默认租约为 30 秒,可在配置中调整。意外断线时,消息调用立即失败,目录条目保留到租约到期;期间玩家连接仍可能尝试该游戏地址。重连并重新认证后恢复消息通道。正常关闭会发送 `GOODBYE` 并立即注销。目录撤销不会强制踢掉已经在该服的玩家。
`send` 是定点通知,其 `SendReceipt` 含消息 ID。`ACCEPTED` 只表示目标节点的消息服务接受了有界处理任务,不表示业务执行完成;没有订阅者、目标断连、过载和权限拒绝分别为 `NO_SUBSCRIBER`、`NOT_CONNECTED`、`BACKPRESSURED`、`REJECTED`。`TIMED_OUT` 表示未及时收到回执,`FAILED` 表示其他故障,这两者均不能证明业务尚未执行。
控制协议本身没有加密。跨机器部署应把控制监听器放在私有加密链路后,或使用可信 TLS 终止器;HMAC 不能防止窃听。当前每个实例配置一个密钥 ID 和密钥,轮换需要协调更新配置与客户端。代理对未认证连接设握手期限,并限制连接数、帧长、每连接队列、在途请求和入站消息速率。
`request` 是一对一请求。超时从提交时开始,覆盖排队、传输和等待回复;默认 5 秒,最长 60 秒。无处理器、超时、断线、权限拒绝和处理失败有可区分的错误。过期的排队请求不能恢复后再发出。
## 消息语义
`publish(payload)` 面向 proxy 与当前有接收权限的在线 backend,包括发布者自身;节点按当时的本地订阅者分发。返回结果按候选节点列出投递状态,也会包含 `NO_SUBSCRIBER`。多节点投递不是原子事务,部分节点可能接受、部分失败。首版按在线连接扇出,避免维护远端订阅目录及其重连同步状态。一次发布最多 256 个候选节点(包含 proxy);超过时在任何投递前明确拒绝,不截断结果。定点 `send` / `request` 不受此发布上限影响。
第一版支持单向 `send` 和带回复的 `request`。消息是二进制负载,最大 64 KiB;代理每连接最多排队 1 MiB 或 128 条出站消息、128 个在途请求,默认请求期限 5 秒。入站处理器使用独立于玩家准入和命令的有界工作池。代理 `send` 返回 `SENT` 只说明写入控制连接,不代表后端插件已处理;断线、过载和无处理器分别有明确失败结果。后端 SDK 也限制在途请求和处理器队列。
消息不离线保存,断线不自动重放。超时或断线不能证明远端没有执行;需要重试的业务应有自己的稳定幂等键。UUID 提供身份和追踪能力,不承诺 exactly-once。多个并发请求可以乱序回复,框架根据关联信息匹配。
控制通道不保存离线消息,不保证断线前的请求是否已被远端执行,也不跨连接代次自动重放。业务重试需要自己的幂等键;可靠保存的玩法数据仍应写入业务存储。消息收发不进入普通 PLAY 转发路径。
## 配置与部署
代理在 `backendChannel.listen` 启动独立监听器。配置示例见 [strataproxy-channel.example.yml](../proxy-core/src/main/resources/config/strataproxy-channel.example.yml)。每个实例的 `secret` 至少 32 个 UTF-8 字节,`allowedHosts` 限制其广告游戏地址;`allowedNamespaces` 控制发送,`allowedReceiveNamespaces` 控制接收,未配置接收列表时继承发送列表,显式空列表表示禁止该方向的所有消息。跨后端路由同时检查源发送权限与目的接收权限。
实例的游戏地址必须是代理实际可达的 `tcp://host:port`。Docker 中使用容器服务名,容器间不要使用 `127.0.0.1`。仅将玩家端口公开,控制端口放在私有网络。不要把真实凭据写入镜像或提交到仓库。
HMAC 挑战认证本身不加密链路;跨机器使用私有加密链路或可信 TLS 终止器。每个实例配置独立的实例 ID、后端名称和密钥;重复认证同一实例会接管旧连接,旧连接的迟到回复不能影响新连接。
协议已升级为版本 2,proxy 与后端宿主必须一起升级;旧协议和 Java API 已移除,版本 1 连接会明确拒绝。关闭时在有限等待内尝试发送注销帧,代理收到后立即注销目录;若链路阻塞或意外断线,则保留到租约过期,不强制关闭已经存在的玩家游戏连接。
## 验证范围
自动化测试覆盖零玩家通信、同 IP 不同端口注册、双向请求、无处理器响应、无效凭据、旧连接接管和意外断线后租约过期。Java 8 SDK 已通过 Java 8 编译;真实 Forge/Uranium 服务端装载、跨机器加密链路和生产负载仍需在目标部署环境验证。源码入口为 [`BackendControlService`](../proxy-core/src/main/java/dev/strataproxy/core/control/BackendControlService.java)、[`BackendChannels`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannels.java) 和 [`BackendChannelClient`](../backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelClient.java)。
自动化验证针对 Java 8 API/宿主字节码、宿主 JAR 打包边界、真实 TCP 的零玩家双向及跨后端请求、发布、多插件关闭、并发回复关联、超时与异常帧。构建和独立 TCP 测试不能代替真实 Bukkit/Uranium 装载、玩家整合包验收或生产容量测试。
本次构建、244 项回归和实际 Java 8 进程的结果见 [2026-09-27 验证记录](../smoke/results/2026-09-27-channel-messaging.md)。
安装发行包的 `backend-host/` 提供可直接安装的共享宿主 JAR;`backend-client/` 提供非 Bukkit 宿主所需的 SDK、消息 API 和协议三个 JAR。运行 `:proxy-core:installDist` 后,可用实际 JDK 8 运行独立进程验收:
```powershell
.\smoke\channel-java8.ps1 -Java8Home 'C:\path\to\jdk8' -Java25Home 'C:\path\to\jdk25'
```
脚本用 JDK 25 启动本机 router,再用 JDK 8 编译并运行两个后端客户端,校验请求关联、来源与三个插件订阅者;不启动 Minecraft 玩家或 Bukkit 服务端。
+40
View File
@@ -0,0 +1,40 @@
# Channel messaging: contract and rationale
## Problem and acceptance criteria
A plugin on backend A must be able to notify or query a proxy plugin or backend B using a named channel. Plugins must not create network connections, distribute credentials, implement proxy relay plugins, or depend on a connected player. Multiple plugins in one backend share one authenticated connection. Both ends receive an immutable message with a framework-assigned identity and an authenticated source.
Acceptance requires real socket tests with no players: backend/proxy and backend/backend requests; notification fan-out; concurrent responses matched to their requests; independent plugin disable; bounded overload; timeout including queueing; disconnect without replay; and old-connection isolation. A Java 8 Bukkit host artifact provides the shared service. Unit tests and socket tests do not establish deployment acceptance in a running Forge pack.
## Deriving the model
1. A connection authenticates a backend process, not each plugin in that process. The proxy therefore derives the source from the authenticated connection. A plugin label is useful for local lifecycle ownership, but is not a separately authenticated remote principal.
2. A channel identifies a business communication domain and its permission boundary. A destination identifies the receiving node. Conflating these would force applications to encode backend names in channels or build their own routing protocol.
3. A notification has no business response and may have several subscribers. A request needs exactly one responsible handler at its destination. Separate `subscribe` and `onRequest` registrations remove the multiple-reply ambiguity; registering a second request handler for the same node/channel fails.
4. Network failure can occur after execution but before a reply. Neither a timeout nor a successful socket write proves whether the business operation executed. Messaging is live-only, with bounded queues and no automatic cross-connection replay. Durable state and idempotency belong to the application.
5. A message identity is useful across proxy hops and in logs. It is independent of a transport operation's temporary receipt correlation. Every message, including a notification and a reply, gets a UUID. A reply points to the request UUID. Routing preserves identities; applications do not generate correlation fields for ordinary calls.
6. The backend game thread must not perform socket I/O or wait for remote replies. The host supplies the executor for callbacks. Timeouts start at submission and cover queueing, dispatch, and waiting; expired queued messages must not be transmitted later.
## Public model
`Endpoint` is either the proxy or a named backend. `Message` contains `id`, `kind` (`EVENT`, `REQUEST`, `REPLY`), `channel`, authenticated `source`, `target`, `replyTo`, and a defensively copied binary `payload`. A null event target means publication to subscribers; requests and replies always have one target. Only replies have `replyTo`. Business message types and schema versions can be encoded in the payload; they are not transport kinds. Connection generations remain internal.
Each plugin gets an owned `Messaging` scope and obtains a `MessageChannel` by name. `subscribe` receives events, `onRequest` produces an asynchronous reply, `send` sends an event to one endpoint, `request` obtains one full reply message, and `publish` fans out an event. Closing a plugin scope revokes only that plugin's registrations and pending calls. Retained handles cannot create new work after close.
`send` returns a receipt containing the message ID and a result. `ACCEPTED` means that the destination node accepted the notification into its bounded handler dispatch; it does not mean that business code finished. `NO_SUBSCRIBER`, `NOT_CONNECTED`, `BACKPRESSURED`, `REJECTED`, `TIMED_OUT`, and `FAILED` are distinct. A timeout or unknown failure cannot prove that the recipient did not execute the operation. A request fails with a typed error for no handler, overload, timeout, disconnect, rejection, or handler failure.
`publish` visits the proxy and each currently connected backend authorized to receive the channel, including the publisher. Each node dispatches to its current local subscribers. The result contains one result per candidate node, including `NO_SUBSCRIBER`; it is not an atomic transaction across nodes. This first implementation deliberately avoids a replicated subscription directory and its registration/reconnect races. A publication accepts at most 256 candidate nodes including the proxy; exceeding that limit rejects the whole publication before dispatch. Direct sends and requests do not have this fan-out limit. A future optimization may introduce acknowledged subscription routing without changing the public semantics.
## Ownership and transport
The proxy provides the messaging service through `PluginContext`. A single Java 8 Bukkit bridge owns the backend client and publishes a service through Bukkit's `ServicesManager`. Consumer plugins declare a dependency on the bridge and use its API with a provided/compile-only dependency. They must not shade separate copies of the service API or client. The bridge supplies game-thread callback execution and automatically closes scopes on `PluginDisableEvent`.
The existing authenticated registration, advertised game address validation, heartbeat lease, and backend directory ownership remain the transport foundation. Backend-to-backend traffic travels through the proxy's built-in router. Routing neither changes game connections nor requires a proxy business plugin.
Protocol version 2 adds a shared bounded message codec. Per-hop operation IDs correlate delivery receipts and transport responses; end-to-end message UUIDs and reply references survive forwarding. The proxy overwrites or validates the source against the authenticated connection and binds pending calls to the exact connection. Receiving permissions are checked independently of sending permissions. Version 1 peers must be upgraded together; incompatible versions fail explicitly rather than decoding each other's frames.
## Verification boundaries
The implementation must retain the rich-text/command-completion tests while integrating current main. Required messaging tests cover two independent backend clients and the proxy, event subscribers versus a unique request handler, correct source and reply identities, caller payload mutation, malformed/bounded protocol frames, stalled handlers, slow socket readers, plugin disable, and disconnect/reconnect. A packaged bridge must contain the Java 8 API/client implementation once and exclude Bukkit classes.
Actual Bukkit/Uranium loading, main-thread behavior under real server scheduling, and production capacity are separate acceptance evidence and must not be inferred from compilation or isolated socket tests.
+62
View File
@@ -0,0 +1,62 @@
# 消息构造与命令补全
## 目标
插件用统一组件表达消息,用简单回调执行命令和补全。代理负责协议 5 编码、连接身份、线程切换和有界资源。消息模板、权限、业务参数与候选过滤由插件决定。
## 富文本
API 导出 Adventure Component 与 MiniMessage。`Players.sendMessage`、`Players.disconnect`、`CommandInvocation.reply`、`AccessDecision.deny` 和 `PlacementDecision.reject` 接受 Component;String 重载表示纯文本,绝不自动解释标签。固定组件可提前构造并复用。
```java
var message = Component.text("返回主城", NamedTextColor.GREEN)
.hoverEvent(Component.text("点击返回主城"))
.clickEvent(ClickEvent.runCommand("/spawn"));
context.players().sendMessage(player.identity(), message);
var greeting = MiniMessage.miniMessage().deserialize(
"<green>欢迎 <player>!</green>",
Placeholder.unparsed("player", player.username()));
invocation.reply(greeting);
```
动态字符串通过 `Placeholder.unparsed` 插入;组件通过 `Placeholder.component` 插入。插件显式解析模板,解析与组件构造放在插件工作线程。普通后端消息仍沿用转发路径。
1.7.10 支持文字、翻译组件、16 色与文字样式、打开网址、执行命令、填入命令和悬浮文字。RGB 映射到最近的传统颜色;不支持的组件、样式或事件明确拒绝。当前不提供物品/实体悬浮适配。LOGIN/PLAY 使用相同 JSON 编码;踢出界面不保证聊天窗口的交互能力。
树深度最多 32、节点最多 256,编码后的 JSON 最多 32,767 UTF-8 字节;悬浮文本和翻译参数也计入树限制。纯文本快捷入口最多 1,024 个 Unicode 码点。`Players` 在调用线程验证、编码,再将编码结果提交玩家 EventLoop;不在消息写入时重复解析模板。既有背压、最多 64 条未完成消息和连接代次检查继续生效。
## 插件命令
```java
context.commands().register("server", invocation -> {
invocation.reply(Component.text("当前后端:")
.append(Component.text(invocation.player().currentServer().orElse("未连接"),
NamedTextColor.YELLOW)));
}, completion -> {
String args = completion.arguments();
String prefix = args.substring(args.lastIndexOf(' ') + 1);
return CompletableFuture.completedFuture(context.servers().all().stream()
.map(ServerView::name)
.filter(name -> name.startsWith(prefix))
.toList());
});
```
两参数 `register(name, handler)` 仍适合无需参数补全的命令。三参数版本增加 `CommandCompleter`,返回 `CompletionStage<List<String>>`,可接数据库或其他异步查询。`CommandCompletion` 提供玩家快照、无斜杠的根命令名和根命令后去掉一个分隔字符的参数原文;末尾空格保留。`/server ` 会以空参数调用补全器。
候选是客户端用于替换最后一个词的字符串;前缀过滤由插件处理。最多返回 100 项,去重,忽略空值、空字符串、含空白或超过 100 个 UTF-16 单元的候选,并限制总字节数。插件负责命令执行的权限检查;补全器应只返回允许当前玩家看到的候选。根命令名本身公开可补全,不提供隐式权限系统。
## 线程与协议归属
- 插件执行与补全回调在有界命令工作池运行,补全等待最多 1 秒,全宿主最多 128 个未决请求。异常、超时、卸载或过载得到空候选;关闭注册或玩家连接会尽力取消异步任务。
- `/prefix` 请求转给后端,返回时加入匹配的代理根命令并去重。已注册代理命令的参数只由代理插件补全,即使没有补全器也不泄漏到后端。未知命令和普通玩家名补全转给后端;正常返回包保持原始字节。
- 1.7.10 没有补全请求 ID。每连接同时处理一个请求,额外排队最多 8 个;超出队列限制关闭连接,避免无限积压。按请求顺序回答,协议本身无法识别客户端已经编辑了哪一个输入框状态。
- 后端 1 秒未回复时,根补全先返回代理候选,其他请求返回空候选。保留一个迟到回复标记;在收到并丢弃该回复前,不再向该后端发补全请求,以免把旧回复归给新请求。插件补全仍可用。转服切换后使用新的状态。
- 切换后端、断线和主动踢出关闭旧补全状态,释放暂存帧并取消插件等待。普通 PLAY 帧只检查包 ID,不解析、复制或派发补全回调。
## 验收
组件与协议测试覆盖颜色、点击、悬浮、翻译参数、转义、尺寸/深度限制和不支持的能力。补全测试覆盖根合并、参数末尾空格、后端透传、异步完成、超时、迟到回复隔离及关闭清理;真实 TCP 测试覆盖发行路径使用的会话和插件宿主连线。构建与测试结果记录在工作交付中,客户端视觉交互单独标明是否完成实测。
本机额外用 Java 8 加载 Uranium 实际组件解析器,读取当前发行包生成的富文本 JSON;正文、点击命令 /spawn 和悬浮正文均匹配。所用 Uranium JAR SHA-256 为 B16747D08BAD4B7C67DB8F1F41C9C31066FEAAAC22B527775F604BDFA885BAD2。该检查不启动客户端,不证明鼠标交互或渲染效果已实测。
+3 -3
View File
@@ -44,7 +44,7 @@ status:
后端控制通道示例见 [`strataproxy-channel.example.yml`](../proxy-core/src/main/resources/config/strataproxy-channel.example.yml),使用独立监听端口接收实例注册、心跳和双向消息;使用方法与限制见[后端控制通道](backend-channel-design.md)。
Docker 容器间的监听、服务名和地址配置见[Docker 部署](backend-channel-design.md#docker-部署);`sh smoke/container-channel.sh` 可运行跨容器烟测。
Docker 容器间的监听、服务名和地址配置见[配置与部署](backend-channel-design.md#配置与部署);`sh smoke/container-channel.sh` 可运行跨容器烟测。
控制通道默认关闭。启用后,后端实例通过独立 TCP 连接认证和注册;代理不提供 DNS 发现或 HTTP 注册。代理核心没有内置云实例发现或健康探测。
@@ -52,13 +52,13 @@ Docker 容器间的监听、服务名和地址配置见[Docker 部署](backend-c
## 验证命令和现有证据
源码测试集中在四个模块。项目定义的常规验证入口为:
项目定义的常规验证入口为:
```powershell
.\gradlew.bat check
```
该任务包含插件 API、Java 8 后端 SDK 和核心测试,并在核心 `check` 中依赖安装发行包后的帮助、版本及配置校验烟测。详细烟测、性能测量方法与历史记录见[验证方法](testing.md)。
该任务包含插件 API、消息 API/协议、Java 8 后端 SDK、Bukkit 宿主和核心测试,并检查宿主 JAR 的 Java 8 字节码和依赖边界。核心 `check` 还依赖安装发行包后的帮助、版本及配置校验烟测。详细烟测、性能测量方法与历史记录见[验证方法](testing.md)。
现有测试可用于定位行为范围:
+1 -1
View File
@@ -1,6 +1,6 @@
# 玩家操作与部署完善设计
状态:首批实现与本地自动化验收已完成。设计基线为 `ea78523`;真实在线认证和目标整合包验收仍待完成。
状态:本文保留首批玩家操作的设计基线。当前消息模型见[富文本与命令补全](messages-and-completion.md),真实在线认证与当前整合包结果见[验收记录](../benchmarks/results/2026-09-26-prism-pack.md)。
## 目标与边界
+6 -4
View File
@@ -21,16 +21,16 @@
### 后端控制消息
`PluginContext.backendChannels()` 让代理插件与已注册的后端实例双向通信。后端侧使用独立的 Java 8 `backend-channel-client` SDK,不依赖玩家在线。代理插件在 `onLoad` 或 `onEnable` 调用 `subscribe("命名空间:操作", handler)`;同一通道只允许一个处理器,停用时自动撤销。处理器收到的 `BackendMessage` 含已认证的实例身份和连接代次,返回 `CompletionStage<byte[]>` 作为请求响应。
`PluginContext.messaging().channel("命名空间:通道")` 提供与后端相同的消息 API,不依赖玩家在线。`subscribe` 注册通知订阅者,`onRequest` 注册唯一请求处理器;每个插件持有独立 scope,停用时自动撤销。处理器收到不可变 `Message`,其中包含消息 UUID、认证来源、目标和回复关联;请求处理器返回 `CompletionStage<byte[]>`,框架创建完整的回复消息。
`send(backendName, channel, payload)` 返回 `SENT`、`NOT_CONNECTED` 或 `BACKPRESSURED`;`SENT` 只表示消息写入控制连接。`request(...)` 在五秒内等待后端处理结果,断线、无处理器或超时会异常完成。负载在提交时复制,单条最多 64 KiB;回调在有界工作池运行,不应同步等待数据库或网络任务。配置与交付边界见[后端控制通道](backend-channel-design.md)。
`send(Endpoint.backend(name), payload)` 返回含消息 ID 的投递回执,`ACCEPTED` 表示目标消息服务接受处理任务;`request(...)` 返回完整回复消息,默认期限五秒;`publish` 返回各候选节点的投递结果。负载在提交时复制,单条最多 64 KiB;回调在有界工作池运行,不应同步等待数据库或网络任务。后端插件通过唯一的 Bukkit 宿主服务获取相同 API,不自行创建连接。旧 `backendChannels()` 已移除。完整配置、Java 8 宿主安装与故障语义见[后端 Channel 消息服务](backend-channel-design.md)。
### 消息与主动断开
`Players.sendMessage(identity, text)` 和 `disconnect(identity, reason)` 均绑定完整的 `PlayerIdentity`,可从任意线程调用;通过插件上下文获取的 stage 在 I/O 线程外完成。参数错误立即报告,插件停用后拒绝继续提交。
- 消息是纯文本,最多 1024 个 Unicode 码点。返回 `MessageResult.SENT` 表示网络写入完成;连接消失或正在关闭返回 `NOT_CONNECTED`;登录、初次 Forge 握手未就绪或转服交接期间返回 `NOT_READY`;不可写或该连接已有 64 条未完成消息返回 `BACKPRESSURED`。网络写失败以异常完成。
- 主动断开要求非空白原因,最多 1024 个 Unicode 码点。核心按实际 LOGIN/PLAY 阶段发包,最长等待 5 秒后清理连接、未决转服和候选后端。返回 `DisconnectResult.DISCONNECTED` 表示连接清理完成,原因文本仅尽力交付;身份已失效返回 `NOT_CONNECTED`。重复断开合并处理,取消调用方等待不撤销已提交的断开。
- 消息支持 Adventure Component;String 入口为纯文本,最多 1024 个 Unicode 码点。返回 `MessageResult.SENT` 表示网络写入完成;连接消失或正在关闭返回 `NOT_CONNECTED`;登录、初次 Forge 握手未就绪或转服交接期间返回 `NOT_READY`;不可写或该连接已有 64 条未完成消息返回 `BACKPRESSURED`。网络写失败以异常完成。
- 主动断开接受非空白的 Component 原因;String 入口最多 1024 个 Unicode 码点。核心按实际 LOGIN/PLAY 阶段发包,最长等待 5 秒后清理连接、未决转服和候选后端。返回 `DisconnectResult.DISCONNECTED` 表示连接清理完成,原因文本仅尽力交付;身份已失效返回 `NOT_CONNECTED`。重复断开合并处理,取消调用方等待不撤销已提交的断开。
- `disconnect` 也支持准入或初始选服回调中的已识别玩家,此时玩家还未出现在 `online()` 中。
```java
@@ -134,3 +134,5 @@ context.events().subscribe(PlayerDisconnectedEvent.class, event ->
统一事件模型让准入决策和会话通知共享同一类型、监听器、订阅表与派发实现,同时保留各类事件所需的运行策略。准入阶段执行有序且失败关闭的结果聚合;生命周期通知异步 FIFO 尽力交付并与会话处理隔离。没有 PLAY 包事件。目前没有事件 API 的专门基准,不能据此宣称性能收益或无开销。
源码入口:[`PluginContext`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/PluginContext.java)、[`Events`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/event/Events.java)、[`AccessDecision`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/AccessDecision.java)。
富文本构造、MiniMessage 和异步命令补全示例见[消息与命令补全](messages-and-completion.md)。
+22
View File
@@ -102,3 +102,25 @@ PR 的 `.gitea/workflows/verify.yml` 独立运行 `gradlew check`,不使用 Ma
将 `-Window` 改为 `16` 可测每连接最多 16 个在途往返的情形。
原 stop-and-wait 小样本见 `benchmarks/results/proxy-session-benchmark-smoke-2026-09-25.md`;Window 参数的小样本和 Window=1/16 重复测量见 `benchmarks/results/2026-09-25-proxy-session-window.md`。这些是环回网络上的合成帧对照,不能代表 Forge 整合包、真实后端或跨主机部署的性能。
## 富文本与命令补全验证(2026-09-27)
实现设计见[消息与命令补全](messages-and-completion.md)。本地 `gradlew check` 通过:217 项测试(API 7、核心 197、DNS 6、Agent 7),失败、错误、跳过均为 0;发行包帮助、版本、配置校验通过。
新增验证包含组件构造与旧版 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)。
+5
View File
@@ -4,6 +4,7 @@ netty = "4.2.2.Final"
jackson = "2.20.0"
slf4j = "2.0.18"
logback = "1.6.3"
adventure = "5.2.0"
[libraries]
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
@@ -16,3 +17,7 @@ jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", ver
jackson-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure" }
adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" }
adventure-gson = { module = "net.kyori:adventure-text-serializer-gson", version.ref = "adventure" }
adventure-plain = { module = "net.kyori:adventure-text-serializer-plain", version.ref = "adventure" }
+52
View File
@@ -0,0 +1,52 @@
plugins {
`java-library`
`maven-publish`
}
group = "uk.potatolab"
version = "0.1.0-SNAPSHOT"
java {
withSourcesJar()
withJavadocJar()
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(8)
options.encoding = "UTF-8"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
testImplementation(platform("org.junit:junit-bom:5.14.1"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
publishing {
publications {
create<MavenPublication>("messagingApi") {
from(components["java"])
artifactId = "messaging-api"
pom {
name.set("StrataProxy Messaging API")
description.set("Java 8-compatible shared message envelope and channel API.")
}
}
}
repositories {
maven {
name = "giteaPackages"
url = uri(providers.environmentVariable("MAVEN_URL")
.orElse("https://git.nest.potatolab.uk:8443/api/packages/layue13/maven").get())
credentials {
username = providers.environmentVariable("MAVEN_USER").orElse("NONE").get()
password = providers.environmentVariable("MAVEN_PASSWORD").orElse("NONE").get()
}
}
}
}
@@ -0,0 +1,55 @@
package dev.strataproxy.messaging;
import java.util.Objects;
/** A logical messaging node. Backend endpoints are addressed by proxy backend name. */
public final class Endpoint {
private static final Endpoint PROXY = new Endpoint(true, null);
private final boolean proxy;
private final String backendName;
private Endpoint(boolean proxy, String backendName) {
this.proxy = proxy;
this.backendName = backendName;
}
public static Endpoint proxy() {
return PROXY;
}
public static Endpoint backend(String backendName) {
Objects.requireNonNull(backendName, "backendName");
if (backendName.trim().isEmpty()) {
throw new IllegalArgumentException("backendName must not be blank");
}
return new Endpoint(false, backendName);
}
public boolean isProxy() {
return proxy;
}
/** Returns {@code null} for the proxy endpoint. */
public String backendName() {
return backendName;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof Endpoint)) return false;
Endpoint endpoint = (Endpoint) other;
return proxy == endpoint.proxy && Objects.equals(backendName, endpoint.backendName);
}
@Override
public int hashCode() {
return Objects.hash(proxy, backendName);
}
@Override
public String toString() {
return proxy ? "proxy" : "backend(" + backendName + ")";
}
}
@@ -0,0 +1,90 @@
package dev.strataproxy.messaging;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.UUID;
/**
* Immutable message envelope shared by the proxy and Java 8 backend clients. The UUID identifies a message
* for tracing and request/reply correlation; it does not imply durable delivery, deduplication, or exactly-once
* processing. Sending APIs assign the source from the authenticated host identity.
*/
public final class Message {
public static final int MAX_PAYLOAD_BYTES = 64 * 1024;
public static final int MAX_CHANNEL_BYTES = 128;
private final UUID id;
private final MessageKind kind;
private final String channel;
private final Endpoint source;
private final Endpoint target;
private final UUID replyTo;
private final byte[] payload;
/** Creates a decoded message. Sending APIs replace the source with their authenticated host identity. */
public Message(UUID id, MessageKind kind, String channel, Endpoint source, Endpoint target,
UUID replyTo, byte[] payload) {
this.id = Objects.requireNonNull(id, "id");
this.kind = Objects.requireNonNull(kind, "kind");
this.channel = validateChannel(channel);
this.source = Objects.requireNonNull(source, "source");
this.target = target;
this.replyTo = replyTo;
if (kind == MessageKind.REPLY && replyTo == null) {
throw new IllegalArgumentException("replyTo is required for a reply");
}
if (kind != MessageKind.REPLY && replyTo != null) {
throw new IllegalArgumentException("replyTo is only valid for a reply");
}
if (kind != MessageKind.EVENT && target == null) {
throw new IllegalArgumentException("target is required for requests and replies");
}
this.payload = copyPayload(payload);
}
public static Message event(String channel, Endpoint source, Endpoint target, byte[] payload) {
return new Message(UUID.randomUUID(), MessageKind.EVENT, channel, source, target, null, payload);
}
public static Message request(String channel, Endpoint source, Endpoint target, byte[] payload) {
return new Message(UUID.randomUUID(), MessageKind.REQUEST, channel, source,
Objects.requireNonNull(target, "target"), null, payload);
}
public static Message reply(Message request, Endpoint source, byte[] payload) {
Objects.requireNonNull(request, "request");
if (request.kind != MessageKind.REQUEST) {
throw new IllegalArgumentException("request must have REQUEST kind");
}
return new Message(UUID.randomUUID(), MessageKind.REPLY, request.channel, source,
request.source, request.id, payload);
}
public UUID id() { return id; }
public MessageKind kind() { return kind; }
public String channel() { return channel; }
public Endpoint source() { return source; }
public Endpoint target() { return target; }
public UUID replyTo() { return replyTo; }
/** Returns a defensive copy of the payload. */
public byte[] payload() { return payload.clone(); }
public static String validateChannel(String channel) {
Objects.requireNonNull(channel, "channel");
byte[] bytes = channel.getBytes(StandardCharsets.US_ASCII);
if (bytes.length == 0 || bytes.length > MAX_CHANNEL_BYTES || !channel.matches(
"[a-z0-9][a-z0-9_.-]*:[a-z0-9][a-z0-9_.-]*")) {
throw new IllegalArgumentException("channel must be namespace:name using lowercase ASCII letters, digits, '.', '_' or '-'");
}
return channel;
}
private static byte[] copyPayload(byte[] payload) {
Objects.requireNonNull(payload, "payload");
if (payload.length > MAX_PAYLOAD_BYTES) {
throw new IllegalArgumentException("payload exceeds 64 KiB");
}
return payload.clone();
}
}
@@ -0,0 +1,54 @@
package dev.strataproxy.messaging;
import java.time.Duration;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
/**
* Operations and subscriptions for one named application channel. Channels are routing and access-control
* names; application message types belong in the payload schema.
*/
public interface MessageChannel {
String name();
/**
* Adds a local listener for events. Every currently registered listener receives its own event callback;
* event subscriptions do not handle requests or send replies.
*/
Subscription subscribe(Consumer<Message> listener);
/**
* Registers the single request handler for this channel on this node. A second active handler for the
* same local channel is rejected; this keeps request delivery and reply ownership unambiguous.
*/
Subscription onRequest(MessageHandler handler);
/**
* Sends a one-way event to one endpoint. {@link SendResult#ACCEPTED} means the target accepted the event
* into its bounded processing queue; it does not mean a listener ran or completed successfully. Routing
* outcomes such as no subscriber, backpressure, disconnection, rejection, and timeout are returned in the
* receipt. A timeout means no receipt arrived by the deadline and does not prove the event was not delivered.
* Closing the scope completes the operation exceptionally with {@link MessagingException.Code#CLOSED};
* cancelling the returned future cancels the operation on a best-effort basis.
*/
CompletionStage<SendReceipt> send(Endpoint target, byte[] payload);
/**
* Publishes one event to the current eligible online endpoints, including the publisher. The returned
* result has one status per candidate endpoint and may report {@link SendResult#NO_SUBSCRIBER}. Delivery
* is live-only and is not durably retained.
*/
CompletionStage<PublishResult> publish(byte[] payload);
/**
* Sends a request to one endpoint and returns its correlated reply. The default deadline is five seconds.
*/
CompletionStage<Message> request(Endpoint target, byte[] payload);
/**
* Sends a request to one endpoint and returns its correlated reply. The deadline starts at this call,
* includes local queueing and transport time, and must be positive and no more than 60 seconds. The
* returned REPLY has a new message ID and its {@code replyTo} points to this request's ID.
*/
CompletionStage<Message> request(Endpoint target, byte[] payload, Duration timeout);
}
@@ -0,0 +1,13 @@
package dev.strataproxy.messaging;
import java.util.concurrent.CompletionStage;
/**
* Handles one request and asynchronously supplies its reply payload. The handler is invoked on its owning
* scope's executor, never on the transport reader. Its returned stage supplies only the payload; the host
* creates the correlated REPLY envelope.
*/
@FunctionalInterface
public interface MessageHandler {
CompletionStage<byte[]> handle(Message request) throws Exception;
}
@@ -0,0 +1,8 @@
package dev.strataproxy.messaging;
/** Transport-level message semantics, independent of the application payload. */
public enum MessageKind {
EVENT,
REQUEST,
REPLY
}
@@ -0,0 +1,14 @@
package dev.strataproxy.messaging;
/**
* Messaging facade owned by one plugin scope. Closing the facade removes only this owner's subscriptions and
* completes its unfinished public operations with {@link MessagingException.Code#CLOSED}; queued callbacks
* check the scope before invoking plugin code.
*/
public interface Messaging extends AutoCloseable {
/** Gets a channel facade for an ASCII {@code namespace:name} channel. */
MessageChannel channel(String name);
@Override
void close();
}
@@ -0,0 +1,34 @@
package dev.strataproxy.messaging;
/** A failure with a stable messaging-layer category. */
public class MessagingException extends RuntimeException {
public enum Code {
NOT_CONNECTED,
NO_HANDLER,
BACKPRESSURED,
TIMED_OUT,
CLOSED,
REJECTED,
HANDLER_FAILED,
PROTOCOL_ERROR
}
private final Code code;
public MessagingException(Code code, String message) {
super(message);
this.code = requireCode(code);
}
public MessagingException(Code code, String message, Throwable cause) {
super(message, cause);
this.code = requireCode(code);
}
public Code code() { return code; }
private static Code requireCode(Code code) {
if (code == null) throw new NullPointerException("code");
return code;
}
}
@@ -0,0 +1,27 @@
package dev.strataproxy.messaging;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
/** Per-endpoint queue acceptance results for a single published event. */
public final class PublishResult {
private final UUID messageId;
private final Map<Endpoint, SendResult> results;
public PublishResult(UUID messageId, Map<Endpoint, SendResult> results) {
this.messageId = Objects.requireNonNull(messageId, "messageId");
Objects.requireNonNull(results, "results");
Map<Endpoint, SendResult> copy = new LinkedHashMap<Endpoint, SendResult>();
for (Map.Entry<Endpoint, SendResult> entry : results.entrySet()) {
copy.put(Objects.requireNonNull(entry.getKey(), "endpoint"),
Objects.requireNonNull(entry.getValue(), "result"));
}
this.results = Collections.unmodifiableMap(copy);
}
public UUID messageId() { return messageId; }
public Map<Endpoint, SendResult> results() { return results; }
}
@@ -0,0 +1,18 @@
package dev.strataproxy.messaging;
import java.util.Objects;
import java.util.UUID;
/** Message identity and target-side queue acceptance for a one-way send. */
public final class SendReceipt {
private final UUID messageId;
private final SendResult result;
public SendReceipt(UUID messageId, SendResult result) {
this.messageId = Objects.requireNonNull(messageId, "messageId");
this.result = Objects.requireNonNull(result, "result");
}
public UUID messageId() { return messageId; }
public SendResult result() { return result; }
}
@@ -0,0 +1,22 @@
package dev.strataproxy.messaging;
/**
* Delivery status reported for one event destination. These are transport/queue observations, not proof
* that application code did or did not execute: a timeout or connection loss can occur after delivery.
*/
public enum SendResult {
/** The destination accepted the event into its bounded processing queue. */
ACCEPTED,
/** The destination is reachable but has no local event subscriber for this channel. */
NO_SUBSCRIBER,
/** The destination was not connected when routing was attempted. */
NOT_CONNECTED,
/** The destination could not accept the event because a bounded queue was full. */
BACKPRESSURED,
/** The destination or proxy rejected the event, for example due to policy or protocol validation. */
REJECTED,
/** No acceptance receipt arrived before the deadline; the event may still have been delivered. */
TIMED_OUT,
/** Delivery failed for another reason; application execution may be unknown. */
FAILED
}
@@ -0,0 +1,7 @@
package dev.strataproxy.messaging;
/** Handle for one local listener or request handler. Closing it is idempotent and unregisters only this handle. */
public interface Subscription extends AutoCloseable {
@Override
void close();
}
@@ -0,0 +1,750 @@
package dev.strataproxy.messaging.internal;
import dev.strataproxy.messaging.Endpoint;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.MessageChannel;
import dev.strataproxy.messaging.MessageHandler;
import dev.strataproxy.messaging.MessageKind;
import dev.strataproxy.messaging.Messaging;
import dev.strataproxy.messaging.MessagingException;
import dev.strataproxy.messaging.PublishResult;
import dev.strataproxy.messaging.SendReceipt;
import dev.strataproxy.messaging.SendResult;
import dev.strataproxy.messaging.Subscription;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Host-side scope registry and bounded local message dispatcher. The host owns the supplied scheduler and
* executors and must keep the completion executor-independent messaging service alive until it is closed.
* Plugin handlers/listeners run on their scope executor; public operation futures complete on a separate
* completion executor so closing a plugin executor cannot strand them or run continuations on a transport reader.
*/
public final class LocalMessaging implements AutoCloseable {
public static final int MAX_IN_FLIGHT = 128;
public static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(5);
public static final Duration MAX_REQUEST_TIMEOUT = Duration.ofSeconds(60);
private static final Executor COMPLETION_EXECUTOR = ForkJoinPool.commonPool();
private final Endpoint self;
private final Outbound outbound;
private final Executor defaultExecutor;
private final Executor completionExecutor;
private final ScheduledExecutorService scheduler;
private final Object lock = new Object();
private final Map<String, Scope> scopes = new HashMap<String, Scope>();
private final Map<String, ChannelEntry> channels = new HashMap<String, ChannelEntry>();
private final Set<Operation<?>> operations = new HashSet<Operation<?>>();
private boolean closed;
private int outboundInFlight;
private int inboundInFlight;
public LocalMessaging(Endpoint self, Outbound outbound, Executor defaultExecutor,
ScheduledExecutorService scheduler) {
this(self, outbound, defaultExecutor, scheduler, COMPLETION_EXECUTOR);
}
/** Package-private constructor for deterministic completion-dispatch tests. */
LocalMessaging(Endpoint self, Outbound outbound, Executor defaultExecutor,
ScheduledExecutorService scheduler, Executor completionExecutor) {
this.self = Objects.requireNonNull(self, "self");
this.outbound = Objects.requireNonNull(outbound, "outbound");
this.defaultExecutor = Objects.requireNonNull(defaultExecutor, "defaultExecutor");
this.scheduler = Objects.requireNonNull(scheduler, "scheduler");
this.completionExecutor = Objects.requireNonNull(completionExecutor, "completionExecutor");
}
/** Opens an exclusively named plugin scope using the host's default callback executor. */
public Messaging openScope(String owner) {
return openScope(owner, defaultExecutor);
}
/** Opens an exclusively named plugin scope using the supplied callback executor. */
public Messaging openScope(String owner, Executor executor) {
Objects.requireNonNull(owner, "owner");
if (owner.trim().isEmpty()) throw new IllegalArgumentException("owner must not be blank");
Objects.requireNonNull(executor, "executor");
synchronized (lock) {
ensureHostOpen();
if (scopes.containsKey(owner)) {
throw new MessagingException(MessagingException.Code.REJECTED,
"messaging scope already exists: " + owner);
}
Scope scope = new Scope(owner, executor);
scopes.put(owner, scope);
return scope;
}
}
/**
* Enqueues an inbound event for each active local listener. The result describes only local queue
* acceptance, not callback success. Queue slots are released if an owner closes while callbacks remain
* queued, even when its executor discards those callbacks.
*/
public SendResult receiveEvent(Message message) {
Objects.requireNonNull(message, "message");
if (message.kind() != MessageKind.EVENT) {
throw new IllegalArgumentException("message must have EVENT kind");
}
if (message.target() != null && !self.equals(message.target())) {
throw new IllegalArgumentException("event targets a different endpoint");
}
final List<Registration> listeners;
final List<EventDelivery> deliveries;
synchronized (lock) {
if (closed) return SendResult.REJECTED;
ChannelEntry entry = channels.get(message.channel());
listeners = entry == null ? Collections.<Registration>emptyList()
: activeListeners(entry);
if (listeners.isEmpty()) return SendResult.NO_SUBSCRIBER;
if (listeners.size() > MAX_IN_FLIGHT - inboundInFlight) return SendResult.BACKPRESSURED;
inboundInFlight += listeners.size();
deliveries = new ArrayList<EventDelivery>(listeners.size());
for (Registration listener : listeners) {
EventDelivery delivery = new EventDelivery(listener.scope);
listener.scope.deliveries.add(delivery);
deliveries.add(delivery);
}
}
boolean allEnqueued = true;
for (int index = 0; index < listeners.size(); index++) {
Registration listener = listeners.get(index);
EventDelivery delivery = deliveries.get(index);
try {
listener.scope.executor.execute(new Runnable() {
@Override public void run() {
if (delivery.isDone()) return;
try {
if (isActive(listener)) {
@SuppressWarnings("unchecked") Consumer<Message> consumer =
(Consumer<Message>) listener.callback;
consumer.accept(message);
}
} catch (Throwable ignored) {
// One listener failing must not prevent other listeners from seeing the event.
} finally {
delivery.finish();
}
}
});
} catch (RejectedExecutionException rejected) {
delivery.finish();
allEnqueued = false;
}
}
return allEnqueued ? SendResult.ACCEPTED : SendResult.BACKPRESSURED;
}
/**
* Dispatches an inbound request and completes with its correlated reply. The deadline includes waiting
* for the owning callback executor and asynchronous handler completion.
*/
public CompletionStage<Message> receiveRequest(Message request, Duration timeout) {
Objects.requireNonNull(request, "request");
if (request.kind() != MessageKind.REQUEST) {
throw new IllegalArgumentException("message must have REQUEST kind");
}
if (!self.equals(request.target())) {
throw new IllegalArgumentException("request targets a different endpoint");
}
final long timeoutMillis = timeoutMillis(timeout);
final Registration handler;
final Operation<Message> operation;
synchronized (lock) {
if (closed) return failed(new MessagingException(MessagingException.Code.CLOSED,
"messaging host is closed"));
ChannelEntry entry = channels.get(request.channel());
handler = entry == null ? null : activeHandler(entry);
if (handler == null) return failed(new MessagingException(MessagingException.Code.NO_HANDLER,
"no request handler for channel " + request.channel()));
if (inboundInFlight >= MAX_IN_FLIGHT) return failed(new MessagingException(
MessagingException.Code.BACKPRESSURED, "inbound request limit reached"));
inboundInFlight++;
operation = new Operation<Message>(handler.scope, new Runnable() {
@Override public void run() { inboundInFlight--; }
});
operations.add(operation);
handler.scope.operations.add(operation);
}
if (!operation.armTimeout(timeoutMillis)) return operation.future;
try {
handler.scope.executor.execute(new Runnable() {
@Override public void run() {
if (operation.isDone() || !isActive(handler)) {
operation.fail(new MessagingException(MessagingException.Code.CLOSED,
"request handler scope is closed"));
return;
}
CompletionStage<byte[]> response;
try {
response = ((MessageHandler) handler.callback).handle(request);
if (response == null) throw new NullPointerException("handler returned null stage");
} catch (Throwable failure) {
operation.fail(handlerFailure(failure));
return;
}
operation.setCancellation(response);
response.whenComplete((payload, failure) -> {
if (failure != null) {
operation.fail(handlerFailure(unwrap(failure)));
} else if (payload == null) {
operation.fail(new MessagingException(MessagingException.Code.HANDLER_FAILED,
"request handler returned null payload"));
} else {
try {
operation.succeed(Message.reply(request, self, payload));
} catch (Throwable invalid) {
operation.fail(handlerFailure(invalid));
}
}
});
}
});
} catch (RejectedExecutionException rejected) {
operation.fail(new MessagingException(MessagingException.Code.BACKPRESSURED,
"request handler executor rejected work", rejected));
}
return operation.future;
}
/** Returns a snapshot of channels and their current local subscription flags. */
public Map<String, Integer> subscriptions() {
synchronized (lock) {
return subscriptionSnapshotLocked();
}
}
@Override
public void close() {
List<Scope> toClose;
synchronized (lock) {
if (closed) return;
closed = true;
toClose = new ArrayList<Scope>(scopes.values());
}
for (Scope scope : toClose) scope.close();
}
private void closeScope(Scope scope) {
List<Registration> registrations;
List<Operation<?>> pending;
List<EventDelivery> deliveries;
synchronized (lock) {
if (!scope.active) return;
scope.active = false;
scopes.remove(scope.owner);
registrations = new ArrayList<Registration>(scope.registrations);
pending = new ArrayList<Operation<?>>(scope.operations);
deliveries = new ArrayList<EventDelivery>(scope.deliveries);
scope.deliveries.clear();
scope.registrations.clear();
for (Registration registration : registrations) {
registration.active = false;
removeRegistrationLocked(registration);
}
}
for (Operation<?> operation : pending) {
operation.fail(new MessagingException(MessagingException.Code.CLOSED,
"messaging scope is closed: " + scope.owner));
}
for (EventDelivery delivery : deliveries) delivery.finish();
}
private Subscription register(Scope scope, String channel, Object callback, boolean request) {
Message.validateChannel(channel);
Objects.requireNonNull(callback, "callback");
Registration registration = new Registration(scope, channel, callback, request);
synchronized (lock) {
ensureScopeOpen(scope);
ChannelEntry entry = channels.get(channel);
if (entry == null) {
entry = new ChannelEntry();
channels.put(channel, entry);
}
if (request && entry.requestHandler != null && entry.requestHandler.active) {
throw new MessagingException(MessagingException.Code.REJECTED,
"request handler already registered for channel " + channel);
}
if (request) entry.requestHandler = registration;
else entry.listeners.add(registration);
scope.registrations.add(registration);
}
return registration;
}
private void unregister(Registration registration) {
synchronized (lock) {
if (!registration.active) return;
registration.active = false;
registration.scope.registrations.remove(registration);
removeRegistrationLocked(registration);
}
}
private void removeRegistrationLocked(Registration registration) {
ChannelEntry entry = channels.get(registration.channel);
if (entry == null) return;
if (entry.requestHandler == registration) entry.requestHandler = null;
entry.listeners.remove(registration);
if (entry.requestHandler == null && entry.listeners.isEmpty()) channels.remove(registration.channel);
}
private Map<String, Integer> subscriptionSnapshotLocked() {
Map<String, Integer> snapshot = new LinkedHashMap<String, Integer>();
for (Map.Entry<String, ChannelEntry> entry : channels.entrySet()) {
int flags = 0;
for (Registration listener : entry.getValue().listeners) {
if (listener.active && listener.scope.active) { flags |= 1; break; }
}
Registration handler = entry.getValue().requestHandler;
if (handler != null && handler.active && handler.scope.active) flags |= 2;
if (flags != 0) snapshot.put(entry.getKey(), flags);
}
return Collections.unmodifiableMap(snapshot);
}
private List<Registration> activeListeners(ChannelEntry entry) {
List<Registration> result = new ArrayList<Registration>();
for (Registration listener : entry.listeners) {
if (listener.active && listener.scope.active) result.add(listener);
}
return result;
}
private Registration activeHandler(ChannelEntry entry) {
Registration registration = entry.requestHandler;
return registration != null && registration.active && registration.scope.active ? registration : null;
}
private boolean isActive(Registration registration) {
synchronized (lock) {
return !closed && registration.active && registration.scope.active;
}
}
private boolean reserveOutbound(Scope scope, Operation<?> operation) {
synchronized (lock) {
if (closed || !scope.active) {
operation.failLocked(new MessagingException(MessagingException.Code.CLOSED,
"messaging scope is closed: " + scope.owner));
return false;
}
if (outboundInFlight >= MAX_IN_FLIGHT) {
operation.failLocked(new MessagingException(MessagingException.Code.BACKPRESSURED,
"outbound operation limit reached"));
return false;
}
outboundInFlight++;
operations.add(operation);
scope.operations.add(operation);
return true;
}
}
private void operationFinished(Operation<?> operation) {
synchronized (lock) {
operations.remove(operation);
operation.scope.operations.remove(operation);
operation.release.run();
}
}
private void completePublic(Runnable task) {
try {
completionExecutor.execute(task);
} catch (RejectedExecutionException rejected) {
Thread fallback = new Thread(task, "strataproxy-message-completion");
fallback.setDaemon(true);
fallback.start();
}
}
private void completeOperation(Operation<?> operation, Runnable completion) {
completePublic(new Runnable() {
@Override public void run() {
// Keep the admission slot while this public completion is queued. Release before invoking
// CompletableFuture continuations, which may run arbitrary user code inline.
operationFinished(operation);
completion.run();
}
});
}
private static long timeoutMillis(Duration timeout) {
Objects.requireNonNull(timeout, "timeout");
final long millis;
try {
millis = timeout.toMillis();
} catch (ArithmeticException overflow) {
throw new IllegalArgumentException("timeout is too large", overflow);
}
if (millis <= 0 || millis > MAX_REQUEST_TIMEOUT.toMillis()) {
throw new IllegalArgumentException("timeout must be positive and at most 60 seconds");
}
return millis;
}
private static MessagingException handlerFailure(Throwable failure) {
return new MessagingException(MessagingException.Code.HANDLER_FAILED,
"message handler failed", failure);
}
private static SendReceipt sendReceiptForFailure(UUID messageId, Throwable failure) {
Throwable cause = unwrap(failure);
if (cause instanceof MessagingException) {
MessagingException messagingFailure = (MessagingException) cause;
switch (messagingFailure.code()) {
case TIMED_OUT: return new SendReceipt(messageId, SendResult.TIMED_OUT);
case BACKPRESSURED: return new SendReceipt(messageId, SendResult.BACKPRESSURED);
case NOT_CONNECTED: return new SendReceipt(messageId, SendResult.NOT_CONNECTED);
case NO_HANDLER: return new SendReceipt(messageId, SendResult.NO_SUBSCRIBER);
case REJECTED: return new SendReceipt(messageId, SendResult.REJECTED);
case HANDLER_FAILED:
case PROTOCOL_ERROR: return new SendReceipt(messageId, SendResult.FAILED);
case CLOSED: throw messagingFailure;
default: return new SendReceipt(messageId, SendResult.FAILED);
}
}
return new SendReceipt(messageId, SendResult.FAILED);
}
private static Throwable unwrap(Throwable failure) {
if (failure instanceof java.util.concurrent.CompletionException && failure.getCause() != null) {
return failure.getCause();
}
return failure;
}
private static <T> CompletionStage<T> failed(Throwable failure) {
CompletableFuture<T> future = new CompletableFuture<T>();
future.completeExceptionally(failure);
return future;
}
private void ensureHostOpen() {
if (closed) throw new MessagingException(MessagingException.Code.CLOSED, "messaging host is closed");
}
private void ensureScopeOpen(Scope scope) {
ensureHostOpen();
if (!scope.active) throw new MessagingException(MessagingException.Code.CLOSED,
"messaging scope is closed: " + scope.owner);
}
/** Host transport boundary. The source identity is already fixed in each message. */
public interface Outbound {
CompletionStage<SendResult> send(Message message);
CompletionStage<Message> request(Message request, Duration timeout);
CompletionStage<PublishResult> publish(Message event);
}
private final class Scope implements Messaging {
private final String owner;
private final Executor executor;
private final Set<Registration> registrations = new HashSet<Registration>();
private final Set<Operation<?>> operations = new HashSet<Operation<?>>();
private final Set<EventDelivery> deliveries = new HashSet<EventDelivery>();
private volatile boolean active = true;
private Scope(String owner, Executor executor) {
this.owner = owner;
this.executor = executor;
}
@Override public MessageChannel channel(String name) {
Message.validateChannel(name);
synchronized (lock) { ensureScopeOpen(this); }
return new Channel(this, name);
}
@Override public void close() { closeScope(this); }
}
private final class Channel implements MessageChannel {
private final Scope scope;
private final String name;
private Channel(Scope scope, String name) {
this.scope = scope;
this.name = name;
}
@Override public String name() { return name; }
@Override public Subscription subscribe(Consumer<Message> listener) {
return register(scope, name, Objects.requireNonNull(listener, "listener"), false);
}
@Override public Subscription onRequest(MessageHandler handler) {
return register(scope, name, Objects.requireNonNull(handler, "handler"), true);
}
@Override public CompletionStage<SendReceipt> send(Endpoint target, byte[] payload) {
Objects.requireNonNull(target, "target");
final Message message = Message.event(name, self, target, payload);
final Operation<SendReceipt> operation = new Operation<SendReceipt>(scope,
new Runnable() { @Override public void run() { outboundInFlight--; } },
failure -> sendReceiptForFailure(message.id(), failure));
if (!reserveOutbound(scope, operation)) return operation.future;
if (!operation.armTimeout(DEFAULT_REQUEST_TIMEOUT.toMillis())) return operation.future;
if (operation.isDone()) return operation.future;
CompletionStage<SendResult> stage;
try {
stage = Objects.requireNonNull(outbound.send(message), "outbound send stage");
} catch (Throwable failure) {
operation.fail(failure);
return operation.future;
}
operation.setCancellation(stage);
stage.whenComplete((result, failure) -> {
if (failure != null) operation.fail(unwrap(failure));
else if (result == null) operation.fail(new MessagingException(
MessagingException.Code.PROTOCOL_ERROR, "outbound returned null send result"));
else operation.succeed(new SendReceipt(message.id(), result));
});
return operation.future;
}
@Override public CompletionStage<PublishResult> publish(byte[] payload) {
final Message event = Message.event(name, self, null, payload);
final Operation<PublishResult> operation = new Operation<PublishResult>(scope,
new Runnable() { @Override public void run() { outboundInFlight--; } });
if (!reserveOutbound(scope, operation)) return operation.future;
if (!operation.armTimeout(DEFAULT_REQUEST_TIMEOUT.toMillis())) return operation.future;
if (operation.isDone()) return operation.future;
CompletionStage<PublishResult> stage;
try {
stage = Objects.requireNonNull(outbound.publish(event), "outbound publish stage");
} catch (Throwable failure) {
operation.fail(failure);
return operation.future;
}
operation.setCancellation(stage);
stage.whenComplete((result, failure) -> {
if (failure != null) operation.fail(unwrap(failure));
else if (result == null || !event.id().equals(result.messageId())) operation.fail(
new MessagingException(MessagingException.Code.PROTOCOL_ERROR,
"outbound returned invalid publish result"));
else operation.succeed(result);
});
return operation.future;
}
@Override public CompletionStage<Message> request(Endpoint target, byte[] payload) {
return request(target, payload, DEFAULT_REQUEST_TIMEOUT);
}
@Override public CompletionStage<Message> request(Endpoint target, byte[] payload, Duration timeout) {
Objects.requireNonNull(target, "target");
final long timeoutMillis = timeoutMillis(timeout);
final Message request = Message.request(name, self, target, payload);
final Operation<Message> operation = new Operation<Message>(scope,
new Runnable() { @Override public void run() { outboundInFlight--; } });
if (!reserveOutbound(scope, operation)) return operation.future;
if (!operation.armTimeout(timeoutMillis)) return operation.future;
if (operation.isDone()) return operation.future;
CompletionStage<Message> stage;
try {
stage = Objects.requireNonNull(outbound.request(request, Duration.ofMillis(timeoutMillis)),
"outbound request stage");
} catch (Throwable failure) {
operation.fail(failure);
return operation.future;
}
operation.setCancellation(stage);
stage.whenComplete((reply, failure) -> {
if (failure != null) {
operation.fail(unwrap(failure));
} else if (!isValidReply(request, reply)) {
operation.fail(new MessagingException(MessagingException.Code.PROTOCOL_ERROR,
"outbound returned an uncorrelated reply"));
} else {
operation.succeed(reply);
}
});
return operation.future;
}
}
private boolean isValidReply(Message request, Message reply) {
return reply != null && reply.kind() == MessageKind.REPLY
&& request.id().equals(reply.replyTo())
&& self.equals(reply.target())
&& request.target().equals(reply.source())
&& request.channel().equals(reply.channel());
}
private final class Registration implements Subscription {
private final Scope scope;
private final String channel;
private final Object callback;
private final boolean request;
private volatile boolean active = true;
private Registration(Scope scope, String channel, Object callback, boolean request) {
this.scope = scope;
this.channel = channel;
this.callback = callback;
this.request = request;
}
@Override public void close() { unregister(this); }
}
private final class ChannelEntry {
private final List<Registration> listeners = new ArrayList<Registration>();
private Registration requestHandler;
}
private final class Operation<T> {
private final Scope scope;
private final Runnable release;
private final Function<Throwable, T> failureMapper;
private final OperationFuture<T> future = new OperationFuture<T>(this);
private final AtomicBoolean done = new AtomicBoolean();
private volatile ScheduledFuture<?> timer;
private volatile CompletionStage<?> underlying;
private Operation(Scope scope, Runnable release) {
this(scope, release, null);
}
private Operation(Scope scope, Runnable release, Function<Throwable, T> failureMapper) {
this.scope = scope;
this.release = release;
this.failureMapper = failureMapper;
}
private boolean isDone() { return done.get(); }
private boolean armTimeout(long millis) {
if (done.get()) return false;
try {
ScheduledFuture<?> scheduled = scheduler.schedule(new Runnable() {
@Override public void run() {
fail(new MessagingException(MessagingException.Code.TIMED_OUT,
"message request timed out"));
}
}, millis, TimeUnit.MILLISECONDS);
timer = scheduled;
if (done.get()) {
scheduled.cancel(false);
return false;
}
return true;
} catch (RejectedExecutionException rejected) {
fail(new MessagingException(MessagingException.Code.CLOSED,
"request timer is unavailable", rejected));
return false;
}
}
private void succeed(T value) { finish(value, null); }
private void fail(Throwable failure) { finish(null, failure); }
private void failLocked(Throwable failure) {
if (!done.compareAndSet(false, true)) return;
// This operation was rejected before admission and its future has not escaped the API call.
completeFailureNow(failure);
}
private void setCancellation(CompletionStage<?> stage) {
underlying = stage;
if (done.get()) cancelUnderlying();
}
private void cancel() {
if (!done.compareAndSet(false, true)) return;
ScheduledFuture<?> scheduled = timer;
if (scheduled != null) scheduled.cancel(false);
cancelUnderlying();
operationFinished(this);
}
private void finish(T value, Throwable failure) {
if (!done.compareAndSet(false, true)) return;
ScheduledFuture<?> scheduled = timer;
if (scheduled != null) scheduled.cancel(false);
if (failure instanceof MessagingException) {
MessagingException.Code code = ((MessagingException) failure).code();
if (code == MessagingException.Code.TIMED_OUT || code == MessagingException.Code.CLOSED) {
cancelUnderlying();
}
}
if (failure == null) {
completeOperation(this, new Runnable() { @Override public void run() { future.complete(value); } });
} else {
completeOperation(this, new Runnable() {
@Override public void run() { completeFailureNow(failure); }
});
}
}
private void completeFailureNow(Throwable failure) {
if (failureMapper == null) {
future.completeExceptionally(failure);
return;
}
try { future.complete(failureMapper.apply(failure)); }
catch (Throwable mappingFailure) { future.completeExceptionally(mappingFailure); }
}
private void cancelUnderlying() {
CompletionStage<?> stage = underlying;
if (stage != null) {
try { stage.toCompletableFuture().cancel(true); } catch (Throwable ignored) { }
}
}
}
private final class EventDelivery {
private final Scope scope;
private final AtomicBoolean done = new AtomicBoolean();
private EventDelivery(Scope scope) { this.scope = scope; }
private boolean isDone() { return done.get(); }
private void finish() {
if (!done.compareAndSet(false, true)) return;
synchronized (lock) {
scope.deliveries.remove(this);
inboundInFlight--;
}
}
}
private final class OperationFuture<T> extends CompletableFuture<T> {
private final Operation<T> operation;
private OperationFuture(Operation<T> operation) { this.operation = operation; }
@Override public boolean cancel(boolean mayInterruptIfRunning) {
boolean cancelled = super.cancel(mayInterruptIfRunning);
if (cancelled) operation.cancel();
return cancelled;
}
}
}
@@ -0,0 +1,412 @@
package dev.strataproxy.messaging.internal;
import dev.strataproxy.messaging.Endpoint;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.MessageKind;
import dev.strataproxy.messaging.Messaging;
import dev.strataproxy.messaging.MessagingException;
import dev.strataproxy.messaging.PublishResult;
import dev.strataproxy.messaging.SendResult;
import dev.strataproxy.messaging.SendReceipt;
import dev.strataproxy.messaging.Subscription;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LocalMessagingTest {
private final ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(1);
private final TestOutbound outbound = new TestOutbound();
private final Endpoint self = Endpoint.backend("island-a");
private final LocalMessaging host = new LocalMessaging(self, outbound, DIRECT, scheduler);
private static final Executor DIRECT = new Executor() {
@Override public void execute(Runnable command) { command.run(); }
};
@AfterEach
void tearDown() {
host.close();
scheduler.shutdownNow();
}
@Test
void messageDefensivelyCopiesPayloadAndReplyLinksAutomatically() {
byte[] original = new byte[] {1, 2};
Message request = Message.request("islands:prepare", Endpoint.proxy(), self, original);
original[0] = 9;
assertEquals(1, request.payload()[0]);
byte[] read = request.payload();
read[1] = 8;
assertEquals(2, request.payload()[1]);
Message reply = Message.reply(request, self, new byte[] {3});
assertEquals(MessageKind.REPLY, reply.kind());
assertEquals(request.id(), reply.replyTo());
assertEquals(request.source(), reply.target());
assertNotEquals(request.id(), reply.id());
}
@Test
void eventListenersFanOutAndScopeCloseRemovesOnlyThatOwner() {
Messaging first = host.openScope("plugin-one");
Messaging second = host.openScope("plugin-two");
AtomicInteger one = new AtomicInteger();
AtomicInteger two = new AtomicInteger();
Subscription firstSub = first.channel("islands:event").subscribe(message -> one.incrementAndGet());
Subscription secondSub = second.channel("islands:event").subscribe(message -> two.incrementAndGet());
assertEquals(Integer.valueOf(1), host.subscriptions().get("islands:event"));
assertEquals(SendResult.ACCEPTED, host.receiveEvent(Message.event("islands:event", Endpoint.proxy(), self,
new byte[] {1})));
assertEquals(1, one.get());
assertEquals(1, two.get());
first.close();
assertEquals(Integer.valueOf(1), host.subscriptions().get("islands:event"));
assertEquals(SendResult.ACCEPTED, host.receiveEvent(Message.event("islands:event", Endpoint.proxy(), self,
new byte[] {2})));
assertEquals(1, one.get());
assertEquals(2, two.get());
secondSub.close();
assertEquals(SendResult.NO_SUBSCRIBER, host.receiveEvent(Message.event("islands:event", Endpoint.proxy(), self,
new byte[0])));
firstSub.close();
}
@Test
void requestHandlerIsExclusiveAndReplyIsGeneratedByTheHost() throws Exception {
Messaging first = host.openScope("request-owner");
Messaging second = host.openScope("duplicate-owner");
first.channel("islands:query").onRequest(request ->
CompletableFuture.completedFuture(new byte[] {request.payload()[0]}));
MessagingException conflict = assertThrows(MessagingException.class,
() -> second.channel("islands:query").onRequest(request ->
CompletableFuture.completedFuture(new byte[0])));
assertEquals(MessagingException.Code.REJECTED, conflict.code());
assertEquals(Integer.valueOf(2), host.subscriptions().get("islands:query"));
Message request = Message.request("islands:query", Endpoint.proxy(), self, new byte[] {7});
Message reply = host.receiveRequest(request, Duration.ofSeconds(1)).toCompletableFuture()
.get(1, TimeUnit.SECONDS);
assertEquals(request.id(), reply.replyTo());
assertArrayEquals(new byte[] {7}, reply.payload());
assertEquals(self, reply.source());
assertEquals(Endpoint.proxy(), reply.target());
}
@Test
void neverCompletingHandlerTimesOutAndReleasesItsInboundSlot() throws Exception {
Messaging scope = host.openScope("slow-handler");
scope.channel("islands:slow").onRequest(request -> new CompletableFuture<byte[]>());
List<CompletionStage<Message>> pending = new ArrayList<CompletionStage<Message>>();
for (int index = 0; index < LocalMessaging.MAX_IN_FLIGHT; index++) {
pending.add(host.receiveRequest(request("islands:slow"), Duration.ofMillis(40)));
}
MessagingException saturated = failure(host.receiveRequest(request("islands:slow"), Duration.ofMillis(40)));
assertEquals(MessagingException.Code.BACKPRESSURED, saturated.code());
MessagingException timedOut = failure(pending.get(0));
assertEquals(MessagingException.Code.TIMED_OUT, timedOut.code());
assertNotNull(host.receiveRequest(request("islands:slow"), Duration.ofMillis(40)));
}
@Test
void outboundRequestsRemainCorrelatedWhenRepliesArriveOutOfOrder() throws Exception {
Messaging scope = host.openScope("caller");
CompletionStage<Message> first = scope.channel("islands:rpc").request(Endpoint.proxy(), new byte[] {1},
Duration.ofSeconds(1));
CompletionStage<Message> second = scope.channel("islands:rpc").request(Endpoint.proxy(), new byte[] {2},
Duration.ofSeconds(1));
assertEquals(2, outbound.requests.size());
Message firstRequest = outbound.requests.get(0);
Message secondRequest = outbound.requests.get(1);
assertNotEquals(firstRequest.id(), secondRequest.id());
outbound.requestFutures.get(secondRequest.id()).complete(Message.reply(secondRequest, Endpoint.proxy(),
new byte[] {22}));
outbound.requestFutures.get(firstRequest.id()).complete(Message.reply(firstRequest, Endpoint.proxy(),
new byte[] {11}));
assertArrayEquals(new byte[] {11}, first.toCompletableFuture().get(1, TimeUnit.SECONDS).payload());
assertArrayEquals(new byte[] {22}, second.toCompletableFuture().get(1, TimeUnit.SECONDS).payload());
}
@Test
void outgoingRequestTimeoutIncludesAnUnresponsiveOutbound() throws Exception {
Messaging scope = host.openScope("timeout-caller");
CompletionStage<Message> pending = scope.channel("islands:timeout").request(Endpoint.proxy(), new byte[0],
Duration.ofMillis(30));
MessagingException timeout = failure(pending);
assertEquals(MessagingException.Code.TIMED_OUT, timeout.code());
}
@Test
void closingScopeReleasesEventsWhoseExecutorQueueWasDiscarded() {
QueuedExecutor queued = new QueuedExecutor();
Messaging cancelled = host.openScope("queued-listener", queued);
cancelled.channel("islands:queued").subscribe(message -> { });
for (int index = 0; index < LocalMessaging.MAX_IN_FLIGHT; index++) {
assertEquals(SendResult.ACCEPTED, host.receiveEvent(Message.event("islands:queued", Endpoint.proxy(),
self, new byte[0])));
}
assertEquals(SendResult.BACKPRESSURED, host.receiveEvent(Message.event("islands:queued", Endpoint.proxy(),
self, new byte[0])));
queued.clear(); // Models an executor that discards queued work during plugin disable.
cancelled.close();
QueuedExecutor replacementQueue = new QueuedExecutor();
Messaging replacement = host.openScope("replacement", replacementQueue);
replacement.channel("islands:queued").subscribe(message -> { });
assertEquals(SendResult.ACCEPTED, host.receiveEvent(Message.event("islands:queued", Endpoint.proxy(),
self, new byte[0])));
replacement.close();
}
@Test
void closedOwnerExecutorCannotStrandPublicCompletion() throws Exception {
QueuedExecutor ownerExecutor = new QueuedExecutor();
Messaging scope = host.openScope("queued-owner", ownerExecutor);
CompletionStage<Message> pending = scope.channel("islands:rpc").request(Endpoint.proxy(), new byte[0],
Duration.ofSeconds(1));
scope.close();
MessagingException failure = failure(pending);
assertEquals(MessagingException.Code.CLOSED, failure.code());
assertTrue(ownerExecutor.size() == 0);
}
@Test
void cancellingPublicFutureReleasesOutboundCapacity() {
outbound.neverCompleteSends = true;
Messaging scope = host.openScope("cancel-sends");
List<CompletableFuture<?>> sends = new ArrayList<CompletableFuture<?>>();
for (int index = 0; index < LocalMessaging.MAX_IN_FLIGHT; index++) {
sends.add(scope.channel("islands:send").send(Endpoint.proxy(), new byte[0]).toCompletableFuture());
}
assertTrue(sends.get(0).cancel(true));
assertTrue(outbound.sendFutures.get(0).isCancelled(),
"cancelling the public send must cancel its underlying outbound stage");
CompletableFuture<?> afterCancel = scope.channel("islands:send").send(Endpoint.proxy(), new byte[0])
.toCompletableFuture();
assertNotNull(afterCancel);
afterCancel.cancel(true);
scope.close();
}
@Test
void sendMapsTypedTransportFailuresToReceipts() throws Exception {
Messaging scope = host.openScope("send-statuses");
Map<MessagingException.Code, SendResult> expected = new LinkedHashMap<>();
expected.put(MessagingException.Code.TIMED_OUT, SendResult.TIMED_OUT);
expected.put(MessagingException.Code.BACKPRESSURED, SendResult.BACKPRESSURED);
expected.put(MessagingException.Code.NOT_CONNECTED, SendResult.NOT_CONNECTED);
expected.put(MessagingException.Code.NO_HANDLER, SendResult.NO_SUBSCRIBER);
expected.put(MessagingException.Code.REJECTED, SendResult.REJECTED);
expected.put(MessagingException.Code.HANDLER_FAILED, SendResult.FAILED);
expected.put(MessagingException.Code.PROTOCOL_ERROR, SendResult.FAILED);
for (Map.Entry<MessagingException.Code, SendResult> entry : expected.entrySet()) {
CompletableFuture<SendResult> transport = new CompletableFuture<>();
transport.completeExceptionally(new MessagingException(entry.getKey(), "test transport result"));
outbound.nextSendStage = transport;
SendReceipt receipt = scope.channel("islands:send-status").send(Endpoint.proxy(), new byte[0])
.toCompletableFuture().get(1, TimeUnit.SECONDS);
assertEquals(entry.getValue(), receipt.result(), "mapping for " + entry.getKey());
}
CompletableFuture<SendResult> closedTransport = new CompletableFuture<>();
closedTransport.completeExceptionally(new MessagingException(MessagingException.Code.CLOSED, "closed"));
outbound.nextSendStage = closedTransport;
CompletionStage<SendReceipt> closed = scope.channel("islands:send-status").send(Endpoint.proxy(), new byte[0]);
assertEquals(MessagingException.Code.CLOSED, failure(closed).code());
scope.close();
}
@Test
void sendOwnDeadlineReturnsTimedOutReceiptAndCancelsTransportStage() throws Exception {
ManualTimeoutScheduler manualScheduler = new ManualTimeoutScheduler();
TestOutbound manualOutbound = new TestOutbound();
LocalMessaging manualHost = new LocalMessaging(self, manualOutbound, DIRECT, manualScheduler);
try {
manualOutbound.neverCompleteSends = true;
Messaging scope = manualHost.openScope("manual-send-timeout");
CompletableFuture<SendReceipt> pending = scope.channel("islands:send-timeout")
.send(Endpoint.proxy(), new byte[0]).toCompletableFuture();
assertEquals(1, manualOutbound.sendFutures.size());
manualScheduler.fireTimeout();
SendReceipt receipt = pending.get(1, TimeUnit.SECONDS);
assertEquals(SendResult.TIMED_OUT, receipt.result());
assertTrue(manualOutbound.sendFutures.get(0).isCancelled(),
"deadline conversion must still cancel the underlying transport stage");
scope.close();
} finally {
manualHost.close();
manualScheduler.shutdownNow();
}
}
@Test
void queuedPublicCompletionsRemainCoveredByOutboundAdmissionLimit() throws Exception {
QueuedExecutor completions = new QueuedExecutor();
ScheduledThreadPoolExecutor localScheduler = new ScheduledThreadPoolExecutor(1);
TestOutbound fastOutbound = new TestOutbound();
LocalMessaging localHost = new LocalMessaging(self, fastOutbound, DIRECT, localScheduler, completions);
try {
Messaging scope = localHost.openScope("queued-completions");
List<CompletionStage<SendReceipt>> accepted = new ArrayList<CompletionStage<SendReceipt>>();
for (int index = 0; index < LocalMessaging.MAX_IN_FLIGHT; index++) {
accepted.add(scope.channel("islands:bounded").send(Endpoint.proxy(), new byte[0]));
}
assertEquals(LocalMessaging.MAX_IN_FLIGHT, completions.size());
CompletableFuture<SendReceipt> rejected = scope.channel("islands:bounded")
.send(Endpoint.proxy(), new byte[0]).toCompletableFuture();
assertTrue(rejected.isDone(), "pre-admission rejection must complete without entering the queue");
assertEquals(SendResult.BACKPRESSURED, rejected.get(1, TimeUnit.SECONDS).result());
assertEquals(LocalMessaging.MAX_IN_FLIGHT, completions.size());
completions.drain();
for (CompletionStage<SendReceipt> receipt : accepted) {
assertEquals(SendResult.ACCEPTED, receipt.toCompletableFuture().get(1, TimeUnit.SECONDS).result());
}
CompletionStage<SendReceipt> recovered = scope.channel("islands:bounded")
.send(Endpoint.proxy(), new byte[0]);
assertEquals(1, completions.size());
completions.drain();
assertEquals(SendResult.ACCEPTED, recovered.toCompletableFuture().get(1, TimeUnit.SECONDS).result());
scope.close();
} finally {
localHost.close();
localScheduler.shutdownNow();
}
}
@Test
void closingScopeCancelsUnderlyingOutboundRequest() throws Exception {
Messaging scope = host.openScope("close-request");
CompletableFuture<Message> transport = new CompletableFuture<Message>();
outbound.nextRequestStage = transport;
CompletionStage<Message> pending = scope.channel("islands:close").request(Endpoint.proxy(), new byte[0],
Duration.ofSeconds(1));
assertFalse(pending.toCompletableFuture().isDone());
scope.close();
assertTrue(transport.isCancelled(), "closing the owner scope must cancel its outbound request stage");
assertEquals(MessagingException.Code.CLOSED, failure(pending).code());
}
private Message request(String channel) {
return Message.request(channel, Endpoint.proxy(), self, new byte[0]);
}
private static MessagingException failure(CompletionStage<?> stage) throws Exception {
try {
stage.toCompletableFuture().get(2, TimeUnit.SECONDS);
throw new AssertionError("expected stage failure");
} catch (java.util.concurrent.ExecutionException failure) {
Throwable cause = failure.getCause();
if (cause instanceof CompletionException && cause.getCause() != null) cause = cause.getCause();
assertTrue(cause instanceof MessagingException, "unexpected failure: " + cause);
return (MessagingException) cause;
}
}
private static final class TestOutbound implements LocalMessaging.Outbound {
private final List<Message> requests = Collections.synchronizedList(new ArrayList<Message>());
private final Map<UUID, CompletableFuture<Message>> requestFutures =
Collections.synchronizedMap(new LinkedHashMap<UUID, CompletableFuture<Message>>());
private final List<CompletableFuture<SendResult>> sendFutures =
Collections.synchronizedList(new ArrayList<CompletableFuture<SendResult>>());
private volatile boolean neverCompleteSends;
private volatile CompletableFuture<Message> nextRequestStage;
private volatile CompletableFuture<SendResult> nextSendStage;
@Override public CompletionStage<SendResult> send(Message message) {
CompletableFuture<SendResult> future = nextSendStage;
nextSendStage = null;
if (future == null) {
future = neverCompleteSends ? new CompletableFuture<SendResult>()
: CompletableFuture.completedFuture(SendResult.ACCEPTED);
}
sendFutures.add(future);
return future;
}
@Override public CompletionStage<Message> request(Message request, Duration timeout) {
requests.add(request);
CompletableFuture<Message> future = nextRequestStage;
nextRequestStage = null;
if (future == null) future = new CompletableFuture<Message>();
requestFutures.put(request.id(), future);
return future;
}
@Override public CompletionStage<PublishResult> publish(Message event) {
return CompletableFuture.completedFuture(new PublishResult(event.id(), Collections.<Endpoint, SendResult>emptyMap()));
}
}
private static final class QueuedExecutor implements Executor {
private final List<Runnable> queued = Collections.synchronizedList(new ArrayList<Runnable>());
@Override public void execute(Runnable command) { queued.add(command); }
private void clear() { queued.clear(); }
private int size() { return queued.size(); }
private void drain() {
while (true) {
Runnable task;
synchronized (queued) {
if (queued.isEmpty()) return;
task = queued.remove(0);
}
task.run();
}
}
}
private static final class ManualTimeoutScheduler extends ScheduledThreadPoolExecutor {
private volatile Runnable timeout;
private ManualTimeoutScheduler() { super(1); }
@Override public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
timeout = command;
return super.schedule(new Runnable() { @Override public void run() { } }, 1, TimeUnit.DAYS);
}
private void fireTimeout() {
Runnable scheduled = timeout;
if (scheduled == null) throw new AssertionError("no operation timeout was armed");
scheduled.run();
}
}
}
+53
View File
@@ -0,0 +1,53 @@
plugins {
`java-library`
`maven-publish`
}
group = "uk.potatolab"
version = "0.1.0-SNAPSHOT"
java {
withSourcesJar()
withJavadocJar()
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(8)
options.encoding = "UTF-8"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
api(project(":messaging-api"))
testImplementation(platform("org.junit:junit-bom:5.14.1"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
publishing {
publications {
create<MavenPublication>("messagingProtocol") {
from(components["java"])
artifactId = "messaging-protocol"
pom {
name.set("StrataProxy Messaging Protocol")
description.set("Java 8-compatible message wire codec shared by proxy and backend clients.")
}
}
}
repositories {
maven {
name = "giteaPackages"
url = uri(providers.environmentVariable("MAVEN_URL")
.orElse("https://git.nest.potatolab.uk:8443/api/packages/layue13/maven").get())
credentials {
username = providers.environmentVariable("MAVEN_USER").orElse("NONE").get()
password = providers.environmentVariable("MAVEN_PASSWORD").orElse("NONE").get()
}
}
}
}
@@ -0,0 +1,450 @@
package dev.strataproxy.messaging.protocol;
import dev.strataproxy.messaging.Endpoint;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.MessageKind;
import dev.strataproxy.messaging.MessagingException;
import dev.strataproxy.messaging.PublishResult;
import dev.strataproxy.messaging.SendResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/** Binary message frames shared by proxy-core and Java 8 backend clients. */
public final class MessageCodec {
public static final int MESSAGE = 9;
public static final int RESPONSE = 10;
public static final int MAX_FRAME_BYTES = 66 * 1024;
public static final int MAX_NODE_BYTES = 128;
public static final int MAX_PUBLISH_RESULTS = 256;
public static final long MIN_TIMEOUT_MILLIS = 1L;
public static final long MAX_TIMEOUT_MILLIS = 60_000L;
private static final int EVENT = 1;
private static final int REQUEST = 2;
private static final int REPLY = 3;
private static final int SEND_RESULT = 1;
private static final int REPLY_RESULT = 2;
private static final int PUBLISH_RESULT = 3;
private static final int ERROR_RESULT = 4;
private static final int MAX_DETAIL_BYTES = 1024;
private MessageCodec() { }
/** Returns a message frame body (the transport adds the outer 4-byte length prefix). */
public static byte[] message(long operationId, long timeoutMillis, Message message) throws IOException {
requireOperationId(operationId);
requireTimeout(timeoutMillis);
if (message == null) throw new IllegalArgumentException("message is required");
if (message.kind() == MessageKind.REPLY) throw new IllegalArgumentException("reply cannot be sent as a message request");
return encode(MESSAGE, out -> {
out.writeLong(operationId);
out.writeLong(timeoutMillis);
writeMessage(out, message);
});
}
public static IncomingMessage decodeMessage(byte[] frame) throws IOException {
DataInputStream in = body(frame, MESSAGE);
long operationId = in.readLong();
requireWireOperationId(operationId);
long timeoutMillis = in.readLong();
requireWireTimeout(timeoutMillis);
Message message = readMessage(in);
if (message.kind() == MessageKind.REPLY) throw new IOException("reply cannot be sent as a message request");
requireEnd(in);
return new IncomingMessage(operationId, timeoutMillis, message);
}
public static byte[] sendResult(long operationId, SendResult result) throws IOException {
requireOperationId(operationId);
if (result == null) throw new IllegalArgumentException("send result is required");
return encode(RESPONSE, out -> {
out.writeLong(operationId);
out.writeByte(SEND_RESULT);
out.writeByte(sendResultCode(result));
});
}
public static byte[] reply(long operationId, Message message) throws IOException {
requireOperationId(operationId);
if (message == null || message.kind() != MessageKind.REPLY) {
throw new IllegalArgumentException("a reply message is required");
}
return encode(RESPONSE, out -> {
out.writeLong(operationId);
out.writeByte(REPLY_RESULT);
writeMessage(out, message);
});
}
public static byte[] published(long operationId, PublishResult result) throws IOException {
requireOperationId(operationId);
if (result == null) throw new IllegalArgumentException("publish result is required");
Map<Endpoint, SendResult> results = result.results();
if (results.size() > MAX_PUBLISH_RESULTS) throw new IllegalArgumentException("too many publish results");
return encode(RESPONSE, out -> {
out.writeLong(operationId);
out.writeByte(PUBLISH_RESULT);
writeUuid(out, result.messageId());
out.writeShort(results.size());
for (Map.Entry<Endpoint, SendResult> entry : results.entrySet()) {
writeEndpoint(out, entry.getKey());
out.writeByte(sendResultCode(entry.getValue()));
}
});
}
public static byte[] error(long operationId, MessagingException.Code code, String detail) throws IOException {
requireOperationId(operationId);
if (code == null) throw new IllegalArgumentException("error code is required");
byte[] encodedDetail = detail == null || detail.isEmpty()
? new byte[0] : encodeUtf8(detail, MAX_DETAIL_BYTES, "error detail");
return encode(RESPONSE, out -> {
out.writeLong(operationId);
out.writeByte(ERROR_RESULT);
out.writeByte(errorCode(code));
out.writeShort(encodedDetail.length);
out.write(encodedDetail);
});
}
public static Response decodeResponse(byte[] frame) throws IOException {
DataInputStream in = body(frame, RESPONSE);
long operationId = in.readLong();
requireWireOperationId(operationId);
int typeCode = in.readUnsignedByte();
Response response;
switch (typeCode) {
case SEND_RESULT:
response = Response.send(operationId, readSendResult(in));
break;
case REPLY_RESULT:
Message reply = readMessage(in);
if (reply.kind() != MessageKind.REPLY) throw new IOException("response message is not a reply");
response = Response.reply(operationId, reply);
break;
case PUBLISH_RESULT:
UUID messageId = readUuid(in);
int count = in.readUnsignedShort();
if (count > MAX_PUBLISH_RESULTS) throw new IOException("too many publish results");
Map<Endpoint, SendResult> results = new LinkedHashMap<Endpoint, SendResult>();
for (int i = 0; i < count; i++) {
Endpoint endpoint = readEndpoint(in);
SendResult result = readSendResult(in);
if (results.put(endpoint, result) != null) throw new IOException("duplicate publish endpoint");
}
response = Response.published(operationId, new PublishResult(messageId, results));
break;
case ERROR_RESULT:
MessagingException.Code errorCode = readErrorCode(in);
String detail = readOptionalString(in, MAX_DETAIL_BYTES, "error detail");
response = Response.error(operationId, errorCode, detail);
break;
default:
throw new IOException("unknown messaging response type: " + typeCode);
}
requireEnd(in);
return response;
}
private static void writeMessage(DataOutputStream out, Message message) throws IOException {
writeUuid(out, message.id());
out.writeByte(messageKindCode(message.kind()));
writeString(out, message.channel(), Message.MAX_CHANNEL_BYTES, "channel");
writeEndpoint(out, message.source());
writeNullableEndpoint(out, message.target());
writeNullableUuid(out, message.replyTo());
byte[] payload = message.payload();
if (payload.length > Message.MAX_PAYLOAD_BYTES) throw new IOException("message payload exceeds limit");
out.writeInt(payload.length);
out.write(payload);
}
private static Message readMessage(DataInputStream in) throws IOException {
UUID id = readUuid(in);
MessageKind kind = readMessageKind(in.readUnsignedByte());
String channel = readString(in, Message.MAX_CHANNEL_BYTES, "channel");
try {
Message.validateChannel(channel);
} catch (RuntimeException invalid) {
throw new IOException("invalid message channel", invalid);
}
Endpoint source = readEndpoint(in);
Endpoint target = readNullableEndpoint(in);
UUID replyTo = readNullableUuid(in);
int length = in.readInt();
if (length < 0 || length > Message.MAX_PAYLOAD_BYTES) throw new IOException("message payload length out of bounds");
byte[] payload = new byte[length];
in.readFully(payload);
try {
return new Message(id, kind, channel, source, target, replyTo, payload);
} catch (RuntimeException invalid) {
throw new IOException("invalid message envelope", invalid);
}
}
private static void writeEndpoint(DataOutputStream out, Endpoint endpoint) throws IOException {
if (endpoint == null) throw new IllegalArgumentException("endpoint is required");
out.writeBoolean(endpoint.isProxy());
if (!endpoint.isProxy()) writeString(out, endpoint.backendName(), MAX_NODE_BYTES, "backend name");
}
private static Endpoint readEndpoint(DataInputStream in) throws IOException {
if (readBoolean(in)) return Endpoint.proxy();
String name = readString(in, MAX_NODE_BYTES, "backend name");
try {
return Endpoint.backend(name);
} catch (RuntimeException invalid) {
throw new IOException("invalid backend endpoint", invalid);
}
}
private static void writeNullableEndpoint(DataOutputStream out, Endpoint endpoint) throws IOException {
out.writeBoolean(endpoint != null);
if (endpoint != null) writeEndpoint(out, endpoint);
}
private static Endpoint readNullableEndpoint(DataInputStream in) throws IOException {
return readBoolean(in) ? readEndpoint(in) : null;
}
private static void writeNullableUuid(DataOutputStream out, UUID value) throws IOException {
out.writeBoolean(value != null);
if (value != null) writeUuid(out, value);
}
private static UUID readNullableUuid(DataInputStream in) throws IOException {
return readBoolean(in) ? readUuid(in) : null;
}
private static boolean readBoolean(DataInputStream in) throws IOException {
int value = in.readUnsignedByte();
if (value != 0 && value != 1) throw new IOException("invalid boolean value");
return value == 1;
}
private static void writeUuid(DataOutputStream out, UUID uuid) throws IOException {
if (uuid == null) throw new IllegalArgumentException("UUID is required");
out.writeLong(uuid.getMostSignificantBits());
out.writeLong(uuid.getLeastSignificantBits());
}
private static UUID readUuid(DataInputStream in) throws IOException {
return new UUID(in.readLong(), in.readLong());
}
private static int messageKindCode(MessageKind kind) {
if (kind == MessageKind.EVENT) return EVENT;
if (kind == MessageKind.REQUEST) return REQUEST;
if (kind == MessageKind.REPLY) return REPLY;
throw new IllegalArgumentException("unknown message kind");
}
private static MessageKind readMessageKind(int code) throws IOException {
switch (code) {
case EVENT: return MessageKind.EVENT;
case REQUEST: return MessageKind.REQUEST;
case REPLY: return MessageKind.REPLY;
default: throw new IOException("unknown message kind: " + code);
}
}
private static int sendResultCode(SendResult result) {
switch (result) {
case ACCEPTED: return 1;
case NO_SUBSCRIBER: return 2;
case NOT_CONNECTED: return 3;
case BACKPRESSURED: return 4;
case REJECTED: return 5;
case TIMED_OUT: return 6;
case FAILED: return 7;
default: throw new IllegalArgumentException("unknown send result");
}
}
private static SendResult readSendResult(DataInputStream in) throws IOException {
switch (in.readUnsignedByte()) {
case 1: return SendResult.ACCEPTED;
case 2: return SendResult.NO_SUBSCRIBER;
case 3: return SendResult.NOT_CONNECTED;
case 4: return SendResult.BACKPRESSURED;
case 5: return SendResult.REJECTED;
case 6: return SendResult.TIMED_OUT;
case 7: return SendResult.FAILED;
default: throw new IOException("unknown send result");
}
}
private static int errorCode(MessagingException.Code code) {
switch (code) {
case NOT_CONNECTED: return 1;
case NO_HANDLER: return 2;
case BACKPRESSURED: return 3;
case TIMED_OUT: return 4;
case CLOSED: return 5;
case REJECTED: return 6;
case HANDLER_FAILED: return 7;
case PROTOCOL_ERROR: return 8;
default: throw new IllegalArgumentException("unknown messaging error code");
}
}
private static MessagingException.Code readErrorCode(DataInputStream in) throws IOException {
switch (in.readUnsignedByte()) {
case 1: return MessagingException.Code.NOT_CONNECTED;
case 2: return MessagingException.Code.NO_HANDLER;
case 3: return MessagingException.Code.BACKPRESSURED;
case 4: return MessagingException.Code.TIMED_OUT;
case 5: return MessagingException.Code.CLOSED;
case 6: return MessagingException.Code.REJECTED;
case 7: return MessagingException.Code.HANDLER_FAILED;
case 8: return MessagingException.Code.PROTOCOL_ERROR;
default: throw new IOException("unknown messaging error code");
}
}
private static void writeString(DataOutputStream out, String value, int maxBytes, String label) throws IOException {
byte[] bytes = encodeUtf8(value, maxBytes, label);
out.writeShort(bytes.length);
out.write(bytes);
}
private static String readString(DataInputStream in, int maxBytes, String label) throws IOException {
int length = in.readUnsignedShort();
if (length == 0 || length > maxBytes) throw new IOException(label + " length out of bounds");
return readUtf8(in, length, label);
}
private static String readOptionalString(DataInputStream in, int maxBytes, String label) throws IOException {
int length = in.readUnsignedShort();
if (length > maxBytes) throw new IOException(label + " length out of bounds");
if (length == 0) return "";
return readUtf8(in, length, label);
}
private static String readUtf8(DataInputStream in, int length, String label) throws IOException {
byte[] bytes = new byte[length];
in.readFully(bytes);
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException invalid) {
throw new IOException("invalid UTF-8 in " + label, invalid);
}
}
private static byte[] encodeUtf8(String value, int maxBytes, String label) throws IOException {
if (value == null || value.isEmpty()) throw new IllegalArgumentException(label + " is required");
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > maxBytes) throw new IllegalArgumentException(label + " length out of bounds");
return bytes;
}
private static DataInputStream body(byte[] frame, int type) throws IOException {
if (frame == null || frame.length < 1 || frame.length > MAX_FRAME_BYTES || (frame[0] & 0xff) != type) {
throw new IOException("invalid message frame type or size");
}
return new DataInputStream(new ByteArrayInputStream(frame, 1, frame.length - 1));
}
private static byte[] encode(int type, Writer writer) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(bytes);
out.writeByte(type);
writer.write(out);
out.flush();
byte[] frame = bytes.toByteArray();
if (frame.length > MAX_FRAME_BYTES) throw new IOException("message frame exceeds maximum size");
return frame;
}
private static void requireEnd(DataInputStream in) throws IOException {
if (in.read() != -1) throw new IOException("trailing message frame data");
}
private static void requireOperationId(long operationId) {
if (operationId <= 0) throw new IllegalArgumentException("operationId must be positive");
}
private static void requireWireOperationId(long operationId) throws IOException {
if (operationId <= 0) throw new IOException("operationId must be positive");
}
private static void requireTimeout(long timeoutMillis) {
if (timeoutMillis < MIN_TIMEOUT_MILLIS || timeoutMillis > MAX_TIMEOUT_MILLIS) {
throw new IllegalArgumentException("timeoutMillis out of bounds");
}
}
private static void requireWireTimeout(long timeoutMillis) throws IOException {
if (timeoutMillis < MIN_TIMEOUT_MILLIS || timeoutMillis > MAX_TIMEOUT_MILLIS) {
throw new IOException("timeoutMillis out of bounds");
}
}
@FunctionalInterface
private interface Writer { void write(DataOutputStream out) throws IOException; }
/** Decoded inbound message with its hop-local acknowledgement operation and deadline. */
public static final class IncomingMessage {
public final long operationId;
public final long timeoutMillis;
public final Message message;
private IncomingMessage(long operationId, long timeoutMillis, Message message) {
this.operationId = operationId;
this.timeoutMillis = timeoutMillis;
this.message = message;
}
}
/** Decoded response. Unused value fields are null. */
public static final class Response {
public enum Type { SEND, REPLY, PUBLISH, ERROR }
public final long operationId;
public final Type type;
public final SendResult sendResult;
public final Message message;
public final PublishResult publishResult;
public final MessagingException.Code errorCode;
public final String detail;
private Response(long operationId, Type type, SendResult sendResult, Message message,
PublishResult publishResult, MessagingException.Code errorCode, String detail) {
this.operationId = operationId;
this.type = type;
this.sendResult = sendResult;
this.message = message;
this.publishResult = publishResult;
this.errorCode = errorCode;
this.detail = detail;
}
private static Response send(long id, SendResult result) {
return new Response(id, Type.SEND, result, null, null, null, null);
}
private static Response reply(long id, Message message) {
return new Response(id, Type.REPLY, null, message, null, null, null);
}
private static Response published(long id, PublishResult result) {
return new Response(id, Type.PUBLISH, null, null, result, null, null);
}
private static Response error(long id, MessagingException.Code code, String detail) {
return new Response(id, Type.ERROR, null, null, null, code, detail);
}
}
}
@@ -0,0 +1,140 @@
package dev.strataproxy.messaging.protocol;
import dev.strataproxy.messaging.Endpoint;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.MessageKind;
import dev.strataproxy.messaging.PublishResult;
import dev.strataproxy.messaging.SendResult;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class MessageCodecTest {
private static final Endpoint PROXY = Endpoint.proxy();
private static final Endpoint BACKEND = Endpoint.backend("island-a");
@Test
void messageRoundTripsEnvelopeIdAndPayloadAtMaximumSize() throws Exception {
byte[] payload = new byte[Message.MAX_PAYLOAD_BYTES];
Arrays.fill(payload, (byte) 0x5a);
UUID id = UUID.fromString("a4e57b18-09b0-4ef0-a7c6-4b8e2d70a029");
Message event = new Message(id, MessageKind.EVENT, "islands:bulk", BACKEND, PROXY, null, payload);
MessageCodec.IncomingMessage decoded = MessageCodec.decodeMessage(
MessageCodec.message(41, 60000, event));
assertEquals(41, decoded.operationId);
assertEquals(60000, decoded.timeoutMillis);
assertEquals(id, decoded.message.id());
assertEquals(MessageKind.EVENT, decoded.message.kind());
assertEquals("islands:bulk", decoded.message.channel());
assertEquals(BACKEND, decoded.message.source());
assertEquals(PROXY, decoded.message.target());
assertArrayEquals(payload, decoded.message.payload());
}
@Test
void replyRoundTripsMessageIdAndReplyTo() throws Exception {
UUID requestId = UUID.fromString("b5366a10-3a44-4b8d-8c31-83e91ae7af1c");
UUID replyId = UUID.fromString("6e1bf5ca-3ce1-422b-92e3-c49e1ce3e198");
Message request = new Message(requestId, MessageKind.REQUEST, "islands:query", PROXY, BACKEND,
null, new byte[] {1, 2});
Message reply = new Message(replyId, MessageKind.REPLY, request.channel(), BACKEND, PROXY,
request.id(), new byte[] {3, 4});
MessageCodec.Response decoded = MessageCodec.decodeResponse(MessageCodec.reply(9, reply));
assertEquals(MessageCodec.Response.Type.REPLY, decoded.type);
assertEquals(9, decoded.operationId);
assertEquals(replyId, decoded.message.id());
assertEquals(requestId, decoded.message.replyTo());
assertEquals(BACKEND, decoded.message.source());
assertEquals(PROXY, decoded.message.target());
assertArrayEquals(new byte[] {3, 4}, decoded.message.payload());
}
@Test
void timeoutAndFailureUseStableWireCodesSixAndSeven() throws Exception {
byte[] timeout = MessageCodec.sendResult(1, SendResult.TIMED_OUT);
byte[] failed = MessageCodec.sendResult(2, SendResult.FAILED);
assertEquals(6, timeout[10] & 0xff);
assertEquals(7, failed[10] & 0xff);
assertEquals(SendResult.TIMED_OUT, MessageCodec.decodeResponse(timeout).sendResult);
assertEquals(SendResult.FAILED, MessageCodec.decodeResponse(failed).sendResult);
}
@Test
void rejectsPayloadLargerThanThePublicLimit() {
assertThrows(IllegalArgumentException.class, () -> Message.event("islands:bulk", PROXY, BACKEND,
new byte[Message.MAX_PAYLOAD_BYTES + 1]));
}
@Test
void rejectsMalformedUtf8BooleanAndMessageKind() throws Exception {
byte[] valid = MessageCodec.message(3, 1000,
Message.event("islands:wire", BACKEND, PROXY, new byte[0]));
byte[] invalidUtf8 = valid.clone();
int channelOffset = 1 + 8 + 8 + 16 + 1 + 2;
invalidUtf8[channelOffset] = (byte) 0xc3;
invalidUtf8[channelOffset + 1] = 0x28;
assertThrows(IOException.class, () -> MessageCodec.decodeMessage(invalidUtf8));
byte[] invalidBoolean = valid.clone();
int sourceBooleanOffset = channelOffset + "islands:wire".getBytes(StandardCharsets.US_ASCII).length;
invalidBoolean[sourceBooleanOffset] = 2;
assertThrows(IOException.class, () -> MessageCodec.decodeMessage(invalidBoolean));
byte[] invalidKind = valid.clone();
invalidKind[1 + 8 + 8 + 16] = 0x7f;
assertThrows(IOException.class, () -> MessageCodec.decodeMessage(invalidKind));
}
@Test
void rejectsTrailingBytesAndOversizedFrames() throws Exception {
byte[] message = MessageCodec.message(5, 1000,
Message.event("islands:wire", BACKEND, PROXY, new byte[0]));
byte[] trailing = Arrays.copyOf(message, message.length + 1);
assertThrows(IOException.class, () -> MessageCodec.decodeMessage(trailing));
byte[] response = MessageCodec.sendResult(6, SendResult.ACCEPTED);
byte[] responseTrailing = Arrays.copyOf(response, response.length + 1);
assertThrows(IOException.class, () -> MessageCodec.decodeResponse(responseTrailing));
byte[] oversized = new byte[MessageCodec.MAX_FRAME_BYTES + 1];
oversized[0] = (byte) MessageCodec.RESPONSE;
assertThrows(IOException.class, () -> MessageCodec.decodeResponse(oversized));
}
@Test
void rejectsPublishResultsAboveTheFanoutLimit() throws Exception {
Map<Endpoint, SendResult> tooMany = new LinkedHashMap<>();
for (int index = 0; index <= MessageCodec.MAX_PUBLISH_RESULTS; index++) {
tooMany.put(Endpoint.backend("node-" + index), SendResult.ACCEPTED);
}
PublishResult result = new PublishResult(UUID.randomUUID(), tooMany);
assertThrows(IllegalArgumentException.class, () -> MessageCodec.published(12, result));
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(bytes);
out.writeByte(MessageCodec.RESPONSE);
out.writeLong(13);
out.writeByte(3); // PUBLISH_RESULT
out.writeLong(1L);
out.writeLong(2L);
out.writeShort(MessageCodec.MAX_PUBLISH_RESULTS + 1);
assertThrows(IOException.class, () -> MessageCodec.decodeResponse(bytes.toByteArray()));
}
}
+12
View File
@@ -7,6 +7,7 @@ plugins {
val runtimeJavaHome = System.getProperty("java.home")
dependencies {
implementation(project(":messaging-protocol"))
// The plugin contract is the only stable external boundary. Everything else
// in this project remains an implementation package of the proxy core.
api(project(":proxy-plugin-api"))
@@ -17,6 +18,8 @@ dependencies {
implementation(libs.findLibrary("netty-resolver-dns").get())
implementation(libs.findLibrary("jackson-databind").get())
implementation(libs.findLibrary("jackson-yaml").get())
implementation(libs.findLibrary("adventure-gson").get())
implementation(libs.findLibrary("adventure-plain").get())
runtimeOnly(libs.findLibrary("logback-classic").get())
testImplementation(project(":backend-channel-client"))
}
@@ -90,6 +93,15 @@ distributions {
from(project(":backend-channel-client").tasks.named("jar")) {
into("backend-client")
}
from(project(":messaging-api").tasks.named("jar")) {
into("backend-client")
}
from(project(":messaging-protocol").tasks.named("jar")) {
into("backend-client")
}
from(project(":backend-bukkit").tasks.named("jar")) {
into("backend-host")
}
}
}
}
@@ -155,7 +155,12 @@ public record ProxyConfiguration(String listen, Authentication authentication, L
}
public record Client(String backendName, String keyId, String secret, Set<String> allowedHosts,
Set<String> allowedNamespaces) {
Set<String> allowedNamespaces, Set<String> allowedReceiveNamespaces) {
public Client(String backendName, String keyId, String secret, Set<String> allowedHosts,
Set<String> allowedNamespaces) {
this(backendName, keyId, secret, allowedHosts, allowedNamespaces, allowedNamespaces);
}
public Client {
if (backendName == null || backendName.isBlank())
throw new IllegalArgumentException("backendChannel client backendName is required");
@@ -167,9 +172,14 @@ public record ProxyConfiguration(String listen, Authentication authentication, L
if (allowedHosts.isEmpty() || allowedHosts.stream().anyMatch(String::isBlank))
throw new IllegalArgumentException("backendChannel client allowedHosts must not be empty");
allowedNamespaces = allowedNamespaces == null ? Set.of() : Set.copyOf(allowedNamespaces);
if (allowedNamespaces.isEmpty() || allowedNamespaces.stream()
if (allowedNamespaces.stream()
.anyMatch(namespace -> !namespace.matches("[a-z0-9][a-z0-9_.-]{0,63}")))
throw new IllegalArgumentException("backendChannel client allowedNamespaces is invalid");
allowedReceiveNamespaces = allowedReceiveNamespaces == null
? allowedNamespaces : Set.copyOf(allowedReceiveNamespaces);
if (allowedReceiveNamespaces.stream()
.anyMatch(namespace -> !namespace.matches("[a-z0-9][a-z0-9_.-]{0,63}")))
throw new IllegalArgumentException("backendChannel client allowedReceiveNamespaces is invalid");
}
}
@@ -85,7 +85,7 @@ public final class ProxyMain {
try (var plugins = new PluginHost(catalog, listener, placementTimeout, eventTimeout);
var control = configuration.backendChannel() == null ? null
: new BackendControlService(configuration.backendChannel(), catalog,
plugins::dispatchBackendMessage)) {
plugins.localMessaging())) {
if (control != null) plugins.setBackendChannelTransport(control);
listener.setMaxConnections(configuration.maxConnections());
plugins.loadPlugins(pluginDirectory(configPath, configuration.plugins().directory()),
@@ -95,6 +95,7 @@ public final class ProxyMain {
listener.setEvents(plugins, eventTimeout);
listener.setPlacement(plugins::placeInitial);
listener.setCommandDispatcher(plugins::dispatchCommand);
listener.setCommandCompletion(plugins::commandNames, plugins::completeCommand);
var serverChannel = listener.start().toCompletableFuture().join();
LOGGER.info("Listening on {}", serverChannel.localAddress());
var shutdown = new Thread(() -> {
@@ -1,11 +1,16 @@
package dev.strataproxy.core.control;
import dev.strataproxy.api.BackendSendResult;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.PublishResult;
import dev.strataproxy.messaging.SendResult;
import java.time.Duration;
import java.util.concurrent.CompletionStage;
/** Outbound operations exposed to the proxy plugin host. */
public interface BackendChannelTransport {
CompletionStage<BackendSendResult> send(String backendName, String channel, byte[] payload);
CompletionStage<SendResult> send(Message message);
CompletionStage<byte[]> request(String backendName, String channel, byte[] payload);
CompletionStage<Message> request(Message message, Duration timeout);
CompletionStage<PublishResult> publish(Message event);
}
@@ -1,13 +1,19 @@
package dev.strataproxy.core.control;
import dev.strataproxy.api.BackendMessage;
import dev.strataproxy.api.BackendSendResult;
import dev.strataproxy.app.ProxyConfiguration;
import dev.strataproxy.core.backend.BackendCatalog;
import dev.strataproxy.core.backend.BackendHandle;
import dev.strataproxy.core.backend.BackendId;
import dev.strataproxy.core.backend.BackendOwner;
import dev.strataproxy.core.backend.BackendRegistration;
import dev.strataproxy.messaging.Endpoint;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.MessageKind;
import dev.strataproxy.messaging.MessagingException;
import dev.strataproxy.messaging.PublishResult;
import dev.strataproxy.messaging.SendResult;
import dev.strataproxy.messaging.internal.LocalMessaging;
import dev.strataproxy.messaging.protocol.MessageCodec;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,6 +34,9 @@ import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@@ -43,24 +52,27 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
/** Independent authenticated backend connection for registration and plugin control messages. */
public final class BackendControlService implements BackendChannelTransport, AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(BackendControlService.class);
private static final int VERSION = 1;
private static final int MAX_FRAME = 66 * 1024;
private static final int MAX_PAYLOAD = 65_536;
private static final int VERSION = 2;
private static final int MAX_FRAME = MessageCodec.MAX_FRAME_BYTES;
private static final long MAX_QUEUED_BYTES = 1_048_576;
private static final long MAX_GLOBAL_QUEUED_BYTES = 64L * 1_048_576;
private static final int MAX_WRITE_TASKS = 512;
private static final int MAX_REQUESTS = 128;
private static final int HELLO = 1, REGISTER = 2, REGISTERED = 3, HEARTBEAT = 4,
PONG = 5, GOODBYE = 6, MESSAGE = 7, RESPONSE = 8;
private static final int OK = 0, NO_HANDLER = 1, ERROR = 2;
PONG = 5, GOODBYE = 6;
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
private final ProxyConfiguration.BackendChannel configuration;
private final BackendCatalog catalog;
private final Function<BackendMessage, CompletionStage<byte[]>> inbound;
private final LocalMessaging messaging;
private final Object lock = new Object();
private final Map<String, Lease> byInstance = new HashMap<>();
private final Map<String, Lease> byBackend = new HashMap<>();
@@ -69,14 +81,19 @@ public final class BackendControlService implements BackendChannelTransport, Aut
Thread.ofVirtual().name("strataproxy-control-timer", 0).factory());
private final SecureRandom random = new SecureRandom();
private final AtomicLong nextEpoch = new AtomicLong();
private final AtomicLong globalQueuedBytes = new AtomicLong();
private final Semaphore writeTasks = new Semaphore(MAX_WRITE_TASKS);
private final ThreadPoolExecutor routingWorkers = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(512), namedFactory("strataproxy-control-routing"),
new ThreadPoolExecutor.AbortPolicy());
private volatile boolean closed;
private ServerSocket listener;
public BackendControlService(ProxyConfiguration.BackendChannel configuration, BackendCatalog catalog,
Function<BackendMessage, CompletionStage<byte[]>> inbound) {
LocalMessaging messaging) {
this.configuration = Objects.requireNonNull(configuration, "configuration");
this.catalog = Objects.requireNonNull(catalog, "catalog");
this.inbound = Objects.requireNonNull(inbound, "inbound");
this.messaging = Objects.requireNonNull(messaging, "messaging");
this.connectionSlots = new Semaphore(configuration.maxConnections());
}
@@ -139,15 +156,15 @@ public final class BackendControlService implements BackendChannelTransport, Aut
case HEARTBEAT -> {
requireEmpty(frame.input);
renew(connection);
connection.write(PONG, ignored -> { });
writeAsync(connection, singleByteFrame(PONG));
}
case GOODBYE -> {
requireEmpty(frame.input);
unregister(connection);
return;
}
case MESSAGE -> receiveMessage(connection, frame.input);
case RESPONSE -> receiveResponse(connection, frame.input);
case MessageCodec.MESSAGE -> receiveMessage(connection, frame.bytes);
case MessageCodec.RESPONSE -> receiveResponse(connection, frame.bytes);
default -> throw new IOException("unexpected backend control frame " + frame.type);
}
}
@@ -188,7 +205,8 @@ public final class BackendControlService implements BackendChannelTransport, Aut
mac.init(new SecretKeySpec(client.secret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
if (!MessageDigest.isEqual(signature, mac.doFinal(canonical.toByteArray())))
throw new IOException("backend signature rejected");
return new Registration(instanceId, name, generation, uri, client.allowedNamespaces());
return new Registration(instanceId, name, generation, uri, client.allowedNamespaces(),
client.allowedReceiveNamespaces());
}
private void register(Connection connection, Registration requested) throws IOException {
@@ -212,6 +230,7 @@ public final class BackendControlService implements BackendChannelTransport, Aut
connection.name = requested.name;
connection.epoch = epoch;
connection.allowedNamespaces = requested.allowedNamespaces;
connection.allowedReceiveNamespaces = requested.allowedReceiveNamespaces;
Lease replacement = new Lease(requested.instanceId, requested.name, requested.generation, epoch, handle,
System.nanoTime() + TimeUnit.SECONDS.toNanos(configuration.leaseSeconds()), connection);
byInstance.put(requested.instanceId, replacement);
@@ -267,125 +286,507 @@ public final class BackendControlService implements BackendChannelTransport, Aut
if (lease.connection != null) lease.connection.close();
}
private void receiveMessage(Connection connection, DataInputStream input) throws IOException {
String channel = readString(input, 128);
validateChannel(channel);
if (!connection.allowedNamespaces.contains(channel.substring(0, channel.indexOf(':'))))
throw new IOException("backend message namespace rejected");
private void receiveMessage(Connection connection, byte[] frame) throws IOException {
MessageCodec.IncomingMessage incoming = MessageCodec.decodeMessage(frame);
if (!connection.allowMessage()) throw new IOException("backend message rate exceeded");
long requestId = input.readLong();
byte[] payload = readPayload(input);
requireEmpty(input);
Message received = incoming.message;
if (received.kind() == MessageKind.REPLY) throw new IOException("backend cannot send an unsolicited reply message");
long deadline = deadlineAfterMillis(incoming.timeoutMillis);
String namespace = namespace(received.channel());
if (!connection.allowedNamespaces.contains(namespace)) {
if (received.kind() == MessageKind.EVENT && received.target() != null) {
writeAsync(connection, MessageCodec.sendResult(incoming.operationId, SendResult.REJECTED), deadline);
} else {
writeAsync(connection, MessageCodec.error(incoming.operationId, MessagingException.Code.REJECTED,
"backend message namespace rejected"), deadline);
}
return;
}
Message authenticated = new Message(received.id(), received.kind(), received.channel(),
Endpoint.backend(connection.name), received.target(), received.replyTo(), received.payload());
if (connection.inboundRequests.incrementAndGet() > MAX_REQUESTS) {
connection.inboundRequests.decrementAndGet();
throw new IOException("too many backend requests");
writeAsync(connection, MessageCodec.error(incoming.operationId,
MessagingException.Code.BACKPRESSURED, "inbound operation limit reached"), deadline);
return;
}
BackendMessage message = new BackendMessage(connection.name, connection.instanceId,
connection.epoch, channel, payload);
CompletionStage<byte[]> result;
try { result = Objects.requireNonNull(inbound.apply(message), "handler result"); }
catch (Throwable failure) { result = CompletableFuture.failedFuture(failure); }
result.whenComplete((reply, failure) -> {
connection.inboundRequests.decrementAndGet();
if (requestId == 0 || !isCurrent(connection)) return;
Throwable cause = failure instanceof java.util.concurrent.CompletionException && failure.getCause() != null
? failure.getCause() : failure;
int status = failure == null ? OK : cause instanceof BackendNoHandlerException ? NO_HANDLER : ERROR;
byte[] response = failure == null && reply != null ? reply.clone() : new byte[0];
if (response.length > MAX_PAYLOAD) { status = ERROR; response = new byte[0]; }
int finalStatus = status;
byte[] finalResponse = response;
Thread.ofVirtual().start(() -> {
try { connection.write(RESPONSE, out -> {
out.writeLong(requestId);
out.writeByte(finalStatus);
writePayload(out, finalResponse);
}); }
catch (IOException ignored) { connection.close(); }
switch (authenticated.kind()) {
case EVENT:
if (authenticated.target() == null) {
routePublish(connection, incoming.operationId, authenticated, deadline);
} else {
routeSend(connection, incoming.operationId, authenticated, deadline);
}
break;
case REQUEST:
routeRequest(connection, incoming.operationId, authenticated, deadline);
break;
default:
connection.inboundRequests.decrementAndGet();
throw new IOException("unsupported backend message kind");
}
}
private void routeSend(Connection origin, long operationId, Message message, long deadline) {
CompletionStage<SendResult> delivery;
Endpoint target = message.target();
if (target.isProxy()) {
delivery = CompletableFuture.completedFuture(messaging.receiveEvent(message));
} else {
Connection destination = current(target.backendName());
if (destination == null) {
delivery = CompletableFuture.completedFuture(SendResult.NOT_CONNECTED);
} else if (!destination.allowedReceiveNamespaces.contains(namespace(message.channel()))) {
delivery = CompletableFuture.completedFuture(SendResult.REJECTED);
} else {
Message forwarded = retarget(message, target);
delivery = sendTo(destination, forwarded, remaining(deadline));
}
}
delivery.whenComplete((result, failure) -> {
if (failure != null) respondError(origin, operationId, asMessagingException(failure), deadline);
else respondSend(origin, operationId, result, deadline);
origin.inboundRequests.decrementAndGet();
});
}
private void routePublish(Connection origin, long operationId, Message event, long deadline) {
List<Connection> destinations = connectedBackends().stream()
.filter(candidate -> candidate.allowedReceiveNamespaces.contains(namespace(event.channel())))
.toList();
if (destinations.size() + 1 > MessageCodec.MAX_PUBLISH_RESULTS) {
respondError(origin, operationId, new MessagingException(MessagingException.Code.REJECTED,
"publish exceeds the maximum of " + MessageCodec.MAX_PUBLISH_RESULTS + " authorized nodes"), deadline);
origin.inboundRequests.decrementAndGet();
return;
}
Map<Endpoint, SendResult> results = new LinkedHashMap<>();
results.put(Endpoint.proxy(), messaging.receiveEvent(event));
List<CompletableFuture<Void>> completions = new ArrayList<>();
for (Connection destination : destinations) {
Endpoint endpoint = Endpoint.backend(destination.name);
Message forwarded = retarget(event, endpoint);
CompletableFuture<Void> completion = new CompletableFuture<>();
completions.add(completion);
sendTo(destination, forwarded, remaining(deadline)).whenComplete((status, failure) -> {
synchronized (results) {
results.put(endpoint, failure == null ? status : sendFailure(failure));
}
completion.complete(null);
});
}
CompletableFuture.allOf(completions.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> {
Map<Endpoint, SendResult> copy;
synchronized (results) { copy = new LinkedHashMap<>(results); }
try { writeAsync(origin, MessageCodec.published(operationId, new PublishResult(event.id(), copy)), deadline); }
catch (IOException invalid) { writeAsync(origin, errorFrame(operationId, invalid), deadline); }
finally { origin.inboundRequests.decrementAndGet(); }
});
}
private void receiveResponse(Connection connection, DataInputStream input) throws IOException {
long requestId = input.readLong();
int status = input.readUnsignedByte();
byte[] payload = readPayload(input);
requireEmpty(input);
CompletableFuture<byte[]> request = connection.pending.remove(requestId);
if (request != null) {
if (status == OK) request.complete(payload);
else request.completeExceptionally(new IOException("backend response status " + status));
private void routeRequest(Connection origin, long operationId, Message request, long deadline) {
CompletionStage<Message> response;
Endpoint target = request.target();
if (target.isProxy()) {
response = dispatchProxyRequest(origin, request, deadline);
} else {
Connection destination = current(target.backendName());
if (destination == null) {
response = failed(new MessagingException(MessagingException.Code.NOT_CONNECTED,
"target backend is not connected"));
} else if (!destination.allowedReceiveNamespaces.contains(namespace(request.channel()))) {
response = failed(new MessagingException(MessagingException.Code.REJECTED,
"target backend does not allow this message namespace"));
} else {
response = requestFrom(destination, request, remaining(deadline));
}
}
}
@Override public CompletionStage<BackendSendResult> send(String backendName, String channel, byte[] payload) {
validateMessage(channel, payload);
Connection connection = current(backendName);
if (connection == null) return CompletableFuture.completedFuture(BackendSendResult.NOT_CONNECTED);
byte[] copy = payload.clone();
if (!connection.reserve(copy.length)) return CompletableFuture.completedFuture(BackendSendResult.BACKPRESSURED);
CompletableFuture<BackendSendResult> result = new CompletableFuture<>();
Thread.ofVirtual().start(() -> {
try {
connection.write(MESSAGE, out -> {
writeString(out, channel);
out.writeLong(0);
writePayload(out, copy);
});
result.complete(BackendSendResult.SENT);
} catch (IOException failure) { connection.close(); result.completeExceptionally(failure); }
finally { connection.release(copy.length); }
response.whenComplete((reply, failure) -> {
if (failure != null) respondError(origin, operationId, asMessagingException(failure), deadline);
else {
try { writeAsync(origin, MessageCodec.reply(operationId, reply), deadline); }
catch (IOException invalid) { writeAsync(origin, errorFrame(operationId, invalid), deadline); }
}
origin.inboundRequests.decrementAndGet();
});
return result;
}
@Override public CompletionStage<byte[]> request(String backendName, String channel, byte[] payload) {
validateMessage(channel, payload);
Connection connection = current(backendName);
if (connection == null) return CompletableFuture.failedFuture(new IOException("backend control is disconnected"));
byte[] copy = payload.clone();
if (!connection.reserve(copy.length))
return CompletableFuture.failedFuture(new IOException("backend control is backpressured"));
if (!connection.requestSlots.tryAcquire()) {
connection.release(copy.length);
return CompletableFuture.failedFuture(new IOException("backend control has too many pending requests"));
private CompletionStage<Message> dispatchProxyRequest(Connection origin, Message request, long deadline) {
Duration timeout = remaining(deadline);
if (timeout.isZero()) return failed(new MessagingException(MessagingException.Code.TIMED_OUT,
"request expired before proxy dispatch"));
return messaging.receiveRequest(retarget(request, Endpoint.proxy()), timeout);
}
@Override public CompletionStage<SendResult> send(Message message) {
Objects.requireNonNull(message, "message");
if (!Endpoint.proxy().equals(message.source()) || message.kind() != MessageKind.EVENT
|| message.target() == null || message.target().isProxy()) {
return failed(new MessagingException(MessagingException.Code.REJECTED,
"proxy send must be an event targeted at a backend"));
}
long id = connection.nextRequest.getAndIncrement();
if (id <= 0) {
connection.release(copy.length);
connection.requestSlots.release();
return CompletableFuture.failedFuture(new IOException("backend request ID exhausted"));
Connection connection = current(message.target().backendName());
if (connection == null) return CompletableFuture.completedFuture(SendResult.NOT_CONNECTED);
if (!connection.allowedReceiveNamespaces.contains(namespace(message.channel())))
return CompletableFuture.completedFuture(SendResult.REJECTED);
return sendTo(connection, message, REQUEST_TIMEOUT);
}
@Override public CompletionStage<Message> request(Message message, Duration timeout) {
Objects.requireNonNull(message, "message");
if (!Endpoint.proxy().equals(message.source()) || message.kind() != MessageKind.REQUEST
|| message.target() == null || message.target().isProxy()) {
return failed(new MessagingException(MessagingException.Code.REJECTED,
"proxy request must target a backend"));
}
CompletableFuture<byte[]> result = new CompletableFuture<>();
connection.pending.put(id, result);
final ScheduledFuture<?> timeout;
try {
timeout = timer.schedule(() -> result.completeExceptionally(new IOException("backend request timed out")),
REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
} catch (RuntimeException failure) {
connection.pending.remove(id, result);
connection.requestSlots.release();
connection.release(copy.length);
result.completeExceptionally(failure);
return result;
Connection connection = current(message.target().backendName());
if (connection == null) return failed(new MessagingException(
MessagingException.Code.NOT_CONNECTED, "target backend is not connected"));
if (!connection.allowedReceiveNamespaces.contains(namespace(message.channel())))
return failed(new MessagingException(MessagingException.Code.REJECTED,
"target backend does not allow this message namespace"));
return requestFrom(connection, message, timeout);
}
@Override public CompletionStage<PublishResult> publish(Message event) {
Objects.requireNonNull(event, "event");
if (!Endpoint.proxy().equals(event.source()) || event.kind() != MessageKind.EVENT || event.target() != null)
return failed(new MessagingException(MessagingException.Code.REJECTED,
"proxy publish must be an untargeted event"));
List<Connection> destinations = connectedBackends().stream()
.filter(candidate -> candidate.allowedReceiveNamespaces.contains(namespace(event.channel())))
.toList();
if (destinations.size() + 1 > MessageCodec.MAX_PUBLISH_RESULTS) {
return failed(new MessagingException(MessagingException.Code.REJECTED,
"publish exceeds the maximum of " + MessageCodec.MAX_PUBLISH_RESULTS + " authorized nodes"));
}
Map<Endpoint, SendResult> results = new LinkedHashMap<>();
List<CompletableFuture<Void>> completions = new ArrayList<>();
List<CompletableFuture<?>> deliveries = new ArrayList<>();
for (Connection destination : destinations) {
Endpoint endpoint = Endpoint.backend(destination.name);
CompletableFuture<Void> completion = new CompletableFuture<>();
completions.add(completion);
CompletionStage<SendResult> delivery = sendTo(destination, retarget(event, endpoint), REQUEST_TIMEOUT);
deliveries.add(delivery.toCompletableFuture());
delivery.whenComplete((status, failure) -> {
synchronized (results) { results.put(endpoint, failure == null ? status : sendFailure(failure)); }
completion.complete(null);
});
}
CompletableFuture<PublishResult> result = new CompletableFuture<>();
result.whenComplete((ignored, failure) -> {
connection.pending.remove(id, result);
timeout.cancel(false);
connection.requestSlots.release();
if (result.isCancelled()) deliveries.forEach(delivery -> delivery.cancel(false));
});
Thread.ofVirtual().start(() -> {
try { connection.write(MESSAGE, out -> {
writeString(out, channel);
out.writeLong(id);
writePayload(out, copy);
}); }
catch (IOException failure) { connection.close(); result.completeExceptionally(failure); }
finally { connection.release(copy.length); }
CompletableFuture.allOf(completions.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> {
synchronized (results) { result.complete(new PublishResult(event.id(), results)); }
});
return result;
}
private CompletionStage<SendResult> sendTo(Connection connection, Message message, Duration timeout) {
return mapCancellable(exchange(connection, message, timeout, MessageCodec.Response.Type.SEND),
response -> response.sendResult);
}
private CompletionStage<Message> requestFrom(Connection connection, Message message, Duration timeout) {
return mapCancellable(exchange(connection, message, timeout, MessageCodec.Response.Type.REPLY), response -> {
Message reply = response.message;
if (reply == null || !message.id().equals(reply.replyTo())
|| !message.channel().equals(reply.channel())
|| !Endpoint.backend(connection.name).equals(reply.source())
|| !message.source().equals(reply.target())) {
throw new MessagingException(MessagingException.Code.PROTOCOL_ERROR,
"backend returned a reply for a different request or identity");
}
return reply;
});
}
private CompletionStage<MessageCodec.Response> exchange(Connection connection, Message message, Duration timeout,
MessageCodec.Response.Type expected) {
if (timeout == null || timeout.isNegative() || timeout.isZero())
return failed(new MessagingException(MessagingException.Code.TIMED_OUT, "message operation timed out"));
long timeoutMillis;
try { timeoutMillis = Math.max(1, Math.min(MessageCodec.MAX_TIMEOUT_MILLIS, timeout.toMillis())); }
catch (ArithmeticException overflow) { timeoutMillis = MessageCodec.MAX_TIMEOUT_MILLIS; }
if (!connection.requestSlots.tryAcquire()) return failed(new MessagingException(
MessagingException.Code.BACKPRESSURED, "backend has too many in-flight operations"));
long operationId = connection.nextRequest.getAndIncrement();
if (operationId <= 0) {
connection.requestSlots.release();
return failed(new MessagingException(MessagingException.Code.REJECTED,
"backend operation ID space exhausted"));
}
final byte[] frame;
try { frame = MessageCodec.message(operationId, timeoutMillis, message); }
catch (IOException | RuntimeException invalid) {
connection.requestSlots.release();
return failed(new MessagingException(MessagingException.Code.REJECTED,
"message cannot be encoded", invalid));
}
final long deadline = deadlineAfterMillis(timeoutMillis);
if (!reserveWrite(connection, frame.length)) {
connection.requestSlots.release();
return failed(new MessagingException(MessagingException.Code.BACKPRESSURED,
"backend outbound queue is full"));
}
CompletableFuture<MessageCodec.Response> result = new CompletableFuture<>();
PendingOperation pending = new PendingOperation(result, expected);
if (connection.pending.putIfAbsent(operationId, pending) != null) {
releaseWrite(connection, frame.length);
connection.requestSlots.release();
return failed(new MessagingException(MessagingException.Code.REJECTED,
"backend operation ID collision"));
}
AtomicBoolean writeStarted = new AtomicBoolean();
ScheduledFuture<?> timeoutTask;
try {
timeoutTask = timer.schedule(() -> {
if (result.isCancelled()) {
if (writeStarted.get()) connection.close();
} else if (result.completeExceptionally(new MessagingException(
MessagingException.Code.TIMED_OUT, "backend message operation timed out"))
&& writeStarted.get()) connection.close();
},
timeoutMillis, TimeUnit.MILLISECONDS);
} catch (RuntimeException closedTimer) {
result.completeExceptionally(new MessagingException(MessagingException.Code.CLOSED,
"backend control service is closed", closedTimer));
timeoutTask = null;
}
final ScheduledFuture<?> operationTimeout = timeoutTask;
result.whenComplete((value, failure) -> {
connection.pending.remove(operationId, pending);
if (operationTimeout != null && (!result.isCancelled() || !writeStarted.get()))
operationTimeout.cancel(false);
connection.requestSlots.release();
});
Thread.ofVirtual().name("strataproxy-control-write").start(() -> {
try {
boolean written = connection.writeFrame(() -> {
long remainingNanos = deadline - System.nanoTime();
if (result.isDone() || remainingNanos <= 0) return null;
long remainingMillis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos));
byte[] currentFrame = MessageCodec.message(operationId, remainingMillis, message);
writeStarted.set(true);
if (result.isDone() || deadline - System.nanoTime() <= 0) {
writeStarted.set(false);
return null;
}
return currentFrame;
}, () -> writeStarted.set(false));
if (!written && !result.isDone() && deadline - System.nanoTime() <= 0) {
result.completeExceptionally(new MessagingException(MessagingException.Code.TIMED_OUT,
"backend message operation timed out before write"));
}
} catch (IOException failure) {
connection.close();
result.completeExceptionally(new MessagingException(MessagingException.Code.NOT_CONNECTED,
"backend connection was lost while writing", failure));
} finally { releaseWrite(connection, frame.length); }
});
return result;
}
private void receiveResponse(Connection connection, byte[] frame) throws IOException {
MessageCodec.Response response = MessageCodec.decodeResponse(frame);
PendingOperation pending = connection.pending.get(response.operationId);
if (pending == null) return; // A timed-out operation may receive one late receipt.
try {
routingWorkers.execute(() -> {
if (response.type == MessageCodec.Response.Type.ERROR) {
pending.result.completeExceptionally(new MessagingException(response.errorCode,
response.detail == null || response.detail.isEmpty()
? "remote message operation failed" : response.detail));
} else if (response.type != pending.expected) {
pending.result.completeExceptionally(new MessagingException(MessagingException.Code.PROTOCOL_ERROR,
"backend returned the wrong response type"));
connection.close();
} else {
pending.result.complete(response);
}
});
} catch (RejectedExecutionException overloaded) {
connection.close();
throw new IOException("backend response dispatch is overloaded", overloaded);
}
}
private void respondSend(Connection connection, long operationId, SendResult result, long deadline) {
try { writeAsync(connection, MessageCodec.sendResult(operationId, result), deadline); }
catch (IOException invalid) { writeAsync(connection, errorFrame(operationId, invalid), deadline); }
}
private void respondError(Connection connection, long operationId, MessagingException error, long deadline) {
try { writeAsync(connection, MessageCodec.error(operationId, error.code(), safeDetail(error)), deadline); }
catch (IOException invalid) { writeAsync(connection, errorFrame(operationId, invalid), deadline); }
}
private void writeAsync(Connection connection, byte[] frame) {
writeAsync(connection, frame, deadlineAfterMillis(REQUEST_TIMEOUT.toMillis()));
}
private void writeAsync(Connection connection, byte[] frame, long deadline) {
if (!reserveWrite(connection, frame.length)) {
connection.close();
return;
}
AtomicBoolean writeStarted = new AtomicBoolean();
java.util.concurrent.atomic.AtomicReference<ScheduledFuture<?>> flushTimeout =
new java.util.concurrent.atomic.AtomicReference<>();
Thread.ofVirtual().name("strataproxy-control-write").start(() -> {
try {
connection.writeFrame(() -> {
if (deadline - System.nanoTime() <= 0) return null;
writeStarted.set(true);
if (deadline - System.nanoTime() <= 0) {
writeStarted.set(false);
return null;
}
try {
flushTimeout.set(timer.schedule(connection::close,
REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS));
} catch (RuntimeException timerClosed) {
writeStarted.set(false);
throw new IOException("backend control service closed before response write", timerClosed);
}
return frame;
}, () -> {
writeStarted.set(false);
ScheduledFuture<?> timeout = flushTimeout.getAndSet(null);
if (timeout != null) timeout.cancel(false);
});
}
catch (IOException ignored) { connection.close(); }
finally {
ScheduledFuture<?> timeout = flushTimeout.getAndSet(null);
if (timeout != null) timeout.cancel(false);
releaseWrite(connection, frame.length);
}
});
}
private boolean reserveWrite(Connection connection, int size) {
if (!writeTasks.tryAcquire()) return false;
if (!connection.reserve(size)) {
writeTasks.release();
return false;
}
for (;;) {
long used = globalQueuedBytes.get();
if (used + size > MAX_GLOBAL_QUEUED_BYTES) {
connection.release(size);
writeTasks.release();
return false;
}
if (globalQueuedBytes.compareAndSet(used, used + size)) return true;
}
}
private void releaseWrite(Connection connection, int size) {
connection.release(size);
globalQueuedBytes.addAndGet(-size);
writeTasks.release();
}
private static byte[] errorFrame(long operationId, Throwable failure) {
try { return MessageCodec.error(operationId, MessagingException.Code.PROTOCOL_ERROR, safeDetail(failure)); }
catch (IOException impossible) { throw new IllegalStateException(impossible); }
}
private static byte[] singleByteFrame(int type) {
return new byte[]{(byte) type};
}
private static <T, R> CompletionStage<R> mapCancellable(CompletionStage<T> source,
java.util.function.Function<T, R> mapper) {
CompletableFuture<T> sourceFuture = source.toCompletableFuture();
CompletableFuture<R> mapped = new CompletableFuture<>();
source.whenComplete((value, failure) -> {
if (failure != null) mapped.completeExceptionally(unwrap(failure));
else {
try { mapped.complete(mapper.apply(value)); }
catch (Throwable mappingFailure) { mapped.completeExceptionally(mappingFailure); }
}
});
mapped.whenComplete((ignored, failure) -> {
if (mapped.isCancelled()) sourceFuture.cancel(false);
});
return mapped;
}
private static Message retarget(Message message, Endpoint target) {
return new Message(message.id(), message.kind(), message.channel(), message.source(), target,
message.replyTo(), message.payload());
}
private List<Connection> connectedBackends() {
synchronized (lock) {
return byBackend.values().stream().map(lease -> lease.connection)
.filter(Objects::nonNull).filter(connection -> connection.live.get())
.sorted(java.util.Comparator.comparing(connection -> connection.name)).toList();
}
}
private static String namespace(String channel) { return channel.substring(0, channel.indexOf(':')); }
private static long deadlineAfterMillis(long timeoutMillis) {
long now = System.nanoTime();
long nanos = TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
return now > Long.MAX_VALUE - nanos ? Long.MAX_VALUE : now + nanos;
}
private static Duration remaining(long deadline) {
long nanos = deadline - System.nanoTime();
return nanos <= 0 ? Duration.ZERO : Duration.ofNanos(nanos);
}
private static Throwable unwrap(Throwable failure) {
Throwable current = failure;
while ((current instanceof java.util.concurrent.CompletionException
|| current instanceof java.util.concurrent.ExecutionException) && current.getCause() != null) {
current = current.getCause();
}
return current;
}
private static MessagingException asMessagingException(Throwable failure) {
Throwable cause = unwrap(failure);
if (cause instanceof MessagingException messagingFailure) return messagingFailure;
return new MessagingException(MessagingException.Code.HANDLER_FAILED,
"message delivery failed", cause);
}
private static SendResult sendFailure(Throwable failure) {
Throwable cause = unwrap(failure);
if (cause instanceof MessagingException messagingFailure) {
return switch (messagingFailure.code()) {
case BACKPRESSURED -> SendResult.BACKPRESSURED;
case NOT_CONNECTED, CLOSED -> SendResult.NOT_CONNECTED;
case TIMED_OUT -> SendResult.TIMED_OUT;
case NO_HANDLER -> SendResult.NO_SUBSCRIBER;
case REJECTED -> SendResult.REJECTED;
case HANDLER_FAILED, PROTOCOL_ERROR -> SendResult.FAILED;
};
}
return SendResult.FAILED;
}
private static String safeDetail(Throwable failure) {
String detail = failure.getMessage();
return detail == null ? failure.getClass().getSimpleName() : detail;
}
private static <T> CompletionStage<T> failed(Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
private static ThreadFactory namedFactory(String prefix) {
AtomicInteger sequence = new AtomicInteger();
return task -> Thread.ofVirtual().name(prefix + "-" + sequence.incrementAndGet()).unstarted(task);
}
private Connection current(String name) {
synchronized (lock) {
Lease lease = byBackend.get(name);
@@ -394,24 +795,13 @@ public final class BackendControlService implements BackendChannelTransport, Aut
}
}
private static void validateMessage(String channel, byte[] payload) {
validateChannel(channel);
Objects.requireNonNull(payload, "payload");
if (payload.length > MAX_PAYLOAD) throw new IllegalArgumentException("backend payload exceeds 64 KiB");
}
private static void validateChannel(String channel) {
if (channel == null || !channel.matches("[a-z0-9][a-z0-9_.-]{0,63}:[a-z0-9][a-z0-9_.-]{0,63}"))
throw new IllegalArgumentException("invalid backend message channel");
}
private static Frame readFrame(DataInputStream input) throws IOException {
int length = input.readInt();
if (length < 1 || length > MAX_FRAME) throw new IOException("invalid backend control frame length");
byte[] bytes = input.readNBytes(length);
if (bytes.length != length) throw new EOFException();
DataInputStream body = new DataInputStream(new ByteArrayInputStream(bytes));
return new Frame(body.readUnsignedByte(), body);
return new Frame(body.readUnsignedByte(), body, bytes);
}
private static String readString(DataInputStream input, int maximum) throws IOException {
@@ -431,19 +821,6 @@ public final class BackendControlService implements BackendChannelTransport, Aut
output.write(bytes);
}
private static byte[] readPayload(DataInputStream input) throws IOException {
int length = input.readInt();
if (length < 0 || length > MAX_PAYLOAD) throw new IOException("invalid backend payload length");
byte[] bytes = input.readNBytes(length);
if (bytes.length != length) throw new EOFException();
return bytes;
}
private static void writePayload(DataOutputStream output, byte[] bytes) throws IOException {
output.writeInt(bytes.length);
output.write(bytes);
}
private static void requireEmpty(DataInputStream input) throws IOException {
if (input.available() != 0) throw new IOException("trailing backend control bytes");
}
@@ -455,15 +832,28 @@ public final class BackendControlService implements BackendChannelTransport, Aut
try { listener.close(); } catch (IOException ignored) { }
}
timer.shutdownNow();
routingWorkers.shutdownNow();
synchronized (lock) {
for (Lease lease : java.util.List.copyOf(byInstance.values())) removeLease(lease);
}
}
private record Registration(String instanceId, String name, String generation, URI address,
java.util.Set<String> allowedNamespaces) { }
private record Frame(int type, DataInputStream input) { }
java.util.Set<String> allowedNamespaces,
java.util.Set<String> allowedReceiveNamespaces) { }
private record Frame(int type, DataInputStream input, byte[] bytes) { }
private interface Writer { void write(DataOutputStream output) throws IOException; }
private interface FrameSupplier { byte[] get() throws IOException; }
private static final class PendingOperation {
private final CompletableFuture<MessageCodec.Response> result;
private final MessageCodec.Response.Type expected;
private PendingOperation(CompletableFuture<MessageCodec.Response> result,
MessageCodec.Response.Type expected) {
this.result = result;
this.expected = expected;
}
}
private static final class Lease {
private final String instanceId, name, generation;
@@ -493,11 +883,12 @@ public final class BackendControlService implements BackendChannelTransport, Aut
private final AtomicLong queuedBytes = new AtomicLong();
private final AtomicInteger queuedMessages = new AtomicInteger();
private final AtomicLong nextRequest = new AtomicLong(1);
private final ConcurrentHashMap<Long, CompletableFuture<byte[]>> pending = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, PendingOperation> pending = new ConcurrentHashMap<>();
private final AtomicInteger inboundRequests = new AtomicInteger();
private final Semaphore requestSlots = new Semaphore(MAX_REQUESTS);
private String instanceId, name;
private java.util.Set<String> allowedNamespaces = java.util.Set.of();
private java.util.Set<String> allowedReceiveNamespaces = java.util.Set.of();
private long epoch;
private long rateWindowNanos = System.nanoTime();
private int messagesInWindow;
@@ -547,11 +938,26 @@ public final class BackendControlService implements BackendChannelTransport, Aut
output.flush();
}
}
private boolean writeFrame(FrameSupplier frameSupplier, Runnable afterWrite) throws IOException {
synchronized (writeLock) {
if (!live.get()) throw new SocketException("backend control disconnected");
byte[] frame = frameSupplier.get();
if (frame == null) return false;
if (frame.length < 1 || frame.length > MAX_FRAME)
throw new IOException("control frame length out of bounds");
output.writeInt(frame.length);
output.write(frame);
output.flush();
afterWrite.run();
return true;
}
}
private void close() {
if (!live.compareAndSet(true, false)) return;
try { socket.close(); } catch (IOException ignored) { }
for (CompletableFuture<byte[]> result : pending.values())
result.completeExceptionally(new IOException("backend control disconnected"));
for (PendingOperation operation : pending.values())
operation.result.completeExceptionally(new MessagingException(MessagingException.Code.NOT_CONNECTED,
"backend control disconnected"));
pending.clear();
}
}
@@ -1,8 +0,0 @@
package dev.strataproxy.core.control;
/** No proxy plugin currently owns the requested backend message channel. */
public final class BackendNoHandlerException extends IllegalStateException {
public BackendNoHandlerException(String channel) {
super("No active plugin handler for backend channel " + channel);
}
}
@@ -1,11 +1,6 @@
package dev.strataproxy.core.plugin;
import dev.strataproxy.api.AccessDecision;
import dev.strataproxy.api.BackendChannelSubscription;
import dev.strataproxy.api.BackendChannels;
import dev.strataproxy.api.BackendMessage;
import dev.strataproxy.api.BackendMessageHandler;
import dev.strataproxy.api.BackendSendResult;
import dev.strataproxy.api.MessageResult;
import dev.strataproxy.api.DisconnectResult;
import dev.strataproxy.api.event.ConnectionAdmissionEvent;
@@ -16,8 +11,18 @@ import dev.strataproxy.api.event.Events;
import dev.strataproxy.api.event.PlayerAdmissionEvent;
import dev.strataproxy.api.event.PlayerDisconnectedEvent;
import dev.strataproxy.api.event.ServerConnectedEvent;
import dev.strataproxy.messaging.Endpoint;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.MessageKind;
import dev.strataproxy.messaging.Messaging;
import dev.strataproxy.messaging.MessagingException;
import dev.strataproxy.messaging.PublishResult;
import dev.strataproxy.messaging.SendResult;
import dev.strataproxy.messaging.internal.LocalMessaging;
import dev.strataproxy.api.InitialPlacementHandler;
import dev.strataproxy.api.CommandHandler;
import dev.strataproxy.api.CommandCompleter;
import dev.strataproxy.api.CommandCompletion;
import dev.strataproxy.api.CommandInvocation;
import dev.strataproxy.api.CommandRegistration;
import dev.strataproxy.api.Commands;
@@ -32,13 +37,13 @@ import dev.strataproxy.api.ServerRegistration;
import dev.strataproxy.api.ServerView;
import dev.strataproxy.api.Servers;
import dev.strataproxy.api.TransferResult;
import net.kyori.adventure.text.Component;
import dev.strataproxy.core.backend.BackendCatalog;
import dev.strataproxy.core.backend.BackendId;
import dev.strataproxy.core.backend.BackendOwner;
import dev.strataproxy.core.backend.BackendRegistration;
import dev.strataproxy.core.backend.BackendView;
import dev.strataproxy.core.control.BackendChannelTransport;
import dev.strataproxy.core.control.BackendNoHandlerException;
import dev.strataproxy.core.event.EventDispatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -78,6 +83,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
@@ -87,6 +93,12 @@ import java.util.jar.JarFile;
public final class PluginHost implements AutoCloseable, EventDispatcher {
private static final Logger LOGGER = LoggerFactory.getLogger(PluginHost.class);
private static final AtomicLong NEXT_HOST_GENERATION = new AtomicLong();
private static final int MAX_PENDING_COMMAND_COMPLETIONS = 128;
private static final int MAX_COMPLETION_RESULTS = 100;
// Leave room for bounded protocol encoding overhead beyond suggestion UTF-8 bodies.
private static final int MAX_COMPLETION_BYTES = 32_000;
private static final int MAX_COMPLETION_CANDIDATES_SCANNED = 4096;
private static final Duration COMMAND_COMPLETION_TIMEOUT = Duration.ofSeconds(1);
private static final ThreadFactory PLAYER_COMPLETION_THREADS = Thread.ofVirtual()
.name("strataproxy-plugin-player-", 0).factory();
private static final Executor PLAYER_COMPLETIONS = task -> PLAYER_COMPLETION_THREADS.newThread(task).start();
@@ -98,7 +110,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private final ThreadPoolExecutor commandWorkers;
private final ThreadPoolExecutor backendWorkers;
private final ConcurrentHashMap<String, RegisteredCommand> commands = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, BackendHandlerRegistration> backendHandlers = new ConcurrentHashMap<>();
private final Set<PendingCompletion> pendingCompletions = ConcurrentHashMap.newKeySet();
private final AtomicInteger pendingCompletionCount = new AtomicInteger();
private final LocalMessaging localMessaging;
private final ScheduledThreadPoolExecutor timer;
private final AsyncEventDispatcher admissionEvents;
private final AsyncEventDispatcher notificationEvents;
@@ -109,7 +123,6 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private final List<URLClassLoader> classLoaders = new ArrayList<>();
private final List<LoadedPlugin> plugins = new ArrayList<>();
private final Set<PendingPlacement> pendingPlacements = ConcurrentHashMap.newKeySet();
private final Set<CompletableFuture<byte[]>> pendingBackendMessages = ConcurrentHashMap.newKeySet();
private volatile State state = State.LOADING;
private volatile BackendChannelTransport backendChannelTransport;
private ScheduledFuture<?> notificationDropReporter;
@@ -184,6 +197,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
timer, namedThreads("strataproxy-plugin-admission"));
this.notificationEvents = new AsyncEventDispatcher(eventTimeout, 1, 128,
timer, namedThreads("strataproxy-plugin-notification"));
this.localMessaging = new LocalMessaging(Endpoint.proxy(), new HostMessagingOutbound(), backendWorkers, timer);
}
/** Loads every JAR in the directory in filename order through Java's service provider mechanism. */
@@ -295,12 +309,10 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
for (LoadedPlugin loaded : plugins) {
loaded.enabled = true;
loaded.context.beginEventRegistration();
loaded.context.beginBackendChannelRegistration();
try {
loaded.plugin.onEnable();
} finally {
loaded.context.endEventRegistration();
loaded.context.endBackendChannelRegistration();
}
}
freezeEventRegistrations();
@@ -359,65 +371,8 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
backendChannelTransport = Objects.requireNonNull(transport, "transport");
}
/** Dispatches an authenticated request from a backend to the proxy plugin registered for its channel. */
public CompletionStage<byte[]> dispatchBackendMessage(BackendMessage message) {
Objects.requireNonNull(message, "message");
final BackendHandlerRegistration registration;
final CompletableFuture<byte[]> result = new CompletableFuture<>();
final ScheduledFuture<?> timeout;
synchronized (this) {
if (state != State.ENABLED) {
return CompletableFuture.failedFuture(new IllegalStateException("Plugin host is not enabled"));
}
registration = backendHandlers.get(message.channel());
if (registration == null || !registration.isActive()) {
return CompletableFuture.failedFuture(new BackendNoHandlerException(message.channel()));
}
pendingBackendMessages.add(result);
try {
timeout = timer.schedule(() -> result.completeExceptionally(
new TimeoutException("Backend channel handler exceeded five seconds")),
5, TimeUnit.SECONDS);
} catch (RuntimeException failure) {
pendingBackendMessages.remove(result);
return CompletableFuture.failedFuture(failure);
}
}
result.whenComplete((ignored, failure) -> {
timeout.cancel(false);
pendingBackendMessages.remove(result);
});
Runnable invocation = () -> {
if (result.isDone()) return;
if (state != State.ENABLED || !registration.isActive()
|| backendHandlers.get(message.channel()) != registration) {
result.completeExceptionally(new IllegalStateException("Backend channel handler is inactive"));
return;
}
try {
CompletionStage<byte[]> response = Objects.requireNonNull(
registration.handler.handle(message), "backend message handler stage");
response.whenComplete((payload, failure) -> {
if (failure != null) {
result.completeExceptionally(failure);
} else if (payload == null) {
result.completeExceptionally(new IllegalStateException(
"Backend message handler returned null"));
} else {
result.complete(payload.clone());
}
});
} catch (Throwable failure) {
result.completeExceptionally(failure);
}
};
try {
backendWorkers.execute(invocation);
} catch (RejectedExecutionException overloaded) {
result.completeExceptionally(new PluginOverloadedException(overloaded));
}
return result;
}
/** Host-side dispatcher used by the authenticated control transport. */
public LocalMessaging localMessaging() { return localMessaging; }
/** Returns empty when no plugin handles placement; plugin code always runs off the caller's event loop. */
public CompletionStage<Optional<PlacementDecision>> placeInitial(PlayerView player) {
@@ -504,13 +459,168 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
return result;
}
/** Returns up to 100 registered roots matching a case-insensitive prefix without a leading slash. */
public List<String> commandNames(String prefix) {
Objects.requireNonNull(prefix, "prefix");
if (state != State.ENABLED) return List.of();
String normalized = prefix.toLowerCase(Locale.ROOT);
return commands.values().stream()
.filter(command -> command.context.active && command.name.startsWith(normalized))
.map(command -> "/" + command.name)
.sorted()
.limit(MAX_COMPLETION_RESULTS)
.toList();
}
/**
* Completes arguments for a known slash command. Root-only input belongs to {@link #commandNames(String)};
* the callback receives arguments after exactly one root separator, preserving all remaining whitespace.
*/
public Optional<CompletionStage<List<String>>> completeCommand(PlayerView player, String input) {
Objects.requireNonNull(player, "player");
Objects.requireNonNull(input, "input");
if (state != State.ENABLED || input.length() < 2 || input.charAt(0) != '/') return Optional.empty();
int end = 1;
while (end < input.length() && !Character.isWhitespace(input.charAt(end))) end++;
if (end == input.length()) return Optional.empty();
String name = input.substring(1, end).toLowerCase(Locale.ROOT);
RegisteredCommand registered = commands.get(name);
if (registered == null || !registered.context.active) return Optional.empty();
String arguments = input.substring(end + 1);
if (registered.completer == null) {
return Optional.of(CompletableFuture.completedFuture(List.of()));
}
synchronized (this) {
if (state != State.ENABLED || commands.get(name) != registered || !registered.context.active) {
return Optional.empty();
}
if (!reserveCompletionSlot()) {
return Optional.of(CompletableFuture.failedFuture(new PluginOverloadedException(
new RejectedExecutionException("Too many pending command completions"))));
}
PendingCompletion pending = new PendingCompletion(registered, player, name, arguments);
pendingCompletions.add(pending);
registered.pendingCompletions.add(pending);
pending.result.whenComplete((ignored, failure) -> {
if (pending.result.isCancelled()) finishCompletion(pending, null,
new java.util.concurrent.CancellationException("Command completion cancelled"), true);
});
try {
pending.timeout = timer.schedule(() -> finishCompletion(pending, null,
new TimeoutException("Plugin command completion timed out"), true),
COMMAND_COMPLETION_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS);
FutureTask<Void> invocation = new FutureTask<>(() -> {
invokeCompleter(pending);
return null;
});
pending.invocation = invocation;
commandWorkers.execute(invocation);
if (pending.finished.get()) {
invocation.cancel(true);
commandWorkers.remove(invocation);
}
} catch (RejectedExecutionException overloaded) {
finishCompletion(pending, null, new PluginOverloadedException(overloaded), true);
} catch (RuntimeException failure) {
finishCompletion(pending, null, failure, true);
}
return Optional.of(pending.result);
}
}
private boolean reserveCompletionSlot() {
while (true) {
int current = pendingCompletionCount.get();
if (current >= MAX_PENDING_COMMAND_COMPLETIONS) return false;
if (pendingCompletionCount.compareAndSet(current, current + 1)) return true;
}
}
private void invokeCompleter(PendingCompletion pending) {
if (pending.finished.get()) return;
if (state != State.ENABLED || !pending.registered.context.active
|| commands.get(pending.commandName) != pending.registered) {
finishCompletion(pending, null, new IllegalStateException("Command is no longer registered"), true);
return;
}
try {
CompletionStage<List<String>> stage = Objects.requireNonNull(
pending.registered.completer.complete(new CommandCompletion(
pending.player, pending.commandName, pending.arguments)),
"Command completer returned a null stage");
CompletableFuture<List<String>> cancellable = stage.toCompletableFuture();
pending.pluginStage.set(cancellable);
if (pending.finished.get()) cancellable.cancel(true);
stage.whenCompleteAsync((suggestions, failure) -> {
if (failure != null) finishCompletion(pending, null, failure, false);
else {
try {
finishCompletion(pending, sanitizeSuggestions(
Objects.requireNonNull(suggestions, "Command completer returned null suggestions")),
null, false);
} catch (Throwable invalid) {
finishCompletion(pending, null, invalid, false);
}
}
}, commandWorkers);
} catch (Throwable failure) {
finishCompletion(pending, null, failure, true);
}
}
private static List<String> sanitizeSuggestions(List<String> suggestions) {
var result = new ArrayList<String>(Math.min(suggestions.size(), MAX_COMPLETION_RESULTS));
var unique = new HashSet<String>();
int utf8Bytes = 0;
int scanned = 0;
for (String suggestion : suggestions) {
if (scanned++ >= MAX_COMPLETION_CANDIDATES_SCANNED || result.size() >= MAX_COMPLETION_RESULTS) break;
if (suggestion == null || suggestion.isEmpty() || suggestion.length() > 100 || containsWhitespace(suggestion)
|| !unique.add(suggestion)) continue;
int bytes = suggestion.getBytes(StandardCharsets.UTF_8).length;
if (utf8Bytes + bytes > MAX_COMPLETION_BYTES) continue;
result.add(suggestion);
utf8Bytes += bytes;
}
return List.copyOf(result);
}
private static boolean containsWhitespace(String value) {
for (int offset = 0; offset < value.length();) {
int codePoint = value.codePointAt(offset);
if (Character.isWhitespace(codePoint) || Character.isSpaceChar(codePoint)) return true;
offset += Character.charCount(codePoint);
}
return false;
}
private void finishCompletion(PendingCompletion pending, List<String> suggestions, Throwable failure,
boolean cancelExecution) {
if (!pending.finished.compareAndSet(false, true)) return;
pendingCompletions.remove(pending);
pending.registered.pendingCompletions.remove(pending);
pendingCompletionCount.decrementAndGet();
if (pending.timeout != null) pending.timeout.cancel(false);
FutureTask<Void> invocation = pending.invocation;
if (invocation != null && cancelExecution && !invocation.isDone()) {
commandWorkers.remove(invocation);
invocation.cancel(true);
}
CompletableFuture<List<String>> pluginStage = pending.pluginStage.get();
if (pluginStage != null && cancelExecution && !pluginStage.isDone()) {
PLAYER_COMPLETIONS.execute(() -> pluginStage.cancel(true));
}
if (failure != null) pending.result.completeExceptionally(failure);
else pending.result.complete(Objects.requireNonNull(suggestions, "suggestions"));
}
/** Claims only known root commands; caller retains and forwards every other chat frame. */
public boolean dispatchCommand(PlayerView player, String message, Consumer<String> reply) {
public boolean dispatchCommand(PlayerView player, String message, Consumer<Component> reply) {
return dispatchCommand(player, message, reply, () -> true);
}
/** Admission is checked only after the command name is known, preserving unknown-command passthrough. */
public boolean dispatchCommand(PlayerView player, String message, Consumer<String> reply,
public boolean dispatchCommand(PlayerView player, String message, Consumer<Component> reply,
BooleanSupplier admission) {
Objects.requireNonNull(player, "player");
Objects.requireNonNull(message, "message");
@@ -523,9 +633,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
RegisteredCommand registered = commands.get(name);
if (registered == null || !registered.context.active) return false;
if (!admission.getAsBoolean()) return true;
String arguments = end == message.length() ? "" : message.substring(end).stripLeading();
String arguments = end == message.length() ? "" : message.substring(end + 1);
CommandInvocation invocation = new CommandInvocation(player, name, arguments,
text -> { if (registered.context.active) reply.accept(text); });
component -> { if (registered.context.active) reply.accept(component); });
try {
commandWorkers.execute(() -> {
if (state != State.ENABLED || !registered.context.active || commands.get(name) != registered) return;
@@ -537,7 +647,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
});
} catch (RejectedExecutionException overloaded) {
reply.accept("Proxy command service is busy. Please try again.");
reply.accept(Component.text("Proxy command service is busy. Please try again."));
}
return true;
}
@@ -552,17 +662,17 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
// chain can skip the revoked subscriptions and incorrectly finish with allow().
admissionEvents.close();
notificationEvents.close();
localMessaging.close();
if (notificationDropReporter != null) notificationDropReporter.cancel(false);
logNotificationDrops();
for (LoadedPlugin loaded : plugins) loaded.context.revokeEventSubscriptions();
for (LoadedPlugin loaded : plugins) loaded.context.revokeBackendChannelSubscriptions();
for (CompletableFuture<byte[]> request : pendingBackendMessages) {
request.completeExceptionally(new IllegalStateException("Plugin host closed"));
}
for (PendingPlacement request : pendingPlacements) {
request.decided().set(true);
request.result().completeExceptionally(new IllegalStateException("Plugin host closed"));
}
for (PendingCompletion pending : pendingCompletions) {
finishCompletion(pending, null, new IllegalStateException("Plugin host closed"), true);
}
callbacks.shutdownNow();
commandWorkers.shutdownNow();
backendWorkers.shutdownNow();
@@ -586,7 +696,6 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
placementHandler = null;
eventListeners = Map.of();
eventRegistrations.clear();
backendHandlers.clear();
backendChannelTransport = null;
}
@@ -626,7 +735,6 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
LoadedPlugin loaded = new LoadedPlugin(plugin, owner, context);
plugins.add(loaded);
context.beginEventRegistration();
context.beginBackendChannelRegistration();
try {
plugin.onLoad(context);
} catch (Throwable failure) {
@@ -635,7 +743,6 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
throw new PluginLoadException(id, failure);
} finally {
context.endEventRegistration();
context.endBackendChannelRegistration();
}
}
@@ -674,95 +781,6 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
}
private BackendChannelSubscription registerBackendHandler(
PluginContextImpl context, String channel, BackendMessageHandler handler) {
Objects.requireNonNull(channel, "channel");
Objects.requireNonNull(handler, "handler");
if (!channel.matches("[a-z0-9][a-z0-9_.-]{0,63}:[a-z0-9][a-z0-9_.-]{0,63}")) {
throw new IllegalArgumentException("invalid backend channel: " + channel);
}
synchronized (this) {
synchronized (context) {
context.requireBackendChannelRegistrationOpen();
BackendHandlerRegistration registration = new BackendHandlerRegistration(context, channel, handler);
if (backendHandlers.putIfAbsent(channel, registration) != null) {
throw new IllegalArgumentException("Backend channel already registered: " + channel);
}
context.backendChannelSubscriptions.add(registration);
return registration;
}
}
}
private CompletionStage<BackendSendResult> sendBackendMessage(
PluginContextImpl context, String backendName, String channel, byte[] payload) {
Objects.requireNonNull(backendName, "backendName");
Objects.requireNonNull(channel, "channel");
byte[] copy = Objects.requireNonNull(payload, "payload").clone();
BackendChannelTransport transport;
synchronized (this) {
synchronized (context) {
context.requireActive();
if (state == State.CLOSED) return CompletableFuture.failedFuture(
new IllegalStateException("Plugin host is closed"));
transport = backendChannelTransport;
}
if (transport == null) return CompletableFuture.failedFuture(
new IllegalStateException("Backend channel transport is not available"));
try {
return Objects.requireNonNull(transport.send(backendName, channel, copy), "transport send stage");
} catch (Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
}
}
private CompletionStage<byte[]> requestBackendMessage(
PluginContextImpl context, String backendName, String channel, byte[] payload) {
Objects.requireNonNull(backendName, "backendName");
Objects.requireNonNull(channel, "channel");
byte[] copy = Objects.requireNonNull(payload, "payload").clone();
BackendChannelTransport transport;
synchronized (this) {
synchronized (context) {
context.requireActive();
if (state == State.CLOSED) return CompletableFuture.failedFuture(
new IllegalStateException("Plugin host is closed"));
transport = backendChannelTransport;
}
if (transport == null) return CompletableFuture.failedFuture(
new IllegalStateException("Backend channel transport is not available"));
try {
CompletionStage<byte[]> response = Objects.requireNonNull(
transport.request(backendName, channel, copy), "transport request stage");
CompletableFuture<byte[]> result = new CompletableFuture<>();
pendingBackendMessages.add(result);
ScheduledFuture<?> timeout;
try {
timeout = timer.schedule(() -> result.completeExceptionally(
new TimeoutException("Backend request exceeded five seconds")),
5, TimeUnit.SECONDS);
} catch (RuntimeException failure) {
pendingBackendMessages.remove(result);
return CompletableFuture.failedFuture(failure);
}
result.whenComplete((ignored, failure) -> {
timeout.cancel(false);
pendingBackendMessages.remove(result);
});
response.whenComplete((reply, failure) -> {
if (failure != null) result.completeExceptionally(failure);
else if (reply == null) result.completeExceptionally(
new IllegalStateException("Backend channel transport returned null"));
else result.complete(reply.clone());
});
return result;
} catch (Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
}
}
private void logNotificationDrops() {
long count = notificationDrops.getAndSet(0);
if (count != 0) {
@@ -798,23 +816,38 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private record PendingPlacement(CompletableFuture<Optional<PlacementDecision>> result,
AtomicBoolean decided) { }
private record RegisteredCommand(String name, PluginContextImpl context, CommandHandler handler) { }
private final class BackendHandlerRegistration implements BackendChannelSubscription {
private final class RegisteredCommand {
private final String name;
private final PluginContextImpl context;
private final String channel;
private final BackendMessageHandler handler;
private final AtomicBoolean active = new AtomicBoolean(true);
private final CommandHandler handler;
private final CommandCompleter completer;
private final Set<PendingCompletion> pendingCompletions = ConcurrentHashMap.newKeySet();
private BackendHandlerRegistration(PluginContextImpl context, String channel, BackendMessageHandler handler) {
private RegisteredCommand(String name, PluginContextImpl context, CommandHandler handler,
CommandCompleter completer) {
this.name = name;
this.context = context;
this.channel = channel;
this.handler = handler;
this.completer = completer;
}
}
private boolean isActive() { return active.get() && context.active; }
@Override public void close() {
if (active.compareAndSet(true, false)) backendHandlers.remove(channel, this);
private final class PendingCompletion {
private final RegisteredCommand registered;
private final PlayerView player;
private final String commandName;
private final String arguments;
private final CompletableFuture<List<String>> result = new CompletableFuture<>();
private final AtomicBoolean finished = new AtomicBoolean();
private final AtomicReference<CompletableFuture<List<String>>> pluginStage = new AtomicReference<>();
private volatile FutureTask<Void> invocation;
private volatile ScheduledFuture<?> timeout;
private PendingCompletion(RegisteredCommand registered, PlayerView player, String commandName, String arguments) {
this.registered = registered;
this.player = player;
this.commandName = commandName;
this.arguments = arguments;
}
}
@@ -856,26 +889,24 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private final class PluginContextImpl implements PluginContext {
private final BackendOwner owner;
private final Messaging pluginMessaging;
private final Players pluginPlayers;
private final PluginServers servers;
private final Commands pluginCommands;
private final Events pluginEvents;
private final BackendChannels pluginBackendChannels;
private final Logger logger;
private final Map<String, String> settings;
private final Set<EventRegistration<?, ?>> eventSubscriptions = new HashSet<>();
private final Set<BackendHandlerRegistration> backendChannelSubscriptions = new HashSet<>();
private volatile boolean active = true;
private boolean eventRegistrationOpen;
private boolean backendChannelRegistrationOpen;
private PluginContextImpl(BackendOwner owner, Map<String, String> settings) {
this.owner = owner;
this.pluginMessaging = localMessaging.openScope(owner.id());
this.pluginPlayers = new PluginPlayers(this);
this.servers = new PluginServers(this);
this.pluginCommands = new PluginCommands(this);
this.pluginEvents = new PluginEvents(this);
this.pluginBackendChannels = new PluginBackendChannels(this);
this.logger = LoggerFactory.getLogger("plugin." + owner.id());
this.settings = Map.copyOf(settings);
}
@@ -884,16 +915,22 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
@Override public Servers servers() { return servers; }
@Override public Commands commands() { return pluginCommands; }
@Override public Events events() { return pluginEvents; }
@Override public BackendChannels backendChannels() { return pluginBackendChannels; }
@Override public Messaging messaging() { return pluginMessaging; }
@Override public Logger logger() { return logger; }
@Override public Map<String, String> settings() { return settings; }
private synchronized void deactivateAndRemove() {
active = false;
eventRegistrationOpen = false;
backendChannelRegistrationOpen = false;
pluginMessaging.close();
revokeEventSubscriptions();
revokeBackendChannelSubscriptions();
for (RegisteredCommand command : commands.values()) {
if (command.context == this) {
for (PendingCompletion pending : command.pendingCompletions) {
finishCompletion(pending, null, new IllegalStateException("Plugin unloaded"), true);
}
}
}
commands.entrySet().removeIf(entry -> entry.getValue().context == this);
catalog.removeOwner(owner);
}
@@ -911,19 +948,6 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private synchronized void endEventRegistration() { eventRegistrationOpen = false; }
private synchronized void beginBackendChannelRegistration() {
if (!active) throw new IllegalStateException("Plugin context is inactive");
backendChannelRegistrationOpen = true;
}
private synchronized void endBackendChannelRegistration() { backendChannelRegistrationOpen = false; }
private synchronized void revokeBackendChannelSubscriptions() {
backendChannelRegistrationOpen = false;
backendChannelSubscriptions.forEach(BackendHandlerRegistration::close);
backendChannelSubscriptions.clear();
}
private synchronized void requireEventRegistrationOpen() {
requireActive();
if (!eventRegistrationOpen || state != State.LOADING) {
@@ -931,13 +955,6 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
}
private synchronized void requireBackendChannelRegistrationOpen() {
requireActive();
if (!backendChannelRegistrationOpen || state != State.LOADING) {
throw new IllegalStateException("Backend channel subscriptions are only allowed during onLoad or onEnable");
}
}
private void requireActive() {
if (!active) throw new IllegalStateException("Plugin context is inactive");
}
@@ -954,22 +971,80 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
}
private final class PluginBackendChannels implements BackendChannels {
private final PluginContextImpl context;
private PluginBackendChannels(PluginContextImpl context) { this.context = context; }
@Override public BackendChannelSubscription subscribe(String channel, BackendMessageHandler handler) {
return registerBackendHandler(context, channel, handler);
private final class HostMessagingOutbound implements LocalMessaging.Outbound {
@Override public CompletionStage<SendResult> send(Message message) {
if (!Endpoint.proxy().equals(message.source()) || message.kind() != MessageKind.EVENT
|| message.target() == null) {
return CompletableFuture.failedFuture(new MessagingException(
MessagingException.Code.REJECTED, "invalid proxy message send"));
}
if (message.target().isProxy()) {
return CompletableFuture.completedFuture(localMessaging.receiveEvent(message));
}
BackendChannelTransport transport = backendChannelTransport;
if (transport == null) return CompletableFuture.completedFuture(SendResult.NOT_CONNECTED);
try {
return Objects.requireNonNull(transport.send(message), "transport send stage");
} catch (Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
}
@Override public CompletionStage<BackendSendResult> send(String backendName, String channel, byte[] payload) {
return sendBackendMessage(context, backendName, channel, payload);
@Override public CompletionStage<Message> request(Message message, Duration timeout) {
if (!Endpoint.proxy().equals(message.source()) || message.kind() != MessageKind.REQUEST
|| message.target() == null) {
return CompletableFuture.failedFuture(new MessagingException(
MessagingException.Code.REJECTED, "invalid proxy message request"));
}
if (message.target().isProxy()) return localMessaging.receiveRequest(message, timeout);
BackendChannelTransport transport = backendChannelTransport;
if (transport == null) return CompletableFuture.failedFuture(new MessagingException(
MessagingException.Code.NOT_CONNECTED, "backend control transport is not available"));
try {
return Objects.requireNonNull(transport.request(message, timeout), "transport request stage");
} catch (Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
}
@Override public CompletionStage<byte[]> request(String backendName, String channel, byte[] payload) {
return requestBackendMessage(context, backendName, channel, payload);
@Override public CompletionStage<PublishResult> publish(Message event) {
if (!Endpoint.proxy().equals(event.source()) || event.kind() != MessageKind.EVENT
|| event.target() != null) {
return CompletableFuture.failedFuture(new MessagingException(
MessagingException.Code.REJECTED, "invalid proxy event publication"));
}
BackendChannelTransport transport = backendChannelTransport;
if (transport == null) return CompletableFuture.completedFuture(new PublishResult(
event.id(), Map.of(Endpoint.proxy(), localMessaging.receiveEvent(event))));
final CompletionStage<PublishResult> remote;
try {
remote = Objects.requireNonNull(transport.publish(event), "transport publish stage");
} catch (Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
CompletableFuture<PublishResult> combinedResult = new CompletableFuture<>();
remote.whenComplete((result, failure) -> {
if (combinedResult.isCancelled()) return;
if (failure != null) {
combinedResult.completeExceptionally(failure);
return;
}
if (!event.id().equals(result.messageId()) || result.results().containsKey(Endpoint.proxy())) {
combinedResult.completeExceptionally(new MessagingException(MessagingException.Code.PROTOCOL_ERROR,
"transport returned an invalid publish result"));
return;
}
Map<Endpoint, SendResult> combined = new java.util.LinkedHashMap<>();
combined.put(Endpoint.proxy(), localMessaging.receiveEvent(event));
combined.putAll(result.results());
combinedResult.complete(new PublishResult(event.id(), combined));
});
combinedResult.whenComplete((ignored, failure) -> {
if (combinedResult.isCancelled()) remote.toCompletableFuture().cancel(false);
});
return combinedResult;
}
}
private final class PluginCommands implements Commands {
@@ -977,7 +1052,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private PluginCommands(PluginContextImpl context) { this.context = context; }
@Override public CommandRegistration register(String name, CommandHandler handler) {
@Override public CommandRegistration register(String name, CommandHandler handler, CommandCompleter completer) {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(handler, "handler");
String normalized = name.toLowerCase(Locale.ROOT);
@@ -986,11 +1061,19 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
synchronized (context) {
context.requireActive();
RegisteredCommand registration = new RegisteredCommand(normalized, context, handler);
RegisteredCommand registration = new RegisteredCommand(normalized, context, handler, completer);
if (commands.putIfAbsent(normalized, registration) != null) {
throw new IllegalArgumentException("Command already registered: /" + normalized);
}
return () -> commands.remove(normalized, registration);
return () -> {
synchronized (PluginHost.this) {
if (commands.remove(normalized, registration)) {
for (PendingCompletion pending : registration.pendingCompletions) {
finishCompletion(pending, null, new IllegalStateException("Command unregistered"), true);
}
}
}
};
}
}
}
@@ -1026,7 +1109,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
synchronized (context) {
context.requireActive();
return players.sendMessage(identity, message)
@@ -1034,7 +1117,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
synchronized (context) {
context.requireActive();
return players.disconnect(identity, reason)
@@ -1,15 +1,14 @@
package dev.strataproxy.core.protocol;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import net.kyori.adventure.text.Component;
import java.util.Optional;
import java.util.OptionalInt;
/** The few protocol 5 PLAY packets a backend switch must understand or synthesize. */
public final class Minecraft1710PlayPackets {
private static final ObjectMapper JSON = new ObjectMapper();
public static final int CLIENT_CHAT = 0x01;
public static final int SERVER_CHAT = 0x02;
public static final int JOIN_GAME = 0x01;
@@ -44,11 +43,17 @@ public final class Minecraft1710PlayPackets {
/** Encodes a plain-text proxy reply as a protocol 5 clientbound PLAY chat frame. */
public static ByteBuf chatReply(ByteBufAllocator allocator, String message) {
validateText(message, true);
String component = JSON.createObjectNode().put("text", message).toString();
return chatReplyEncoded(allocator, MinecraftText.encode(Component.text(message)));
}
/** Encodes a protocol 5 clientbound PLAY chat frame from prevalidated component JSON. */
public static ByteBuf chatReplyEncoded(ByteBufAllocator allocator, String componentJson) {
MinecraftText.validateEncoded(componentJson);
if (allocator == null) throw new NullPointerException("allocator");
ByteBuf packet = allocator.buffer();
try {
ProtocolVarInt.write(packet, SERVER_CHAT);
ProtocolStrings.write(packet, component, 32767);
ProtocolStrings.write(packet, componentJson, 32767);
return frame(allocator, packet);
} finally {
packet.release();
@@ -58,11 +63,17 @@ public final class Minecraft1710PlayPackets {
/** Encodes an unframed protocol 5 PLAY Disconnect payload. */
public static ByteBuf disconnect(ByteBufAllocator allocator, String reason) {
validateText(reason, false);
String component = JSON.createObjectNode().put("text", reason).toString();
return disconnectEncoded(allocator, MinecraftText.encodeReason(Component.text(reason)));
}
/** Encodes an unframed protocol 5 PLAY Disconnect payload from prevalidated component JSON. */
public static ByteBuf disconnectEncoded(ByteBufAllocator allocator, String componentJson) {
MinecraftText.validateEncoded(componentJson);
if (allocator == null) throw new NullPointerException("allocator");
ByteBuf packet = allocator.buffer();
try {
ProtocolVarInt.write(packet, SERVER_DISCONNECT);
ProtocolStrings.write(packet, component, 32767);
ProtocolStrings.write(packet, componentJson, 32767);
return packet;
} catch (RuntimeException failure) {
packet.release();
@@ -1,14 +1,13 @@
package dev.strataproxy.core.protocol;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import net.kyori.adventure.text.Component;
import java.util.Objects;
/** Protocol 5 LOGIN clientbound Disconnect (packet id 0x00). */
public final class MinecraftLoginDisconnect {
private static final ObjectMapper JSON = new ObjectMapper();
private MinecraftLoginDisconnect() { }
@@ -18,11 +17,17 @@ public final class MinecraftLoginDisconnect {
if (reason.isBlank() || reason.codePointCount(0, reason.length()) > 1024) {
throw new IllegalArgumentException("reason must contain 1 to 1024 characters");
}
String component = JSON.createObjectNode().put("text", reason).toString();
return encodeJson(allocator, MinecraftText.encodeReason(Component.text(reason)));
}
/** Encodes a LOGIN Disconnect payload from prevalidated component JSON. */
public static ByteBuf encodeJson(ByteBufAllocator allocator, String componentJson) {
Objects.requireNonNull(allocator, "allocator");
MinecraftText.validateEncoded(componentJson);
ByteBuf packet = allocator.buffer();
try {
ProtocolVarInt.write(packet, 0);
ProtocolStrings.write(packet, component, 32767);
ProtocolStrings.write(packet, componentJson, 32767);
return packet;
} catch (RuntimeException failure) {
packet.release();
@@ -0,0 +1,63 @@
package dev.strataproxy.core.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import java.util.ArrayList;
import java.util.List;
/** Protocol 5 completion packets contain no request identifier. */
public final class MinecraftTabCompletion {
public static final int REQUEST = 0x14;
public static final int RESPONSE = 0x3a;
private MinecraftTabCompletion() { }
public static boolean matches(ByteBuf frame, int packetId) {
int offset = frame.readerIndex();
for (int i = 0; i < 3 && offset < frame.writerIndex(); i++) {
if ((frame.getUnsignedByte(offset++) & 0x80) == 0) {
return offset < frame.writerIndex() && frame.getUnsignedByte(offset) == packetId;
}
}
return false;
}
private static ByteBuf body(ByteBuf frame, int id) {
ByteBuf input = frame.duplicate();
if (ProtocolVarInt.read(input) != input.readableBytes() || ProtocolVarInt.read(input) != id) {
throw new ProtocolException("invalid completion packet");
}
return input;
}
public static String request(ByteBuf frame) {
ByteBuf input = body(frame, REQUEST);
String text = ProtocolStrings.read(input, 32767);
if (input.isReadable()) throw new ProtocolException("trailing completion request bytes");
return text;
}
public static List<String> response(ByteBuf frame) {
ByteBuf input = body(frame, RESPONSE);
int count = ProtocolVarInt.read(input);
if (count < 0 || count > 4096) throw new ProtocolException("too many backend completions");
List<String> result = new ArrayList<>(Math.min(count, 100));
for (int i = 0; i < count; i++) {
String text = ProtocolStrings.read(input, 32767);
if (result.size() < 100 && text.length() <= 100) result.add(text);
}
if (input.isReadable()) throw new ProtocolException("trailing completion response bytes");
return result;
}
public static ByteBuf response(ByteBufAllocator allocator, List<String> suggestions) {
ByteBuf body = allocator.buffer();
try {
if (suggestions.size() > 100) throw new IllegalArgumentException("too many completions");
ProtocolVarInt.write(body, RESPONSE);
ProtocolVarInt.write(body, suggestions.size());
for (String suggestion : suggestions) ProtocolStrings.write(body, suggestion, 100);
if (body.readableBytes() > 32767) throw new IllegalArgumentException("completion response too large");
return Minecraft1710PlayPackets.frame(allocator, body);
} finally { body.release(); }
}
}
@@ -0,0 +1,131 @@
package dev.strataproxy.core.protocol;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentLike;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.TranslatableComponent;
import net.kyori.adventure.text.TranslationArgument;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Validates and serializes Adventure components for Minecraft 1.7.10 JSON chat. */
public final class MinecraftText {
public static final int MAX_JSON_BYTES = 32_767;
private static final int MAX_DEPTH = 32;
private static final int MAX_NODES = 256;
private static final GsonComponentSerializer SERIALIZER = GsonComponentSerializer.colorDownsamplingGson().toBuilder()
.editOptions(options -> options.value(
net.kyori.adventure.text.serializer.json.JSONOptions.EMIT_COMPACT_TEXT_COMPONENT, false))
.build();
private MinecraftText() { }
/** Serializes a supported component tree, failing before a caller schedules a network write. */
public static String encode(Component component) {
Objects.requireNonNull(component, "component");
validateAdventureNodes(component, 1, new ValidationState());
return encodeValidated(component);
}
/** Serializes a disconnect component and requires at least one non-whitespace visible character. */
public static String encodeReason(Component component) {
Objects.requireNonNull(component, "component");
validateAdventureNodes(component, 1, new ValidationState());
String plain = PlainTextComponentSerializer.plainText().serialize(component);
if (plain.isBlank()) throw new IllegalArgumentException("disconnect reason must not be blank");
return encodeValidated(component);
}
/** Applies the shared wire-size bound to a pre-encoded component before packet allocation. */
public static void validateEncoded(String encoded) {
Objects.requireNonNull(encoded, "encoded");
if (encoded.length() > MAX_JSON_BYTES) {
throw new IllegalArgumentException("encoded component exceeds " + MAX_JSON_BYTES + " bytes");
}
int bytes = encoded.getBytes(StandardCharsets.UTF_8).length;
if (bytes > MAX_JSON_BYTES) {
throw new IllegalArgumentException("encoded component exceeds " + MAX_JSON_BYTES + " bytes");
}
}
private static String encodeValidated(Component component) {
String encoded = SERIALIZER.serialize(component);
validateEncoded(encoded);
return encoded;
}
private static void validateAdventureNodes(Component component, int depth, ValidationState state) {
if (depth > MAX_DEPTH || ++state.count > MAX_NODES) {
throw new IllegalArgumentException("component tree exceeds supported depth or node count");
}
if (!(component instanceof TextComponent) && !(component instanceof TranslatableComponent)) {
throw new IllegalArgumentException("Minecraft 1.7.10 supports only text and translatable components");
}
if (component instanceof TextComponent text) {
state.addText(text.content());
} else if (component instanceof TranslatableComponent translatable) {
state.addText(translatable.key());
if (translatable.fallback() != null) {
throw new IllegalArgumentException("translatable fallback text is unsupported by Minecraft 1.7.10");
}
for (TranslationArgument argument : translatable.arguments()) {
Object value = argument.value();
if (value instanceof ComponentLike child) validateAdventureNodes(child.asComponent(), depth + 1, state);
else if (++state.count > MAX_NODES) {
throw new IllegalArgumentException("too many translation arguments");
}
else if (value instanceof String string) state.addText(string);
else if (!(value instanceof Number) && !(value instanceof Boolean)) {
throw new IllegalArgumentException("unsupported translatable argument type: "
+ (value == null ? "null" : value.getClass().getName()));
}
}
}
var style = component.style();
if (style.insertion() != null || style.font() != null || style.shadowColor() != null) {
throw new IllegalArgumentException("component style contains a field unsupported by Minecraft 1.7.10");
}
var click = style.clickEvent();
if (click != null && click.action() != net.kyori.adventure.text.event.ClickEvent.Action.OPEN_URL
&& click.action() != net.kyori.adventure.text.event.ClickEvent.Action.RUN_COMMAND
&& click.action() != net.kyori.adventure.text.event.ClickEvent.Action.SUGGEST_COMMAND) {
throw new IllegalArgumentException("unsupported Minecraft 1.7.10 click action: " + click.action());
}
if (click != null) {
if (!(click.payload() instanceof net.kyori.adventure.text.event.ClickEvent.Payload.Text payload)) {
throw new IllegalArgumentException("supported Minecraft 1.7.10 click actions require a text payload");
}
state.addText(payload.value());
}
var hover = style.hoverEvent();
if (hover != null && hover.action() != net.kyori.adventure.text.event.HoverEvent.Action.SHOW_TEXT) {
throw new IllegalArgumentException("unsupported Minecraft 1.7.10 hover action: " + hover.action());
}
if (hover != null) {
if (!(hover.value() instanceof Component hoverText)) {
throw new IllegalArgumentException("show_text hover value must be a text component");
}
validateAdventureNodes(hoverText, depth + 1, state);
}
for (Component child : component.children()) validateAdventureNodes(child, depth + 1, state);
}
private static final class ValidationState {
private int count;
private int inputBytes;
private void addText(String value) {
if (value.length() > MAX_JSON_BYTES) {
throw new IllegalArgumentException("component text exceeds " + MAX_JSON_BYTES + " bytes");
}
int bytes = value.getBytes(StandardCharsets.UTF_8).length;
inputBytes += bytes;
if (inputBytes > MAX_JSON_BYTES) {
throw new IllegalArgumentException("component text exceeds " + MAX_JSON_BYTES + " bytes");
}
}
}
}
@@ -38,6 +38,8 @@ import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.List;
import net.kyori.adventure.text.Component;
import dev.strataproxy.core.protocol.MinecraftText;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@@ -56,7 +58,7 @@ import java.util.concurrent.ThreadFactory;
public final class ProxySessionListener implements Players {
@FunctionalInterface public interface CommandDispatcher {
/** Returns true only if this proxy owns the command. */
boolean dispatch(PlayerView player, String message, Consumer<String> reply, BooleanSupplier admission);
boolean dispatch(PlayerView player, String message, Consumer<Component> reply, BooleanSupplier admission);
}
private final SocketAddress bindAddress;
private final BackendCatalog catalog;
@@ -77,6 +79,9 @@ public final class ProxySessionListener implements Players {
private final AtomicInteger connectionCount = new AtomicInteger();
private volatile Function<PlayerView, CompletionStage<Optional<PlacementDecision>>> placement;
private volatile CommandDispatcher commandDispatcher;
private Function<String, List<String>> commandNames;
private java.util.function.BiFunction<PlayerView, String, Optional<CompletionStage<List<String>>>> completions;
private EventDispatcher events;
private Duration eventTimeout = Duration.ofSeconds(5);
private int maxConnections = 4096;
@@ -177,6 +182,17 @@ public final class ProxySessionListener implements Players {
commandDispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
}
public synchronized void setCommandCompletion(Function<String, List<String>> names,
java.util.function.BiFunction<PlayerView, String, Optional<CompletionStage<List<String>>>> handler) {
if (started || closed || completions != null) throw new IllegalStateException("Completion must be configured before start");
commandNames = Objects.requireNonNull(names);
completions = Objects.requireNonNull(handler);
}
Function<String, List<String>> commandNames() { return commandNames; }
Optional<CompletionStage<List<String>>> completeCommand(PlayerView player, String text) {
return completions.apply(player, text);
}
/** Bounds accepted sessions, including clients that have not completed login. */
public synchronized void setMaxConnections(int limit) {
if (started || closed) throw new IllegalStateException("Connection limit must be set before listener start");
@@ -291,24 +307,24 @@ public final class ProxySessionListener implements Players {
return session.transferTo(backendName);
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
Objects.requireNonNull(identity, "identity");
Session.validateMessage(message);
String encoded = MinecraftText.encode(message);
Session session = sessionsByPlayerId.get(identity.playerId());
if (session == null || !session.matchesIdentity(identity)) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
return session.sendMessage(message);
return session.sendEncodedMessage(encoded);
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
Objects.requireNonNull(identity, "identity");
Session.validateDisconnectReason(reason);
String encoded = MinecraftText.encodeReason(reason);
Session session = sessionsByPlayerId.get(identity.playerId());
if (session == null || !session.matchesIdentity(identity)) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
return session.disconnect(reason);
return session.disconnectEncoded(encoded);
}
public synchronized CompletionStage<Void> close() {
@@ -53,6 +53,8 @@ import java.util.UUID;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import net.kyori.adventure.text.Component;
import dev.strataproxy.core.protocol.MinecraftText;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.RejectedExecutionException;
@@ -100,6 +102,7 @@ final class Session extends ChannelInboundHandlerAdapter {
private boolean disconnectPacketStarted;
private boolean disconnectRelayDrained;
private String pendingDisconnectReason;
private TabCompletionBridge tabCompletion;
private CompletableFuture<DisconnectResult> disconnectResult;
private ScheduledFuture<?> disconnectDeadline;
private int commandTokens = COMMAND_BURST;
@@ -428,7 +431,15 @@ final class Session extends ChannelInboundHandlerAdapter {
pipeline.addLast("minecraft-frame-encoder", new SessionFrameEncoder());
}
private void disconnectLogin(String reason) {
private void disconnectLogin(String reason) { disconnectLogin(Component.text(reason)); }
private void disconnectLogin(Component message) {
String reason;
try { reason = MinecraftText.encodeReason(message); }
catch (RuntimeException invalid) {
LOGGER.warn("Invalid plugin login rejection message", invalid);
reason = MinecraftText.encodeReason(Component.text("Connection rejected."));
}
if (frontend.eventLoop().inEventLoop()) beginDisconnect(reason);
else requestDisconnect(reason);
}
@@ -460,6 +471,7 @@ final class Session extends ChannelInboundHandlerAdapter {
if (id == 0) { // Login Disconnect.
loginDisconnectStarted = true;
disconnecting = true;
if (tabCompletion != null) tabCompletion.close();
disconnectResult = new CompletableFuture<>();
frontend.config().setAutoRead(false);
resetLoginDeadline(LOGIN_DISCONNECT_DRAIN_TIMEOUT);
@@ -556,6 +568,7 @@ final class Session extends ChannelInboundHandlerAdapter {
this::dispatchPlayerCommand, this::closePair));
}
target.pipeline().addLast("keep-alive-bridge", new KeepAliveBridge(keepAlives, false, this::closePair));
installTabCompletion(target);
var link = RawRelay.attach(frontend, target,
bytes -> observation.observeFrame(false, bytes),
bytes -> observation.observeFrame(true, bytes));
@@ -581,6 +594,16 @@ final class Session extends ChannelInboundHandlerAdapter {
}
}
private void installTabCompletion(Channel target) {
if (owner.commandNames() == null) return;
if (tabCompletion != null) tabCompletion.close();
if (frontend.pipeline().get("tab-completion") != null) frontend.pipeline().remove("tab-completion");
tabCompletion = new TabCompletionBridge(frontend, owner.commandNames(),
text -> owner.completeCommand(view, text), this::closePair);
frontend.pipeline().addLast("tab-completion", tabCompletion.frontendHandler());
target.pipeline().addLast("tab-completion", tabCompletion.backendHandler());
}
private boolean dispatchPlayerCommand(String message) {
if (closed.get() || disconnecting || !published) return false;
return owner.commandDispatcher().dispatch(view, message, this::sendCommandReply, this::admitPlayerCommand);
@@ -601,13 +624,21 @@ final class Session extends ChannelInboundHandlerAdapter {
if (!commandNoticeSent || now - lastCommandNoticeNanos >= COMMAND_NOTICE_NANOS) {
commandNoticeSent = true;
lastCommandNoticeNanos = now;
sendCommandReply("Too many proxy commands. Please slow down.");
sendCommandReply(Component.text("Too many proxy commands. Please slow down."));
}
return false;
}
CompletionStage<MessageResult> sendMessage(String message) {
validateMessage(message);
return sendMessage(Component.text(message));
}
CompletionStage<MessageResult> sendMessage(Component message) {
return sendEncodedMessage(MinecraftText.encode(message));
}
CompletionStage<MessageResult> sendEncodedMessage(String message) {
CompletableFuture<MessageResult> result = new CompletableFuture<>();
if (closed.get() || disconnecting) {
result.complete(MessageResult.NOT_CONNECTED);
@@ -645,7 +676,7 @@ final class Session extends ChannelInboundHandlerAdapter {
return;
}
try {
ByteBuf frame = Minecraft1710PlayPackets.chatReply(frontend.alloc(), message);
ByteBuf frame = Minecraft1710PlayPackets.chatReplyEncoded(frontend.alloc(), message);
frontend.writeAndFlush(frame).addListener(write -> {
pendingMessages.decrementAndGet();
if (write.isSuccess()) result.complete(MessageResult.SENT);
@@ -683,12 +714,16 @@ final class Session extends ChannelInboundHandlerAdapter {
if (disconnecting) writeDisconnectReasonIfDrained();
}
private void sendCommandReply(String message) {
private void sendCommandReply(Component message) {
sendMessage(message);
}
CompletionStage<DisconnectResult> disconnect(String reason) {
validateDisconnectReason(reason);
return requestDisconnect(MinecraftText.encodeReason(Component.text(reason)));
}
CompletionStage<DisconnectResult> disconnectEncoded(String reason) {
return requestDisconnect(reason);
}
@@ -722,6 +757,7 @@ final class Session extends ChannelInboundHandlerAdapter {
private void beginDisconnect(String reason) {
if (closed.get() || disconnecting) return;
disconnecting = true;
if (tabCompletion != null) tabCompletion.close();
loginDisconnectStarted = true;
pendingDisconnectReason = reason;
disconnectResult = new CompletableFuture<>();
@@ -766,7 +802,7 @@ final class Session extends ChannelInboundHandlerAdapter {
try {
ByteBuf packet;
if (frontendPlayPhase) {
ByteBuf payload = Minecraft1710PlayPackets.disconnect(frontend.alloc(), pendingDisconnectReason);
ByteBuf payload = Minecraft1710PlayPackets.disconnectEncoded(frontend.alloc(), pendingDisconnectReason);
try {
packet = frontend.pipeline().get("minecraft-frame-encoder") == null
? Minecraft1710PlayPackets.frame(frontend.alloc(), payload) : payload.copy();
@@ -774,7 +810,7 @@ final class Session extends ChannelInboundHandlerAdapter {
payload.release();
}
} else {
packet = MinecraftLoginDisconnect.encode(frontend.alloc(), pendingDisconnectReason);
packet = MinecraftLoginDisconnect.encodeJson(frontend.alloc(), pendingDisconnectReason);
}
frontend.writeAndFlush(packet).addListener(ignored -> closePair());
} catch (RuntimeException failure) {
@@ -1137,6 +1173,7 @@ final class Session extends ChannelInboundHandlerAdapter {
attempt.channel.pipeline().addLast("keep-alive-bridge",
new KeepAliveBridge(keepAlives, false, this::closePair));
installTransferFrameHandlers(attempt.frameState, attempt.channel);
installTabCompletion(attempt.channel);
next = RawRelay.attach(frontend, attempt.channel,
bytes -> nextObservation.observeFrame(false, bytes),
bytes -> nextObservation.observeFrame(true, bytes));
@@ -1321,6 +1358,7 @@ final class Session extends ChannelInboundHandlerAdapter {
void closePair() {
if (!closed.compareAndSet(false, true)) return;
Runnable cleanup = () -> {
if (tabCompletion != null) tabCompletion.close();
if (initialLoginDeadline != null) initialLoginDeadline.cancel(false);
if (initialPlayDeadline != null) initialPlayDeadline.cancel(false);
if (disconnectDeadline != null) disconnectDeadline.cancel(false);
@@ -0,0 +1,193 @@
package dev.strataproxy.core.session;
import dev.strataproxy.core.protocol.MinecraftTabCompletion;
import dev.strataproxy.core.relay.RawRelay;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.ReferenceCountUtil;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/** One backend generation, confined to the shared frontend/backend event loop. */
final class TabCompletionBridge implements AutoCloseable {
private final Channel frontend;
private final Function<String, List<String>> roots;
private final Function<String, Optional<CompletionStage<List<String>>>> complete;
private final Runnable closeSession;
private final ArrayDeque<Request> queued = new ArrayDeque<>();
private Request active;
private boolean backendReplyOutstanding;
private boolean closed;
private static final class Request {
final ChannelHandlerContext context;
final ByteBuf frame;
final String text;
boolean owned = true;
Request(ChannelHandlerContext context, ByteBuf frame, String text) {
this.context = context; this.frame = frame; this.text = text;
}
void release() { if (owned) { owned = false; frame.release(); } }
void forward() { owned = false; context.fireChannelRead(frame); }
}
private ScheduledFuture<?> timeout;
private CompletableFuture<?> pluginResult;
TabCompletionBridge(Channel frontend, Function<String, List<String>> roots,
Function<String, Optional<CompletionStage<List<String>>>> complete,
Runnable closeSession) {
this.frontend = frontend;
this.roots = roots;
this.complete = complete;
this.closeSession = closeSession;
}
ChannelInboundHandlerAdapter frontendHandler() { return new Handler(true); }
ChannelInboundHandlerAdapter backendHandler() { return new Handler(false); }
private final class Handler extends ChannelInboundHandlerAdapter {
private final boolean fromClient;
private Handler(boolean fromClient) { this.fromClient = fromClient; }
@Override public void channelRead(ChannelHandlerContext ctx, Object message) {
if (!(message instanceof ByteBuf frame)) {
ReferenceCountUtil.release(message);
closeSession.run();
return;
}
if (!MinecraftTabCompletion.matches(frame,
fromClient ? MinecraftTabCompletion.REQUEST : MinecraftTabCompletion.RESPONSE)) {
ctx.fireChannelRead(frame);
return;
}
try {
if (fromClient) {
if (closed) { frame.release(); return; }
String text = MinecraftTabCompletion.request(frame);
if (queued.size() >= 8) {
frame.release();
closeSession.run();
return;
}
queued.add(new Request(ctx, frame, text));
pump();
} else {
try { acceptBackend(frame); } finally { frame.release(); }
}
} catch (RuntimeException invalid) {
// A request not admitted to the queue is still ours.
if (fromClient && frame.refCnt() > 0 && queued.stream().noneMatch(r -> r.frame == frame)
&& (active == null || active.frame != frame)) frame.release();
closeSession.run();
} finally {
RawRelay.continueAfterDrop(ctx.channel());
}
}
}
private void pump() {
if (closed || active != null || queued.isEmpty()) return;
Request request = queued.removeFirst();
active = request;
boolean root = isRoot(request.text);
Optional<CompletionStage<List<String>>> local = root ? Optional.empty() : complete.apply(request.text);
if (local.isPresent()) {
request.release();
CompletableFuture<List<String>> future = local.get().toCompletableFuture();
pluginResult = future;
timeout = frontend.eventLoop().schedule(() -> {
if (active != request) return;
finish(List.of());
future.cancel(false);
}, 1, TimeUnit.SECONDS);
future.whenComplete((values, error) -> {
try {
frontend.eventLoop().execute(() -> {
if (!closed && active == request) finish(error == null ? values : List.of());
});
} catch (java.util.concurrent.RejectedExecutionException ignored) { }
});
} else if (backendReplyOutstanding) {
// An expired backend request has no wire ID. Do not issue another until its reply arrives.
request.release();
finish(root ? roots.apply(request.text.substring(1)) : List.of());
} else {
backendReplyOutstanding = true;
timeout = frontend.eventLoop().schedule(() -> {
if (active == request) finish(root ? roots.apply(request.text.substring(1)) : List.of());
}, 1, TimeUnit.SECONDS);
request.forward();
}
}
private static boolean isRoot(String text) {
return text.startsWith("/") && text.chars().noneMatch(Character::isWhitespace);
}
private void acceptBackend(ByteBuf frame) {
if (closed || !backendReplyOutstanding) return;
backendReplyOutstanding = false;
if (active == null || pluginResult != null) return; // expired request; never deliver its reply
if (!isRoot(active.text)) { finish(List.of(), frame); return; }
List<String> values = MinecraftTabCompletion.response(frame);
if (isRoot(active.text)) {
LinkedHashSet<String> merged = new LinkedHashSet<>(roots.apply(active.text.substring(1)));
merged.addAll(values);
values = new ArrayList<>(merged).subList(0, Math.min(100, merged.size()));
}
finish(values);
}
private void finish(List<String> values) {
finish(values, null);
}
private void finish(List<String> values, ByteBuf original) {
if (timeout != null) timeout.cancel(false);
timeout = null;
pluginResult = null;
active = null;
if (!closed && frontend.isActive() && frontend.isWritable()) {
try {
frontend.writeAndFlush(original == null
? MinecraftTabCompletion.response(frontend.alloc(), bounded(values)) : original.retainedDuplicate())
.addListener(write -> { if (!write.isSuccess()) closeSession.run(); });
} catch (RuntimeException failure) { closeSession.run(); return; }
}
pump();
}
private static List<String> bounded(List<String> values) {
List<String> result = new ArrayList<>();
int bytes = 2; // packet ID and result count (at most 100)
for (String value : values) {
if (result.size() == 100) break;
if (value == null || value.length() > 100) continue;
int length = value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
int framed = length + (length < 128 ? 1 : 2);
if (bytes + framed > 32767) break;
bytes += framed;
result.add(value);
}
return result;
}
@Override public void close() {
if (closed) return;
closed = true;
if (timeout != null) timeout.cancel(false);
if (pluginResult != null) pluginResult.cancel(false);
if (active != null) active.release();
active = null;
Request request;
while ((request = queued.pollFirst()) != null) request.release();
}
}
@@ -16,9 +16,11 @@ backendChannel:
secret: "replace-with-a-random-secret-of-32-bytes-or-more"
allowedHosts: ["127.0.0.1"]
allowedNamespaces: ["islands"]
allowedReceiveNamespaces: ["islands"]
island-b:
backendName: "island-b"
keyId: "primary"
secret: "replace-with-another-random-secret-of-32-bytes"
allowedHosts: ["127.0.0.1"]
allowedNamespaces: ["islands"]
allowedReceiveNamespaces: ["islands"]
@@ -15,6 +15,7 @@ import javax.imageio.ImageIO;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class ProxyConfigurationTest {
@Test
@@ -253,7 +254,7 @@ final class ProxyConfigurationTest {
}
@Test
void loadsBackendControlCredentialsAndRejectsMissingNamespace() throws Exception {
void loadsIndependentBackendSendAndReceiveNamespacesIncludingEmptySets() throws Exception {
Path config = Files.createTempFile("strataproxy-backend-channel", ".yml");
try {
String yaml = """
@@ -275,9 +276,17 @@ final class ProxyConfigurationTest {
assertEquals(28081, channel.listenAddress().getPort());
assertEquals("island-a", channel.clients().get("island-a").backendName());
assertEquals(java.util.Set.of("islands"), channel.clients().get("island-a").allowedNamespaces());
assertEquals(java.util.Set.of("islands"), channel.clients().get("island-a").allowedReceiveNamespaces(),
"receiving defaults to the sending namespace set");
Files.writeString(config, yaml.replace("allowedNamespaces: [islands]", "allowedNamespaces: []"));
assertThrows(com.fasterxml.jackson.databind.JsonMappingException.class,
() -> new ProxyConfigurationLoader().load(config));
var denyAll = new ProxyConfigurationLoader().load(config).backendChannel().clients().get("island-a");
assertTrue(denyAll.allowedNamespaces().isEmpty());
assertTrue(denyAll.allowedReceiveNamespaces().isEmpty(), "implicit receive set inherits the empty send set");
Files.writeString(config, yaml.replace("allowedNamespaces: [islands]",
"allowedNamespaces: [islands]\n allowedReceiveNamespaces: []"));
var receiveNone = new ProxyConfigurationLoader().load(config).backendChannel().clients().get("island-a");
assertEquals(java.util.Set.of("islands"), receiveNone.allowedNamespaces());
assertTrue(receiveNone.allowedReceiveNamespaces().isEmpty(), "an explicit empty receive ACL is send-only");
} finally {
Files.deleteIfExists(config);
}
@@ -1,182 +1,185 @@
package dev.strataproxy.core.control;
import dev.strataproxy.api.*;
import dev.strataproxy.app.ProxyConfiguration;
import dev.strataproxy.backendchannel.BackendChannelClient;
import dev.strataproxy.backendchannel.FrameCodec;
import dev.strataproxy.backendchannel.Wire;
import dev.strataproxy.core.backend.BackendId;
import dev.strataproxy.core.backend.InMemoryBackendCatalog;
import dev.strataproxy.core.plugin.PluginHost;
import dev.strataproxy.messaging.Endpoint;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.SendResult;
import dev.strataproxy.messaging.internal.LocalMessaging;
import dev.strataproxy.messaging.protocol.MessageCodec;
import org.junit.jupiter.api.Test;
import java.net.ServerSocket;
import java.net.Socket;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BackendControlServiceTest {
private static final String SECRET = "test-secret-at-least-thirty-two-bytes-long";
@Test void twoInstancesOnOneHostCommunicateWithNoPlayers() throws Exception {
int port = freePort();
var catalog = new InMemoryBackendCatalog();
var received = new AtomicReference<BackendMessage>();
var pluginContext = new AtomicReference<PluginContext>();
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
pluginContext.set(context);
context.backendChannels().subscribe("islands:prepare", message -> {
received.set(message);
return CompletableFuture.completedFuture("prepared".getBytes(StandardCharsets.UTF_8));
});
}
};
var config = new ProxyConfiguration.BackendChannel("127.0.0.1:" + port, Map.of(
"island-a", client("island-a"), "island-b", client("island-b")), 5, 8);
try (var host = new PluginHost(catalog, noPlayers(), Duration.ofSeconds(1));
var control = new BackendControlService(config, catalog, host::dispatchBackendMessage)) {
host.setBackendChannelTransport(control);
host.load(List.of(plugin));
host.enable();
control.start();
try (var a = backend(port, "island-a", 25565);
var b = backend(port, "island-b", 25566)) {
a.registerHandler("islands:echo", payload -> CompletableFuture.completedFuture(payload));
b.registerHandler("islands:echo", payload -> CompletableFuture.completedFuture(payload));
assertTrue(a.awaitRegistered(5, TimeUnit.SECONDS));
assertTrue(b.awaitRegistered(5, TimeUnit.SECONDS));
assertEquals(2, catalog.snapshot().size());
assertEquals("tcp://127.0.0.1:25565", catalog.find(new BackendId("island-a")).orElseThrow().address().toString());
assertEquals("tcp://127.0.0.1:25566", catalog.find(new BackendId("island-b")).orElseThrow().address().toString());
assertEquals("prepared", new String(a.request("islands:prepare", "hello".getBytes(StandardCharsets.UTF_8))
.get(5, TimeUnit.SECONDS), StandardCharsets.UTF_8));
assertEquals("island-a", received.get().instanceId());
assertTrue(received.get().epoch() > 0);
assertEquals("hello", new String(received.get().payload(), StandardCharsets.UTF_8));
var missing = assertThrows(java.util.concurrent.ExecutionException.class,
() -> a.request("islands:missing", new byte[0]).get(5, TimeUnit.SECONDS));
assertEquals(Wire.STATUS_NO_HANDLER,
((BackendChannelClient.ChannelResponseException) missing.getCause()).getStatus());
assertEquals("world", new String(pluginContext.get().backendChannels().request("island-b", "islands:echo",
"world".getBytes(StandardCharsets.UTF_8)).toCompletableFuture().get(5, TimeUnit.SECONDS),
StandardCharsets.UTF_8));
assertEquals(BackendSendResult.SENT, pluginContext.get().backendChannels().send("island-a",
"islands:echo", new byte[]{1}).toCompletableFuture().get(5, TimeUnit.SECONDS));
a.close();
await(() -> catalog.find(new BackendId("island-a")).isEmpty());
assertTrue(catalog.find(new BackendId("island-b")).isPresent());
}
await(() -> catalog.snapshot().isEmpty());
}
}
@Test void badCredentialNeverRegistersBackend() throws Exception {
int port = freePort();
int port;
try (var socket = new ServerSocket(0)) { port = socket.getLocalPort(); }
var catalog = new InMemoryBackendCatalog();
var config = new ProxyConfiguration.BackendChannel("127.0.0.1:" + port,
Map.of("island-a", client("island-a")), 5, 8);
try (var control = new BackendControlService(config, catalog,
ignored -> CompletableFuture.completedFuture(new byte[0]))) {
var timer = Executors.newSingleThreadScheduledExecutor();
var local = new LocalMessaging(Endpoint.proxy(), new LocalMessaging.Outbound() {
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.messaging.SendResult> send(
dev.strataproxy.messaging.Message message) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.messaging.Message> request(
dev.strataproxy.messaging.Message message, Duration timeout) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.messaging.PublishResult> publish(
dev.strataproxy.messaging.Message event) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
}, Runnable::run, timer);
try (var control = new BackendControlService(config, catalog, local)) {
control.start();
try (var invalid = new BackendChannelClient("127.0.0.1", port, "island-a", "island-a",
"tcp://127.0.0.1:25565", UUID.randomUUID().toString(), "primary",
"wrong-secret-at-least-thirty-two-bytes".getBytes(StandardCharsets.UTF_8),
1000, 1000, 100, 200)) {
1000, 100, 200)) {
assertFalse(invalid.awaitRegistered(700, TimeUnit.MILLISECONDS));
assertTrue(catalog.snapshot().isEmpty());
}
} finally {
local.close();
timer.shutdownNow();
}
}
@Test void goodbyeRemovesRegisteredBackendFromCatalog() throws Exception {
int port;
try (var socket = new ServerSocket(0)) { port = socket.getLocalPort(); }
var catalog = new InMemoryBackendCatalog();
var config = new ProxyConfiguration.BackendChannel("127.0.0.1:" + port,
Map.of("island-a", client("island-a")), 5, 8);
var timer = Executors.newSingleThreadScheduledExecutor();
var local = localMessaging(timer);
try (var control = new BackendControlService(config, catalog, local)) {
control.start();
try (var backend = backend(port, "island-a", 25565)) {
assertTrue(backend.awaitRegistered(5, TimeUnit.SECONDS));
assertEquals(1, catalog.snapshot().size());
assertEquals("tcp://127.0.0.1:25565",
catalog.find(new BackendId("island-a")).orElseThrow().address().toString());
backend.close();
await(() -> catalog.snapshot().isEmpty());
}
} finally {
local.close();
timer.shutdownNow();
}
}
@Test void replacementFencesOldConnectionAndAbruptLossExpiresLease() throws Exception {
int port = freePort();
int port;
try (var socket = new ServerSocket(0)) { port = socket.getLocalPort(); }
var catalog = new InMemoryBackendCatalog();
var config = new ProxyConfiguration.BackendChannel("127.0.0.1:" + port,
Map.of("island-a", client("island-a")), 5, 8);
try (var control = new BackendControlService(config, catalog,
ignored -> CompletableFuture.completedFuture(new byte[0]))) {
var timer = Executors.newSingleThreadScheduledExecutor();
var local = localMessaging(timer);
try (var control = new BackendControlService(config, catalog, local)) {
control.start();
try (var old = rawRegistered(port); var current = rawRegistered(port)) {
assertTrue(catalog.find(new BackendId("island-a")).isPresent());
assertEquals(BackendSendResult.SENT, control.send("island-a", "islands:ping", new byte[0])
.toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals(Wire.MESSAGE, FrameCodec.read(
new java.io.DataInputStream(current.getInputStream()))[0]);
var outbound = control.send(Message.event("islands:ping", Endpoint.proxy(),
Endpoint.backend("island-a"), new byte[0]));
byte[] frame = FrameCodec.read(new DataInputStream(current.getInputStream()));
MessageCodec.IncomingMessage sent = MessageCodec.decodeMessage(frame);
assertEquals(Endpoint.backend("island-a"), sent.message.target());
FrameCodec.write(new DataOutputStream(current.getOutputStream()),
MessageCodec.sendResult(sent.operationId, SendResult.ACCEPTED));
assertEquals(SendResult.ACCEPTED, outbound.toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(java.io.IOException.class,
() -> FrameCodec.read(new DataInputStream(old.getInputStream())));
}
assertTrue(catalog.find(new BackendId("island-a")).isPresent(),
"an abrupt socket close retains the lease briefly");
await(7, () -> catalog.find(new BackendId("island-a")).isEmpty());
} finally {
local.close();
timer.shutdownNow();
}
}
private static ProxyConfiguration.Client client(String name) {
return new ProxyConfiguration.Client(name, "primary", SECRET, Set.of("127.0.0.1"), Set.of("islands"));
}
private static BackendChannelClient backend(int port, String name, int gamePort) {
return new BackendChannelClient("127.0.0.1", port, name, name,
"tcp://127.0.0.1:" + gamePort, UUID.randomUUID().toString(), "primary",
SECRET.getBytes(StandardCharsets.UTF_8), 1000, 5000, 100, 500);
}
private static int freePort() throws Exception {
try (var socket = new ServerSocket(0)) { return socket.getLocalPort(); }
SECRET.getBytes(StandardCharsets.UTF_8), 1000, 100, 200);
}
private static Socket rawRegistered(int port) throws Exception {
var socket = new Socket("127.0.0.1", port);
socket.setSoTimeout(2000);
var input = new java.io.DataInputStream(socket.getInputStream());
var output = new java.io.DataOutputStream(socket.getOutputStream());
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream());
var hello = Wire.decodeHello(FrameCodec.read(input));
String generation = UUID.randomUUID().toString();
byte[] canonical = Wire.canonicalRegistration("island-a", "island-a", "tcp://127.0.0.1:25565",
generation, "primary");
String address = "tcp://127.0.0.1:25565";
byte[] canonical = Wire.canonicalRegistration("island-a", "island-a", address, generation, "primary");
byte[] signed = new byte[hello.nonce.length + canonical.length];
System.arraycopy(hello.nonce, 0, signed, 0, hello.nonce.length);
System.arraycopy(canonical, 0, signed, hello.nonce.length, canonical.length);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
FrameCodec.write(output, Wire.register("island-a", "island-a", "tcp://127.0.0.1:25565",
FrameCodec.write(output, Wire.register("island-a", "island-a", address,
generation, "primary", mac.doFinal(signed)));
assertTrue(Wire.decodeRegistered(FrameCodec.read(input)) > 0);
return socket;
}
private static void await(BooleanSupplier condition) throws Exception {
await(3, condition);
private static LocalMessaging localMessaging(java.util.concurrent.ScheduledExecutorService timer) {
return new LocalMessaging(Endpoint.proxy(), new LocalMessaging.Outbound() {
@Override public java.util.concurrent.CompletionStage<SendResult> send(Message message) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
@Override public java.util.concurrent.CompletionStage<Message> request(Message message, Duration timeout) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.messaging.PublishResult> publish(
Message event) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
}, Runnable::run, timer);
}
private static void await(int seconds, BooleanSupplier condition) throws Exception {
private static void await(java.util.function.BooleanSupplier condition) throws Exception { await(3, condition); }
private static void await(int seconds, java.util.function.BooleanSupplier condition) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
while (!condition.getAsBoolean()) {
if (System.nanoTime() >= deadline) fail("condition not met");
Thread.sleep(10);
}
}
private static Players noPlayers() {
return new Players() {
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
@Override public List<PlayerView> online() { return List.of(); }
@Override public CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
return CompletableFuture.completedFuture(TransferResult.failed("unavailable"));
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
};
}
}
@@ -0,0 +1,270 @@
package dev.strataproxy.core.control;
import dev.strataproxy.app.ProxyConfiguration;
import dev.strataproxy.backendchannel.BackendChannelClient;
import dev.strataproxy.core.backend.InMemoryBackendCatalog;
import dev.strataproxy.messaging.*;
import dev.strataproxy.messaging.internal.LocalMessaging;
import org.junit.jupiter.api.Test;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
/** Actual TCP paths; the fixture has no Minecraft clients or player sessions. */
class UnifiedMessagingSocketTest {
private static final String CHANNEL = "islands:control";
private static final byte[] DATA = "payload".getBytes(StandardCharsets.UTF_8);
@Test void backendProxyAndBackendBackendKeepEndToEndMessageIdentity() throws Exception {
try (Fixture f = new Fixture()) {
AtomicReference<Message> proxyRequest = new AtomicReference<>();
AtomicReference<Message> backendRequest = new AtomicReference<>();
f.proxy.channel(CHANNEL).onRequest(message -> {
proxyRequest.set(message);
return CompletableFuture.completedFuture(message.payload());
});
f.b.messaging("receiver").channel(CHANNEL).onRequest(message -> {
backendRequest.set(message);
return CompletableFuture.completedFuture(message.payload());
});
var a = f.a.messaging("sender").channel(CHANNEL);
Message proxyReply = await(a.request(Endpoint.proxy(), DATA));
assertExchange(proxyRequest.get(), proxyReply, Endpoint.backend("a"), Endpoint.proxy());
Message backendReply = await(a.request(Endpoint.backend("b"), DATA));
assertExchange(backendRequest.get(), backendReply, Endpoint.backend("a"), Endpoint.backend("b"));
assertNotEquals(proxyRequest.get().id(), backendRequest.get().id());
AtomicReference<Message> reverseRequest = new AtomicReference<>();
a.onRequest(message -> {
reverseRequest.set(message);
return CompletableFuture.completedFuture(message.payload());
});
Message reverseReply = await(f.proxy.channel(CHANNEL).request(Endpoint.backend("a"), DATA));
assertExchange(reverseRequest.get(), reverseReply, Endpoint.proxy(), Endpoint.backend("a"));
}
}
@Test void publishSharesIdentityAndDisablingOnePluginKeepsOtherSubscribers() throws Exception {
try (Fixture f = new Fixture()) {
List<Message> proxyEvents = new CopyOnWriteArrayList<>();
List<Message> aEvents = new CopyOnWriteArrayList<>();
List<Message> bFirstEvents = new CopyOnWriteArrayList<>();
List<Message> bSecondEvents = new CopyOnWriteArrayList<>();
f.proxy.channel(CHANNEL).subscribe(proxyEvents::add);
var sender = f.a.messaging("sender").channel(CHANNEL);
sender.subscribe(aEvents::add);
Messaging bFirst = f.b.messaging("first");
bFirst.channel(CHANNEL).subscribe(bFirstEvents::add);
Messaging bSecond = f.b.messaging("second");
bSecond.channel(CHANNEL).subscribe(bSecondEvents::add);
PublishResult first = await(sender.publish(DATA));
assertEquals(Map.of(Endpoint.proxy(), SendResult.ACCEPTED, Endpoint.backend("a"), SendResult.ACCEPTED,
Endpoint.backend("b"), SendResult.ACCEPTED), first.results());
eventually(() -> proxyEvents.size() == 1 && aEvents.size() == 1
&& bFirstEvents.size() == 1 && bSecondEvents.size() == 1);
for (Message event : List.of(proxyEvents.getFirst(), aEvents.getFirst(),
bFirstEvents.getFirst(), bSecondEvents.getFirst())) {
assertEquals(first.messageId(), event.id());
assertEquals(Endpoint.backend("a"), event.source());
assertEquals(MessageKind.EVENT, event.kind());
assertNull(event.replyTo());
}
bFirst.close();
assertEquals(MessagingException.Code.CLOSED,
assertThrows(MessagingException.class, () -> bFirst.channel(CHANNEL)).code());
SendReceipt direct = await(sender.send(Endpoint.backend("b"), DATA));
assertEquals(SendResult.ACCEPTED, direct.result());
eventually(() -> bSecondEvents.size() == 2);
assertEquals(direct.messageId(), bSecondEvents.get(1).id());
assertEquals(1, bFirstEvents.size());
assertEquals(1, aEvents.size());
assertEquals(1, proxyEvents.size());
bSecond.close();
assertEquals(SendResult.NO_SUBSCRIBER, await(sender.send(Endpoint.backend("b"), DATA)).result());
}
}
@Test void concurrentRepliesCanArriveInReverseOrder() throws Exception {
try (Fixture f = new Fixture()) {
Map<Integer, CompletableFuture<byte[]>> replies = new ConcurrentHashMap<>();
Map<Integer, Message> requests = new ConcurrentHashMap<>();
f.b.messaging("handler").channel(CHANNEL).onRequest(message -> {
int number = message.payload()[0] & 255;
requests.put(number, message);
CompletableFuture<byte[]> reply = new CompletableFuture<>();
replies.put(number, reply);
return reply;
});
var channel = f.a.messaging("caller").channel(CHANNEL);
List<CompletionStage<Message>> calls = new ArrayList<>();
for (int i = 0; i < 12; i++) calls.add(channel.request(Endpoint.backend("b"), new byte[]{(byte) i}));
eventually(() -> replies.size() == 12);
for (int i = 11; i >= 0; i--) replies.get(i).complete(new byte[]{(byte) i});
for (int i = 0; i < 12; i++) {
Message response = await(calls.get(i));
assertEquals(requests.get(i).id(), response.replyTo());
assertArrayEquals(new byte[]{(byte) i}, response.payload());
}
}
}
@Test void timeoutDoesNotReplayAndMissingHandlerHasDistinctFailure() throws Exception {
try (Fixture f = new Fixture()) {
AtomicInteger executed = new AtomicInteger();
CompletableFuture<byte[]> slowReply = new CompletableFuture<>();
f.b.messaging("handler").channel(CHANNEL).onRequest(message -> {
executed.incrementAndGet();
return slowReply;
});
var channel = f.a.messaging("caller").channel(CHANNEL);
var pending = channel.request(Endpoint.backend("b"), DATA, Duration.ofMillis(200));
assertEquals(MessagingException.Code.TIMED_OUT, failure(pending).code());
slowReply.complete(DATA);
assertEquals(1, executed.get());
assertEquals(MessagingException.Code.NO_HANDLER,
failure(f.a.messaging("missing").channel("islands:missing")
.request(Endpoint.backend("b"), DATA)).code());
assertEquals(SendResult.NOT_CONNECTED,
await(channel.send(Endpoint.backend("offline"), DATA)).result());
}
}
@Test void sendAndReceivePermissionsAreIndependentAndRejectionKeepsConnection() throws Exception {
try (Fixture f = new Fixture(Set.of())) {
AtomicInteger bExecutions = new AtomicInteger();
var receiver = f.b.messaging("receiver").channel(CHANNEL);
receiver.subscribe(message -> bExecutions.incrementAndGet());
receiver.onRequest(message -> {
bExecutions.incrementAndGet();
return CompletableFuture.completedFuture(DATA);
});
f.proxy.channel(CHANNEL).onRequest(message -> CompletableFuture.completedFuture(DATA));
var sender = f.a.messaging("sender").channel(CHANNEL);
assertEquals(SendResult.REJECTED, await(sender.send(Endpoint.backend("b"), DATA)).result());
assertEquals(MessagingException.Code.REJECTED,
failure(sender.request(Endpoint.backend("b"), DATA)).code());
assertFalse(await(sender.publish(DATA)).results().containsKey(Endpoint.backend("b")));
assertEquals(0, bExecutions.get());
// B can still send to the proxy despite having no receive permission.
assertArrayEquals(DATA, await(receiver.request(Endpoint.proxy(), DATA)).payload());
var forbidden = f.a.messaging("forbidden").channel("private:control");
assertEquals(MessagingException.Code.REJECTED,
failure(forbidden.request(Endpoint.proxy(), DATA)).code());
assertEquals(SendResult.REJECTED, await(forbidden.send(Endpoint.proxy(), DATA)).result());
assertArrayEquals(DATA, await(sender.request(Endpoint.proxy(), DATA)).payload());
}
}
private static void assertExchange(Message request, Message reply, Endpoint source, Endpoint target) {
assertNotNull(request);
assertEquals(source, request.source());
assertEquals(target, request.target());
assertEquals(MessageKind.REQUEST, request.kind());
assertEquals(MessageKind.REPLY, reply.kind());
assertEquals(request.id(), reply.replyTo());
assertNotEquals(request.id(), reply.id());
assertEquals(target, reply.source());
assertEquals(source, reply.target());
assertArrayEquals(DATA, reply.payload());
}
private static <T> T await(CompletionStage<T> value) throws Exception {
return value.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static MessagingException failure(CompletionStage<?> stage) {
ExecutionException error = assertThrows(ExecutionException.class, () -> await(stage));
Throwable cause = error.getCause();
while (cause instanceof CompletionException && cause.getCause() != null) cause = cause.getCause();
return assertInstanceOf(MessagingException.class, cause);
}
private static void eventually(java.util.function.BooleanSupplier condition) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(4);
while (!condition.getAsBoolean()) {
if (System.nanoTime() >= deadline) fail("condition did not become true");
Thread.sleep(5);
}
}
private static final class Fixture implements AutoCloseable {
static final String SECRET = "test-messaging-secret-at-least-thirty-two-bytes";
final ExecutorService workers = Executors.newFixedThreadPool(4, Fixture::daemon);
final ScheduledExecutorService timer = Executors.newScheduledThreadPool(2, Fixture::daemon);
final LocalMessaging local;
final Messaging proxy;
final BackendControlService control;
final BackendChannelClient a;
final BackendChannelClient b;
Fixture() throws Exception { this(Set.of("islands")); }
Fixture(Set<String> bReceiveNamespaces) throws Exception {
AtomicReference<BackendControlService> transport = new AtomicReference<>();
local = new LocalMessaging(Endpoint.proxy(), new LocalMessaging.Outbound() {
public CompletionStage<SendResult> send(Message message) { return transport.get().send(message); }
public CompletionStage<Message> request(Message message, Duration timeout) {
return transport.get().request(message, timeout);
}
public CompletionStage<PublishResult> publish(Message message) { return transport.get().publish(message); }
}, workers, timer);
int port;
try (ServerSocket reservation = new ServerSocket(0)) { port = reservation.getLocalPort(); }
var clients = Map.of("instance-a", clientConfig("a"), "instance-b",
new ProxyConfiguration.Client("b", "primary", SECRET, Set.of("127.0.0.1"),
Set.of("islands"), bReceiveNamespaces));
var config = new ProxyConfiguration.BackendChannel("127.0.0.1:" + port, clients, 5, 8);
control = new BackendControlService(config, new InMemoryBackendCatalog(), local);
transport.set(control);
control.start();
proxy = local.openScope("proxy");
a = client(port, "a", 25565);
b = client(port, "b", 25566);
try {
assertTrue(a.awaitRegistered(5, TimeUnit.SECONDS));
assertTrue(b.awaitRegistered(5, TimeUnit.SECONDS));
} catch (Throwable failure) {
close();
throw failure;
}
}
static ProxyConfiguration.Client clientConfig(String name) {
return new ProxyConfiguration.Client(name, "primary", SECRET, Set.of("127.0.0.1"), Set.of("islands"));
}
static BackendChannelClient client(int port, String name, int gamePort) {
return new BackendChannelClient("127.0.0.1", port, "instance-" + name, name,
"tcp://127.0.0.1:" + gamePort, UUID.randomUUID().toString(), "primary",
SECRET.getBytes(StandardCharsets.UTF_8), 1_000, 50, 100);
}
static Thread daemon(Runnable work) {
Thread thread = new Thread(work, "unified-messaging-test");
thread.setDaemon(true);
return thread;
}
@Override public void close() {
a.close();
b.close();
control.close();
local.close();
workers.shutdownNow();
timer.shutdownNow();
}
}
}
@@ -0,0 +1,218 @@
package dev.strataproxy.core.plugin;
import dev.strataproxy.api.CommandCompletion;
import dev.strataproxy.api.CommandRegistration;
import dev.strataproxy.api.PlayerIdentity;
import dev.strataproxy.api.PlayerView;
import dev.strataproxy.api.Plugin;
import dev.strataproxy.api.PluginContext;
import dev.strataproxy.api.Players;
import dev.strataproxy.api.MessageResult;
import dev.strataproxy.api.DisconnectResult;
import dev.strataproxy.api.TransferResult;
import dev.strataproxy.core.backend.InMemoryBackendCatalog;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PluginHostCompletionTest {
private static final PlayerView PLAYER = new PlayerView(
new PlayerIdentity(UUID.randomUUID(), 1), "TestPlayer", Optional.empty());
@Test
void commandRootsAndArgumentCompletionPreserveWhitespaceAndBoundSuggestions() throws Exception {
var request = new CompletableFuture<CommandCompletion>();
var emptyArguments = new CompletableFuture<CommandCompletion>();
var callbackThread = new AtomicReference<Thread>();
var candidateList = new ArrayList<String>(Arrays.asList(
"alpha", "alpha", null, "two words", "x".repeat(101), "猫".repeat(11_000), "beta"));
for (int i = 0; i < 120; i++) candidateList.add("item" + i);
CompletableFuture<List<String>> suggestions = CompletableFuture.completedFuture(candidateList);
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext value) {
value.commands().register("Ping", ignored -> { }, completion -> {
callbackThread.set(Thread.currentThread());
if (completion.arguments().isEmpty()) emptyArguments.complete(completion);
else request.complete(completion);
return suggestions;
});
value.commands().register("alpha", ignored -> { });
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
host.load(List.of(plugin));
host.enable();
assertEquals(List.of("/alpha", "/ping"), host.commandNames(""));
assertEquals(List.of("/ping"), host.commandNames("P"));
assertTrue(host.completeCommand(PLAYER, "/unknown arg").isEmpty());
assertTrue(host.completeCommand(PLAYER, "/ping").isEmpty(), "root-only completion belongs to commandNames");
CompletionStage<List<String>> stage = host.completeCommand(PLAYER, "/PiNg first ").orElseThrow();
assertEquals("ping", request.get(5, TimeUnit.SECONDS).commandName());
assertEquals(" first ", request.get(5, TimeUnit.SECONDS).arguments());
assertNotEquals(Thread.currentThread(), callbackThread.get(), "plugin completer must run on a worker");
List<String> completed = stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals(List.of("alpha", "beta"), completed.subList(0, 2));
assertEquals(100, completed.size());
CompletionStage<List<String>> emptyStage = host.completeCommand(PLAYER, "/ping ").orElseThrow();
assertEquals("", emptyArguments.get(5, TimeUnit.SECONDS).arguments());
assertEquals(100, emptyStage.toCompletableFuture().get(5, TimeUnit.SECONDS).size());
assertEquals(List.of(), host.completeCommand(PLAYER, "/alpha ").orElseThrow()
.toCompletableFuture().get(1, TimeUnit.SECONDS));
}
}
@Test
void closingHostCancelsPendingCompleterAndCompletesLocalResult() throws Exception {
var invoked = new CountDownLatch(1);
var pluginStageCancelled = new CountDownLatch(1);
var pluginStage = new CompletableFuture<List<String>>();
pluginStage.whenComplete((ignored, failure) -> pluginStageCancelled.countDown());
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
context.commands().register("wait", ignored -> { }, completion -> {
invoked.countDown();
return pluginStage;
});
}
};
var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1));
host.load(List.of(plugin));
host.enable();
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/wait ").orElseThrow()
.toCompletableFuture();
assertTrue(invoked.await(5, TimeUnit.SECONDS));
host.close();
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
() -> local.get(1, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof IllegalStateException);
assertTrue(pluginStageCancelled.await(1, TimeUnit.SECONDS));
assertTrue(pluginStage.isCancelled(), "host close should cancel the plugin's unfinished stage");
}
@Test
void timeoutCancelsPluginStageOffTheTimerThread() throws Exception {
var invoked = new CountDownLatch(1);
var cancellationCallback = new CountDownLatch(1);
var callbackThread = new AtomicReference<Thread>();
var pluginStage = new CompletableFuture<List<String>>();
pluginStage.whenComplete((ignored, failure) -> {
callbackThread.set(Thread.currentThread());
cancellationCallback.countDown();
});
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
context.commands().register("wait", ignored -> { }, completion -> {
invoked.countDown();
return pluginStage;
});
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
host.load(List.of(plugin));
host.enable();
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/wait ").orElseThrow()
.toCompletableFuture();
assertTrue(invoked.await(5, TimeUnit.SECONDS));
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
() -> local.get(3, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof java.util.concurrent.TimeoutException);
assertTrue(cancellationCallback.await(1, TimeUnit.SECONDS));
assertTrue(pluginStage.isCancelled());
assertNotEquals(Thread.currentThread(), callbackThread.get());
assertTrue(callbackThread.get().getName().startsWith("strataproxy-plugin-player-"));
}
}
@Test
void unregisterCancelsPendingCompletionAndRemovesTheRoot() throws Exception {
var invoked = new CountDownLatch(1);
var cancellationCallback = new CountDownLatch(1);
var pluginStage = new CompletableFuture<List<String>>();
pluginStage.whenComplete((ignored, failure) -> cancellationCallback.countDown());
var registration = new AtomicReference<CommandRegistration>();
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
registration.set(context.commands().register("wait", ignored -> { }, completion -> {
invoked.countDown();
return pluginStage;
}));
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
host.load(List.of(plugin));
host.enable();
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/wait ").orElseThrow()
.toCompletableFuture();
assertTrue(invoked.await(5, TimeUnit.SECONDS));
registration.get().unregister();
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
() -> local.get(1, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof IllegalStateException);
assertTrue(cancellationCallback.await(1, TimeUnit.SECONDS));
assertTrue(pluginStage.isCancelled());
assertEquals(List.of(), host.commandNames("wait"));
assertTrue(host.completeCommand(PLAYER, "/wait ").isEmpty());
}
}
@Test
void completerThrowAndNullStageFailPromptly() throws Exception {
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
context.commands().register("throws", ignored -> { }, completion -> {
throw new IllegalArgumentException("bad completer");
});
context.commands().register("nullstage", ignored -> { }, completion -> null);
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
host.load(List.of(plugin));
host.enable();
for (String command : List.of("throws", "nullstage")) {
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/" + command + " ")
.orElseThrow().toCompletableFuture();
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
() -> local.get(500, TimeUnit.MILLISECONDS));
assertFalse(failure.getCause() instanceof java.util.concurrent.TimeoutException);
}
}
}
private static Players players() {
return new Players() {
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
@Override public List<PlayerView> online() { return List.of(); }
@Override public CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
return CompletableFuture.completedFuture(TransferResult.failed("unused"));
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, net.kyori.adventure.text.Component message) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, net.kyori.adventure.text.Component reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
};
}
}
@@ -0,0 +1,127 @@
package dev.strataproxy.core.plugin;
import dev.strataproxy.api.*;
import dev.strataproxy.core.backend.InMemoryBackendCatalog;
import dev.strataproxy.core.control.BackendChannelTransport;
import dev.strataproxy.messaging.*;
import net.kyori.adventure.text.Component;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
class PluginHostMessagingTest {
private static final String CHANNEL = "proxy:events";
@Test void pluginContextMessagingRoutesLocallyAndCombinesProxyPublishWithTransport() throws Exception {
AtomicReference<PluginContext> contextRef = new AtomicReference<>();
CopyOnWriteArrayList<Message> events = new CopyOnWriteArrayList<>();
AtomicReference<CompletionStage<Message>> localRequest = new AtomicReference<>();
AtomicReference<CompletionStage<SendReceipt>> localSend = new AtomicReference<>();
AtomicReference<CompletionStage<PublishResult>> publication = new AtomicReference<>();
var plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
contextRef.set(context);
context.messaging().channel(CHANNEL).subscribe(events::add);
context.messaging().channel(CHANNEL).onRequest(request ->
CompletableFuture.completedFuture(request.payload()));
}
@Override public void onEnable() {
var channel = contextRef.get().messaging().channel(CHANNEL);
localRequest.set(channel.request(Endpoint.proxy(), new byte[]{3}));
localSend.set(channel.send(Endpoint.proxy(), new byte[]{4}));
publication.set(channel.publish(new byte[]{5}));
}
};
BackendChannelTransport transport = new BackendChannelTransport() {
@Override public CompletionStage<SendResult> send(Message message) {
return CompletableFuture.completedFuture(SendResult.ACCEPTED);
}
@Override public CompletionStage<Message> request(Message message, Duration timeout) {
return CompletableFuture.failedFuture(new AssertionError("unexpected remote request"));
}
@Override public CompletionStage<PublishResult> publish(Message event) {
return CompletableFuture.completedFuture(new PublishResult(event.id(),
Map.of(Endpoint.backend("remote"), SendResult.ACCEPTED)));
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), noPlayers(), Duration.ofSeconds(1))) {
host.setBackendChannelTransport(transport);
host.load(List.of(plugin));
host.enable();
Message reply = localRequest.get().toCompletableFuture().get(2, TimeUnit.SECONDS);
assertEquals(Endpoint.proxy(), reply.source());
assertEquals(Endpoint.proxy(), reply.target());
assertArrayEquals(new byte[]{3}, reply.payload());
assertEquals(SendResult.ACCEPTED,
localSend.get().toCompletableFuture().get(2, TimeUnit.SECONDS).result());
PublishResult result = publication.get().toCompletableFuture().get(2, TimeUnit.SECONDS);
assertEquals(Map.of(Endpoint.proxy(), SendResult.ACCEPTED,
Endpoint.backend("remote"), SendResult.ACCEPTED), result.results());
await(() -> events.size() == 2);
assertEquals(2, events.size(), "local direct send and publish each dispatch once");
}
}
@Test void disablingHostCompletesPluginOwnedPendingProxyRequest() throws Exception {
AtomicReference<CompletionStage<Message>> pending = new AtomicReference<>();
Plugin actual = new Plugin() {
private PluginContext context;
@Override public void onLoad(PluginContext value) {
context = value;
value.messaging().channel(CHANNEL).onRequest(request -> new CompletableFuture<>());
}
@Override public void onEnable() {
pending.set(context.messaging().channel(CHANNEL)
.request(Endpoint.proxy(), new byte[0], Duration.ofSeconds(30)));
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), noPlayers(), Duration.ofSeconds(1))) {
host.load(List.of(actual));
host.enable();
assertNotNull(pending.get());
host.close();
var failure = assertThrows(ExecutionException.class,
() -> pending.get().toCompletableFuture().get(1, TimeUnit.SECONDS));
assertInstanceOf(MessagingException.class, failure.getCause());
assertEquals(MessagingException.Code.CLOSED, ((MessagingException) failure.getCause()).code());
}
}
private static Players noPlayers() {
return new Players() {
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
@Override public List<PlayerView> online() { return List.of(); }
@Override public CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
return CompletableFuture.completedFuture(TransferResult.failed("unavailable"));
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
};
}
private static void await(java.util.function.BooleanSupplier condition) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (!condition.getAsBoolean()) {
if (System.nanoTime() >= deadline) fail("condition not met");
Thread.sleep(5);
}
}
}
@@ -23,6 +23,8 @@ import dev.strataproxy.api.ServerDefinition;
import dev.strataproxy.api.ServerRegistration;
import dev.strataproxy.api.TransferResult;
import dev.strataproxy.api.TransferStatus;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent;
import dev.strataproxy.core.backend.BackendCatalog;
import dev.strataproxy.core.backend.BackendHandle;
import dev.strataproxy.core.backend.BackendId;
@@ -537,7 +539,7 @@ class PluginHostTest {
host.load(List.of(plugin));
var observed = new CompletableFuture<CommandInvocation>();
var worker = new CompletableFuture<Thread>();
var reply = new CompletableFuture<String>();
var reply = new CompletableFuture<Component>();
var registration = plugin.context.commands().register("Ping", invocation -> {
worker.complete(Thread.currentThread());
observed.complete(invocation);
@@ -555,7 +557,7 @@ class PluginHostTest {
assertNotEquals(Thread.currentThread(), worker.get(5, TimeUnit.SECONDS));
assertEquals("ping", observed.get(5, TimeUnit.SECONDS).name());
assertEquals("hello world", observed.get(5, TimeUnit.SECONDS).arguments());
assertEquals("pong", reply.get(5, TimeUnit.SECONDS));
assertEquals("pong", ((TextComponent) reply.get(5, TimeUnit.SECONDS)).content());
registration.unregister();
registration.unregister();
assertFalse(host.dispatchCommand(PLAYER, "/ping", text -> { }));
@@ -616,10 +618,10 @@ class PluginHostTest {
assertTrue(host.dispatchCommand(PLAYER, "/slow one", text -> { }));
assertTrue(entered.await(5, TimeUnit.SECONDS));
assertTrue(host.dispatchCommand(PLAYER, "/slow two", text -> { }));
var rejectedReply = new CompletableFuture<String>();
var rejectedReply = new CompletableFuture<Component>();
assertTrue(host.dispatchCommand(PLAYER, "/slow three", rejectedReply::complete));
assertEquals("Proxy command service is busy. Please try again.",
rejectedReply.get(1, TimeUnit.SECONDS));
((TextComponent) rejectedReply.get(1, TimeUnit.SECONDS)).content());
assertEquals(1, calls.get());
} finally {
release.countDown();
@@ -782,15 +784,15 @@ class PluginHostTest {
@Override public java.util.concurrent.CompletionStage<TransferResult> transfer(
PlayerIdentity identity, String backendName) { throw new AssertionError("unexpected transfer"); }
@Override public java.util.concurrent.CompletionStage<MessageResult> sendMessage(
PlayerIdentity identity, String message) {
PlayerIdentity identity, Component message) {
assertEquals(PLAYER.identity(), identity);
assertEquals("hello", message);
assertEquals("hello", ((TextComponent) message).content());
return messageResult;
}
@Override public java.util.concurrent.CompletionStage<DisconnectResult> disconnect(
PlayerIdentity identity, String reason) {
PlayerIdentity identity, Component reason) {
assertEquals(PLAYER.identity(), identity);
assertEquals("maintenance", reason);
assertEquals("maintenance", ((TextComponent) reason).content());
return disconnectResult;
}
};
@@ -994,11 +996,11 @@ class PluginHostTest {
private abstract static class TestPlayers implements Players {
@Override public java.util.concurrent.CompletionStage<MessageResult> sendMessage(
PlayerIdentity identity, String message) {
PlayerIdentity identity, Component message) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
@Override public java.util.concurrent.CompletionStage<DisconnectResult> disconnect(
PlayerIdentity identity, String reason) {
PlayerIdentity identity, Component reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
}
@@ -0,0 +1,117 @@
package dev.strataproxy.core.protocol;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MinecraftTextTest {
private static final ObjectMapper JSON = new ObjectMapper();
@Test
void templateVariablesRemainLiteralAndPrimitiveArgumentsAreBounded() throws Exception {
var message = net.kyori.adventure.text.minimessage.MiniMessage.miniMessage().deserialize(
"<green>Hello <player></green>",
net.kyori.adventure.text.minimessage.tag.resolver.Placeholder.unparsed("player", "<red>Ada"));
var encoded = JSON.readTree(MinecraftText.encode(message));
assertTrue(encoded.toString().contains("<red>Ada"));
var arguments = new java.util.ArrayList<net.kyori.adventure.text.TranslationArgument>();
for (int i = 0; i < 300; i++) arguments.add(net.kyori.adventure.text.TranslationArgument.numeric(i));
assertThrows(IllegalArgumentException.class,
() -> MinecraftText.encode(Component.translatable("key").arguments(arguments)));
}
@Test
void writesLegacyTextTranslationStyleClickAndHoverJson() throws Exception {
Component component = Component.translatable("chat.type.text", Component.text("Ada"),
Component.text("hello"))
.color(NamedTextColor.RED)
.decorate(TextDecoration.BOLD)
.clickEvent(ClickEvent.runCommand("/hello"))
.hoverEvent(HoverEvent.showText(Component.text("Show details")));
JsonNode encoded = JSON.readTree(MinecraftText.encode(component));
assertEquals("chat.type.text", encoded.path("translate").textValue());
assertEquals(2, encoded.path("with").size());
assertEquals("red", encoded.path("color").textValue());
assertTrue(encoded.path("bold").booleanValue());
assertEquals("run_command", encoded.path("clickEvent").path("action").textValue());
assertEquals("/hello", encoded.path("clickEvent").path("value").textValue());
assertEquals("show_text", encoded.path("hoverEvent").path("action").textValue());
JsonNode hoverValue = encoded.path("hoverEvent").path("value");
assertEquals("Show details", hoverValue.isTextual()
? hoverValue.textValue() : hoverValue.path("text").textValue());
assertTrue(encoded.path("hoverEvent").has("value"));
assertTrue(!encoded.path("hoverEvent").has("contents"));
}
@Test
void supportsTextExtraAndAllThreeMinecraftClickActions() throws Exception {
for (ClickEvent<?> click : new ClickEvent<?>[]{
ClickEvent.openUrl("https://example.test"),
ClickEvent.runCommand("/home"),
ClickEvent.suggestCommand("/spawn")}) {
Component component = Component.text("go").clickEvent(click)
.append(Component.text(" now", NamedTextColor.GREEN));
JsonNode encoded = JSON.readTree(MinecraftText.encode(component));
assertEquals(click.action().toString().toLowerCase(), encoded.path("clickEvent").path("action").textValue());
assertEquals(" now", encoded.path("extra").get(0).path("text").textValue());
}
}
@Test
void rejectsComponentKindsAndEventsWithoutMinecraft1710Support() {
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.keybind("key.jump")));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.selector("@a")));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.text("x")
.clickEvent(ClickEvent.copyToClipboard("x"))));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.text("x")
.hoverEvent(HoverEvent.showItem(Key.key("minecraft:stone"), 1))));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.text("x")
.insertion("insert")));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(
Component.translatable("hello").fallback("Hello")));
}
@Test
void rejectsBlankReasonsAndBoundsTheTreeBeforeSerialization() {
assertThrows(IllegalArgumentException.class,
() -> MinecraftText.encodeReason(Component.text(" \n\t")));
Component tooDeep = Component.text("leaf");
for (int i = 0; i < 32; i++) tooDeep = Component.text("x").hoverEvent(HoverEvent.showText(tooDeep));
Component deep = tooDeep;
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(deep));
Component[] children = new Component[256];
for (int i = 0; i < children.length; i++) children[i] = Component.text("x");
Component wide = Component.textOfChildren(children);
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(wide));
assertThrows(IllegalArgumentException.class,
() -> MinecraftText.encode(Component.text("x".repeat(MinecraftText.MAX_JSON_BYTES + 1))));
}
@Test
void packetEncodersRejectOversizedPreencodedJsonBeforeAllocation() {
String tooLarge = "\"" + "x".repeat(MinecraftText.MAX_JSON_BYTES) + "\"";
assertThrows(IllegalArgumentException.class, () -> Minecraft1710PlayPackets.chatReplyEncoded(
io.netty.buffer.UnpooledByteBufAllocator.DEFAULT, tooLarge));
assertThrows(IllegalArgumentException.class, () -> Minecraft1710PlayPackets.disconnectEncoded(
io.netty.buffer.UnpooledByteBufAllocator.DEFAULT, tooLarge));
assertThrows(IllegalArgumentException.class, () -> MinecraftLoginDisconnect.encodeJson(
io.netty.buffer.UnpooledByteBufAllocator.DEFAULT, tooLarge));
assertNotNull(MinecraftText.encode(Component.text("valid")));
}
}
@@ -767,6 +767,19 @@ final class ProxySessionListenerTest {
data.writeFloat(0);
data.writeByte(0);
writeFrame(output, position.toByteArray());
var rootRequest = new java.io.ByteArrayInputStream(readFrame(input));
assertEquals(0x14, readVarInt(rootRequest));
assertEquals("/w", readString(rootRequest, 32767));
var rootReply = new ByteArrayOutputStream();
writeVarInt(rootReply, 0x3a); writeVarInt(rootReply, 2);
writeString(rootReply, "/weather"); writeString(rootReply, "/where");
writeFrame(output, rootReply.toByteArray());
var unknownRequest = new java.io.ByteArrayInputStream(readFrame(input));
assertEquals(0x14, readVarInt(unknownRequest));
assertEquals("/weather c", readString(unknownRequest, 32767));
var unknownReply = new ByteArrayOutputStream();
writeVarInt(unknownReply, 0x3a); writeVarInt(unknownReply, 1); writeString(unknownReply, "clear");
writeFrame(output, unknownReply.toByteArray());
received.complete(List.of(readFrame(input), readFrame(input)));
} catch (Throwable failure) {
received.completeExceptionally(failure);
@@ -782,8 +795,11 @@ final class ProxySessionListenerTest {
@Override public void onLoad(PluginContext context) {
context.commands().register("where", invocation -> {
handlerThread.complete(Thread.currentThread().getName());
invocation.reply(invocation.player().username() + ":" + invocation.arguments());
});
invocation.reply(net.kyori.adventure.text.Component.text(
invocation.player().username() + ":" + invocation.arguments(),
net.kyori.adventure.text.format.NamedTextColor.GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/where lobby")));
}, completion -> { assertEquals("lo ", completion.arguments()); return CompletableFuture.completedFuture(List.of("lobby")); });
context.events().subscribe(ServerConnectedEvent.class, event -> {
assertFalse(Thread.currentThread().getName().startsWith("strataproxy-session-io"));
notificationCount.incrementAndGet();
@@ -809,6 +825,7 @@ final class ProxySessionListenerTest {
listener.setPlacement(plugins::placeInitial);
listener.setEvents(plugins, Duration.ofSeconds(5));
listener.setCommandDispatcher(plugins::dispatchCommand);
listener.setCommandCompletion(plugins::commandNames, plugins::completeCommand);
int port = ((InetSocketAddress) listener.start().toCompletableFuture()
.get(5, TimeUnit.SECONDS).localAddress()).getPort();
try (Socket client = new Socket(InetAddress.getLoopbackAddress(), port)) {
@@ -825,8 +842,17 @@ final class ProxySessionListenerTest {
writeFrame(output, chatPacket("/where lobby"));
var reply = new java.io.ByteArrayInputStream(readFrame(input));
assertEquals(2, readVarInt(reply));
assertEquals("CommandUser:lobby",
new ObjectMapper().readTree(readString(reply, 32767)).path("text").asText());
var replyJson = new ObjectMapper().readTree(readString(reply, 32767));
assertEquals("CommandUser:lobby", replyJson.path("text").asText());
assertEquals("green", replyJson.path("color").asText());
assertEquals("suggest_command", replyJson.path("clickEvent").path("action").asText());
assertEquals("/where lobby", replyJson.path("clickEvent").path("value").asText());
writeFrame(output, completionRequest("/where lo "));
assertEquals(List.of("lobby"), completionResponse(readFrame(input)));
writeFrame(output, completionRequest("/w"));
assertEquals(List.of("/where", "/weather"), completionResponse(readFrame(input)));
writeFrame(output, completionRequest("/weather c"));
assertEquals(List.of("clear"), completionResponse(readFrame(input)));
assertTrue(handlerThread.get(5, TimeUnit.SECONDS).startsWith("strataproxy-plugin-command-"));
assertEquals(2, accessCalls.get());
writeFrame(output, chatPacket("hello"));
@@ -844,6 +870,20 @@ final class ProxySessionListenerTest {
}
}
private static byte[] completionRequest(String text) throws Exception {
var packet = new ByteArrayOutputStream();
writeVarInt(packet, 0x14); writeString(packet, text);
return packet.toByteArray();
}
private static List<String> completionResponse(byte[] frame) throws Exception {
var packet = new java.io.ByteArrayInputStream(frame);
assertEquals(0x3a, readVarInt(packet));
int count = readVarInt(packet);
var values = new ArrayList<String>();
for (int i = 0; i < count; i++) values.add(readString(packet, 32767));
return values;
}
private static byte[] chatPacket(String text) throws Exception {
var packet = new ByteArrayOutputStream();
writeVarInt(packet, 1);
@@ -1611,7 +1651,7 @@ final class ProxySessionListenerTest {
listener.setCommandDispatcher((player, message, reply, admission) -> {
if (!message.equals("/ping")) return false;
if (!admission.getAsBoolean()) return true;
reply.accept("pong");
reply.accept(net.kyori.adventure.text.Component.text("pong"));
return true;
});
try {
@@ -87,6 +87,9 @@ final class SessionTransferTest {
register(catalog, "old", oldServer);
register(catalog, "new", newServer);
var listener = listener(catalog);
listener.setCommandCompletion(prefix -> List.of("/test"),
(player, text) -> Optional.of(CompletableFuture.completedFuture(
List.of(player.currentServer().orElseThrow()))));
try {
int port = ((InetSocketAddress) listener.start().toCompletableFuture()
.get(5, TimeUnit.SECONDS).localAddress()).getPort();
@@ -98,6 +101,7 @@ final class SessionTransferTest {
assertEquals(1, packetId(readFrame(input)));
assertEquals(8, packetId(readFrame(input)));
var player = awaitPlayer(listener);
assertLocalCompletion(client, input, "old");
var session = listener.allSessions().iterator().next();
var frontendField = Session.class.getDeclaredField("frontend");
@@ -127,8 +131,12 @@ final class SessionTransferTest {
var transfer = listener.transfer(player.identity(), "new").toCompletableFuture();
openingHeld.get(5, TimeUnit.SECONDS);
frontend.eventLoop().submit(() -> frontend.pipeline().fireChannelRead(
Unpooled.wrappedBuffer(new byte[]{2, 0x01, 0x55}))).get(5, TimeUnit.SECONDS);
frontend.eventLoop().submit(() -> {
frontend.pipeline().fireChannelRead(Unpooled.wrappedBuffer(new byte[]{2, 0x01, 0x55}));
// Match a real socket read cycle: otherwise the decoder's stale fired-read flag
// can suppress the next manual read when a later packet arrives in fragments.
frontend.pipeline().fireChannelReadComplete();
}).get(5, TimeUnit.SECONDS);
assertThrows(java.util.concurrent.TimeoutException.class,
() -> newReceived.get(250, TimeUnit.MILLISECONDS));
@@ -144,6 +152,7 @@ final class SessionTransferTest {
assertEquals(8, packetId(readFrame(input)));
assertArrayEquals(new byte[]{0x01, 0x55}, newReceived.get(5, TimeUnit.SECONDS));
oldClosed.get(5, TimeUnit.SECONDS);
assertLocalCompletion(client, input, "new");
}
} finally { listener.close().toCompletableFuture().get(5, TimeUnit.SECONDS); }
}
@@ -1589,6 +1598,17 @@ final class SessionTransferTest {
thread.start();
}
private static void assertLocalCompletion(Socket client, DataInputStream input, String backend) throws Exception {
var body = new ByteArrayOutputStream();
var request = new DataOutputStream(body);
writeVarInt(request, 0x14); writeString(request, "/test ");
writeFrame(new DataOutputStream(client.getOutputStream()), body.toByteArray());
var response = new ByteArrayInputStream(readFrame(input));
assertEquals(0x3a, readVarInt(response));
assertEquals(1, readVarInt(response));
assertEquals(backend, new String(response.readNBytes(readVarInt(response)), StandardCharsets.UTF_8));
}
private static int packetId(byte[] bytes) throws Exception {
return readVarInt(new ByteArrayInputStream(bytes));
}
@@ -0,0 +1,175 @@
package dev.strataproxy.core.session;
import dev.strataproxy.core.protocol.Minecraft1710PlayPackets;
import dev.strataproxy.core.protocol.MinecraftTabCompletion;
import dev.strataproxy.core.protocol.ProtocolVarInt;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
class TabCompletionBridgeTest {
private static ByteBuf request(String text) {
ByteBuf body = Unpooled.buffer();
body.writeByte(0x14);
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
ProtocolVarInt.write(body, bytes.length);
body.writeBytes(bytes);
try { return Minecraft1710PlayPackets.frame(UnpooledByteBufAllocatorHolder.ALLOC, body); }
finally { body.release(); }
}
private static final class UnpooledByteBufAllocatorHolder {
static final io.netty.buffer.ByteBufAllocator ALLOC = io.netty.buffer.UnpooledByteBufAllocator.DEFAULT;
}
private static List<String> read(EmbeddedChannel client) {
ByteBuf frame = client.readOutbound();
assertNotNull(frame);
try { return MinecraftTabCompletion.response(frame); } finally { frame.release(); }
}
private static final class Fixture implements AutoCloseable {
final EmbeddedChannel client = new EmbeddedChannel();
final EmbeddedChannel backend = new EmbeddedChannel();
final CompletableFuture<List<String>> result = new CompletableFuture<>();
final AtomicBoolean closed = new AtomicBoolean();
final TabCompletionBridge bridge = new TabCompletionBridge(client,
prefix -> List.of("/proxy"),
text -> text.startsWith("/proxy ") ? Optional.of(result) : Optional.empty(),
() -> { closed.set(true); });
Fixture() {
client.pipeline().addLast(bridge.frontendHandler());
backend.pipeline().addLast(bridge.backendHandler());
}
void backendReply(List<String> values) {
backend.writeInbound(MinecraftTabCompletion.response(backend.alloc(), values));
}
void releaseForwarded() { ByteBuf frame = client.readInbound(); assertNotNull(frame); frame.release(); }
@Override public void close() { bridge.close(); client.finishAndReleaseAll(); backend.finishAndReleaseAll(); }
}
@Test void mergesRootNamesAndDeduplicates() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/p")); f.releaseForwarded();
f.backendReply(List.of("/proxy", "/plugins"));
assertEquals(List.of("/proxy", "/plugins"), read(f.client));
assertFalse(f.closed.get());
}
}
@Test void localArgumentsNeverReachBackendAndCompleteOffCallback() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
assertNull(f.client.readInbound());
f.result.complete(List.of("lobby", "islands"));
f.client.runPendingTasks();
assertEquals(List.of("lobby", "islands"), read(f.client));
}
}
@Test void unknownCommandResponsePreservesExactFrame() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/backend arg")); f.releaseForwarded();
ByteBuf original = MinecraftTabCompletion.response(f.backend.alloc(), List.of("argument"));
ByteBuf expected = original.copy();
f.backend.writeInbound(original);
ByteBuf actual = f.client.readOutbound();
try { assertEquals(expected, actual); } finally { expected.release(); actual.release(); }
}
}
@Test void timedOutBackendReplyCannotCompleteNewLocalRequest() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/p")); f.releaseForwarded();
f.client.advanceTimeBy(2, TimeUnit.SECONDS); f.client.runScheduledPendingTasks();
assertEquals(List.of("/proxy"), read(f.client));
f.client.writeInbound(request("/proxy "));
f.backendReply(List.of("stale"));
assertNull(f.client.readOutbound());
f.result.complete(List.of("fresh")); f.client.runPendingTasks();
assertEquals(List.of("fresh"), read(f.client));
}
}
@Test void doesNotIssueAnotherBackendRequestUntilExpiredReplyIsConsumed() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/other arg")); f.releaseForwarded();
f.client.advanceTimeBy(2, TimeUnit.SECONDS); f.client.runScheduledPendingTasks(); read(f.client);
f.client.writeInbound(request("/other next"));
assertNull(f.client.readInbound()); assertEquals(List.of(), read(f.client));
f.backendReply(List.of("old")); assertNull(f.client.readOutbound());
f.client.writeInbound(request("/other last")); f.releaseForwarded();
f.backendReply(List.of("last")); assertEquals(List.of("last"), read(f.client));
}
}
@Test void closesGenerationAndCancelsPluginWorkReleasingQueuedFrames() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
ByteBuf queued = request("/p"); f.client.writeInbound(queued);
f.bridge.close();
assertTrue(f.result.isCancelled()); assertEquals(0, queued.refCnt());
f.client.runPendingTasks(); assertNull(f.client.readOutbound());
}
}
@Test void ordinaryFramesPassThroughByIdentity() {
try (Fixture f = new Fixture()) {
ByteBuf frame = Unpooled.wrappedBuffer(new byte[]{2, 1, 0});
f.client.writeInbound(frame);
assertSame(frame, f.client.readInbound()); frame.release();
}
}
@Test void localTimeoutReturnsEmptyAndCancelsStage() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
f.client.advanceTimeBy(2, TimeUnit.SECONDS); f.client.runScheduledPendingTasks();
assertEquals(List.of(), read(f.client)); assertTrue(f.result.isCancelled());
}
}
@Test void replacementGenerationIgnoresOldBackendReplies() {
try (Fixture f = new Fixture()) {
EmbeddedChannel replacement = new EmbeddedChannel();
f.client.writeInbound(request("/p")); f.releaseForwarded();
f.bridge.close();
f.client.pipeline().removeFirst();
var next = new TabCompletionBridge(f.client, prefix -> List.of("/new"),
text -> Optional.empty(), () -> f.closed.set(true));
try {
f.client.pipeline().addLast(next.frontendHandler());
replacement.pipeline().addLast(next.backendHandler());
f.client.writeInbound(request("/n")); f.releaseForwarded();
f.backendReply(List.of("/old"));
assertNull(f.client.readOutbound());
replacement.writeInbound(MinecraftTabCompletion.response(replacement.alloc(), List.of("/native")));
assertEquals(List.of("/new", "/native"), read(f.client));
} finally { next.close(); replacement.finishAndReleaseAll(); }
}
}
@Test void queuedRequestsStayInOrderAndOverflowClosesConnection() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
for (int i = 0; i < 8; i++) f.client.writeInbound(request("/p"));
ByteBuf overflow = request("/p"); f.client.writeInbound(overflow);
assertTrue(f.closed.get()); assertEquals(0, overflow.refCnt());
}
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
f.client.writeInbound(request("/p"));
assertNull(f.client.readInbound());
f.result.complete(List.of("first")); f.client.runPendingTasks();
assertEquals(List.of("first"), read(f.client));
f.releaseForwarded();
f.backendReply(List.of("/second"));
assertEquals(List.of("/proxy", "/second"), read(f.client));
}
}
}
+3
View File
@@ -4,7 +4,10 @@ plugins {
}
dependencies {
api(project(":messaging-api"))
api(libs.findLibrary("slf4j-api").get())
api(libs.findLibrary("adventure-api").get())
api(libs.findLibrary("adventure-minimessage").get())
}
java {
@@ -1,6 +1,7 @@
package dev.strataproxy.api;
import java.util.Objects;
import net.kyori.adventure.text.Component;
/** Allows or denies a connection or login access check. */
public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDecision.Denied {
@@ -9,6 +10,11 @@ public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDec
}
static AccessDecision deny(String reason) {
PlainTextValidation.validateString(reason, false, "reason");
return new Denied(Component.text(reason));
}
static AccessDecision deny(Component reason) {
return new Denied(reason);
}
@@ -16,12 +22,9 @@ public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDec
private static final Allowed INSTANCE = new Allowed();
}
record Denied(String reason) implements AccessDecision {
record Denied(Component reason) implements AccessDecision {
public Denied {
Objects.requireNonNull(reason, "reason");
if (reason.isBlank() || reason.codePointCount(0, reason.length()) > 1024) {
throw new IllegalArgumentException("reason must contain 1 to 1024 characters");
}
}
}
}
@@ -1,7 +0,0 @@
package dev.strataproxy.api;
/** A registered backend channel handler. Closing the subscription more than once is harmless. */
public interface BackendChannelSubscription extends AutoCloseable {
@Override
void close();
}
@@ -1,28 +0,0 @@
package dev.strataproxy.api;
import java.util.concurrent.CompletionStage;
/** Sends messages to, and registers request handlers for, connected backend instances. */
public interface BackendChannels {
/**
* Registers the handler for a channel. A channel may have only one handler at a time.
* Closing the returned subscription unregisters the handler.
*/
BackendChannelSubscription subscribe(String channel, BackendMessageHandler handler);
/**
* Sends a one-way message to a backend. A successful {@link BackendSendResult#SENT} means
* the message was written to the control connection; it does not confirm application handling.
* The implementation copies {@code payload} before this method returns, so the caller may
* reuse or mutate its array after the call.
*/
CompletionStage<BackendSendResult> send(String backendName, String channel, byte[] payload);
/**
* Sends a request and completes with the backend handler's response. The stage completes
* exceptionally if the backend is unavailable, the default five-second request deadline
* expires, or transport fails. The implementation copies {@code payload} before this method
* returns, so the caller may reuse or mutate its array after the call.
*/
CompletionStage<byte[]> request(String backendName, String channel, byte[] payload);
}
@@ -1,36 +0,0 @@
package dev.strataproxy.api;
import java.util.Objects;
/** An inbound request from a backend, with identity taken from its authenticated connection. */
public record BackendMessage(
String backendName,
String instanceId,
long epoch,
String channel,
byte[] payload) {
public BackendMessage {
backendName = requireText(backendName, "backendName");
instanceId = requireText(instanceId, "instanceId");
if (epoch <= 0) {
throw new IllegalArgumentException("epoch must be positive");
}
channel = requireText(channel, "channel");
payload = Objects.requireNonNull(payload, "payload").clone();
}
/** Returns a copy so callers cannot mutate the message held by the proxy. */
@Override
public byte[] payload() {
return payload.clone();
}
private static String requireText(String value, String name) {
Objects.requireNonNull(value, name);
if (value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value;
}
}
@@ -1,9 +0,0 @@
package dev.strataproxy.api;
import java.util.concurrent.CompletionStage;
/** Handles a request received from a backend and produces its response payload. */
@FunctionalInterface
public interface BackendMessageHandler {
CompletionStage<byte[]> handle(BackendMessage message);
}
@@ -1,11 +0,0 @@
package dev.strataproxy.api;
/** Immediate outcome of writing a one-way message to a backend connection. */
public enum BackendSendResult {
/** The message was written to the control connection. */
SENT,
/** The named backend has no currently connected instance. */
NOT_CONNECTED,
/** The bounded outbound queue is full or the connection is not writable. */
BACKPRESSURED
}
@@ -0,0 +1,13 @@
package dev.strataproxy.api;
import java.util.List;
import java.util.concurrent.CompletionStage;
/**
* Produces last-token suggestions asynchronously on bounded plugin workers. The plugin owns prefix matching;
* the proxy validates and bounds results but does not filter them against the player's current token.
*/
@FunctionalInterface
public interface CommandCompleter {
CompletionStage<List<String>> complete(CommandCompletion completion) throws Exception;
}
@@ -0,0 +1,12 @@
package dev.strataproxy.api;
import java.util.Objects;
/** Immutable context for completing raw arguments after a known command root, excluding one separator. */
public record CommandCompletion(PlayerView player, String commandName, String arguments) {
public CommandCompletion {
Objects.requireNonNull(player, "player");
Objects.requireNonNull(commandName, "commandName");
Objects.requireNonNull(arguments, "arguments");
}
}
@@ -2,15 +2,16 @@ package dev.strataproxy.api;
import java.util.Objects;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
/** One player command. The raw arguments exclude the leading command name and separator. */
public final class CommandInvocation {
private final PlayerView player;
private final String name;
private final String arguments;
private final Consumer<String> reply;
private final Consumer<Component> reply;
public CommandInvocation(PlayerView player, String name, String arguments, Consumer<String> reply) {
public CommandInvocation(PlayerView player, String name, String arguments, Consumer<Component> reply) {
this.player = Objects.requireNonNull(player, "player");
this.name = Objects.requireNonNull(name, "name");
this.arguments = Objects.requireNonNull(arguments, "arguments");
@@ -21,8 +22,14 @@ public final class CommandInvocation {
public String name() { return name; }
public String arguments() { return arguments; }
/** Queues plain text chat if the session is connected and writable; excess replies may be dropped. */
public void reply(String message) {
/** Queues rich text if the session is connected and writable; excess replies may be dropped. */
public void reply(Component message) {
reply.accept(Objects.requireNonNull(message, "message"));
}
/** Queues plain text, retaining the 1024 Unicode code point convenience limit. */
public void reply(String message) {
PlainTextValidation.validateString(message, true, "message");
reply.accept(Component.text(message));
}
}
@@ -6,5 +6,10 @@ public interface Commands {
* Reserves a root command name. Names are case insensitive and unique across plugins.
* An unregistered command continues to the backend unchanged.
*/
CommandRegistration register(String name, CommandHandler handler);
CommandRegistration register(String name, CommandHandler handler, CommandCompleter completer);
/** Registers a command without argument completion. */
default CommandRegistration register(String name, CommandHandler handler) {
return register(name, handler, null);
}
}
@@ -1,6 +1,7 @@
package dev.strataproxy.api;
import java.util.Objects;
import net.kyori.adventure.text.Component;
/** Decision returned by an initial placement handler. */
public sealed interface PlacementDecision permits PlacementDecision.Select, PlacementDecision.Reject {
@@ -9,6 +10,11 @@ public sealed interface PlacementDecision permits PlacementDecision.Select, Plac
}
static PlacementDecision reject(String reason) {
PlainTextValidation.validateString(reason, false, "reason");
return new Reject(Component.text(reason));
}
static PlacementDecision reject(Component reason) {
return new Reject(reason);
}
@@ -21,12 +27,9 @@ public sealed interface PlacementDecision permits PlacementDecision.Select, Plac
}
}
record Reject(String reason) implements PlacementDecision {
record Reject(Component reason) implements PlacementDecision {
public Reject {
Objects.requireNonNull(reason, "reason");
if (reason.isBlank() || reason.codePointCount(0, reason.length()) > 1024) {
throw new IllegalArgumentException("reason must contain 1 to 1024 characters");
}
}
}
}
@@ -0,0 +1,18 @@
package dev.strataproxy.api;
import java.util.Objects;
/** Shared validation for the plain String convenience overloads. */
final class PlainTextValidation {
private PlainTextValidation() { }
static void validateString(String text, boolean allowEmpty, String name) {
Objects.requireNonNull(text, name);
int length = text.codePointCount(0, text.length());
if ((!allowEmpty && text.isBlank()) || length > 1024) {
throw new IllegalArgumentException(name + (allowEmpty
? " must contain at most 1024 Unicode code points"
: " must contain 1 to 1024 Unicode code points"));
}
}
}
@@ -1,5 +1,7 @@
package dev.strataproxy.api;
import net.kyori.adventure.text.Component;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletionStage;
@@ -21,24 +23,36 @@ public interface Players {
CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName);
/**
* Sends a plain-text message to this exact connection. Text may contain up to 1024 Unicode
* code points, including an empty message. Login, initial Forge negotiation, and transfer
* Sends a rich-text message to this exact connection. Components are bounded and validated
* before serialization. Login, initial Forge negotiation, and transfer
* transitions return NOT_READY. At most 64 messages per connection may be outstanding;
* a full queue or unwritable connection returns BACKPRESSURED without waiting for capacity.
* SENT means the network write completed, not that the client displayed the message.
* Network write failures complete the stage exceptionally; invalid arguments throw immediately.
* Through PluginContext, the result completes off the player's I/O loop.
*/
CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message);
CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message);
/** Sends plain text with a 1024 Unicode code point limit; the empty string is allowed. */
default CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
PlainTextValidation.validateString(message, true, "message");
return sendMessage(identity, Component.text(message));
}
/**
* Closes this exact connection, including an identified player still in admission or placement.
* The nonblank plain-text reason must contain at most 1024 Unicode code points. Its delivery is
* The reason must render as nonblank text. Its delivery is
* best effort, with at most five seconds allowed for the write before closing. DISCONNECTED
* means the connection has been cleaned up and closed, not that the client read the reason.
* Repeated requests while closing join the same operation. Cancelling the returned stage does
* not undo an accepted disconnect. Stale connection identities return NOT_CONNECTED.
* Invalid arguments throw immediately. Through PluginContext, completion occurs off I/O.
*/
CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason);
CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason);
/** Disconnects with plain text, limited to 1 to 1024 Unicode code points. */
default CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
PlainTextValidation.validateString(reason, false, "reason");
return disconnect(identity, Component.text(reason));
}
}
@@ -1,6 +1,7 @@
package dev.strataproxy.api;
import dev.strataproxy.api.event.Events;
import dev.strataproxy.messaging.Messaging;
import java.util.Map;
import org.slf4j.Logger;
@@ -14,14 +15,8 @@ public interface PluginContext {
Events events();
/**
* Returns the service for messaging with independently connected backend instances.
*
* @throws UnsupportedOperationException when the hosting proxy does not provide backend channels
*/
default BackendChannels backendChannels() {
throw new UnsupportedOperationException("Backend channels are not available");
}
/** Returns this plugin's lifecycle-owned message channel scope. */
Messaging messaging();
Logger logger();
@@ -9,6 +9,7 @@ import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import net.kyori.adventure.text.Component;
class ApiValueTypesTest {
@Test
@@ -47,14 +48,16 @@ class ApiValueTypesTest {
@Test
void placementRejectionBoundsTheClientVisibleReason() {
assertEquals("Try again later", ((PlacementDecision.Reject) PlacementDecision.reject("Try again later")).reason());
assertEquals(Component.text("Try again later"),
((PlacementDecision.Reject) PlacementDecision.reject("Try again later")).reason());
assertThrows(IllegalArgumentException.class, () -> PlacementDecision.reject(" "));
assertThrows(IllegalArgumentException.class, () -> PlacementDecision.reject("x".repeat(1025)));
}
@Test
void accessDenialBoundsTheClientVisibleReason() {
assertEquals("Blocked", ((AccessDecision.Denied) AccessDecision.deny("Blocked")).reason());
assertEquals(Component.text("Blocked"),
((AccessDecision.Denied) AccessDecision.deny("Blocked")).reason());
assertThrows(IllegalArgumentException.class, () -> AccessDecision.deny(" "));
assertThrows(IllegalArgumentException.class, () -> AccessDecision.deny("x".repeat(1025)));
}
@@ -1,26 +0,0 @@
package dev.strataproxy.api;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class BackendChannelsApiTest {
@Test
void inboundPayloadIsCopiedOnConstructionAndAccess() {
byte[] input = {1, 2};
var message = new BackendMessage("lobby", "instance-1", 1, "example:ping", input);
input[0] = 9;
byte[] firstRead = message.payload();
firstRead[1] = 9;
assertArrayEquals(new byte[] {1, 2}, message.payload());
}
@Test
void inboundMessageRequiresAValidConnectionEpoch() {
assertThrows(IllegalArgumentException.class,
() -> new BackendMessage("lobby", "instance-1", 0, "example:ping", new byte[0]));
}
}
+7
View File
@@ -14,6 +14,10 @@ dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven {
url = uri("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
content { includeGroup("org.spigotmc") }
}
}
}
@@ -23,4 +27,7 @@ include(
"proxy-core",
"proxy-plugin-api",
"backend-channel-client",
"messaging-api",
"messaging-protocol",
"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");
}
}
+68
View File
@@ -0,0 +1,68 @@
import dev.strataproxy.backendchannel.BackendChannelClient;
import dev.strataproxy.messaging.Endpoint;
import dev.strataproxy.messaging.Message;
import dev.strataproxy.messaging.MessageChannel;
import dev.strataproxy.messaging.PublishResult;
import dev.strataproxy.messaging.SendResult;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** Run with an actual Java 8 VM against ChannelRouterFixture; uses no players. */
public final class ChannelJava8Probe {
public static void main(String[] args) throws Exception {
if (!System.getProperty("java.specification.version").equals("1.8")) {
throw new IllegalStateException("This probe must run on Java 8");
}
int port = Integer.parseInt(args[0]);
byte[] bytes = "java8-payload".getBytes(StandardCharsets.UTF_8);
try (BackendChannelClient a = client(port, "a", 25565);
BackendChannelClient b = client(port, "b", 25566)) {
require(a.awaitRegistered(8, TimeUnit.SECONDS), "backend a did not register");
require(b.awaitRegistered(8, TimeUnit.SECONDS), "backend b did not register");
MessageChannel sender = a.messaging("probe-a").channel("probe:channel");
MessageChannel receiver = b.messaging("probe-b").channel("probe:channel");
AtomicReference<Message> received = new AtomicReference<Message>();
receiver.onRequest(message -> {
received.set(message);
return CompletableFuture.completedFuture(message.payload());
});
Message proxyReply = sender.request(Endpoint.proxy(), bytes).toCompletableFuture().get(5, TimeUnit.SECONDS);
require(Endpoint.proxy().equals(proxyReply.source()), "wrong proxy reply source");
Message reply = sender.request(Endpoint.backend("b"), bytes).toCompletableFuture().get(5, TimeUnit.SECONDS);
require(Endpoint.backend("a").equals(received.get().source()), "wrong forwarded source");
require(Endpoint.backend("b").equals(reply.source()), "wrong reply source");
require(received.get().id().equals(reply.replyTo()), "reply correlation changed in transit");
require(!reply.id().equals(reply.replyTo()), "reply lacks an independent ID");
CountDownLatch notifications = new CountDownLatch(3);
AtomicReference<UUID> publishedId = new AtomicReference<UUID>();
sender.subscribe(message -> notifications.countDown());
receiver.subscribe(message -> { publishedId.set(message.id()); notifications.countDown(); });
b.messaging("probe-second-plugin").channel("probe:channel")
.subscribe(message -> notifications.countDown());
PublishResult publication = sender.publish(bytes).toCompletableFuture().get(5, TimeUnit.SECONDS);
require(publication.results().size() == 3, "publication missed a node");
require(publication.results().get(Endpoint.backend("a")) == SendResult.ACCEPTED, "sender subscriber missing");
require(publication.results().get(Endpoint.backend("b")) == SendResult.ACCEPTED, "backend subscribers missing");
require(notifications.await(5, TimeUnit.SECONDS), "publication missed a plugin");
require(publication.messageId().equals(publishedId.get()), "publication ID changed in transit");
System.out.println("JAVA8_CHANNEL_PASS java=" + System.getProperty("java.version")
+ " requests=2 backendSubscribers=3 endToEndIdentity=true");
}
}
private static BackendChannelClient client(int port, String name, int gamePort) {
return new BackendChannelClient("127.0.0.1", port, "probe-" + name, name,
"tcp://127.0.0.1:" + gamePort, UUID.randomUUID().toString(), "primary",
"isolated-channel-probe-secret-at-least-32-bytes".getBytes(StandardCharsets.UTF_8));
}
private static void require(boolean value, String message) {
if (!value) throw new AssertionError(message);
}
}
+49
View File
@@ -0,0 +1,49 @@
import dev.strataproxy.app.ProxyConfiguration;
import dev.strataproxy.core.backend.InMemoryBackendCatalog;
import dev.strataproxy.core.control.BackendControlService;
import dev.strataproxy.messaging.*;
import dev.strataproxy.messaging.internal.LocalMessaging;
import java.net.ServerSocket;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
/** Isolated JDK 25 router for the separately launched Java 8 probe. */
public final class ChannelRouterFixture {
public static void main(String[] args) throws Exception {
int port;
try (ServerSocket reservation = new ServerSocket(0)) { port = reservation.getLocalPort(); }
ExecutorService workers = Executors.newFixedThreadPool(4);
ScheduledExecutorService timer = Executors.newScheduledThreadPool(2);
AtomicReference<BackendControlService> transport = new AtomicReference<>();
try (LocalMessaging local = new LocalMessaging(Endpoint.proxy(), new LocalMessaging.Outbound() {
public CompletionStage<SendResult> send(Message message) { return transport.get().send(message); }
public CompletionStage<Message> request(Message message, Duration timeout) { return transport.get().request(message, timeout); }
public CompletionStage<PublishResult> publish(Message message) { return transport.get().publish(message); }
}, workers, timer)) {
var config = new ProxyConfiguration.BackendChannel("127.0.0.1:" + port,
Map.of("probe-a", client("a"), "probe-b", client("b")), 30, 8);
try (var control = new BackendControlService(config, new InMemoryBackendCatalog(), local)) {
transport.set(control);
var channel = local.openScope("fixture").channel("probe:channel");
channel.onRequest(message -> CompletableFuture.completedFuture(message.payload()));
channel.subscribe(message -> { });
control.start();
System.out.println("CHANNEL_FIXTURE_READY " + port);
System.out.flush();
System.in.read();
}
} finally {
workers.shutdownNow();
timer.shutdownNow();
}
}
private static ProxyConfiguration.Client client(String name) {
return new ProxyConfiguration.Client(name, "primary",
"isolated-channel-probe-secret-at-least-32-bytes", Set.of("127.0.0.1"), Set.of("probe"));
}
}
+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" }
}
+71
View File
@@ -0,0 +1,71 @@
param(
[Parameter(Mandatory = $true)][string]$Java8Home,
[Parameter(Mandatory = $true)][string]$Java25Home
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$fixtureClasses = Join-Path $repo 'build/channel-smoke/router'
$probeClasses = Join-Path $repo 'build/channel-smoke/probe'
$proxyClasspath = Join-Path $repo 'proxy-core/build/install/strataproxy/lib/*'
$backendClasspath = Join-Path $repo 'proxy-core/build/install/strataproxy/backend-client/*'
if (-not (Test-Path (Join-Path $repo 'proxy-core/build/install/strataproxy/lib'))) {
throw 'Run gradlew :proxy-core:installDist first.'
}
New-Item -ItemType Directory -Force $fixtureClasses, $probeClasses | Out-Null
& (Join-Path $Java25Home 'bin/javac.exe') -cp $proxyClasspath -d $fixtureClasses (Join-Path $PSScriptRoot 'ChannelRouterFixture.java')
if ($LASTEXITCODE -ne 0) { throw 'Router fixture compilation failed.' }
& (Join-Path $Java8Home 'bin/javac.exe') -cp $backendClasspath -d $probeClasses (Join-Path $PSScriptRoot 'ChannelJava8Probe.java')
if ($LASTEXITCODE -ne 0) { throw 'Java 8 probe compilation failed.' }
function Start-ProbeProcess([string]$Executable, [string[]]$Arguments) {
$info = New-Object System.Diagnostics.ProcessStartInfo
$info.FileName = $Executable
$info.UseShellExecute = $false
$info.CreateNoWindow = $true
$info.RedirectStandardInput = $true
$info.RedirectStandardOutput = $true
$info.RedirectStandardError = $true
# JVM paths on Windows cannot contain quotes. Arguments here are local paths/class names/numeric ports.
$info.Arguments = ($Arguments | ForEach-Object { '"' + $_ + '"' }) -join ' '
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $info
[void]$process.Start()
return $process
}
$router = $null
$probe = $null
try {
$router = Start-ProbeProcess (Join-Path $Java25Home 'bin/java.exe') @('-cp', "$fixtureClasses;$proxyClasspath", 'ChannelRouterFixture')
$routerErrors = $router.StandardError.ReadToEndAsync()
$readyDeadline = [DateTime]::UtcNow.AddSeconds(15)
do {
$ready = $router.StandardOutput.ReadLineAsync()
$remainingMillis = [int]($readyDeadline - [DateTime]::UtcNow).TotalMilliseconds
if ($remainingMillis -le 0 -or -not $ready.Wait($remainingMillis)) {
throw 'Router did not become ready within 15 seconds.'
}
$line = $ready.Result
if ($null -eq $line) { throw "Router exited before readiness: $($routerErrors.Result)" }
} until ($line -match '^CHANNEL_FIXTURE_READY (\d+)$')
$port = $Matches[1]
Write-Output $line
$routerOutput = $router.StandardOutput.ReadToEndAsync()
$probe = Start-ProbeProcess (Join-Path $Java8Home 'bin/java.exe') @('-cp', "$probeClasses;$backendClasspath", 'ChannelJava8Probe', $port)
$probeOutput = $probe.StandardOutput.ReadToEndAsync()
$probeErrors = $probe.StandardError.ReadToEndAsync()
if (-not $probe.WaitForExit(25000)) { throw 'Java 8 probe exceeded its 25 second deadline.' }
Write-Output $probeOutput.Result.Trim()
if ($probe.ExitCode -ne 0) { throw "Java 8 probe failed: $($probeErrors.Result)" }
$router.StandardInput.WriteLine()
$router.StandardInput.Flush()
if (-not $router.WaitForExit(5000)) { throw 'Router did not shut down cleanly.' }
if ($router.ExitCode -ne 0) { throw "Router failed: $($routerErrors.Result)" }
} finally {
foreach ($ownedProcess in @($probe, $router)) {
if ($null -ne $ownedProcess) {
if (-not $ownedProcess.HasExited) { $ownedProcess.Kill(); [void]$ownedProcess.WaitForExit(5000) }
$ownedProcess.Dispose()
}
}
}
+1 -1
View File
@@ -196,7 +196,7 @@ def control_register(host, backend_host, secret):
peer = connect(host, 28081)
try:
hello = read_control_frame(peer)
if len(hello) != 37 or hello[0] != 1 or int.from_bytes(hello[1:5], "big") != 1:
if len(hello) != 37 or hello[0] != 1 or int.from_bytes(hello[1:5], "big") != 2:
raise ValueError("invalid control HELLO")
fields = ("container-network-smoke", "remote", f"tcp://{backend_host}:25565",
str(uuid.uuid4()), "primary")
+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#后端宿主真实服务端验收)。
这次验收覆盖后端注册和消息服务;不包含富文本的真实玩家视觉/鼠标交互、完整整合包兼容性、跨物理主机网络或生产负载容量测量。
@@ -0,0 +1,68 @@
# Channel 消息服务验收记录(2026-09-27)
## 源码与范围
在 `feature/messages-completion` 上整合 `main` 的 `230f317d0ae5e8b419228a0522901f8fd40d3294`,保留富文本和命令补全,然后将旧通信 API/业务消息帧替换为统一消息服务。设计与安装方式见 [Channel 文档](../../docs/backend-channel-design.md)。远端 main 是否合并应以 PR #9 当前状态为准;本记录不代表生产发布。
## 完整构建与回归
Windows,Zulu Java 25.0.4.1。执行:
```powershell
.\gradlew.bat --no-daemon check --console=plain
```
结果 `BUILD SUCCESSFUL in 5m 6s`,244 项测试,无失败、错误或跳过:
| 模块 | 测试数 |
| --- | ---: |
| proxy-core | 208 |
| proxy-plugin-api | 7 |
| messaging-api | 13 |
| messaging-protocol | 7 |
| backend-channel-client | 7 |
| backend-channel-bukkit | 2 |
覆盖富文本与命令补全既有回归、真实 TCP 的双向/跨后端请求、消息身份及回复关联、发布与多个本地插件订阅者、发送/接收 ACL、乱序回复、超时及容量释放、重连隔离、插件停用、认证拒绝、GOODBYE 目录清理和租约过期。
本轮测试发现并修复了超时回复入队时错误断开会话的竞态,以及注册后立即关闭时读线程抢先关 socket 的竞态。后者增加了 20 轮注册后立即关闭的 socket 回归。Future 完成队列也计入 admission 限额,确定性测试验证排队 128 项时第 129 项返回背压,排空后恢复。
`check` 同时运行安装发行包的帮助、版本和配置验证,并检查 Bukkit 宿主内所有 class 的 major version 不超过 52、共享消息类各有一份、未打包 Bukkit API。另行检查宿主和 proxy API JAR,确认旧 `BackendChannelHandler`、`BackendChannels`、`BackendMessage`、`BackendSendResult` 未残留。
## 实际 Java 8 独立进程
执行:
```powershell
.\smoke\channel-java8.ps1 -Java8Home 'C:\Program Files\Zulu\zulu-8' -Java25Home 'C:\Program Files\Zulu\zulu-25'
```
使用 JDK 25 启动 router,JDK 8 编译并运行两个独立后端客户端;没有 Minecraft 玩家。结果:
```text
JAVA8_CHANNEL_PASS java=1.8.0_492 requests=2 backendSubscribers=3 endToEndIdentity=true
OLD_MESSAGING_CLASSES_ABSENT
```
验证 backend→proxy、backend A→B 请求,回复有独立 UUID、`replyTo` 对应原请求、来源正确,以及同一发布消息到达三个后端插件订阅者。
## 本地已测构件 SHA-256
| 构件(0.1.0-SNAPSHOT) | SHA-256 |
| --- | --- |
| strataproxy-backend-channel-bukkit | `CD061D9AD46313D3F5CE07705C8E180E61831A4C737261C5B40A89FC39AA13C7` |
| backend-channel-client | `85860F2A7BCCC61C6A260776C713F94EFAFD28A9F8AD3BC175D2F0C504143614` |
| messaging-api | `637A0FB1001BB7F59248707EF2E3CAE7FC2658960EBA601ECB2ABE6C0104554D` |
| messaging-protocol | `F195DD6FC9AF4E7A36ACC2D45039FD22D4A451EDAD22D87ABC3A59F257DF3854` |
## 未覆盖
未运行真实 Bukkit/Uranium 服务端装载与游戏主线程验收,未进行真实玩家的富文本视觉/点击测试,未验证跨物理机器部署或生产并发容量。此次没有重新运行 Docker Minecraft 登录烟测;其注册协议版本检查已同步为版本 2。消息服务不提供离线保存、自动重放或 exactly-once 保证。
## CI 转服测试修复(同日)
[CI run 10040](https://git.nest.potatolab.uk:8443/layue13/StrataProxy/actions/runs/10040) 中,新消息模块全部通过,唯一失败为 `SessionTransferTest.clientFrameWaitsForWorldTransitionDuringCutover`。Linux 容器(Temurin 25.0.4、Netty 4.2.2.Final)复现栈显示,失败发生在转服后的 `assertLocalCompletion`,世界切换和双向转发断言已经通过。
测试手动向 pipeline 注入 `channelRead` 后漏发 `channelReadComplete`,留下解码器的 fired-read 标志。后续补全请求被 TCP 分段时,长度前缀不足以组成完整帧,但旧标志使解码器跳过下一次手动读取。修复只补齐测试的完整读事件周期,不改生产代码、不延长超时、不弱化断言。
对照结果:Windows 原测试连续 20 次通过;Linux 原测试已完成的 13 次全部失败(终止第 14 次以继续诊断);Linux 仅补齐 read-complete 后连续 20 次全部通过。临时重复测试标注和诊断日志均不进入源码;正式回归仍由常规 `check` 执行。