From dcd1c2f582aaef46f8e1ff03748c0d486731a95a Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:46:02 +0200 Subject: [PATCH 1/2] Compare server and client manifests by archive before class declarations --- .../totalDebugCompanion/CompanionApp.java | 2 +- .../script/IndexedJavaFileManager.java | 36 ++-- .../script/ScriptCompilationService.java | 163 ++++++++++++--- .../script/ServerCompatibility.java | 98 +++++++++ .../script/ScriptCompilationServiceTest.java | 110 ++++++++-- .../script/ServerCompatibilityTest.java | 97 +++++++++ docs/USAGE.md | 10 +- .../totaldebug/evaluation/ServerManifest.java | 197 ++++++++++++------ .../evaluation/ServerManifestTest.java | 37 ++-- .../totaldebug/client/TotalDebugClient.java | 7 + .../client/companion/CompanionAppClient.java | 18 ++ .../client/script/ServerScriptTransport.java | 4 +- .../network/ServerManifestPayload.java | 4 +- .../network/ServerSourceRequestPayload.java | 30 +++ .../totaldebug/network/TotalDebugNetwork.java | 5 +- .../server/script/ServerScriptService.java | 53 +++-- .../CompanionHandshakeConcurrencyTest.java | 26 +++ .../network/ServerScriptPayloadTest.java | 19 +- .../protocol/CompanionProtocol.java | 3 +- .../protocol/scnet/ProtocolBindings.java | 2 + .../protocol/scnet/ServerManifestMessage.java | 36 +++- .../scnet/ServerSourceRequestMessage.java | 41 ++++ .../totaldebug/protocol/GoldenMessages.java | 6 +- .../protocol/scnet/ProtocolBindingsTest.java | 22 ++ .../scnet/ServerManifestMessageTest.java | 34 ++- 25 files changed, 883 insertions(+), 177 deletions(-) create mode 100644 companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/ServerCompatibility.java create mode 100644 companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/script/ServerCompatibilityTest.java create mode 100644 mod/src/main/java/com/github/minecraft_ta/totaldebug/network/ServerSourceRequestPayload.java create mode 100644 protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerSourceRequestMessage.java diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java index 9c9e3c6d..9fddc8ad 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java @@ -94,7 +94,7 @@ public final class CompanionApp { private static volatile CodeInsightService codeInsightService; private static volatile RuntimeSourceCatalog runtimeSourceCatalog = RuntimeSourceCatalog.empty(); private static RuntimeIndexService runtimeIndexService; - private static final ScriptCompilationService scriptCompiler = new ScriptCompilationService(CompanionApp::send); + private static final ScriptCompilationService scriptCompiler = new ScriptCompilationService(CompanionApp::send, CompanionApp::send); private static volatile String evaluationClasspath; private static volatile Path activeIndexFile; private static volatile String activeRuntimeSignature; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/IndexedJavaFileManager.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/IndexedJavaFileManager.java index 234ab751..142f8101 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/IndexedJavaFileManager.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/IndexedJavaFileManager.java @@ -2,10 +2,6 @@ import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource.Source; import com.github.tth05.jindex.ClassIndex; -import com.github.minecraft_ta.totaldebug.evaluation.ClassDeclarations; -import com.github.minecraft_ta.totaldebug.evaluation.ServerManifest; -import java.io.ByteArrayInputStream; -import java.util.function.Supplier; import com.github.tth05.jindex.IndexedClass; import com.github.tth05.jindex.IndexedPackage; @@ -15,6 +11,7 @@ import javax.tools.SimpleJavaFileObject; import javax.tools.StandardJavaFileManager; import javax.tools.StandardLocation; + import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -25,21 +22,21 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Supplier; import java.util.jar.JarFile; import java.util.zip.ZipFile; /** Borrows Companion's index and opens only the archives javac actually reads. */ final class IndexedJavaFileManager extends ForwardingJavaFileManager { private final ClassIndex index; - private final Supplier serverManifest; - private final Map archiveHashes = new HashMap<>(); + private final Supplier> unsupportedClasses; private final Map sources = new HashMap<>(); private final Map archives = new HashMap<>(); - IndexedJavaFileManager(StandardJavaFileManager standard, ClassIndex index, List sources, Supplier serverManifest) { + IndexedJavaFileManager(StandardJavaFileManager standard, ClassIndex index, List sources, Supplier> unsupportedClasses) { super(standard); this.index = index; - this.serverManifest = serverManifest; + this.unsupportedClasses = unsupportedClasses; for (Source source : sources) { // --release supplies the platform classes from javac's own standard manager. if (!"jrt:/".equals(source.logicalUri())) this.sources.put(source.sourceId(), source.path()); @@ -122,8 +119,13 @@ private IndexedInput(String name, int sourceId, Path path) { @Override public InputStream openInputStream() throws IOException { + Set unsupported = unsupportedClasses.get(); + if (unsupported != null && unsupported.contains(this.name)) { + throw new IOException("Server compilation unsupported: class " + this.name + + " is absent or its declarations differ on the server. Use matching client/server classes."); + } String resource = this.name.replace('.', '/') + ".class"; - if (Files.isDirectory(this.path)) return checked(Files.newInputStream(this.path.resolve(resource))); + if (Files.isDirectory(this.path)) return Files.newInputStream(this.path.resolve(resource)); JarFile archive = archives.get(this.sourceId); if (archive == null) { archive = new JarFile(this.path.toFile(), false, ZipFile.OPEN_READ, Runtime.Version.parse("21")); @@ -131,22 +133,8 @@ public InputStream openInputStream() throws IOException { } var entry = archive.getJarEntry(resource); if (entry == null) throw new IOException("Runtime index points to a missing class: " + this.path + " / " + resource); - return checked(archive.getInputStream(entry)); + return archive.getInputStream(entry); } - private InputStream checked(InputStream input) throws IOException { - ServerManifest manifest = serverManifest.get(); - if (manifest == null) return input; - try (input) { - byte[] bytes = input.readAllBytes(); - String hash = archiveHashes.get(this.sourceId); - if (hash == null) { - hash = Files.isDirectory(this.path) ? "" : ClassDeclarations.archiveFingerprint(this.path); - archiveHashes.put(this.sourceId, hash); - } - manifest.requireCompatible(this.name, hash, bytes); - return new ByteArrayInputStream(bytes); - } - } } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/ScriptCompilationService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/ScriptCompilationService.java index a4072da8..77596809 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/ScriptCompilationService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/ScriptCompilationService.java @@ -3,16 +3,20 @@ import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService.ReadySnapshot; import com.github.minecraft_ta.totaldebug.evaluation.InMemoryJavaCompiler; import com.github.minecraft_ta.totaldebug.evaluation.ServerManifest; -import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionResult; import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionStatus; import com.github.minecraft_ta.totaldebug.protocol.execution.ScriptBytecode; import com.github.minecraft_ta.totaldebug.protocol.execution.ScriptExecutionEnvironment; import com.github.minecraft_ta.totaldebug.protocol.scnet.RunScriptMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerSourceRequestMessage; import com.github.minecraft_ta.totaldebug.storage.CacheFiles; +import com.github.minecraft_ta.totaldebug.storage.RuntimePhase; import java.io.IOException; import java.util.Map; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -41,49 +45,149 @@ public record CompilationResult(ScriptBytecode bytecode, String inventoryId) {} private volatile ReadySnapshot snapshot; private InMemoryJavaCompiler compiler; private volatile boolean closed; - private record ServerSnapshot(String sessionId, ServerManifest manifest) {} + private record ServerSnapshot(String sessionId, String inventoryId, Set unsupported) {} + private record Baseline(String sessionId, ServerManifest manifest) {} + private record Comparison(String requestId, ReadySnapshot selected, ServerCompatibility work) {} + private final Predicate sourceRequester; private volatile ServerSnapshot serverSnapshot; private volatile String serverUnavailable = "No server handshake is available"; private final ServerManifestMessage.Assembler manifestTransfer = new ServerManifestMessage.Assembler(); + private final ServerManifestMessage.Assembler detailTransfer = new ServerManifestMessage.Assembler(); + private long serverGeneration; private long manifestGeneration; - private ServerManifest compilingForServer; + private Baseline baseline; + private Comparison comparison; + private Set compilingForServer; + + public ScriptCompilationService(Predicate sender, + Predicate sourceRequester) { + this.sender = sender; + this.sourceRequester = sourceRequester; + } public synchronized void acceptServerManifest(ServerManifestMessage message) { if (this.closed) return; - if (message.offset() == 0) { - this.manifestGeneration++; - this.serverSnapshot = null; + if (!message.baseline() && (this.baseline == null || this.comparison == null + || !this.baseline.sessionId().equals(message.sessionId()) + || !this.comparison.requestId().equals(message.requestId()) + || this.comparison.work().nextSource() != message.source())) return; + if (message.baseline() && message.offset() == 0) { + this.serverGeneration++; + this.manifestTransfer.clear(); + invalidateComparison(); + this.baseline = null; this.serverUnavailable = message.total() == 0 ? message.detail() : "Preparing server class compatibility"; } + if (!message.baseline() && message.total() == 0) { + failComparison(this.manifestGeneration, message.detail()); + return; + } byte[] bytes; - try { - bytes = this.manifestTransfer.accept(message); - } catch (IllegalArgumentException exception) { - this.manifestGeneration++; - this.serverSnapshot = null; - this.serverUnavailable = exception.getMessage(); + try { bytes = (message.baseline() ? this.manifestTransfer : this.detailTransfer).accept(message); } + catch (IllegalArgumentException exception) { + failComparison(this.manifestGeneration, exception.getMessage()); return; } if (bytes == null) return; long generation = this.manifestGeneration; + long serverGeneration = this.serverGeneration; this.worker.execute(() -> { try { - var manifest = ServerManifest.decode(bytes); - synchronized (this) { - if (!this.closed && generation == this.manifestGeneration) { - this.serverSnapshot = new ServerSnapshot(message.sessionId(), manifest); + if (message.baseline()) { + var decoded = ServerManifest.decode(bytes); + long currentGeneration; + synchronized (this) { + if (this.closed || serverGeneration != this.serverGeneration) return; + this.baseline = new Baseline(message.sessionId(), decoded); + currentGeneration = this.manifestGeneration; } + prepareComparison(currentGeneration); + } else { + var details = ServerManifest.decodeDetails(bytes); + synchronized (this) { + if (this.closed || generation != this.manifestGeneration || this.comparison == null) return; + this.comparison.work().accept(message.source(), details); + } + advanceComparison(generation); } - } catch (IOException exception) { + } catch (Exception exception) { synchronized (this) { - if (generation == this.manifestGeneration) this.serverUnavailable = exception.getMessage(); + if (message.baseline() && serverGeneration != this.serverGeneration) return; + failComparison(message.baseline() ? this.manifestGeneration : generation, exception.getMessage()); } } }); } - public ScriptCompilationService(Predicate sender) { - this.sender = sender; + private void prepareComparison(long generation) { + ReadySnapshot selected; + Baseline baseline; + synchronized (this) { + if (this.closed || generation != this.manifestGeneration) return; + selected = this.snapshot; + baseline = this.baseline; + } + if (selected == null || baseline == null) return; + try { + ServerCompatibility work = CacheFiles.locked(selected.indexFile().getParent(), () -> { + CacheFiles.requireIdentity(selected.indexFile().getParent().resolve("inventory.json"), "id", selected.inventoryId()); + try (var phase = RuntimePhase.start("server.local-baseline")) { + return new ServerCompatibility(baseline.manifest(), selected.sources()); + } + }); + synchronized (this) { + if (this.closed || generation != this.manifestGeneration || this.snapshot != selected) return; + this.comparison = new Comparison(UUID.randomUUID().toString(), selected, work); + } + advanceComparison(generation); + } catch (Exception exception) { failComparison(generation, exception.getMessage()); } + } + + private void advanceComparison(long generation) throws Exception { + Comparison current; + Baseline baseline; + synchronized (this) { + if (this.closed || generation != this.manifestGeneration || this.comparison == null) return; + current = this.comparison; + baseline = this.baseline; + int source = current.work().nextSource(); + if (source != -1) { + this.serverUnavailable = "Comparing server source " + baseline.manifest().sources().get(source).name(); + if (!this.sourceRequester.test(new ServerSourceRequestMessage(baseline.sessionId(), current.requestId(), source))) { + throw new IOException("Minecraft disconnected before server source details could be requested"); + } + return; + } + } + Set unsupported = CacheFiles.locked(current.selected().indexFile().getParent(), () -> { + synchronized (this.compilerLock) { + if (this.closed || this.snapshot != current.selected()) throw new IOException("The client inventory changed"); + CacheFiles.requireIdentity(current.selected().indexFile().getParent().resolve("inventory.json"), + "id", current.selected().inventoryId()); + try (var phase = RuntimePhase.start("server.compare-declarations")) { + return current.work().finish(current.selected()); + } + } + }); + synchronized (this) { + if (this.closed || generation != this.manifestGeneration || this.comparison != current + || this.snapshot != current.selected()) return; + this.serverSnapshot = new ServerSnapshot(baseline.sessionId(), current.selected().inventoryId(), unsupported); + this.comparison = null; + } + } + + private synchronized void failComparison(long generation, String detail) { + if (generation != this.manifestGeneration) return; + invalidateComparison(); + this.serverUnavailable = detail == null ? "Server class comparison failed" : detail; + } + + private void invalidateComparison() { + this.manifestGeneration++; + this.serverSnapshot = null; + this.comparison = null; + this.detailTransfer.clear(); } /** Must complete before the old native index is closed by its owner. */ @@ -99,6 +203,14 @@ public void bind(ReadySnapshot snapshot) { this.compiler = new InMemoryJavaCompiler(standard -> new IndexedJavaFileManager(standard, snapshot.index(), snapshot.sources(), () -> this.compilingForServer)); } + synchronized (this) { + invalidateComparison(); + this.serverUnavailable = "Waiting for the client index and server handshake comparison"; + long generation = this.manifestGeneration; + if (!this.closed && snapshot != null && this.baseline != null) { + this.worker.execute(() -> prepareComparison(generation)); + } + } } } @@ -141,7 +253,7 @@ public void submit(int id, String source, boolean serverSide, ScriptExecutionEnv return; } ServerSnapshot server = serverSide ? this.serverSnapshot : null; - if (serverSide && server == null) { + if (serverSide && (server == null || !server.inventoryId().equals(selected.inventoryId()))) { failureHandler.accept(failure(this.serverUnavailable)); return; } @@ -191,7 +303,7 @@ private CompilationResult compileSelected(ReadySnapshot selected, ServerSnapshot } CacheFiles.requireIdentity(selected.indexFile().getParent().resolve("inventory.json"), "id", selected.inventoryId()); - this.compilingForServer = server == null ? null : server.manifest(); + this.compilingForServer = server == null ? null : server.unsupported(); try { return new CompilationResult(new ScriptBytecode(entryClass, this.compiler.compile(source, entryClass, "")), selected.inventoryId()); @@ -216,10 +328,11 @@ public boolean cancel(int id) { public void runtimeDisconnected() { synchronized (this) { - this.manifestGeneration++; - this.serverSnapshot = null; - this.serverUnavailable = "Minecraft disconnected"; + this.serverGeneration++; this.manifestTransfer.clear(); + invalidateComparison(); + this.baseline = null; + this.serverUnavailable = "Minecraft disconnected"; } for (int id : this.pending.keySet()) cancel(id); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/ServerCompatibility.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/ServerCompatibility.java new file mode 100644 index 00000000..296897f9 --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/ServerCompatibility.java @@ -0,0 +1,98 @@ +package com.github.minecraft_ta.totalDebugCompanion.script; + +import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource.Source; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService.ReadySnapshot; +import com.github.minecraft_ta.totaldebug.evaluation.ClassDeclarations; +import com.github.minecraft_ta.totaldebug.evaluation.ServerManifest; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Temporary handshake work. Only the resulting unsupported names survive comparison. */ +final class ServerCompatibility { + private final ServerManifest server; + private final Map localSources = new HashMap<>(); + private final Map localHashes = new HashMap<>(); + private final Map> localNames = new HashMap<>(); + private final Map matchingSources = new HashMap<>(); + private final Map> details = new LinkedHashMap<>(); + + ServerCompatibility(ServerManifest server, List sources) throws IOException { + this.server = server; + var byHash = new HashMap(); + for (Source source : sources) { + if ("jrt:/".equals(source.logicalUri())) continue; + localSources.put(source.sourceId(), source); + String hash = Files.isDirectory(source.path()) ? "" : ClassDeclarations.archiveFingerprint(source.path()); + localHashes.put(source.sourceId(), hash); + if (!hash.isEmpty()) byHash.putIfAbsent(hash, source.path()); + localNames.put(source.path(), ServerManifest.readClasses(source.path(), false)); + } + for (int i = 0; i < server.sources().size(); i++) { + Path matching = byHash.get(server.sources().get(i).archiveHash()); + if (matching == null) details.put(i, null); + else matchingSources.put(i, matching); + } + } + + int nextSource() { + for (var entry : details.entrySet()) if (entry.getValue() == null) return entry.getKey(); + return -1; + } + + void accept(int source, Map classes) throws IOException { + if (source != nextSource()) throw new IOException("Unexpected server source details"); + details.put(source, classes); + } + + /** The caller holds the compiler lock so the borrowed native index cannot close. */ + Set finish(ReadySnapshot snapshot) throws IOException { + if (nextSource() != -1) throw new IOException("Server class comparison is incomplete"); + var winners = new HashMap(); + for (int i = 0; i < server.sources().size(); i++) { + Path matching = matchingSources.get(i); + var names = matching == null ? details.get(i).keySet() : localNames.get(matching).keySet(); + for (String name : names) winners.putIfAbsent(name, i); + } + var declarations = new HashMap>(); + var unsupported = new HashSet(); + var visited = new HashSet(); + for (var names : localNames.values()) { + for (String name : names.keySet()) { + if (!visited.add(name)) continue; + var indexed = snapshot.index().findClass(name); + if (indexed == null) continue; + Source local = localSources.get(indexed.getSourceId()); + if (local == null) continue; // javac supplies JDK classes through --release. + Integer winner = winners.get(name); + if (winner == null) { + unsupported.add(name); + continue; + } + String serverHash = server.sources().get(winner).archiveHash(); + if (!serverHash.isEmpty() && serverHash.equals(localHashes.get(local.sourceId()))) continue; + Path matching = matchingSources.get(winner); + String expected = matching == null ? details.get(winner).get(name) + : declarations(declarations, matching).get(name); + if (!expected.equals(declarations(declarations, local.path()).get(name))) unsupported.add(name); + } + } + return Set.copyOf(unsupported); + } + + private static Map declarations(Map> cache, Path path) throws IOException { + var classes = cache.get(path); + if (classes == null) { + classes = ServerManifest.readClasses(path, true); + cache.put(path, classes); + } + return classes; + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/script/ScriptCompilationServiceTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/script/ScriptCompilationServiceTest.java index 0d169e11..03e7f1f9 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/script/ScriptCompilationServiceTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/script/ScriptCompilationServiceTest.java @@ -6,6 +6,7 @@ import com.github.minecraft_ta.totaldebug.evaluation.ScriptClassLoader; import com.github.minecraft_ta.totaldebug.evaluation.ServerManifest; import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerSourceRequestMessage; import java.util.HashMap; import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionResult; import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionStatus; @@ -24,6 +25,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Arrays; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -103,7 +105,7 @@ void compilesUsingSharedIndexAndSendsAllClassesWithoutLoadingGameTypes() throws var compiler = new ScriptCompilationService(message -> { assertNotEquals("AWT-EventQueue-0", Thread.currentThread().getName()); return this.sent.add(message); - })) { + }, this.requests::add)) { compiler.bind(snapshot); compiler.submit(7, SOURCE, false, ScriptExecutionEnvironment.POST_TICK, this.failures::add); RunScriptMessage message = this.sent.poll(10, TimeUnit.SECONDS); @@ -269,10 +271,8 @@ void indexedCompilerDoesNotLeakCompanionsClasspathAndMatchesStandardCompiler() t void serverCompileChecksTheOverloadDependencyAndClientStillWorks() throws Exception { try (ReadySnapshot snapshot = fixture(); var compiler = service()) { compiler.bind(snapshot); - var manifest = ServerManifest.scan(snapshot.sources().stream().map(source -> source.path()).toList()); - var classes = new HashMap<>(manifest.classes()); - classes.remove("fixture.QuadView"); - installServer(compiler, new ServerManifest(manifest.sources(), classes), "server-session"); + installServer(compiler, new ServerManifest.Catalog(List.of(snapshot.sources().get(0).path(), + snapshot.sources().get(2).path())), "server-session"); compiler.submit(1, SOURCE, true, ScriptExecutionEnvironment.THREAD, this.failures::add); var failure = this.failures.poll(10, TimeUnit.SECONDS); assertNotNull(failure); @@ -297,7 +297,7 @@ public class Api extends Base { """, "fixture.Api", snapshot.sources().get(1).path().toString())); var paths = List.of(serverApi, snapshot.sources().get(1).path(), snapshot.sources().get(2).path()); compiler.bind(snapshot); - installServer(compiler, ServerManifest.scan(paths), "server-session"); + installServer(compiler, new ServerManifest.Catalog(paths), "server-session"); compiler.submit(1, SOURCE, true, ScriptExecutionEnvironment.THREAD, this.failures::add); RunScriptMessage compiled = this.sent.poll(10, TimeUnit.SECONDS); assertNotNull(compiled, () -> this.failures.toString()); @@ -317,12 +317,12 @@ void serverDeclarationMismatchNamesTheClassAndDoesNotSendBytecode() throws Excep try (ReadySnapshot snapshot = fixture(); var compiler = service(); var javac = new InMemoryJavaCompiler()) { Path changed = jar("changed.jar", javac.compile("package fixture; public class Api {}", "fixture.Api", "")); compiler.bind(snapshot); - installServer(compiler, ServerManifest.scan(List.of(changed, snapshot.sources().get(1).path(), + installServer(compiler, new ServerManifest.Catalog(List.of(changed, snapshot.sources().get(1).path(), snapshot.sources().get(2).path())), "server-session"); compiler.submit(1, SOURCE, true, ScriptExecutionEnvironment.THREAD, this.failures::add); var failure = this.failures.poll(10, TimeUnit.SECONDS); assertNotNull(failure); - assertTrue(failure.error().text().contains("declarations differ for fixture.Api"), failure.error().text()); + assertTrue(failure.error().text().contains("class fixture.Api is absent or its declarations differ"), failure.error().text()); assertTrue(this.sent.isEmpty()); } } @@ -357,21 +357,103 @@ void serverCompilationRequiresACompletedHandshake() throws Exception { } } + @Test + void baselineArrivingBeforeIndexBindingStillCompletes() throws Exception { + try (ReadySnapshot snapshot = fixture(); var compiler = service()) { + var catalog = new ServerManifest.Catalog(snapshot.sources().stream().map(source -> source.path()).toList()); + byte[] bytes = catalog.baseline(); + int middle = bytes.length / 2; + compiler.acceptServerManifest(new ServerManifestMessage("session", "", 0, bytes.length, + Arrays.copyOfRange(bytes, 0, middle))); + compiler.bind(snapshot); + compiler.acceptServerManifest(new ServerManifestMessage("session", "", middle, bytes.length, + Arrays.copyOfRange(bytes, middle, bytes.length))); + finishHandshake(compiler, catalog); + compiler.submit(1, SOURCE, true, ScriptExecutionEnvironment.THREAD, this.failures::add); + assertNotNull(this.sent.poll(10, TimeUnit.SECONDS), () -> this.failures.toString()); + assertTrue(this.requests.isEmpty()); + } + } + + @Test + void pendingDetailsBlockServerOnlyAndOldRepliesCannotCompleteAReopenedComparison() throws Exception { + try (ReadySnapshot snapshot = fixture(); var compiler = service(); var javac = new InMemoryJavaCompiler()) { + Path changed = jar("changed.jar", javac.compile("package fixture; public class Api {}", "fixture.Api", "")); + var catalog = new ServerManifest.Catalog(List.of(changed, snapshot.sources().get(1).path(), snapshot.sources().get(2).path())); + compiler.bind(snapshot); + for (var message : ServerManifestMessage.split("session", catalog.baseline())) compiler.acceptServerManifest(message); + compiler.compile("public class Barrier {}", "Barrier").get(10, TimeUnit.SECONDS); + var old = this.requests.remove(); + compiler.submit(1, SOURCE, true, ScriptExecutionEnvironment.THREAD, this.failures::add); + assertTrue(this.failures.poll(10, TimeUnit.SECONDS).error().text().contains("Comparing server source")); + compiler.submit(2, SOURCE, false, ScriptExecutionEnvironment.THREAD, this.failures::add); + assertNotNull(this.sent.poll(10, TimeUnit.SECONDS)); + + // The same server session is replayed when Companion reconnects; request identity must still change. + compiler.runtimeDisconnected(); + for (var message : ServerManifestMessage.split("session", catalog.baseline())) compiler.acceptServerManifest(message); + compiler.compile("public class Barrier {}", "Barrier").get(10, TimeUnit.SECONDS); + var current = this.requests.remove(); + assertNotEquals(old.requestId(), current.requestId()); + for (var message : ServerManifestMessage.split(old.sessionId(), old.requestId(), old.source(), catalog.details(old.source()))) { + compiler.acceptServerManifest(message); + } + compiler.compile("public class Barrier {}", "Barrier").get(10, TimeUnit.SECONDS); + compiler.submit(3, SOURCE, true, ScriptExecutionEnvironment.THREAD, this.failures::add); + assertTrue(this.failures.poll(10, TimeUnit.SECONDS).error().text().contains("Comparing server source")); + this.requests.add(current); + finishHandshake(compiler, catalog); + compiler.submit(4, SOURCE, true, ScriptExecutionEnvironment.THREAD, this.failures::add); + assertTrue(this.failures.poll(10, TimeUnit.SECONDS).error().text().contains("class fixture.Api")); + assertTrue(this.sent.isEmpty()); + } + } + + @Test + void rebindDiscardsCompletedResultAndRecomparesTheRetainedBaseline() throws Exception { + try (ReadySnapshot snapshot = fixture(); var compiler = service()) { + compiler.bind(snapshot); + installServer(compiler, snapshot, "session"); + var release = new CountDownLatch(1); + var held = holdCacheLock(release); + try { + compiler.bind(snapshot); + compiler.submit(1, SOURCE, true, ScriptExecutionEnvironment.THREAD, this.failures::add); + assertTrue(this.failures.poll(5, TimeUnit.SECONDS).error().text().contains("handshake")); + } finally { release.countDown(); } + held.get(10, TimeUnit.SECONDS); + compiler.compile("public class Barrier {}", "Barrier").get(10, TimeUnit.SECONDS); + compiler.submit(2, SOURCE, true, ScriptExecutionEnvironment.THREAD, this.failures::add); + assertNotNull(this.sent.poll(10, TimeUnit.SECONDS), () -> this.failures.toString()); + } + } + private void installServer(ScriptCompilationService compiler, ReadySnapshot snapshot, String session) throws Exception { - installServer(compiler, ServerManifest.scan(snapshot.sources().stream().map(source -> source.path()).toList()), session); + installServer(compiler, new ServerManifest.Catalog(snapshot.sources().stream().map(source -> source.path()).toList()), session); + } + + private void installServer(ScriptCompilationService compiler, ServerManifest.Catalog manifest, String session) throws Exception { + for (var message : ServerManifestMessage.split(session, manifest.baseline())) compiler.acceptServerManifest(message); + finishHandshake(compiler, manifest); } - private void installServer(ScriptCompilationService compiler, ServerManifest manifest, String session) throws Exception { - for (var message : ServerManifestMessage.split(session, manifest.encode())) compiler.acceptServerManifest(message); - // The compile worker also decodes manifests, so this waits for installation without a sleep. - compiler.compile("public class Barrier {}", "Barrier").get(10, TimeUnit.SECONDS); + private void finishHandshake(ScriptCompilationService compiler, ServerManifest.Catalog manifest) throws Exception { + for (;;) { + // Drain queued comparison work without sleeping or reaching into service internals. + compiler.compile("public class Barrier {}", "Barrier").get(10, TimeUnit.SECONDS); + var request = this.requests.poll(); + if (request == null) return; + for (var message : ServerManifestMessage.split(request.sessionId(), request.requestId(), request.source(), + manifest.details(request.source()))) compiler.acceptServerManifest(message); + } } + private final BlockingQueue requests = new LinkedBlockingQueue<>(); private final BlockingQueue sent = new LinkedBlockingQueue<>(); private final BlockingQueue failures = new LinkedBlockingQueue<>(); private ScriptCompilationService service() { - return new ScriptCompilationService(this.sent::add); + return new ScriptCompilationService(this.sent::add, this.requests::add); } private CompletableFuture holdCacheLock(CountDownLatch release) throws Exception { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/script/ServerCompatibilityTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/script/ServerCompatibilityTest.java new file mode 100644 index 00000000..017957bc --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/script/ServerCompatibilityTest.java @@ -0,0 +1,97 @@ +package com.github.minecraft_ta.totalDebugCompanion.script; + +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService.ReadySnapshot; +import com.github.minecraft_ta.totaldebug.evaluation.InMemoryJavaCompiler; +import com.github.minecraft_ta.totaldebug.evaluation.ServerManifest; +import com.github.tth05.jindex.ClassIndex; +import com.github.tth05.jindex.IndexSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeTestSources.librarySource; +import static org.junit.jupiter.api.Assertions.*; + +class ServerCompatibilityTest { + @TempDir Path directory; + + @Test + void matchingArchivesNeedNoDetailsAndDifferentPlayersOwnDifferentResults() throws Exception { + Path api = jar("api.jar", "public class Api { public int value() { return 1; } }"); + Path extra = jar("extra.jar", "public class Extra {}"); + var baseline = ServerManifest.scan(List.of(api)); + try (var first = snapshot(List.of(api)); var second = snapshot(List.of(api, extra))) { + var alice = new ServerCompatibility(baseline, first.sources()); + var bob = new ServerCompatibility(baseline, second.sources()); + assertEquals(-1, alice.nextSource()); + assertEquals(-1, bob.nextSource()); + assertEquals(Set.of(), alice.finish(first)); + assertEquals(Set.of("Extra"), bob.finish(second)); + } + } + + @Test + void reversedIdenticalArchivesCompareTheActualWinningDefinitions() throws Exception { + Path first = jar("first.jar", "public class Api { public int value; }"); + Path second = jar("second.jar", "public class Api { public long value; }"); + try (var local = snapshot(List.of(first, second))) { + assertEquals(0, local.index().findClass("Api").getSourceId()); + var comparison = new ServerCompatibility(ServerManifest.scan(List.of(second, first)), local.sources()); + assertEquals(-1, comparison.nextSource(), "Both archives are already local"); + assertEquals(Set.of("Api"), comparison.finish(local)); + } + } + + @Test + void changedEarlierSourceCannotHideBehindAnIdenticalLaterArchive() throws Exception { + Path local = jar("local.jar", "public class Api { public int value; }"); + Path changed = jar("changed.jar", "public class Api { public long value; }"); + try (var snapshot = snapshot(List.of(local))) { + var comparison = new ServerCompatibility(ServerManifest.scan(List.of(changed, local)), snapshot.sources()); + assertEquals(0, comparison.nextSource()); + assertThrows(Exception.class, () -> comparison.finish(snapshot)); + comparison.accept(0, ServerManifest.readClasses(changed, true)); + assertEquals(Set.of("Api"), comparison.finish(snapshot)); + } + } + + @Test + void methodBodiesRemainUsableAndUnmatchedDirectoriesUseDetails() throws Exception { + Path local = jar("local.jar", "public class Api { public int value() { return 1; } }"); + Path server = Files.createDirectory(directory.resolve("server")); + try (var compiler = new InMemoryJavaCompiler(); var snapshot = snapshot(List.of(local))) { + Files.write(server.resolve("Api.class"), compiler.compile( + "public class Api { public int value() { return 2000; } }", "Api", "").get("Api")); + var comparison = new ServerCompatibility(ServerManifest.scan(List.of(server)), snapshot.sources()); + assertEquals(0, comparison.nextSource()); + comparison.accept(0, ServerManifest.readClasses(server, true)); + assertEquals(Set.of(), comparison.finish(snapshot)); + } + } + + private ReadySnapshot snapshot(List paths) { + var sources = IntStream.range(0, paths.size()).mapToObj(i -> librarySource(i, paths.get(i))).toList(); + var index = ClassIndex.fromSources(sources.stream().map(source -> IndexSource.archive(source.sourceId(), source.path().toString())).toList()); + return new ReadySnapshot("inventory", "signature", directory.resolve("index.jindex"), sources, index); + } + + private Path jar(String filename, String source) throws Exception { + String name = source.substring("public class ".length()).split(" ")[0]; + Path path = directory.resolve(filename); + try (var compiler = new InMemoryJavaCompiler(); var output = new JarOutputStream(Files.newOutputStream(path))) { + for (var entry : compiler.compile(source, name, "").entrySet()) { + output.putNextEntry(new JarEntry(entry.getKey() + ".class")); + output.write(entry.getValue()); + output.closeEntry(); + } + } + return path; + } +} diff --git a/docs/USAGE.md b/docs/USAGE.md index b0ada15c..b925fe60 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -16,11 +16,15 @@ The index describes selected runtime archives and prepared class files. It does Saved scripts contain imports and Java statements. Use `return` to produce a structured value, and `log` or `logln` for output. Companion compiles scripts using its existing runtime index and sends the generated classes to Minecraft for execution. Wait for the current runtime index to become ready before running a script. -The client and server execution choices target their respective game contexts. Integrated and dedicated servers publish an ordered class manifest after joining. Companion keeps the client index and compiles against local class files. Matching archives pass directly; otherwise each class read by the compiler must have matching declarations on the server. Missing classes and changed declarations fail with the class name. Method implementations may differ, but fields, signatures, inheritance, access, generic metadata and compile-time constants must match. This is an exact class-level check, so even an unused declaration change can reject that class. +The client and server execution choices target their respective game contexts. Integrated and dedicated servers publish an ordered archive baseline after joining. Companion compares archive hashes using the existing client files and index. Matching archives need only local class entry names. Companion requests declaration fingerprints for differing or unmatched server sources, one source at a time, then compares the actual winning class definitions in source order. Directories use the detailed path. -The server builds its compressed manifest in the background once per server lifetime and reuses it for subsequent connections. Each player connection receives a fresh handshake identity. Disconnecting invalidates pending server compilations, and the receiving server rejects bytecode carrying an old identity. Open Companion before or after joining; the client replays the current handshake. There is no second index or automatic download of server class files. +The server hashes its archives in the background once per server lifetime. Requested source details are calculated lazily and cached once for all players as compressed metadata. Each player connection has a fresh handshake identity. Minecraft relays the comparison messages and retains only the baseline for opening Companion later. Each Companion retains its own unsupported-class names, bound to the current server session and client inventory; temporary class fingerprints are discarded. There is no second index or automatic download of server class files. -The manifest describes prepared filesystem class files, not final post-Mixin or agent-transformed definitions. It does not guarantee identical behavior or validate types named dynamically through reflection. Server-only types remain unavailable to client-index completion. Source inspection and remote debugger support are separate from compilation. +Server compilation waits for the entire comparison to finish. Javac then checks that local set when reading a class, with no network requests or fingerprint calculation during compilation. Missing classes and changed declarations fail with the class name. Method implementations may differ, but fields, signatures, inheritance, access, generic metadata and compile-time constants must match. This is an exact class-level check, so even an unused declaration change can reject that class. Client compilation remains available while the server comparison is pending. + +Disconnecting invalidates pending server compilations, and the receiving server rejects bytecode carrying an old identity. Reopening Companion or replacing its client index repeats comparison against the retained baseline; delayed replies from older comparisons cannot complete the new one. The server keeps shared source details until that server runtime ends. + +The comparison describes prepared filesystem class files, not final post-Mixin or agent-transformed definitions. It does not guarantee identical behavior or validate types named dynamically through reflection. Server-only types remain unavailable to client-index completion. Source inspection and remote debugger support are separate from compilation. Server execution follows the server's script configuration and operator restrictions. Install matching TotalDebug builds on both endpoints and use the matching Companion build. Compiled scripts are limited to 1 MiB, with a 30,000-byte compressed limit for server runs. diff --git a/evaluation/src/main/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifest.java b/evaluation/src/main/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifest.java index 8dbc7da3..114babcc 100644 --- a/evaluation/src/main/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifest.java +++ b/evaluation/src/main/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifest.java @@ -8,6 +8,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashMap; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; @@ -17,123 +18,181 @@ import java.util.zip.GZIPOutputStream; import java.util.zip.ZipFile; -/** Ordered filesystem declarations, not an index or a claim about post-Mixin loaded definitions. */ -public record ServerManifest(List sources, Map classes) { +/** Ordered archive baseline. Class declarations are read only for requested sources. */ +public record ServerManifest(List sources) { public static final int MAX_COMPRESSED_BYTES = 32 * 1024 * 1024; + public static final int MAX_SOURCES = 4096; private static final int MAX_DECODED_BYTES = 128 * 1024 * 1024; private static final int MAX_CLASSES = 1_000_000; - public record Source(String name, String archiveHash) {} - public record Definition(int source, String declarationHash) {} + public record Source(String name, String archiveHash) { + public Source { + if (name.length() > 4096 || !archiveHash.matches("(?:[0-9a-f]{64})?")) { + throw new IllegalArgumentException("Invalid server source"); + } + } + } public ServerManifest { sources = List.copyOf(sources); - classes = Map.copyOf(classes); - if (sources.size() > 4096 || classes.size() > MAX_CLASSES) { - throw new IllegalArgumentException("Server manifest exceeds the source/class limit"); - } - for (Definition definition : classes.values()) { - if (definition.source() < 0 || definition.source() >= sources.size()) { - throw new IllegalArgumentException("Invalid server class source"); - } - } + if (sources.size() > MAX_SOURCES) throw new IllegalArgumentException("Too many server sources"); } public static ServerManifest scan(List paths) throws IOException { var sources = new ArrayList(); - var classes = new LinkedHashMap(); for (Path path : paths) { - int source = sources.size(); sources.add(new Source(path.getFileName().toString(), Files.isDirectory(path) ? "" : ClassDeclarations.archiveFingerprint(path))); - if (Files.isDirectory(path)) { - try (var files = Files.walk(path)) { - for (Path file : files.filter(Files::isRegularFile).sorted().toList()) { - String name = path.relativize(file).toString().replace('\\', '/'); - if (classEntry(name)) add(classes, name, source, Files.readAllBytes(file)); - } + } + return new ServerManifest(sources); + } + + /** Shared for one server runtime, accessed on the manifest worker. Retains encoded details once per source. */ + public static final class Catalog { + private final List paths; + private final byte[] baseline; + private final Map details = new HashMap<>(); + + public Catalog(List paths) throws IOException { + this.paths = List.copyOf(paths); + this.baseline = scan(paths).encode(); + } + + public byte[] baseline() { return baseline; } + + public synchronized byte[] details(int source) throws IOException { + if (source < 0 || source >= paths.size()) throw new IOException("Unknown server source " + source); + byte[] bytes = details.get(source); + if (bytes == null) { + bytes = encodeDetails(readClasses(paths.get(source), true)); + details.put(source, bytes); + } + return bytes; + } + } + + /** With declarations=false this reads entry names only, never class bodies. */ + public static Map readClasses(Path path, boolean declarations) throws IOException { + var classes = new LinkedHashMap(); + if (Files.isDirectory(path)) { + try (var files = Files.walk(path)) { + for (Path file : files.filter(Files::isRegularFile).sorted().toList()) { + String resource = path.relativize(file).toString().replace('\\', '/'); + if (classEntry(resource)) classes.put(binaryName(resource), + declarations ? ClassDeclarations.fingerprint(Files.readAllBytes(file)) : ""); } - } else { - try (var jar = new JarFile(path.toFile(), false, ZipFile.OPEN_READ, Runtime.Version.parse("21"))) { - for (var entry : jar.versionedStream().filter(e -> classEntry(e.getName())).toList()) { + } + } else { + try (var jar = openArchive(path)) { + for (var entry : jar.versionedStream().filter(e -> classEntry(e.getName())).toList()) { + String hash = ""; + if (declarations) { try (var input = jar.getInputStream(entry)) { - add(classes, entry.getName(), source, input.readAllBytes()); + hash = ClassDeclarations.fingerprint(input.readAllBytes()); } } + classes.put(binaryName(entry.getName()), hash); } } } - return new ServerManifest(sources, classes); + if (classes.size() > MAX_CLASSES) throw new IOException("Too many source classes"); + return classes; } - private static boolean classEntry(String name) { - return name.endsWith(".class") && !name.startsWith("META-INF/") && !name.equals("module-info.class"); + private static JarFile openArchive(Path path) throws IOException { + return new JarFile(path.toFile(), false, ZipFile.OPEN_READ, Runtime.Version.parse("21")); } - private static void add(Map classes, String resource, int source, byte[] bytes) { - String name = resource.substring(0, resource.length() - 6).replace('/', '.'); - if (!classes.containsKey(name)) classes.put(name, new Definition(source, ClassDeclarations.fingerprint(bytes))); + private static boolean classEntry(String name) { + return name.endsWith(".class") && !name.startsWith("META-INF/") && !name.equals("module-info.class"); } - public void requireCompatible(String name, String localArchiveHash, byte[] localBytes) throws IOException { - Definition definition = classes.get(name); - if (definition == null) throw new IOException("Server compilation unsupported: class " + name + " is absent on the server"); - Source source = sources.get(definition.source()); - if (!localArchiveHash.isEmpty() && localArchiveHash.equals(source.archiveHash())) return; - if (!definition.declarationHash().equals(ClassDeclarations.fingerprint(localBytes))) { - throw new IOException("Server compilation unsupported: declarations differ for " + name - + " in " + source.name() + ". Compile using matching client/server classes."); - } + private static String binaryName(String resource) { + return resource.substring(0, resource.length() - 6).replace('/', '.'); } public byte[] encode() throws IOException { - var bytes = new ByteArrayOutputStream(); - try (var output = new DataOutputStream(new GZIPOutputStream(bytes))) { - output.writeInt(1); + return encode(output -> { output.writeInt(sources.size()); for (Source source : sources) { output.writeUTF(source.name()); output.writeUTF(source.archiveHash()); } + }); + } + + public static ServerManifest decode(byte[] bytes) throws IOException { + try (var input = input(bytes)) { + int count = count(input, MAX_SOURCES); + var sources = new ArrayList(); + for (int i = 0; i < count; i++) sources.add(new Source(input.readUTF(), input.readUTF())); + end(input); + return new ServerManifest(sources); + } catch (IllegalArgumentException exception) { throw new IOException("Invalid server baseline", exception); } + } + + public static byte[] encodeDetails(Map classes) throws IOException { + if (classes.size() > MAX_CLASSES) throw new IOException("Too many source classes"); + return encode(output -> { output.writeInt(classes.size()); for (var entry : classes.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList()) { output.writeUTF(entry.getKey()); - output.writeInt(entry.getValue().source()); - output.write(HexFormat.of().parseHex(entry.getValue().declarationHash())); + byte[] hash = HexFormat.of().parseHex(entry.getValue()); + if (hash.length != 32) throw new IOException("Invalid declaration fingerprint"); + output.write(hash); } - } - if (bytes.size() > MAX_COMPRESSED_BYTES) throw new IOException("Server manifest exceeds the transfer limit"); - return bytes.toByteArray(); + }); } - public static ServerManifest decode(byte[] bytes) throws IOException { - if (bytes.length > MAX_COMPRESSED_BYTES) throw new IOException("Server manifest exceeds the transfer limit"); - byte[] decoded; - try (var gzip = new GZIPInputStream(new ByteArrayInputStream(bytes))) { - decoded = gzip.readNBytes(MAX_DECODED_BYTES + 1); - if (decoded.length > MAX_DECODED_BYTES) throw new IOException("Server manifest exceeds the decoded limit"); - } - try (var input = new DataInputStream(new ByteArrayInputStream(decoded))) { - if (input.readInt() != 1) throw new IOException("Unsupported server manifest format"); - int sourceCount = input.readInt(); - if (sourceCount < 0 || sourceCount > 4096) throw new IOException("Invalid server source count"); - var sources = new ArrayList(); - for (int i = 0; i < sourceCount; i++) sources.add(new Source(input.readUTF(), input.readUTF())); - int count = input.readInt(); - if (count < 0 || count > MAX_CLASSES) throw new IOException("Invalid server class count"); - var classes = new LinkedHashMap(); + public static Map decodeDetails(byte[] bytes) throws IOException { + try (var input = input(bytes)) { + int count = count(input, MAX_CLASSES); + var classes = new HashMap(); for (int i = 0; i < count; i++) { String name = input.readUTF(); - int source = input.readInt(); byte[] hash = new byte[32]; input.readFully(hash); - if (classes.putIfAbsent(name, new Definition(source, HexFormat.of().formatHex(hash))) != null) { + if (classes.putIfAbsent(name, HexFormat.of().formatHex(hash)) != null) { throw new IOException("Duplicate server class " + name); } } - if (input.read() != -1) throw new IOException("Trailing server manifest data"); - try { return new ServerManifest(sources, classes); } - catch (IllegalArgumentException exception) { throw new IOException("Invalid server manifest", exception); } + end(input); + return classes; + } + } + + @FunctionalInterface + private interface Encoder { void write(DataOutputStream output) throws IOException; } + + private static byte[] encode(Encoder encoder) throws IOException { + var bytes = new ByteArrayOutputStream(); + try (var output = new DataOutputStream(new GZIPOutputStream(bytes))) { + output.writeInt(2); + encoder.write(output); + } + if (bytes.size() > MAX_COMPRESSED_BYTES) throw new IOException("Server metadata exceeds the transfer limit"); + return bytes.toByteArray(); + } + + private static DataInputStream input(byte[] bytes) throws IOException { + if (bytes.length > MAX_COMPRESSED_BYTES) throw new IOException("Server metadata exceeds the transfer limit"); + byte[] decoded; + try (var gzip = new GZIPInputStream(new ByteArrayInputStream(bytes))) { + decoded = gzip.readNBytes(MAX_DECODED_BYTES + 1); + if (decoded.length > MAX_DECODED_BYTES) throw new IOException("Server metadata exceeds the decoded limit"); } + var input = new DataInputStream(new ByteArrayInputStream(decoded)); + if (input.readInt() != 2) throw new IOException("Unsupported server metadata format"); + return input; + } + + private static int count(DataInputStream input, int maximum) throws IOException { + int count = input.readInt(); + if (count < 0 || count > maximum) throw new IOException("Invalid server metadata count"); + return count; + } + + private static void end(DataInputStream input) throws IOException { + if (input.read() != -1) throw new IOException("Trailing server metadata"); } } diff --git a/evaluation/src/test/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifestTest.java b/evaluation/src/test/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifestTest.java index fbb62d0d..d967cc4f 100644 --- a/evaluation/src/test/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifestTest.java +++ b/evaluation/src/test/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifestTest.java @@ -24,16 +24,14 @@ void bodiesMayDifferButConstantsSignaturesAndHierarchyMustMatch() throws Excepti byte[] original = compiler.compile(source, "Api", "").get("Api"); var manifest = ServerManifest.scan(List.of(jar("server.jar", Map.of("Api", original)))); byte[] body = compiler.compile(source.replace("return 1", "return 2000"), "Api", "").get("Api"); - manifest.requireCompatible("Api", "", body); + assertEquals(ClassDeclarations.fingerprint(original), ClassDeclarations.fingerprint(body)); for (String changed : List.of(source.replace("LIMIT = 7", "LIMIT = 8"), source.replace("int value()", "long value()"), source.replace("class Api", "class Api implements java.io.Serializable"), source.replace("public int value", "private int value"))) { byte[] bytes = compiler.compile(changed, "Api", "").get("Api"); - IOException failure = assertThrows(IOException.class, () -> manifest.requireCompatible("Api", "", bytes)); - assertTrue(failure.getMessage().contains("declarations differ for Api")); + assertNotEquals(ClassDeclarations.fingerprint(original), ClassDeclarations.fingerprint(bytes)); } - assertThrows(IOException.class, () -> manifest.requireCompatible("Missing", "", original)); assertEquals(manifest, ServerManifest.decode(manifest.encode())); } } @@ -49,15 +47,21 @@ void genericSignaturesAndInheritedConstantsAreDeclarations() throws Exception { } @Test - void serverSourceOrderWinsEvenWhenLaterArchiveMatchesClientExactly() throws Exception { + void baselineAndNamesDoNotReadClassBodiesAndRequestedDetailsAreCachedOnce() throws Exception { + Path broken = jar("broken.jar", Map.of("Broken", new byte[]{1, 2, 3})); + var brokenCatalog = new ServerManifest.Catalog(List.of(broken)); + assertEquals(1, ServerManifest.decode(brokenCatalog.baseline()).sources().size()); + assertEquals(Map.of("Broken", ""), ServerManifest.readClasses(broken, false)); + assertThrows(RuntimeException.class, () -> brokenCatalog.details(0)); try (var compiler = new InMemoryJavaCompiler()) { - byte[] client = compiler.compile("public class Api { public int value; }", "Api", "").get("Api"); - byte[] server = compiler.compile("public class Api { public long value; }", "Api", "").get("Api"); - Path first = jar("first.jar", Map.of("Api", server)); - Path second = jar("second.jar", Map.of("Api", client)); - var manifest = ServerManifest.scan(List.of(first, second)); - assertThrows(IOException.class, () -> manifest.requireCompatible("Api", ClassDeclarations.archiveFingerprint(second), client)); - manifest.requireCompatible("Api", ClassDeclarations.archiveFingerprint(first), server); + Path valid = jar("valid.jar", compiler.compile("public class Api {}", "Api", "")); + var catalog = new ServerManifest.Catalog(List.of(valid)); + byte[] details = catalog.details(0); + assertTrue(ServerManifest.decodeDetails(details).containsKey("Api")); + Files.delete(valid); + assertSame(details, catalog.details(0), "Second player must reuse the cached encoded source"); + var cached = catalog; + assertThrows(IOException.class, () -> cached.details(1)); } } @@ -78,12 +82,13 @@ void directoriesAndMultiReleaseArchivesUseTheJava21View() throws Exception { output.closeEntry(); } } - var manifest = ServerManifest.scan(List.of(archive)); - manifest.requireCompatible("Api", "", release); - assertThrows(IOException.class, () -> manifest.requireCompatible("Api", "", base)); + var details = ServerManifest.readClasses(archive, true); + assertEquals(Map.of("Api", ClassDeclarations.fingerprint(release)), details); + assertEquals(Map.of("Api", ""), ServerManifest.readClasses(archive, false)); + assertEquals(details, ServerManifest.decodeDetails(ServerManifest.encodeDetails(details))); Path classes = Files.createDirectory(directory.resolve("classes")); Files.write(classes.resolve("Api.class"), release); - assertEquals(manifest.classes(), ServerManifest.scan(List.of(classes)).classes()); + assertEquals(details, ServerManifest.readClasses(classes, true)); } } diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/TotalDebugClient.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/TotalDebugClient.java index 66cb65c3..44355df6 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/TotalDebugClient.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/TotalDebugClient.java @@ -8,6 +8,7 @@ import com.github.minecraft_ta.totaldebug.config.TotalDebugConfig; import com.github.minecraft_ta.totaldebug.TotalDebug; import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; +import com.github.minecraft_ta.totaldebug.network.ServerSourceRequestPayload; import net.minecraft.client.Minecraft; import java.nio.file.Path; @@ -35,6 +36,12 @@ private TotalDebugClient(Path gameDirectory) { ); this.companionApp = companionApp; TotalDebug.get().network().setManifestReceiver(payload -> companionApp.acceptServerManifest(payload.message())); + companionApp.setServerSourceRequestHandler(request -> Minecraft.getInstance().execute(() -> { + var connection = Minecraft.getInstance().getConnection(); + if (connection != null && connection.hasChannel(ServerSourceRequestPayload.TYPE)) { + connection.send(new ServerSourceRequestPayload(request)); + } + })); companionApp.setProgressListener(progress -> CompanionProgressActionBar.show(Minecraft.getInstance(), progress)); this.codeOpen = new ClientCodeOpenService(companionApp); this.openCode = new OpenCodeOperation(new OpenCodeOperation.Actions() { diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java index 17abf6c2..160fc1b0 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java @@ -16,6 +16,7 @@ import com.github.minecraft_ta.totaldebug.protocol.scnet.FocusWindowMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.RunScriptMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerSourceRequestMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.RetryRuntimeInventoryMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.RuntimeInventoryMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.ExecutionResultMessage; @@ -175,11 +176,21 @@ public void setProgressListener(Consumer listener) { this.progressListener = Objects.requireNonNull(listener, "listener"); } + private Consumer serverSourceRequestHandler = message -> {}; + + public void setServerSourceRequestHandler(Consumer handler) { + this.serverSourceRequestHandler = Objects.requireNonNull(handler); + } + private final Object serverManifestLock = new Object(); private final List serverManifest = new ArrayList<>(); public void acceptServerManifest(ServerManifestMessage message) { synchronized (this.serverManifestLock) { + if (!message.baseline()) { + enqueueServerManifest(message); + return; + } if (message.offset() == 0) this.serverManifest.clear(); // Replay the bounded current transfer when Companion opens after joining the server. if (this.serverManifest.size() >= ServerManifestMessage.MAX_BYTES / ServerManifestMessage.CHUNK_BYTES + 1) { @@ -287,6 +298,13 @@ private void registerProtocol() { } this.ready.complete(null); }); + this.client.getMessageBus().listenAlways(ServerSourceRequestMessage.class, message -> { + if (!isAuthenticated()) { + failSession("Companion requested server details before authentication", null); + return; + } + this.serverSourceRequestHandler.accept(message); + }); this.client.getMessageBus().listenAlways(RunScriptMessage.class, message -> { if (!isAuthenticated()) { failSession("Companion sent a script request before authentication", null); diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/script/ServerScriptTransport.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/script/ServerScriptTransport.java index c38c79af..02cb1376 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/script/ServerScriptTransport.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/script/ServerScriptTransport.java @@ -4,6 +4,7 @@ import com.github.minecraft_ta.totaldebug.network.RunServerScriptPayload; import com.github.minecraft_ta.totaldebug.network.StopServerScriptPayload; import com.github.minecraft_ta.totaldebug.network.ServerManifestPayload; +import com.github.minecraft_ta.totaldebug.network.ServerSourceRequestPayload; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientPacketListener; import net.neoforged.neoforge.network.PacketDistributor; @@ -32,7 +33,8 @@ public Availability availability() { if (connection == null) { return Availability.unsupported("Join a world to run server-side scripts"); } - if (!connection.hasChannel(ServerManifestPayload.TYPE) + if (!connection.hasChannel(ServerSourceRequestPayload.TYPE) + || !connection.hasChannel(ServerManifestPayload.TYPE) || !connection.hasChannel(RunServerScriptPayload.TYPE) || !connection.hasChannel(StopServerScriptPayload.TYPE) || !connection.hasChannel(ForwardedCompanionPayload.TYPE)) { diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/ServerManifestPayload.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/ServerManifestPayload.java index 5d1da940..35a26594 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/ServerManifestPayload.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/ServerManifestPayload.java @@ -13,7 +13,7 @@ public record ServerManifestPayload(ServerManifestMessage message) implements Cu public static final StreamCodec STREAM_CODEC = new StreamCodec<>() { @Override public ServerManifestPayload decode(FriendlyByteBuf buffer) { - return new ServerManifestPayload(new ServerManifestMessage(buffer.readUtf(64), buffer.readUtf(2048), + return new ServerManifestPayload(new ServerManifestMessage(buffer.readUtf(64), buffer.readUtf(64), buffer.readInt(), buffer.readUtf(2048), buffer.readInt(), buffer.readInt(), buffer.readByteArray(ServerManifestMessage.CHUNK_BYTES))); } @@ -21,6 +21,8 @@ public ServerManifestPayload decode(FriendlyByteBuf buffer) { public void encode(FriendlyByteBuf buffer, ServerManifestPayload payload) { var message = payload.message(); buffer.writeUtf(message.sessionId(), 64); + buffer.writeUtf(message.requestId(), 64); + buffer.writeInt(message.source()); buffer.writeUtf(message.detail(), 2048); buffer.writeInt(message.offset()); buffer.writeInt(message.total()); diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/ServerSourceRequestPayload.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/ServerSourceRequestPayload.java new file mode 100644 index 00000000..3512b0da --- /dev/null +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/ServerSourceRequestPayload.java @@ -0,0 +1,30 @@ +package com.github.minecraft_ta.totaldebug.network; + +import com.github.minecraft_ta.totaldebug.TotalDebug; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerSourceRequestMessage; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public record ServerSourceRequestPayload(ServerSourceRequestMessage message) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(TotalDebug.MOD_ID, "server_source_request")); + public static final StreamCodec STREAM_CODEC = new StreamCodec<>() { + @Override + public ServerSourceRequestPayload decode(FriendlyByteBuf buffer) { + return new ServerSourceRequestPayload(new ServerSourceRequestMessage( + buffer.readUtf(64), buffer.readUtf(64), buffer.readInt())); + } + + @Override + public void encode(FriendlyByteBuf buffer, ServerSourceRequestPayload payload) { + buffer.writeUtf(payload.message().sessionId(), 64); + buffer.writeUtf(payload.message().requestId(), 64); + buffer.writeInt(payload.message().source()); + } + }; + + @Override + public Type type() { return TYPE; } +} diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/TotalDebugNetwork.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/TotalDebugNetwork.java index 772d5c34..1a1c36f9 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/TotalDebugNetwork.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/network/TotalDebugNetwork.java @@ -10,7 +10,7 @@ import java.util.function.Consumer; public final class TotalDebugNetwork { - public static final String PROTOCOL_VERSION = "4"; + public static final String PROTOCOL_VERSION = "5"; private final ForwardedCompanionPayloadSink forwardedCompanionPayloads = new ForwardedCompanionPayloadSink(); @@ -30,6 +30,9 @@ public void setManifestReceiver(Consumer receiver) { private void registerPayloads(RegisterPayloadHandlersEvent event) { PayloadRegistrar registrar = event.registrar(PROTOCOL_VERSION).optional(); + registrar.playToServer(ServerSourceRequestPayload.TYPE, ServerSourceRequestPayload.STREAM_CODEC, + (payload, context) -> TotalDebug.get().serverScripts().requestSource( + (ServerPlayer) context.player(), payload.message())); registrar.playToClient(ServerManifestPayload.TYPE, ServerManifestPayload.STREAM_CODEC, (payload, context) -> this.manifestReceiver.accept(payload)); registrar.playToClient( diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptService.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptService.java index 31818983..5fff0545 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptService.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptService.java @@ -1,18 +1,21 @@ package com.github.minecraft_ta.totaldebug.server.script; import com.github.minecraft_ta.totaldebug.TotalDebug; -import com.github.minecraft_ta.totaldebug.evaluation.ServerManifest; -import com.github.minecraft_ta.totaldebug.network.ServerManifestPayload; -import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; import com.github.minecraft_ta.totaldebug.config.TotalDebugConfig; +import com.github.minecraft_ta.totaldebug.evaluation.ServerManifest; import com.github.minecraft_ta.totaldebug.network.ForwardedCompanionPayload; import com.github.minecraft_ta.totaldebug.network.ForwardedExecutionResult; import com.github.minecraft_ta.totaldebug.network.RunServerScriptPayload; +import com.github.minecraft_ta.totaldebug.network.ServerManifestPayload; import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionResult; import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionStatus; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerSourceRequestMessage; import com.github.minecraft_ta.totaldebug.script.ScriptRunner; +import com.github.minecraft_ta.totaldebug.storage.RuntimePhase; import com.github.minecraft_ta.totaldebug.tick.TickDomain; import com.github.minecraft_ta.totaldebug.tick.TickTaskScheduler; + import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; @@ -27,7 +30,6 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -35,12 +37,13 @@ /** Owns isolated server-side script runners for the players that requested them. */ public final class ServerScriptService { private static final int MAX_PENDING_RESULT_ENCODINGS = 4; - private final ExecutorService manifestWorker = Executors.newSingleThreadExecutor(runnable -> { + private final ExecutorService manifestWorker = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(64), runnable -> { var thread = new Thread(runnable, "TotalDebug server manifest"); thread.setDaemon(true); return thread; - }); - private CompletableFuture manifest; + }, new ThreadPoolExecutor.AbortPolicy()); + private CompletableFuture manifest; private final Map manifestSessions = new ConcurrentHashMap<>(); private record ManifestSession(ServerPlayer player, String id) {} @@ -57,18 +60,18 @@ public synchronized void sendManifest(ServerPlayer player) { MinecraftServer server = Objects.requireNonNull(player.getServer()); var session = new ManifestSession(player, UUID.randomUUID().toString()); this.manifestSessions.put(player.getUUID(), session); - player.connection.send(new ServerManifestPayload(ServerManifestMessage.unavailable("Preparing server class manifest"))); + player.connection.send(new ServerManifestPayload(ServerManifestMessage.unavailable("Preparing server archive baseline"))); if (this.manifest == null) { this.manifest = CompletableFuture.supplyAsync(() -> { - try { + try (var phase = RuntimePhase.start("server.baseline")) { var sources = TotalDebug.get().runtimeSources(); - return sources.withCurrentSources(() -> ServerManifest.scan(sources.paths()).encode()); + return sources.withCurrentSources(() -> new ServerManifest.Catalog(sources.paths())); } catch (IOException exception) { throw new CompletionException(exception); } }, this.manifestWorker); } - this.manifest.whenComplete((bytes, failure) -> server.execute(() -> { + this.manifest.whenComplete((catalog, failure) -> server.execute(() -> { if (this.manifestSessions.get(player.getUUID()) != session) return; if (failure != null) { this.manifestSessions.remove(player.getUUID(), session); @@ -77,12 +80,38 @@ public synchronized void sendManifest(ServerPlayer player) { "Unable to prepare server class manifest; see the server log"))); return; } - for (var message : ServerManifestMessage.split(session.id(), bytes)) { + for (var message : ServerManifestMessage.split(session.id(), catalog.baseline())) { player.connection.send(new ServerManifestPayload(message)); } })); } + public synchronized void requestSource(ServerPlayer player, ServerSourceRequestMessage request) { + ManifestSession session = this.manifestSessions.get(player.getUUID()); + if (session == null || session.player() != player || !session.id().equals(request.sessionId()) + || this.manifest == null) return; + MinecraftServer server = Objects.requireNonNull(player.getServer()); + this.manifest.thenApplyAsync(catalog -> { + if (this.manifestSessions.get(player.getUUID()) != session) return null; + try (var phase = RuntimePhase.start("server.source-details")) { + var sources = TotalDebug.get().runtimeSources(); + return sources.withCurrentSources(() -> catalog.details(request.source())); + } catch (IOException exception) { throw new CompletionException(exception); } + }, this.manifestWorker).whenComplete((bytes, failure) -> server.execute(() -> { + if (this.manifestSessions.get(player.getUUID()) != session) return; + if (failure != null) { + TotalDebug.LOGGER.error("Unable to prepare requested server source {}", request.source(), failure); + player.connection.send(new ServerManifestPayload(new ServerManifestMessage( + session.id(), request.requestId(), request.source(), + "Unable to prepare server source details; see the server log", 0, 0, new byte[0]))); + } else if (bytes != null) { + for (var message : ServerManifestMessage.split(session.id(), request.requestId(), request.source(), bytes)) { + player.connection.send(new ServerManifestPayload(message)); + } + } + })); + } + public void runScript(ServerPlayer player, RunServerScriptPayload payload) { Objects.requireNonNull(player, "player"); Objects.requireNonNull(payload, "payload"); diff --git a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionHandshakeConcurrencyTest.java b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionHandshakeConcurrencyTest.java index 836115c1..39ac7eb9 100644 --- a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionHandshakeConcurrencyTest.java +++ b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionHandshakeConcurrencyTest.java @@ -3,6 +3,8 @@ import com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol; import com.github.minecraft_ta.totaldebug.storage.CompanionLaunchContract; import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerHelloMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; +import java.util.List; import com.github.tth05.scnet.util.ByteBufferInputStream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -14,6 +16,7 @@ import java.time.Duration; import java.util.concurrent.Executors; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertEquals; class CompanionHandshakeConcurrencyTest { @TempDir @@ -49,6 +52,29 @@ void serverHelloHandlingDoesNotWaitForTheForegroundRequestMonitor() throws Excep } } + @Test + void replayRetainsOnlyTheBaselineAndDisconnectClearsIt() throws Exception { + Path appHome = Files.createDirectories(this.temporaryDirectory.resolve("app-home")); + Path root = Files.createDirectories(this.temporaryDirectory.resolve("instance/total-debug")); + String previousHome = System.getProperty(CompanionLaunchContract.APP_HOME_PROPERTY); + System.setProperty(CompanionLaunchContract.APP_HOME_PROPERTY, appHome.toString()); + try (var client = new CompanionAppClient(root)) { + var baseline = ServerManifestMessage.split("session", new byte[]{1, 2, 3}).getFirst(); + client.acceptServerManifest(baseline); + for (var detail : ServerManifestMessage.split("session", "request", 0, + new byte[ServerManifestMessage.CHUNK_BYTES + 1])) client.acceptServerManifest(detail); + var field = CompanionAppClient.class.getDeclaredField("serverManifest"); + field.setAccessible(true); + assertEquals(List.of(baseline), field.get(client)); + var cleared = ServerManifestMessage.unavailable("Disconnected"); + client.acceptServerManifest(cleared); + assertEquals(List.of(cleared), field.get(client)); + } finally { + if (previousHome == null) System.clearProperty(CompanionLaunchContract.APP_HOME_PROPERTY); + else System.setProperty(CompanionLaunchContract.APP_HOME_PROPERTY, previousHome); + } + } + private static ServerHelloMessage acceptedServerHello() { ByteBuffer bytes = ByteBuffer.allocate(Integer.BYTES + 1 + Integer.BYTES) .putInt(CompanionProtocol.VERSION) diff --git a/mod/src/test/java/com/github/minecraft_ta/totaldebug/network/ServerScriptPayloadTest.java b/mod/src/test/java/com/github/minecraft_ta/totaldebug/network/ServerScriptPayloadTest.java index 848ffca0..26c9bbf3 100644 --- a/mod/src/test/java/com/github/minecraft_ta/totaldebug/network/ServerScriptPayloadTest.java +++ b/mod/src/test/java/com/github/minecraft_ta/totaldebug/network/ServerScriptPayloadTest.java @@ -3,6 +3,7 @@ import com.github.minecraft_ta.totaldebug.protocol.execution.ScriptExecutionEnvironment; import com.github.minecraft_ta.totaldebug.protocol.execution.ScriptBytecode; import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerSourceRequestMessage; import java.util.Map; import java.util.Random; import java.io.ByteArrayOutputStream; @@ -100,11 +101,13 @@ void manifestChunksRoundTripThroughTheGameTransport() { new Random(9).nextBytes(bytes); var assembler = new ServerManifestMessage.Assembler(); byte[] result = null; - for (var message : ServerManifestMessage.split("session", bytes)) { + for (var message : ServerManifestMessage.split("session", "request", 12, bytes)) { FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer()); try { ServerManifestPayload.STREAM_CODEC.encode(buffer, new ServerManifestPayload(message)); var read = ServerManifestPayload.STREAM_CODEC.decode(buffer); + assertEquals("request", read.message().requestId()); + assertEquals(12, read.message().source()); result = assembler.accept(read.message()); assertEquals(0, buffer.readableBytes()); } finally { @@ -114,6 +117,20 @@ void manifestChunksRoundTripThroughTheGameTransport() { assertArrayEquals(bytes, result); } + @Test + void sourceRequestRoundTripsThroughGameTransport() { + FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer()); + try { + ServerSourceRequestPayload.STREAM_CODEC.encode(buffer, + new ServerSourceRequestPayload(new ServerSourceRequestMessage("session", "request", 17))); + var read = ServerSourceRequestPayload.STREAM_CODEC.decode(buffer).message(); + assertEquals("session", read.sessionId()); + assertEquals("request", read.requestId()); + assertEquals(17, read.source()); + assertEquals(0, buffer.readableBytes()); + } finally { buffer.release(); } + } + @Test void stopPayloadRoundTrips() { StopServerScriptPayload original = new StopServerScriptPayload(-1); diff --git a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/CompanionProtocol.java b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/CompanionProtocol.java index ed29b622..82967f49 100644 --- a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/CompanionProtocol.java +++ b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/CompanionProtocol.java @@ -1,7 +1,7 @@ package com.github.minecraft_ta.totaldebug.protocol; public final class CompanionProtocol { - public static final int VERSION = 13; + public static final int VERSION = 14; public static final short READY = 1; public static final short OPEN_CLASS = 2; @@ -17,6 +17,7 @@ public final class CompanionProtocol { // 26 and 27 belong to programmable-object messages. public static final short SERVER_MANIFEST = 28; + public static final short SERVER_SOURCE_REQUEST = 29; private CompanionProtocol() { } diff --git a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindings.java b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindings.java index 1a8c8e82..02901ba8 100644 --- a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindings.java +++ b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindings.java @@ -21,6 +21,7 @@ public static void registerMod(IMessageProcessor processor) { processor.registerIncoming(CompanionProtocol.RETRY_RUNTIME_INVENTORY, RetryRuntimeInventoryMessage.class, RetryRuntimeInventoryMessage::new); processor.registerOutgoing(CompanionProtocol.DEBUG_TARGET, DebugTargetMessage.class); processor.registerOutgoing(CompanionProtocol.SERVER_MANIFEST, ServerManifestMessage.class); + processor.registerIncoming(CompanionProtocol.SERVER_SOURCE_REQUEST, ServerSourceRequestMessage.class, ServerSourceRequestMessage::new); } public static void registerCompanion(IMessageProcessor processor) { @@ -36,5 +37,6 @@ public static void registerCompanion(IMessageProcessor processor) { processor.registerOutgoing(CompanionProtocol.RETRY_RUNTIME_INVENTORY, RetryRuntimeInventoryMessage.class); processor.registerIncoming(CompanionProtocol.DEBUG_TARGET, DebugTargetMessage.class, DebugTargetMessage::new); processor.registerIncoming(CompanionProtocol.SERVER_MANIFEST, ServerManifestMessage.class, ServerManifestMessage::new); + processor.registerOutgoing(CompanionProtocol.SERVER_SOURCE_REQUEST, ServerSourceRequestMessage.class); } } diff --git a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerManifestMessage.java b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerManifestMessage.java index d1caf464..51723b7b 100644 --- a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerManifestMessage.java +++ b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerManifestMessage.java @@ -14,6 +14,8 @@ public final class ServerManifestMessage extends AbstractMessage { public static final int CHUNK_BYTES = 512 * 1024; public static final int MAX_BYTES = 32 * 1024 * 1024; private String sessionId; + private String requestId; + private int source; private String detail; private int offset; private int total; @@ -22,12 +24,19 @@ public final class ServerManifestMessage extends AbstractMessage { public ServerManifestMessage() {} public ServerManifestMessage(String sessionId, String detail, int offset, int total, byte[] bytes) { - if (sessionId.length() > 64 || detail.length() > 2048 || total < 0 || total > MAX_BYTES + this(sessionId, "", -1, detail, offset, total, bytes); + } + + public ServerManifestMessage(String sessionId, String requestId, int source, String detail, int offset, int total, byte[] bytes) { + if (source < -1 || source >= 4096 || requestId.length() > 64 || (source >= 0 && requestId.isBlank()) + || (source == -1 && !requestId.isEmpty()) || sessionId.length() > 64 || detail.length() > 2048 || total < 0 || total > MAX_BYTES || offset < 0 || offset > total || bytes.length > CHUNK_BYTES || (long) offset + bytes.length > total || (total > 0 && (sessionId.isBlank() || bytes.length == 0))) { throw new IllegalArgumentException("Invalid server manifest chunk"); } this.sessionId = sessionId; + this.requestId = requestId; + this.source = source; this.detail = detail; this.offset = offset; this.total = total; @@ -39,10 +48,14 @@ public static ServerManifestMessage unavailable(String detail) { } public static List split(String sessionId, byte[] bytes) { + return split(sessionId, "", -1, bytes); + } + + public static List split(String sessionId, String requestId, int source, byte[] bytes) { if (bytes.length == 0 || bytes.length > MAX_BYTES) throw new IllegalArgumentException("Invalid manifest size"); var messages = new ArrayList(); for (int offset = 0; offset < bytes.length; offset += CHUNK_BYTES) { - messages.add(new ServerManifestMessage(sessionId, "", offset, bytes.length, + messages.add(new ServerManifestMessage(sessionId, requestId, source, "", offset, bytes.length, Arrays.copyOfRange(bytes, offset, Math.min(bytes.length, offset + CHUNK_BYTES)))); } return List.copyOf(messages); @@ -51,13 +64,17 @@ public static List split(String sessionId, byte[] bytes) @Override public void read(ByteBufferInputStream input) { String session = input.readString(); + String request = input.readString(); + int source = input.readInt(); String detail = input.readString(); int offset = input.readInt(); int total = input.readInt(); int length = input.readInt(); if (length < 0 || length > CHUNK_BYTES) throw new IllegalArgumentException("Invalid manifest chunk size"); - var checked = new ServerManifestMessage(session, detail, offset, total, input.readByteArray(length)); + var checked = new ServerManifestMessage(session, request, source, detail, offset, total, input.readByteArray(length)); this.sessionId = checked.sessionId; + this.requestId = checked.requestId; + this.source = checked.source; this.detail = checked.detail; this.offset = checked.offset; this.total = checked.total; @@ -67,6 +84,8 @@ public void read(ByteBufferInputStream input) { @Override public void write(ByteBufferOutputStream output) { output.writeString(sessionId); + output.writeString(requestId); + output.writeInt(source); output.writeString(detail); output.writeInt(offset); output.writeInt(total); @@ -75,6 +94,9 @@ public void write(ByteBufferOutputStream output) { } public String sessionId() { return sessionId; } + public String requestId() { return requestId; } + public int source() { return source; } + public boolean baseline() { return source == -1; } public String detail() { return detail; } public int offset() { return offset; } public int total() { return total; } @@ -82,6 +104,8 @@ public void write(ByteBufferOutputStream output) { public static final class Assembler { private String session = ""; + private String request = ""; + private int source = -1; private int total; private ByteArrayOutputStream buffer = new ByteArrayOutputStream(); @@ -90,9 +114,11 @@ public byte[] accept(ServerManifestMessage message) { if (message.offset == 0) { clear(); this.session = message.sessionId; + this.request = message.requestId; + this.source = message.source; this.total = message.total; } - if (!this.session.equals(message.sessionId) || this.total != message.total || buffer.size() != message.offset) { + if (!this.session.equals(message.sessionId) || !this.request.equals(message.requestId) || this.source != message.source || this.total != message.total || buffer.size() != message.offset) { clear(); throw new IllegalArgumentException("Out-of-order server manifest transfer"); } @@ -105,6 +131,8 @@ public byte[] accept(ServerManifestMessage message) { public void clear() { session = ""; + request = ""; + source = -1; total = 0; buffer = new ByteArrayOutputStream(); } diff --git a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerSourceRequestMessage.java b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerSourceRequestMessage.java new file mode 100644 index 00000000..07efca18 --- /dev/null +++ b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerSourceRequestMessage.java @@ -0,0 +1,41 @@ +package com.github.minecraft_ta.totaldebug.protocol.scnet; + +import com.github.tth05.scnet.message.AbstractMessage; +import com.github.tth05.scnet.util.ByteBufferInputStream; +import com.github.tth05.scnet.util.ByteBufferOutputStream; + +/** Requests one source by its position in the current ordered server baseline. */ +public final class ServerSourceRequestMessage extends AbstractMessage { + private String sessionId; + private String requestId; + private int source; + + public ServerSourceRequestMessage() {} + + public ServerSourceRequestMessage(String sessionId, String requestId, int source) { + if (sessionId.isBlank() || sessionId.length() > 64 || requestId.isBlank() || requestId.length() > 64 + || source < 0 || source >= 4096) throw new IllegalArgumentException("Invalid server source request"); + this.sessionId = sessionId; + this.requestId = requestId; + this.source = source; + } + + @Override + public void read(ByteBufferInputStream input) { + var checked = new ServerSourceRequestMessage(input.readString(), input.readString(), input.readInt()); + this.sessionId = checked.sessionId; + this.requestId = checked.requestId; + this.source = checked.source; + } + + @Override + public void write(ByteBufferOutputStream output) { + output.writeString(sessionId); + output.writeString(requestId); + output.writeInt(source); + } + + public String sessionId() { return sessionId; } + public String requestId() { return requestId; } + public int source() { return source; } +} diff --git a/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/GoldenMessages.java b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/GoldenMessages.java index 7bd589c5..9c5c02e4 100644 --- a/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/GoldenMessages.java +++ b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/GoldenMessages.java @@ -1,11 +1,11 @@ package com.github.minecraft_ta.totaldebug.protocol; -/** Released protocol-13 payloads, kept independently of the encoder. */ +/** Protocol-14 payloads, kept independently of the encoder. */ public final class GoldenMessages { public static final String RUN_SCRIPT = "0000000700000001580000000100000001580000000301020300000009696e76656e746f72790100000009504f53545f5449434b0000000173"; public static final String STOP_SCRIPT = "00000007"; - public static final String CLIENT_HELLO = "0000000d00000003616263000000017000000001640000000177"; - public static final String SERVER_HELLO = "0000000d0100000000"; + public static final String CLIENT_HELLO = "0000000e00000003616263000000017000000001640000000177"; + public static final String SERVER_HELLO = "0000000e0100000000"; public static final String RUNTIME_INVENTORY = "000000010000000269640000000466696c6500000000"; public static final String DEBUG_TARGET = "0000000269640000000467616d6501000000000000002a"; diff --git a/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindingsTest.java b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindingsTest.java index 4ee0e241..8de888b9 100644 --- a/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindingsTest.java +++ b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindingsTest.java @@ -49,6 +49,28 @@ void companionReceivesAChunkedManifestThroughItsRegisteredTransport() throws Exc } } + @Test + void sourceRequestTravelsFromCompanionToTheMod() throws Exception { + try (Server server = endpoint(ProtocolBindings::registerCompanion); Client client = new Client()) { + ProtocolBindings.registerMod(client.getMessageProcessor()); + var connected = new CompletableFuture(); + server.addConnectionListener(new IConnectionListener() { + @Override public void onConnected() { connected.complete(null); } + @Override public void onDisconnected() { } + @Override public void onConnectionError(Throwable cause) { connected.completeExceptionally(cause); } + }); + var received = new CompletableFuture(); + client.getMessageBus().listenAlways(ServerSourceRequestMessage.class, received::complete); + assertTrue(client.connect(server.getLocalAddress())); + connected.get(5, TimeUnit.SECONDS); + server.getMessageProcessor().enqueueMessage(new ServerSourceRequestMessage("session", "request", 7)); + var request = received.get(5, TimeUnit.SECONDS); + assertEquals("session", request.sessionId()); + assertEquals("request", request.requestId()); + assertEquals(7, request.source()); + } + } + @Test void modIgnoresOutgoingOnlyIdsWithoutDecodingTheirPayload() throws Exception { try (Server server = endpoint(ProtocolBindings::registerMod)) { diff --git a/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerManifestMessageTest.java b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerManifestMessageTest.java index adef7189..c77e7ea7 100644 --- a/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerManifestMessageTest.java +++ b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ServerManifestMessageTest.java @@ -15,7 +15,7 @@ void largeManifestRoundTripsAcrossBoundedFrames() { new Random(7).nextBytes(bytes); var assembler = new ServerManifestMessage.Assembler(); byte[] result = null; - for (var message : ServerManifestMessage.split("session", bytes)) { + for (var message : ServerManifestMessage.split("session", "request", 3, bytes)) { var output = new ByteBufferOutputStream(); message.write(output); var input = output.getBuffer().duplicate(); @@ -23,11 +23,43 @@ void largeManifestRoundTripsAcrossBoundedFrames() { var read = new ServerManifestMessage(); read.read(new ByteBufferInputStream(input)); assertFalse(input.hasRemaining()); + assertEquals("request", read.requestId()); + assertEquals(3, read.source()); + assertFalse(read.baseline()); result = assembler.accept(read); } assertArrayEquals(bytes, result); } + @Test + void sourceRequestsRoundTripAndRejectInvalidIdentityOrSource() { + var output = new ByteBufferOutputStream(); + new ServerSourceRequestMessage("session", "request", 27).write(output); + var input = output.getBuffer().duplicate(); + input.flip(); + var read = new ServerSourceRequestMessage(); + read.read(new ByteBufferInputStream(input)); + assertEquals("session", read.sessionId()); + assertEquals("request", read.requestId()); + assertEquals(27, read.source()); + assertFalse(input.hasRemaining()); + assertThrows(IllegalArgumentException.class, () -> new ServerSourceRequestMessage("", "request", 0)); + assertThrows(IllegalArgumentException.class, () -> new ServerSourceRequestMessage("session", "", 0)); + assertThrows(IllegalArgumentException.class, () -> new ServerSourceRequestMessage("session", "request", 4096)); + } + + @Test + void sourceChunksCannotMixRequestsOrSources() { + var first = ServerManifestMessage.split("session", "first", 1, new byte[ServerManifestMessage.CHUNK_BYTES + 1]); + var second = ServerManifestMessage.split("session", "second", 1, new byte[ServerManifestMessage.CHUNK_BYTES + 1]); + var assembler = new ServerManifestMessage.Assembler(); + assembler.accept(first.getFirst()); + assertThrows(IllegalArgumentException.class, () -> assembler.accept(second.getLast())); + var other = ServerManifestMessage.split("session", "first", 2, new byte[ServerManifestMessage.CHUNK_BYTES + 1]); + assembler.accept(first.getFirst()); + assertThrows(IllegalArgumentException.class, () -> assembler.accept(other.getLast())); + } + @Test void disconnectAndNewSessionDiscardPartialTransfer() { var assembler = new ServerManifestMessage.Assembler(); From be418f38fa4c4f086c47ba7da4b90385e5d2f751 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:33:18 +0200 Subject: [PATCH 2/2] Reuse prepared server sources and correct declaration fingerprints --- docs/USAGE.md | 2 +- .../evaluation/ClassDeclarations.java | 14 +++++- .../evaluation/ServerManifestTest.java | 30 +++++++++++++ .../client/companion/CompanionAppClient.java | 4 +- .../server/script/ServerScriptService.java | 21 ++++++--- .../script/ServerScriptManifestTest.java | 45 +++++++++++++++++++ .../protocol/scnet/RunScriptMessage.java | 9 ---- 7 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 mod/src/test/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptManifestTest.java diff --git a/docs/USAGE.md b/docs/USAGE.md index b925fe60..2dd987a7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -20,7 +20,7 @@ The client and server execution choices target their respective game contexts. I The server hashes its archives in the background once per server lifetime. Requested source details are calculated lazily and cached once for all players as compressed metadata. Each player connection has a fresh handshake identity. Minecraft relays the comparison messages and retains only the baseline for opening Companion later. Each Companion retains its own unsupported-class names, bound to the current server session and client inventory; temporary class fingerprints are discarded. There is no second index or automatic download of server class files. -Server compilation waits for the entire comparison to finish. Javac then checks that local set when reading a class, with no network requests or fingerprint calculation during compilation. Missing classes and changed declarations fail with the class name. Method implementations may differ, but fields, signatures, inheritance, access, generic metadata and compile-time constants must match. This is an exact class-level check, so even an unused declaration change can reject that class. Client compilation remains available while the server comparison is pending. +Server compilation waits for the entire comparison to finish. Javac then checks that local set when reading a class, with no network requests or fingerprint calculation during compilation. Missing classes and changed declarations fail with the class name. Method implementations may differ, but fields, signatures, inheritance, access, generic metadata and compile-time constants must match. This is an exact class-level check, so even an unused declaration change can reject that class. Comparison and compilation share one worker. During joining, reconnecting or index replacement, client compilations may wait behind comparison work. Compilation uses the local result once comparison finishes. Disconnecting invalidates pending server compilations, and the receiving server rejects bytecode carrying an old identity. Reopening Companion or replacing its client index repeats comparison against the retained baseline; delayed replies from older comparisons cannot complete the new one. The server keeps shared source details until that server runtime ends. diff --git a/evaluation/src/main/java/com/github/minecraft_ta/totaldebug/evaluation/ClassDeclarations.java b/evaluation/src/main/java/com/github/minecraft_ta/totaldebug/evaluation/ClassDeclarations.java index 219378e8..3843810f 100644 --- a/evaluation/src/main/java/com/github/minecraft_ta/totaldebug/evaluation/ClassDeclarations.java +++ b/evaluation/src/main/java/com/github/minecraft_ta/totaldebug/evaluation/ClassDeclarations.java @@ -1,7 +1,9 @@ package com.github.minecraft_ta.totaldebug.evaluation; import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; import java.io.IOException; import java.io.InputStream; @@ -18,7 +20,17 @@ private ClassDeclarations() {} public static String fingerprint(byte[] bytecode) { // A fresh constant pool omits constants used only by method bodies. var writer = new ClassWriter(0); - new ClassReader(bytecode).accept(writer, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + var reader = new ClassReader(bytecode); + reader.accept(new ClassVisitor(Opcodes.ASM9, writer) { + @Override + public void visitInnerClass(String name, String outerName, String innerName, int access) { + // Referencing another nested type in a method body also creates an InnerClasses entry. + // Keep this class's own nesting/access metadata and its declared member classes only. + if (reader.getClassName().equals(name) || reader.getClassName().equals(outerName)) { + super.visitInnerClass(name, outerName, innerName, access); + } + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); return HexFormat.of().formatHex(digest().digest(writer.toByteArray())); } diff --git a/evaluation/src/test/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifestTest.java b/evaluation/src/test/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifestTest.java index d967cc4f..cb232bd9 100644 --- a/evaluation/src/test/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifestTest.java +++ b/evaluation/src/test/java/com/github/minecraft_ta/totaldebug/evaluation/ServerManifestTest.java @@ -36,6 +36,36 @@ void bodiesMayDifferButConstantsSignaturesAndHierarchyMustMatch() throws Excepti } } + @Test + void bodyOnlyNestedTypeReferencesDoNotChangeDeclarations() throws Exception { + try (var compiler = new InMemoryJavaCompiler()) { + String source = "import java.util.AbstractMap; public class Api { public Object value() { BODY } }"; + String expected = ClassDeclarations.fingerprint(compiler.compile( + source.replace("BODY", "return null;"), "Api", "").get("Api")); + for (String body : List.of("return new AbstractMap.SimpleEntry<>(1, 2);", + "return \"time: \" + System.nanoTime();")) { + assertEquals(expected, ClassDeclarations.fingerprint(compiler.compile( + source.replace("BODY", body), "Api", "").get("Api")), body); + } + } + } + + @Test + void declaredNestedClassesAndTheirAccessRemainPartOfTheFingerprint() throws Exception { + try (var compiler = new InMemoryJavaCompiler()) { + String source = "public class Api { public static class Nested {} }"; + var original = compiler.compile(source, "Api", ""); + var changed = compiler.compile(source.replace("public static", "private static"), "Api", ""); + for (String name : List.of("Api", "Api$Nested")) { + assertNotEquals(ClassDeclarations.fingerprint(original.get(name)), + ClassDeclarations.fingerprint(changed.get(name)), name); + } + var removed = compiler.compile("public class Api {}", "Api", ""); + assertNotEquals(ClassDeclarations.fingerprint(original.get("Api")), + ClassDeclarations.fingerprint(removed.get("Api"))); + } + } + @Test void genericSignaturesAndInheritedConstantsAreDeclarations() throws Exception { try (var compiler = new InMemoryJavaCompiler()) { diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java index 160fc1b0..cc8c3cdf 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java @@ -176,7 +176,9 @@ public void setProgressListener(Consumer listener) { this.progressListener = Objects.requireNonNull(listener, "listener"); } - private Consumer serverSourceRequestHandler = message -> {}; + private Consumer serverSourceRequestHandler = message -> + enqueueServerManifest(new ServerManifestMessage(message.sessionId(), message.requestId(), message.source(), + "Server source request handler is not installed", 0, 0, new byte[0])); public void setServerSourceRequestHandler(Consumer handler) { this.serverSourceRequestHandler = Objects.requireNonNull(handler); diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptService.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptService.java index 5fff0545..d08af890 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptService.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptService.java @@ -11,6 +11,7 @@ import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionStatus; import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerSourceRequestMessage; +import com.github.minecraft_ta.totaldebug.runtime.PreparedRuntimeSources; import com.github.minecraft_ta.totaldebug.script.ScriptRunner; import com.github.minecraft_ta.totaldebug.storage.RuntimePhase; import com.github.minecraft_ta.totaldebug.tick.TickDomain; @@ -43,7 +44,14 @@ public final class ServerScriptService { thread.setDaemon(true); return thread; }, new ThreadPoolExecutor.AbortPolicy()); - private CompletableFuture manifest; + private CompletableFuture manifest; + + record Manifest(PreparedRuntimeSources sources, ServerManifest.Catalog catalog) { + byte[] details(int source) throws IOException { + return sources.withCurrentSources(() -> catalog.details(source)); + } + } + private final Map manifestSessions = new ConcurrentHashMap<>(); private record ManifestSession(ServerPlayer player, String id) {} @@ -65,13 +73,13 @@ public synchronized void sendManifest(ServerPlayer player) { this.manifest = CompletableFuture.supplyAsync(() -> { try (var phase = RuntimePhase.start("server.baseline")) { var sources = TotalDebug.get().runtimeSources(); - return sources.withCurrentSources(() -> new ServerManifest.Catalog(sources.paths())); + return sources.withCurrentSources(() -> new Manifest(sources, new ServerManifest.Catalog(sources.paths()))); } catch (IOException exception) { throw new CompletionException(exception); } }, this.manifestWorker); } - this.manifest.whenComplete((catalog, failure) -> server.execute(() -> { + this.manifest.whenComplete((manifest, failure) -> server.execute(() -> { if (this.manifestSessions.get(player.getUUID()) != session) return; if (failure != null) { this.manifestSessions.remove(player.getUUID(), session); @@ -80,7 +88,7 @@ public synchronized void sendManifest(ServerPlayer player) { "Unable to prepare server class manifest; see the server log"))); return; } - for (var message : ServerManifestMessage.split(session.id(), catalog.baseline())) { + for (var message : ServerManifestMessage.split(session.id(), manifest.catalog().baseline())) { player.connection.send(new ServerManifestPayload(message)); } })); @@ -91,11 +99,10 @@ public synchronized void requestSource(ServerPlayer player, ServerSourceRequestM if (session == null || session.player() != player || !session.id().equals(request.sessionId()) || this.manifest == null) return; MinecraftServer server = Objects.requireNonNull(player.getServer()); - this.manifest.thenApplyAsync(catalog -> { + this.manifest.thenApplyAsync(manifest -> { if (this.manifestSessions.get(player.getUUID()) != session) return null; try (var phase = RuntimePhase.start("server.source-details")) { - var sources = TotalDebug.get().runtimeSources(); - return sources.withCurrentSources(() -> catalog.details(request.source())); + return manifest.details(request.source()); } catch (IOException exception) { throw new CompletionException(exception); } }, this.manifestWorker).whenComplete((bytes, failure) -> server.execute(() -> { if (this.manifestSessions.get(player.getUUID()) != session) return; diff --git a/mod/src/test/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptManifestTest.java b/mod/src/test/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptManifestTest.java new file mode 100644 index 00000000..2d6b47ea --- /dev/null +++ b/mod/src/test/java/com/github/minecraft_ta/totaldebug/server/script/ServerScriptManifestTest.java @@ -0,0 +1,45 @@ +package com.github.minecraft_ta.totaldebug.server.script; + +import com.github.minecraft_ta.totaldebug.evaluation.InMemoryJavaCompiler; +import com.github.minecraft_ta.totaldebug.evaluation.ServerManifest; +import com.github.minecraft_ta.totaldebug.runtime.RuntimeSourceInventory; +import com.github.minecraft_ta.totaldebug.runtime.RuntimeSourceMaterializer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ServerScriptManifestTest { + @TempDir Path directory; + + @Test + void detailsReusePreparedSourcesAndCachedBytesButStillCheckRuntimeIdentity() throws Exception { + Path input = directory.resolve("input.zip"); + Path cache = directory.resolve("cache/sources"); + ServerScriptService.Manifest manifest; + try (var compiler = new InMemoryJavaCompiler(); + var archive = FileSystems.newFileSystem(input, Map.of("create", "true"))) { + Files.write(archive.getPath("/Api.class"), compiler.compile("public class Api {}", "Api", "").get("Api")); + var sources = RuntimeSourceMaterializer.prepare( + List.of(new RuntimeSourceInventory.Source(archive.getPath("/"), "fixture")), cache); + manifest = sources.withCurrentSources(() -> + new ServerScriptService.Manifest(sources, new ServerManifest.Catalog(sources.paths()))); + } + // Repreparing would now fail: the original filesystem is closed and its archive is gone. + Files.delete(input); + byte[] details = manifest.details(0); + assertTrue(ServerManifest.decodeDetails(details).containsKey("Api")); + Files.delete(manifest.sources().paths().getFirst()); + assertSame(details, manifest.details(0), "A later player reuses the encoded response without reading classes"); + Files.writeString(cache.resolve("manifest.json"), "{\"id\":\"replaced\"}"); + var failure = assertThrows(IOException.class, () -> manifest.details(0)); + assertTrue(failure.getMessage().contains("Runtime cache has changed")); + } +} diff --git a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/RunScriptMessage.java b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/RunScriptMessage.java index 1f244924..b8d26fc5 100644 --- a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/RunScriptMessage.java +++ b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/RunScriptMessage.java @@ -1,6 +1,5 @@ package com.github.minecraft_ta.totaldebug.protocol.scnet; -import com.github.minecraft_ta.totaldebug.protocol.execution.ScriptExecutionEnvironment; import com.github.minecraft_ta.totaldebug.protocol.execution.ScriptBytecode; import com.github.minecraft_ta.totaldebug.protocol.message.RunScriptPayload; import com.github.tth05.scnet.message.AbstractMessage; @@ -13,14 +12,6 @@ public final class RunScriptMessage extends AbstractMessage { public RunScriptMessage() { } - public RunScriptMessage(int scriptId, ScriptBytecode bytecode, String inventoryId, boolean serverSide, ScriptExecutionEnvironment executionEnvironment) { - this(scriptId, bytecode, inventoryId, serverSide, executionEnvironment.name()); - } - - public RunScriptMessage(int scriptId, ScriptBytecode bytecode, String inventoryId, boolean serverSide, String executionEnvironment) { - this(scriptId, bytecode, inventoryId, serverSide, executionEnvironment, ""); - } - public RunScriptMessage(int scriptId, ScriptBytecode bytecode, String inventoryId, boolean serverSide, String executionEnvironment, String serverSessionId) { this.payload = new RunScriptPayload(scriptId, bytecode, inventoryId, serverSide, executionEnvironment, serverSessionId); }