feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,21 @@
package com.modrinth.theseus;
public final class JavaInfo {
private static final String[] CHECKED_PROPERTIES = new String[] {"os.arch", "java.version"};
public static void main(String[] args) {
int returnCode = 0;
for (String key : CHECKED_PROPERTIES) {
String property = System.getProperty(key);
if (property != null) {
System.out.println(key + "=" + property);
} else {
returnCode = 1;
}
}
System.exit(returnCode);
}
}

View File

@ -0,0 +1,121 @@
package com.modrinth.theseus;
import com.modrinth.theseus.rpc.RpcHandlers;
import com.modrinth.theseus.rpc.TheseusRpc;
import java.io.IOException;
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.concurrent.CompletableFuture;
public final class MinecraftLaunch {
private static final String UTF8_GAME_ARGUMENT_PREFIX = "__THESEUS_UTF8__:";
public static void main(String[] args) throws IOException, ReflectiveOperationException {
final String mainClass = args[0];
final String[] gameArgs = Arrays.stream(args, 1, args.length)
.map(MinecraftLaunch::decodeGameArgument)
.toArray(String[]::new);
System.setProperty("modrinth.process.args", String.join("\u001f", gameArgs));
final CompletableFuture<Void> waitForLaunch = new CompletableFuture<>();
TheseusRpc.connectAndStart(
System.getProperty("modrinth.internal.ipc.host"),
Integer.getInteger("modrinth.internal.ipc.port"),
new RpcHandlers()
.handler("set_system_property", String.class, String.class, System::setProperty)
.handler("launch", () -> waitForLaunch.complete(null)));
waitForLaunch.join();
relaunch(mainClass, gameArgs);
}
static String decodeGameArgument(String argument) {
if (!argument.startsWith(UTF8_GAME_ARGUMENT_PREFIX)) {
return argument;
}
final byte[] encoded = Base64.getDecoder().decode(argument.substring(UTF8_GAME_ARGUMENT_PREFIX.length()));
return new String(encoded, StandardCharsets.UTF_8);
}
private static void relaunch(String mainClassName, String[] args) throws ReflectiveOperationException {
final int javaVersion = getJavaVersion();
final Class<?> mainClass = Class.forName(mainClassName);
if (javaVersion >= 25) {
Method mainMethod;
try {
mainMethod = findMainMethodJep512(mainClass);
} catch (ReflectiveOperationException e) {
System.err.println(
"[MODRINTH] Unable to call JDK findMainMethod. Falling back to pre-Java 25 main method finding.");
// If the above fails due to JDK implementation details changing
try {
mainMethod = findSimpleMainMethod(mainClass);
} catch (ReflectiveOperationException innerE) {
e.addSuppressed(innerE);
throw e;
}
}
if (mainMethod == null) {
throw new IllegalArgumentException("Could not find main() method");
}
Object thisObject = null;
if (!Modifier.isStatic(mainMethod.getModifiers())) {
thisObject = forceAccessible(mainClass.getDeclaredConstructor()).newInstance();
}
final Object[] parameters = mainMethod.getParameterCount() > 0 ? new Object[] {args} : new Object[] {};
mainMethod.invoke(thisObject, parameters);
} else {
forceAccessible(findSimpleMainMethod(mainClass)).invoke(null, new Object[] {args});
}
}
private static int getJavaVersion() {
String javaVersion = System.getProperty("java.version");
final int dotIndex = javaVersion.indexOf('.');
if (dotIndex != -1) {
javaVersion = javaVersion.substring(0, dotIndex);
}
final int dashIndex = javaVersion.indexOf('-');
if (dashIndex != -1) {
javaVersion = javaVersion.substring(0, dashIndex);
}
return Integer.parseInt(javaVersion);
}
private static Method findMainMethodJep512(Class<?> mainClass) throws ReflectiveOperationException {
// BEWARE BELOW: This code may break if JDK internals to find the main method
// change.
final Class<?> methodFinderClass = Class.forName("jdk.internal.misc.MethodFinder");
final Method methodFinderMethod = methodFinderClass.getDeclaredMethod("findMainMethod", Class.class);
final Object result = methodFinderMethod.invoke(null, mainClass);
return (Method) result;
}
private static Method findSimpleMainMethod(Class<?> mainClass) throws NoSuchMethodException {
return mainClass.getMethod("main", String[].class);
}
private static <T extends AccessibleObject> T forceAccessible(T object) throws ReflectiveOperationException {
try {
final Method setAccessible0 = AccessibleObject.class.getDeclaredMethod("setAccessible0", boolean.class);
setAccessible0.setAccessible(true);
setAccessible0.invoke(object, true);
} catch (NoSuchMethodException e) {
object.setAccessible(true);
}
return object;
}
}

View File

@ -0,0 +1,45 @@
package com.modrinth.theseus.agent;
import java.util.ListIterator;
import java.util.function.Predicate;
import org.objectweb.asm.Type;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.FieldInsnNode;
public interface InsnPattern extends Predicate<AbstractInsnNode> {
/**
* Advances past the first match of all instructions in the pattern.
* @return {@code true} if the pattern was found, {@code false} if not
*/
static boolean findAndSkip(ListIterator<AbstractInsnNode> iterator, InsnPattern... pattern) {
if (pattern.length == 0) {
return true;
}
int patternIndex = 0;
while (iterator.hasNext()) {
final AbstractInsnNode insn = iterator.next();
if (insn.getOpcode() == -1) continue;
if (pattern[patternIndex].test(insn) && ++patternIndex == pattern.length) {
return true;
} else {
patternIndex = 0;
}
}
return false;
}
static InsnPattern opcode(int opcode) {
return insn -> insn.getOpcode() == opcode;
}
static InsnPattern field(int opcode, Type fieldType) {
final String typeDescriptor = fieldType.getDescriptor();
return insn -> {
if (insn.getOpcode() != opcode || !(insn instanceof FieldInsnNode)) {
return false;
}
final FieldInsnNode fieldInsn = (FieldInsnNode) insn;
return typeDescriptor.equals(fieldInsn.desc);
};
}
}

View File

@ -0,0 +1,12 @@
package com.modrinth.theseus.agent;
// Must be kept up-to-date with quick_play_version.rs
public enum QuickPlayServerVersion {
BUILTIN,
BUILTIN_LEGACY,
INJECTED,
UNSUPPORTED;
public static final QuickPlayServerVersion CURRENT =
valueOf(System.getProperty("modrinth.internal.quickPlay.serverVersion"));
}

View File

@ -0,0 +1,85 @@
package com.modrinth.theseus.agent;
import com.modrinth.theseus.agent.transformers.ClassTransformer;
import com.modrinth.theseus.agent.transformers.MinecraftTransformer;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.instrument.Instrumentation;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.Map;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
@SuppressWarnings({"NullableProblems", "CallToPrintStackTrace"})
public final class TheseusAgent {
private static final boolean DEBUG_AGENT = Boolean.getBoolean("modrinth.debugAgent");
public static void premain(String args, Instrumentation instrumentation) {
final Path debugPath = Paths.get("ModrinthDebugTransformed");
if (DEBUG_AGENT) {
System.out.println(
"===== Theseus agent debugging enabled. Dumping transformed classes to " + debugPath + " =====");
if (Files.exists(debugPath)) {
try {
Files.walkFileTree(debugPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
new UncheckedIOException("Failed to delete " + debugPath, e).printStackTrace();
}
}
System.out.println("===== Quick play server version: " + QuickPlayServerVersion.CURRENT + " =====");
}
final Map<String, ClassTransformer> transformers = new HashMap<>();
transformers.put("net/minecraft/client/Minecraft", new MinecraftTransformer());
instrumentation.addTransformer((loader, className, classBeingRedefined, protectionDomain, classData) -> {
final ClassTransformer transformer = transformers.get(className);
if (transformer == null) {
return null;
}
final ClassReader reader = new ClassReader(classData);
final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
try {
if (!transformer.transform(reader, writer)) {
if (DEBUG_AGENT) {
System.out.println("Not writing " + className + " as its transformer returned false");
}
return null;
}
} catch (Throwable t) {
new IllegalStateException("Failed to transform " + className, t).printStackTrace();
return null;
}
final byte[] result = writer.toByteArray();
if (DEBUG_AGENT) {
try {
final Path path = debugPath.resolve(className + ".class");
Files.createDirectories(path.getParent());
Files.write(path, result);
System.out.println("Dumped class to " + path.toAbsolutePath());
} catch (IOException e) {
new UncheckedIOException("Failed to dump class " + className, e).printStackTrace();
}
}
return result;
});
}
}

View File

@ -0,0 +1,20 @@
package com.modrinth.theseus.agent.transformers;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.tree.ClassNode;
public abstract class ClassNodeTransformer extends ClassTransformer {
protected abstract boolean transform(ClassNode classNode);
@Override
public final boolean transform(ClassReader reader, ClassWriter writer) {
final ClassNode classNode = new ClassNode();
reader.accept(classNode, 0);
if (!transform(classNode)) {
return false;
}
classNode.accept(writer);
return true;
}
}

View File

@ -0,0 +1,14 @@
package com.modrinth.theseus.agent.transformers;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.ClassNode;
public abstract class ClassTransformer {
public abstract boolean transform(ClassReader reader, ClassWriter writer);
protected static boolean needsStackMap(ClassNode classNode) {
return (classNode.version & 0xffff) >= Opcodes.V1_6;
}
}

View File

@ -0,0 +1,99 @@
package com.modrinth.theseus.agent.transformers;
import com.modrinth.theseus.agent.InsnPattern;
import com.modrinth.theseus.agent.QuickPlayServerVersion;
import java.util.ListIterator;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FrameNode;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.JumpInsnNode;
import org.objectweb.asm.tree.LabelNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.VarInsnNode;
public final class MinecraftTransformer extends ClassNodeTransformer {
private static final String SET_SERVER_NAME_DESC = "(Ljava/lang/String;I)V";
private static final InsnPattern[] INITIALIZE_THIS_PATTERN = {InsnPattern.opcode(Opcodes.INVOKESPECIAL)};
@Override
protected boolean transform(ClassNode classNode) {
if (QuickPlayServerVersion.CURRENT == QuickPlayServerVersion.INJECTED) {
return addServerJoinSupport(classNode);
}
return false;
}
private static boolean addServerJoinSupport(ClassNode classNode) {
String setServerName = null;
MethodNode constructor = null;
for (final MethodNode method : classNode.methods) {
if (constructor == null && method.name.equals("<init>")) {
constructor = method;
} else if (method.desc.equals(SET_SERVER_NAME_DESC) && method.name.indexOf('$') == -1) {
// Check for $ is because Mixin-injected methods should have $ in it
if (setServerName == null) {
setServerName = method.name;
} else {
// Already found a setServer method, but we found another one? Since we can't
// know which is real, just return so we don't call something we shouldn't.
// Note this can't happen unless some other mod is adding a method with this
// same descriptor.
return false;
}
}
}
if (constructor == null) {
return false;
}
final ListIterator<AbstractInsnNode> it = constructor.instructions.iterator();
if (!InsnPattern.findAndSkip(it, INITIALIZE_THIS_PATTERN)) {
return true;
}
final LabelNode noQuickPlayLabel = new LabelNode();
final LabelNode doneQuickPlayLabel = new LabelNode();
it.add(new LdcInsnNode("modrinth.internal.quickPlay.host"));
// String
it.add(new MethodInsnNode(
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
// String
it.add(new InsnNode(Opcodes.DUP));
// String String
it.add(new JumpInsnNode(Opcodes.IFNULL, noQuickPlayLabel));
// String
it.add(new VarInsnNode(Opcodes.ALOAD, 0));
// String Minecraft
it.add(new InsnNode(Opcodes.SWAP));
// Minecraft String
it.add(new LdcInsnNode("modrinth.internal.quickPlay.port"));
// Minecraft String String
it.add(new MethodInsnNode(
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
// Minecraft String String
it.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Integer", "parseInt", "(Ljava/lang/String;)I"));
// Minecraft String int
it.add(new MethodInsnNode(
Opcodes.INVOKEVIRTUAL, "net/minecraft/client/Minecraft", setServerName, SET_SERVER_NAME_DESC));
//
it.add(new JumpInsnNode(Opcodes.GOTO, doneQuickPlayLabel));
it.add(noQuickPlayLabel);
if (needsStackMap(classNode)) {
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
}
// String
it.add(new InsnNode(Opcodes.POP));
//
it.add(doneQuickPlayLabel);
if (needsStackMap(classNode)) {
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
}
//
return true;
}
}

View File

@ -0,0 +1,46 @@
package com.modrinth.theseus.rpc;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Function;
public class RpcHandlers {
private final Map<String, Function<JsonElement[], JsonElement>> handlers = new HashMap<>();
private boolean frozen;
public RpcHandlers handler(String functionName, Runnable handler) {
return addHandler(functionName, args -> {
handler.run();
return JsonNull.INSTANCE;
});
}
public <A, B> RpcHandlers handler(
String functionName, Class<A> arg1Type, Class<B> arg2Type, BiConsumer<A, B> handler) {
return addHandler(functionName, args -> {
if (args.length != 2) {
throw new IllegalArgumentException(functionName + " expected 2 arguments");
}
final A arg1 = TheseusRpc.GSON.fromJson(args[0], arg1Type);
final B arg2 = TheseusRpc.GSON.fromJson(args[1], arg2Type);
handler.accept(arg1, arg2);
return JsonNull.INSTANCE;
});
}
private RpcHandlers addHandler(String functionName, Function<JsonElement[], JsonElement> handler) {
if (frozen) {
throw new IllegalStateException("Cannot add handler to frozen RpcHandlers instance");
}
handlers.put(functionName, handler);
return this;
}
Map<String, Function<JsonElement[], JsonElement>> build() {
frozen = true;
return handlers;
}
}

View File

@ -0,0 +1,9 @@
package com.modrinth.theseus.rpc;
public class RpcMethodException extends RuntimeException {
private static final long serialVersionUID = 1922360184188807964L;
public RpcMethodException(String message) {
super(message);
}
}

View File

@ -0,0 +1,214 @@
package com.modrinth.theseus.rpc;
import com.google.gson.*;
import com.google.gson.reflect.TypeToken;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
public final class TheseusRpc {
static final Gson GSON = new GsonBuilder()
.setStrictness(Strictness.STRICT)
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.disableHtmlEscaping()
.create();
private static final TypeToken<RpcMessage> MESSAGE_TYPE = TypeToken.get(RpcMessage.class);
private static final AtomicReference<TheseusRpc> RPC = new AtomicReference<>();
private final BlockingQueue<RpcMessage> mainThreadQueue = new LinkedBlockingQueue<>();
private final Map<UUID, ResponseWaiter<?>> awaitingResponse = new ConcurrentHashMap<>();
private final Map<String, Function<JsonElement[], JsonElement>> handlers;
private final Socket socket;
private TheseusRpc(Socket socket, RpcHandlers handlers) {
this.socket = socket;
this.handlers = handlers.build();
}
public static void connectAndStart(String host, int port, RpcHandlers handlers) throws IOException {
if (RPC.get() != null) {
throw new IllegalStateException("Can only connect to RPC once");
}
final Socket socket = new Socket(host, port);
final TheseusRpc rpc = new TheseusRpc(socket, handlers);
final Thread mainThread = new Thread(rpc::mainThread, "Theseus RPC Main");
final Thread readThread = new Thread(rpc::readThread, "Theseus RPC Read");
mainThread.setDaemon(true);
readThread.setDaemon(true);
mainThread.start();
readThread.start();
RPC.set(rpc);
}
public static TheseusRpc getRpc() {
final TheseusRpc rpc = RPC.get();
if (rpc == null) {
throw new IllegalStateException("Called getRpc before RPC initialized");
}
return rpc;
}
public <T> CompletableFuture<T> callMethod(TypeToken<T> returnType, String method, Object... args) {
final JsonElement[] jsonArgs = new JsonElement[args.length];
for (int i = 0; i < args.length; i++) {
jsonArgs[i] = GSON.toJsonTree(args[i]);
}
final RpcMessage message = new RpcMessage(method, jsonArgs);
final ResponseWaiter<T> responseWaiter = new ResponseWaiter<>(returnType);
awaitingResponse.put(message.id, responseWaiter);
mainThreadQueue.add(message);
return responseWaiter.future;
}
private void mainThread() {
try {
final Writer writer = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8);
while (true) {
final RpcMessage message = mainThreadQueue.take();
final RpcMessage toSend;
if (message.isForSending) {
toSend = message;
} else {
final Function<JsonElement[], JsonElement> handler = handlers.get(message.method);
if (handler == null) {
System.err.println("Unknown theseus RPC method " + message.method);
continue;
}
RpcMessage response;
try {
response = new RpcMessage(message.id, handler.apply(message.args));
} catch (Exception e) {
response = new RpcMessage(message.id, e.toString());
}
toSend = response;
}
GSON.toJson(toSend, writer);
writer.write('\n');
writer.flush();
}
} catch (IOException e) {
failAwaitingResponses(e);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
private void readThread() {
IOException closed = new EOFException("Theseus RPC connection closed");
try {
final BufferedReader reader =
new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
while (true) {
final String line = reader.readLine();
if (line == null) {
return;
}
final RpcMessage message = GSON.fromJson(line, MESSAGE_TYPE);
if (message == null) {
System.err.println("Received null Theseus RPC message");
continue;
}
if (message.id == null) {
System.err.println("Received Theseus RPC message without an id");
continue;
}
if (message.method == null) {
final ResponseWaiter<?> waiter = awaitingResponse.remove(message.id);
if (waiter != null) {
handleResponse(waiter, message);
}
} else {
mainThreadQueue.put(message);
}
}
} catch (IOException e) {
closed = e;
if (!socket.isClosed()) {
System.err.println("Theseus RPC read failed: " + e);
}
} catch (JsonParseException e) {
System.err.println("Invalid Theseus RPC message: " + e);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
} finally {
failAwaitingResponses(closed);
try {
socket.close();
} catch (IOException ignored) {
}
}
}
private void failAwaitingResponses(IOException error) {
awaitingResponse.forEach((id, waiter) -> waiter.future.completeExceptionally(error));
awaitingResponse.clear();
}
private <T> void handleResponse(ResponseWaiter<T> waiter, RpcMessage message) {
if (message.error != null) {
waiter.future.completeExceptionally(new RpcMethodException(message.error));
return;
}
try {
waiter.future.complete(GSON.fromJson(message.response, waiter.type));
} catch (JsonSyntaxException e) {
waiter.future.completeExceptionally(e);
}
}
private static class RpcMessage {
final UUID id;
final String method; // Optional
final JsonElement[] args; // Optional
final JsonElement response; // Optional
final String error; // Optional
final transient boolean isForSending;
RpcMessage(String method, JsonElement[] args) {
id = UUID.randomUUID();
this.method = method;
this.args = args;
response = null;
error = null;
isForSending = true;
}
RpcMessage(UUID id, JsonElement response) {
this.id = id;
method = null;
args = null;
this.response = response;
error = null;
isForSending = true;
}
RpcMessage(UUID id, String error) {
this.id = id;
method = null;
args = null;
response = null;
this.error = error;
isForSending = true;
}
}
private static class ResponseWaiter<T> {
final TypeToken<T> type;
final CompletableFuture<T> future = new CompletableFuture<>();
ResponseWaiter(TypeToken<T> type) {
this.type = type;
}
}
}

View File

@ -0,0 +1,32 @@
package com.modrinth.theseus;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.junit.jupiter.api.Test;
final class MinecraftLaunchTest {
private static final String PREFIX = "__THESEUS_UTF8__:";
@Test
void decodesUtf8GameArgument() {
final String original = "E:\\Games\\Minecraft\\profiles\\Prominence\u2122 II";
final String encoded = PREFIX + Base64.getEncoder().encodeToString(original.getBytes(StandardCharsets.UTF_8));
assertEquals(original, MinecraftLaunch.decodeGameArgument(encoded));
}
@Test
void leavesAsciiGameArgumentUnchanged() {
assertEquals("--username", MinecraftLaunch.decodeGameArgument("--username"));
}
@Test
void restoresEscapedPrefix() {
final String original = PREFIX + "literal";
final String encoded = PREFIX + Base64.getEncoder().encodeToString(original.getBytes(StandardCharsets.UTF_8));
assertEquals(original, MinecraftLaunch.decodeGameArgument(encoded));
}
}

View File

@ -0,0 +1,45 @@
package com.modrinth.theseus.rpc;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.reflect.TypeToken;
import java.io.BufferedReader;
import java.io.EOFException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
final class TheseusRpcTest {
@Test
void eofCompletesPendingResponsesWithoutNullDereference() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
final Thread peer = new Thread(() -> acceptOneRequestAndClose(server));
peer.start();
TheseusRpc.connectAndStart("127.0.0.1", server.getLocalPort(), new RpcHandlers());
final ExecutionException error =
org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class, () -> TheseusRpc.getRpc()
.callMethod(TypeToken.get(String.class), "pending")
.get(5, TimeUnit.SECONDS));
assertInstanceOf(EOFException.class, error.getCause());
peer.join(TimeUnit.SECONDS.toMillis(5));
assertTrue(!peer.isAlive());
}
}
private static void acceptOneRequestAndClose(ServerSocket server) {
try (Socket socket = server.accept();
BufferedReader reader =
new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {
reader.readLine();
} catch (Exception error) {
throw new RuntimeException(error);
}
}
}