diff --git a/companion/MCP.md b/companion/MCP.md index 66395449..c9481052 100644 --- a/companion/MCP.md +++ b/companion/MCP.md @@ -18,6 +18,8 @@ The active endpoint is written to `/run/companion/mcp-endpoi Companion removes the descriptor when it closes. A Minecraft disconnect does not stop MCP. +Project switching also preserves this endpoint and initialized MCP clients. Outstanding code jobs keep their original context and are marked disconnected after cancellation is requested. Project-bound requests reject stale results, and code/debugger mutations are admitted against the project generation in which the request started. Project list/open tools are not yet exposed through MCP. + ## Response policy Each tool returns only the values needed to use that tool. Runtime paths, hashes, profile metadata, timestamps, and artifact locations do not appear in normal status or job responses. diff --git a/companion/build.gradle b/companion/build.gradle index ee6f7f6f..9ba4952f 100644 --- a/companion/build.gradle +++ b/companion/build.gradle @@ -105,6 +105,7 @@ tasks.named('assemble') { tasks.named('test') { useJUnitPlatform() + systemProperty 'totaldebug.testClasspath', sourceSets.test.runtimeClasspath.asPath } // Brings the UI up without a Minecraft session so themes and icons can be eyeballed. 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 9fddc8ad..1f15dacf 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 @@ -39,6 +39,7 @@ import com.github.minecraft_ta.totalDebugCompanion.search.reference.ReferenceSearchService; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.session.ProjectRegistry; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionSession; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionTimeouts; import com.github.minecraft_ta.totalDebugCompanion.resource.FileTypeResolver; @@ -102,6 +103,11 @@ public final class CompanionApp { private static CompanionMcpServer mcpServer; private static volatile DebuggerSessionController debuggerController; private static volatile boolean uiStarted; + private static ProjectRegistry projects; + private static volatile boolean switchingProjects; + private static volatile long projectGeneration; + private static final java.util.concurrent.ExecutorService projectWorker = java.util.concurrent.Executors.newSingleThreadExecutor( + runnable -> Thread.ofPlatform().daemon().name("companion-projects").unstarted(runnable)); private record PendingNavigation(NavigationTarget target, NavigationService.Activation activation) { } @@ -185,7 +191,7 @@ static int run(String[] args, Map environment, CompanionTimeouts ); restoreProfile(); - session = new CompanionSession(token, CompanionApp::activateSessionProfile, new CompanionSession.Listener() { + session = new CompanionSession(token, CompanionApp::attachSelectedProfile, new CompanionSession.Listener() { @Override public void connecting() { updateGameStatus(new ServiceStatus( @@ -235,6 +241,12 @@ public void debugTarget(DebugTargetMessage message) { } }); SERVER = session.server(); + session.setProjectSelectionHandler(hello -> { + try { openProject(CompanionProfile.fromHello(hello)).join(); } + catch (java.util.concurrent.CompletionException failure) { + throw new IOException(failure.getCause().getMessage(), failure.getCause()); + } + }); startUi(); updateGameStatus(new ServiceStatus( ServiceStatus.State.INACTIVE, @@ -256,6 +268,7 @@ public void debugTarget(DebugTargetMessage message) { } finally { startup.close(); try (var shutdown = RuntimePhase.start("companion.shutdown")) { + projectWorker.close(); if (runtimeIndexService != null) { runtimeIndexService.close(); } @@ -299,7 +312,7 @@ public void debugTarget(DebugTargetMessage message) { } } - private static synchronized void activateSessionProfile( + private static synchronized void attachSelectedProfile( com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage hello ) throws IOException { CompanionProfile requested; @@ -308,10 +321,13 @@ private static synchronized void activateSessionProfile( } catch (IllegalArgumentException exception) { throw new IOException("Invalid Minecraft profile", exception); } - activateProfile(requested, true); + if (switchingProjects || !requested.equals(profile)) { + throw new IOException("Select this project explicitly before connecting"); + } } private static void handleDebugTarget(DebugTargetMessage message) { + if (switchingProjects) return; if (message.targetKind() != DebugTargetMessage.LOCAL_JVM) { throw new IllegalArgumentException("Unknown debug target kind: " + message.targetKind()); } @@ -323,14 +339,86 @@ private static void handleDebugTarget(DebugTargetMessage message) { } private static void restoreProfile() throws IOException { - Path profileFile = launchConfiguration.profileFile(); - if (!Files.isRegularFile(profileFile)) { + projects = ProjectRegistry.open(launchConfiguration.paths()); + CompanionProfile selected = projects.selected(); + if (selected != null) { + try { activateProfile(selected); } + catch (IOException failure) { System.err.println("Unable to reopen selected project: " + failure.getMessage()); } + } + } + + public static List projects() { + return projects == null ? List.of() : projects.projects(); + } + + public static CompanionProfile currentProject() { return profile; } + + public static boolean isSwitchingProjects() { return switchingProjects; } + + public static long projectGeneration() { return projectGeneration; } + + /** Admit mutations/queue submissions atomically with starting a switch; never wait here. */ + public static synchronized T inProject(long generation, java.util.function.Supplier action) { + if (switchingProjects || generation != projectGeneration) throw new IllegalStateException("Project changed during the request"); + return action.get(); + } + + /** Application API; selection controls and MCP project tools are added separately. */ + public static CompletableFuture openProject(CompanionProfile requested) { + Objects.requireNonNull(requested); + return CompletableFuture.runAsync(() -> { + try { switchProject(requested); } + catch (IOException failure) { throw new java.util.concurrent.CompletionException(failure); } + }, projectWorker); + } + + private static void switchProject(CompanionProfile requested) throws IOException { + validateProfile(requested); + if (requested.equals(profile)) { + projects.select(requested); return; } - activateProfile(CompanionProfile.read(profileFile), false); + // Validate state before closing the current project; malformed destination state must not displace it. + try (var checked = InstanceState.open(new InstancePaths(requested.dataDirectory()))) { } + synchronized (CompanionApp.class) { + switchingProjects = true; + } + try { + if (uiStarted) { + boolean[] canSwitch = {false}; + SwingUtilities.invokeAndWait(() -> canSwitch[0] = MainWindow.INSTANCE.prepareProjectSwitch()); + if (!canSwitch[0]) throw new IOException("Project switch cancelled because an editor could not be saved"); + } + synchronized (CompanionApp.class) { projectGeneration++; } + if (mcpServer != null) mcpServer.prepareProjectSwitch(); + scriptCompiler.runtimeDisconnected(); + if (session != null) session.disconnect(); + getDebuggerController().clearTarget().join(); + getDebuggerController().replaceBreakpointDefinitions(List.of()).join(); + com.github.minecraft_ta.totalDebugCompanion.jdt.diagnostics.ASTCache.clear(); + synchronized (CompanionApp.class) { + pendingNavigations.clear(); + if (runtimeIndexService != null) runtimeIndexService.clear(); + activateProfile(requested); + } + updateGameStatus(new ServiceStatus(ServiceStatus.State.INACTIVE, "Offline", "Selected project is not connected to Minecraft.")); + try { + projects.select(requested); + } catch (IOException failure) { + throw new IOException("Project opened, but its selection could not be saved: " + failure.getMessage(), failure); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("Project switch interrupted", failure); + } catch (InvocationTargetException failure) { + throw new IOException("Unable to close project editors", failure.getCause()); + } finally { + switchingProjects = false; + if (uiStarted) SwingUtilities.invokeLater(() -> MainWindow.INSTANCE.setEnabled(true)); + } } - private static void activateProfile(CompanionProfile requested, boolean persist) throws IOException { + private static void activateProfile(CompanionProfile requested) throws IOException { validateProfile(requested); CompanionProfile current = profile; boolean profileChanged = !requested.equals(current); @@ -357,9 +445,6 @@ private static void activateProfile(CompanionProfile requested, boolean persist) } profile = requested; setupDataDirectories(); - if (persist) { - requested.writeAtomically(launchConfiguration.profileFile()); - } if (uiStarted && profileChanged) { refreshUiProfile(); } @@ -369,13 +454,15 @@ private static void activateProfile(CompanionProfile requested, boolean persist) } private static void validateProfile(CompanionProfile requested) throws IOException { - Files.createDirectories(requested.dataDirectory()); if (!Files.isDirectory(requested.workspaceDirectory())) { throw new IOException("Minecraft workspace not found"); } + Files.createDirectories(requested.dataDirectory()); + setupDataDirectories(requested.dataDirectory(), true); } private static synchronized void handleRuntimeInventory(RuntimeInventoryMessage message) { + if (switchingProjects) return; CompanionProfile current = profile; if (current == null || runtimeIndexService == null) { return; @@ -454,7 +541,7 @@ private static synchronized void installRuntimeSnapshot(RuntimeIndexService.Read static void configureWithoutSession(CompanionProfile developmentProfile) { debuggerController = createDebuggerController(); try { - activateProfile(Objects.requireNonNull(developmentProfile, "developmentProfile"), false); + activateProfile(Objects.requireNonNull(developmentProfile, "developmentProfile")); } catch (IOException exception) { throw new IllegalStateException("Unable to configure the UI profile", exception); } @@ -725,7 +812,7 @@ public static void exit() { } public static boolean isConnected() { - return session != null && session.isConnected(); + return !switchingProjects && session != null && session.isConnected(); } public static boolean hasProfile() { @@ -737,11 +824,13 @@ public static String getActiveRuntimeSignature() { } public static boolean send(AbstractMessage message) { + if (switchingProjects && !(message instanceof StopScriptMessage)) return false; CompanionSession current = session; return current != null && current.send(message); } public static CompletableFuture compileJava(String source, String entryClass) { + if (switchingProjects) return CompletableFuture.failedFuture(new IllegalStateException("Project is switching")); return scriptCompiler.compile(source, entryClass); } @@ -753,7 +842,7 @@ public static boolean isCurrentRuntimeInventory(String inventoryId) { public static boolean runScript(int id, String source, boolean serverSide, ScriptExecutionEnvironment environment, Consumer failureHandler) { CompanionSession current = session; - if (current == null || !current.isConnected()) return false; + if (switchingProjects || current == null || !current.isConnected()) return false; scriptCompiler.submit(id, source, serverSide, environment, failureHandler); return true; } @@ -770,8 +859,10 @@ public static void openClass(String binaryName, int targetType, String targetIde } private static void openOrQueue(NavigationTarget target, NavigationService.Activation activation) { + if (switchingProjects) return; if (decompilationService == null) { synchronized (CompanionApp.class) { + if (switchingProjects) return; if (decompilationService == null) { pendingNavigations.add(new PendingNavigation(target, activation)); return; @@ -926,6 +1017,7 @@ private static List restoreBreak } private static void persistBreakpoints(DebuggerSessionController controller) { + if (switchingProjects) return; String runtimeSignature = activeRuntimeSignature; if (runtimeSignature == null || runtimeSignature.isBlank()) { return; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerSessionController.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerSessionController.java index 6bb53379..e7b39866 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerSessionController.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/debugger/DebuggerSessionController.java @@ -485,9 +485,9 @@ public void acceptTarget(DebugTargetDescriptor replacement) { }); } - public void clearTarget() { + public CompletableFuture clearTarget() { this.queue.invalidateAdvisoryWork(); - submit(() -> { + return submitFuture(() -> { DebugTargetDescriptor previous = this.target; this.target = null; if (this.engine != null) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/diagnostics/ASTCache.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/diagnostics/ASTCache.java index a8489180..41055560 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/diagnostics/ASTCache.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/diagnostics/ASTCache.java @@ -17,26 +17,29 @@ public class ASTCache { private static final Map>> LISTENERS = new ConcurrentHashMap<>(); - public static void update(String key, String className, String contents) { - update(key, className, contents, JavaEditorSource.identity(contents)); + public static CompletableFuture update(String key, String className, String contents) { + return update(key, className, contents, JavaEditorSource.identity(contents)); } - public static void update(String key, String className, String editorContents, JavaEditorSource source) { - int version = 0; + public static CompletableFuture update(String key, String className, String editorContents, JavaEditorSource source) { + Entry selected; + int version; synchronized (CACHE) { - var existing = CACHE.get(key); - if (existing != null) - version = ++existing.version; + selected = CACHE.computeIfAbsent(key, ignored -> new Entry()); + version = ++selected.version; } int finalVersion = version; - CompletableFuture.runAsync(() -> { + return CompletableFuture.runAsync(() -> { + synchronized (CACHE) { + if (CACHE.get(key) != selected || selected.version != finalVersion) return; + } var ast = rawParse(className, source.text()); - + List> listeners; synchronized (CACHE) { - var entry = CACHE.computeIfAbsent(key, (k) -> new Entry()); + var entry = CACHE.get(key); //There's already something newer available - if (entry.version > finalVersion) + if (entry != selected || entry.version != finalVersion) return; entry.version = finalVersion; @@ -44,8 +47,12 @@ public static void update(String key, String className, String editorContents, J entry.contents = editorContents; entry.sourceMap = source.sourceMap(); entry.privilegedAccess = source.privilegedAccess(); + listeners = List.copyOf(LISTENERS.getOrDefault(key, new CopyOnWriteArrayList<>())); + } + for (var listener : listeners) { + synchronized (CACHE) { if (CACHE.get(key) != selected) return; } + listener.accept(ast, finalVersion); } - notifyListeners(key, ast, finalVersion); }); } @@ -67,7 +74,7 @@ public static Runnable addChangeListener(String key, BiConsumer { listeners.remove(listener); @@ -84,6 +91,13 @@ public static void removeFromCache(String key) { LISTENERS.remove(key); } + public static void clear() { + synchronized (CACHE) { + CACHE.clear(); + LISTENERS.clear(); + } + } + public static CompilationUnit getFromCache(String key) { synchronized (CACHE) { var entry = CACHE.get(key); @@ -97,7 +111,7 @@ public static CompilationUnit getFromCache(String key) { public static Snapshot getSnapshot(String key) { synchronized (CACHE) { Entry entry = CACHE.get(key); - return entry == null ? null : new Snapshot(entry.unit, entry.contents, entry.sourceMap); + return entry == null || entry.unit == null ? null : new Snapshot(entry.unit, entry.contents, entry.sourceMap); } } @@ -132,16 +146,6 @@ public static boolean allowsPrivilegedAccess(String key) { } } - private static void notifyListeners(String key, CompilationUnit ast, int version) { - var listeners = LISTENERS.get(key); - if (listeners == null) { - return; - } - for (var listener : listeners) { - listener.accept(ast, version); - } - } - public static class Entry { public int version; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CodeModeJobService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CodeModeJobService.java index 14ecc846..6d0102bb 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CodeModeJobService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CodeModeJobService.java @@ -313,16 +313,18 @@ private Map currentRuntimeContext() { } public void runtimeDisconnected() { - if (this.statusExecutor != null && !this.closed) { - try { - this.statusExecutor.execute(this::markRuntimeDisconnected); - return; - } catch (RejectedExecutionException ignored) { - } - } markRuntimeDisconnected(); } + public void prepareProjectSwitch() { + var scriptIds = List.copyOf(this.jobsByScriptId.keySet()); + markRuntimeDisconnected(); + for (int scriptId : scriptIds) { + try { this.transport.cancel(scriptId); } + catch (RuntimeException ignored) { /* Disconnected jobs already report that target code may still be running. */ } + } + } + private void markRuntimeDisconnected() { Instant now = this.clock.instant(); for (Job job : new ArrayList<>(this.jobs.values())) { @@ -577,7 +579,7 @@ private synchronized boolean disconnect(Instant now) { null, false, null, - "Minecraft disconnected before the job completed", + "Minecraft disconnected before the job completed; target execution may still be running", now ); return true; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServer.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServer.java index 3e1e38c8..dbfbaad5 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServer.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServer.java @@ -147,13 +147,18 @@ public void runtimeDisconnected() { this.jobs.runtimeDisconnected(); } + public void prepareProjectSwitch() { this.jobs.prepareProjectSwitch(); } + private McpSchema.CallToolResult callTool(McpSchema.CallToolRequest request) { try { CompanionMcpToolCatalog.validateRequest(request); + long project = CompanionApp.projectGeneration(); + boolean projectBound = !List.of("status", "job_wait", "job_cancel", "job_source").contains(request.name()); + if (projectBound && CompanionApp.isSwitchingProjects()) throw new IllegalStateException("Project is switching"); Map result = switch (request.name()) { case "status" -> status(); - case "client_code_execute" -> execute(request.arguments(), CodeModeJobService.ExecutionSide.CLIENT); - case "server_code_execute" -> execute(request.arguments(), CodeModeJobService.ExecutionSide.SERVER); + case "client_code_execute" -> execute(request.arguments(), CodeModeJobService.ExecutionSide.CLIENT, project); + case "server_code_execute" -> execute(request.arguments(), CodeModeJobService.ExecutionSide.SERVER, project); case "job_wait" -> this.jobs.waitFor( requiredString(request.arguments(), "job_id"), optionalInteger(request.arguments(), "wait_ms", 30_000) @@ -181,9 +186,11 @@ private McpSchema.CallToolResult callTool(McpSchema.CallToolRequest request) { case "debugger_status", "debugger_wait", "debugger_control", "debugger_threads", "debugger_breakpoints", "debugger_breakpoint_set", "debugger_breakpoint_remove", "debugger_frames", "debugger_variables", "debugger_evaluate", "debugger_evaluation_wait", "debugger_evaluation_cancel" -> - this.debugger.call(request.name(), request.arguments()); + this.debugger.call(request.name(), request.arguments(), project); default -> throw new IllegalArgumentException("Unknown MCP tool: " + request.name()); }; + if (projectBound && (project != CompanionApp.projectGeneration() || CompanionApp.isSwitchingProjects())) + throw new IllegalStateException("Project changed during the request"); return CompanionMcpToolCatalog.result(result, false); } catch (RuntimeException | IOException exception) { return CompanionMcpToolCatalog.result( @@ -203,7 +210,7 @@ private Map status() { private Map execute( Map arguments, - CodeModeJobService.ExecutionSide side + CodeModeJobService.ExecutionSide side, long project ) { String code = requiredString(arguments, "code"); List imports = optionalStringList(arguments, "imports"); @@ -211,7 +218,7 @@ private Map execute( CodeModeJobService.ExecutionEnvironment.class, Objects.requireNonNullElse(optionalString(arguments, "environment"), "thread") ); - CodeModeJobService.JobSnapshot submitted = this.jobs.submit(code, imports, side, environment); + CodeModeJobService.JobSnapshot submitted = CompanionApp.inProject(project, () -> this.jobs.submit(code, imports, side, environment)); return this.jobs.waitFor( submitted.jobId(), optionalInteger(arguments, "wait_ms", 10_000) diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpService.java index 51729285..f367a5a0 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpService.java @@ -1,5 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.mcp; +import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; + import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerBreakpointResolver; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerSessionController; @@ -25,7 +27,7 @@ final class DebuggerMcpService { this.sources = Objects.requireNonNull(sources, "sources"); } - Map call(String tool, Map args) throws IOException { + Map call(String tool, Map args, long project) throws IOException { DebuggerSessionController session = this.controller.get(); try { return switch (tool) { @@ -34,23 +36,23 @@ Map call(String tool, Map args) throws IOExcepti session.waitForChange(((Number) args.get("after_revision")).longValue(), integer(args, "wait_ms", 30_000)); yield sessionSnapshot(session); } - case "debugger_control" -> control(session, args); + case "debugger_control" -> control(session, args, project); case "debugger_threads" -> Map.of("threads", await(session.threads()).stream() .map(thread -> Map.of("id", thread.id(), "name", thread.name())).toList()); case "debugger_breakpoints" -> Map.of( "muted", session.breakpointsMuted(), "breakpoints", session.breakpointEntries().stream().map(DebuggerMcpService::breakpoint).toList()); - case "debugger_breakpoint_set" -> setBreakpoint(session, args); - case "debugger_breakpoint_remove" -> removeBreakpoint(session, args); + case "debugger_breakpoint_set" -> setBreakpoint(session, args, project); + case "debugger_breakpoint_remove" -> removeBreakpoint(session, args, project); case "debugger_frames" -> Map.of("frames", await(session.frames(text(args, "pause_id"))) .stream().map(DebuggerMcpService::frame).toList()); case "debugger_variables" -> variables(session, args); - case "debugger_evaluate" -> operation(session, await(session.startEvaluation(text(args, "pause_id"), - integer(args, "frame_id", 0), text(args, "source"))), integer(args, "wait_ms", 1000)); + case "debugger_evaluate" -> operation(session, await(CompanionApp.inProject(project, () -> session.startEvaluation(text(args, "pause_id"), + integer(args, "frame_id", 0), text(args, "source")))), integer(args, "wait_ms", 1000)); case "debugger_evaluation_wait" -> operation(session, await(session.evaluationOperation(text(args, "operation_id"))), integer(args, "wait_ms", 1000)); case "debugger_evaluation_cancel" -> { - var requested = await(session.cancelEvaluation(text(args, "operation_id"))); + var requested = await(CompanionApp.inProject(project, () -> session.cancelEvaluation(text(args, "operation_id")))); yield operation(session, requested, 0); } default -> throw new IllegalArgumentException("Unknown debugger tool: " + tool); @@ -114,23 +116,23 @@ private static Map operation(DebuggerSessionController session, return result; } - private Map control(DebuggerSessionController session, Map args) + private Map control(DebuggerSessionController session, Map args, long project) throws InterruptedException { String action = text(args, "action"); - CompletableFuture operation = switch (action) { + CompletableFuture operation = CompanionApp.inProject(project, () -> switch (action) { case "attach" -> session.attach(); case "detach" -> session.detach(); case "pause" -> session.pause(((Number) args.get("thread_id")).longValue()); case "continue", "step_over", "step_into", "step_out" -> session.controlPaused(text(args, "pause_id"), action); default -> throw new IllegalArgumentException("Unknown debugger action: " + action); - }; + }); await(operation); int defaultWait = action.equals("pause") || action.startsWith("step_") ? 10_000 : 0; return snapshot(session.waitUntilStopped(integer(args, "wait_ms", defaultWait))); } - private Map setBreakpoint(DebuggerSessionController session, Map args) + private Map setBreakpoint(DebuggerSessionController session, Map args, long project) throws IOException { String binaryName = text(args, "binary_name"); DebugEngine.Source source; @@ -142,6 +144,7 @@ private Map setBreakpoint(DebuggerSessionController session, Map throw new IOException("Unable to load debugger source for " + binaryName, exception); } if (source == null) throw new IllegalArgumentException("Class not found: " + binaryName); + CompanionApp.inProject(project, () -> null); int line = integer(args, "line", 0); DebugEngine.SourceBreakpoint request = DebuggerBreakpointResolver.resolve(source, line, (String) args.get("condition"), (String) args.get("hit_condition")) @@ -150,19 +153,20 @@ private Map setBreakpoint(DebuggerSessionController session, Map request = request.withAction(new DebugEngine.BreakpointAction((String) action.get("source"), (String) action.get("script"), "continue_on_success".equals(action.get("completion")))); } - await(session.putBreakpoint(source, request, (Boolean) args.getOrDefault("enabled", true))); + DebugEngine.SourceBreakpoint resolvedRequest = request; + await(CompanionApp.inProject(project, () -> session.putBreakpoint(source, resolvedRequest, (Boolean) args.getOrDefault("enabled", true)))); DebuggerSessionController.Breakpoint resolved = session.breakpoint(source.uri(), line); if (resolved == null) throw new IllegalStateException("Breakpoint was removed concurrently"); return Map.of("breakpoint", breakpoint(new DebuggerSessionController.BreakpointEntry( source.uri(), source.binaryName(), resolved))); } - private Map removeBreakpoint(DebuggerSessionController session, Map args) { + private Map removeBreakpoint(DebuggerSessionController session, Map args, long project) { String binaryName = text(args, "binary_name"); int line = integer(args, "line", 0); List matches = session.breakpointEntries().stream() .filter(entry -> entry.binaryName().equals(binaryName) && entry.breakpoint().line() == line).toList(); - for (var entry : matches) await(session.removeBreakpoint(entry.sourceUri(), line)); + for (var entry : matches) await(CompanionApp.inProject(project, () -> session.removeBreakpoint(entry.sourceUri(), line))); return Map.of("removed", !matches.isEmpty()); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationHistory.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationHistory.java index a2a60892..c7f99a41 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationHistory.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationHistory.java @@ -29,6 +29,11 @@ synchronized void recordNewNavigation(NavigationEntry origin) { this.forward.clear(); } + synchronized void clear() { + this.back.clear(); + this.forward.clear(); + } + synchronized NavigationEntry destination(Direction direction, String runtimeSignature) { Deque source = source(direction); while (!source.isEmpty() && !source.peekFirst().isValidForRuntime(runtimeSignature)) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationService.java index 778fdac5..1c0463cd 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationService.java @@ -74,10 +74,13 @@ public CompletableFuture navigate(NavigationTarget target) { public CompletableFuture navigate(NavigationTarget target, Activation activation) { Objects.requireNonNull(target, "target"); Objects.requireNonNull(activation, "activation"); + long generation = this.runtimeGeneration.get(); CompletableFuture navigation = captureCurrentEntry().thenCompose(origin -> - performNavigation(target, activation) + (generation == this.runtimeGeneration.get() ? performNavigation(target, activation) + : CompletableFuture.failedFuture(new java.util.concurrent.CancellationException("Project changed"))) .thenCompose(ignored -> captureDestination(target)) .thenAccept(destination -> { + if (generation != this.runtimeGeneration.get()) return; this.currentEntry = destination; this.history.recordNewNavigation(origin); refreshHistoryActions(); @@ -104,6 +107,14 @@ public void runtimeChanged() { refreshHistoryActions(); } + public void projectChanged() { + this.runtimeGeneration.incrementAndGet(); + this.traversingHistory.set(false); + this.currentEntry = null; + this.history.clear(); + refreshHistoryActions(); + } + public CompletableFuture goBack() { return traverseHistory(NavigationHistory.Direction.BACK); } @@ -174,6 +185,7 @@ private CompletableFuture performNavigation(NavigationTarget target, Activ } private CompletableFuture traverseHistory(NavigationHistory.Direction direction) { + long generation = this.runtimeGeneration.get(); if (!this.traversingHistory.compareAndSet(false, true)) { return CompletableFuture.completedFuture(null); } @@ -188,15 +200,18 @@ private CompletableFuture traverseHistory(NavigationHistory.Direction dire } CompletableFuture navigation = captureCurrentEntry().thenCompose(origin -> - performNavigation(destination.target(), Activation.ACTIVATE_WINDOW) + (generation == this.runtimeGeneration.get() ? performNavigation(destination.target(), Activation.ACTIVATE_WINDOW) + : CompletableFuture.failedFuture(new java.util.concurrent.CancellationException("Project changed"))) .thenCompose(ignored -> restoreSelectedEntry(destination)) .thenRun(() -> { + if (generation != this.runtimeGeneration.get()) return; this.currentEntry = destination; this.history.complete(direction, destination, origin); }) ); navigation.whenComplete((ignored, failure) -> { - if (failure != null) { + if (generation != this.runtimeGeneration.get()) return; + if (failure != null && !(unwrap(failure) instanceof java.util.concurrent.CancellationException)) { this.history.discard(direction, destination); } this.traversingHistory.set(false); @@ -315,8 +330,9 @@ private static boolean sameEditorDestination(NavigationTarget requested, Navigat } private void reportFailure(CompletableFuture navigation, NavigationTarget target) { + long generation = this.runtimeGeneration.get(); navigation.whenComplete((ignored, failure) -> { - if (failure != null) { + if (failure != null && generation == this.runtimeGeneration.get() && !CompanionApp.isSwitchingProjects()) { showFailure(target, unwrap(failure)); } }); @@ -349,7 +365,7 @@ private CompletableFuture openRuntimeSource( int offset = offsetResolver.applyAsInt(source); return onEdt(() -> { if (generation != this.runtimeGeneration.get() || service != CompanionApp.getDecompilationService()) { - return CompletableFuture.failedFuture(new IllegalStateException("Runtime changed during source navigation")); + return CompletableFuture.failedFuture(new java.util.concurrent.CancellationException("Runtime changed during source navigation")); } return this.tabs.focusOrCreateIfAbsent( CodeView.class, @@ -461,8 +477,13 @@ private CompletableFuture onEdt( Activation activation ) { var result = new CompletableFuture(); + long generation = this.runtimeGeneration.get(); SwingUtilities.invokeLater(() -> { try { + if (generation != this.runtimeGeneration.get() || CompanionApp.isSwitchingProjects()) { + result.completeExceptionally(new java.util.concurrent.CancellationException("Project changed")); + return; + } operation.get().whenComplete((ignored, failure) -> { if (failure != null) { result.completeExceptionally(failure); @@ -481,18 +502,23 @@ private CompletableFuture onEdt( } private void showFailure(NavigationTarget target, Throwable failure) { + if (failure instanceof java.util.concurrent.CancellationException) return; + long generation = this.runtimeGeneration.get(); failure.printStackTrace(System.err); String detail = failure.getMessage(); if (detail == null || detail.isBlank()) { detail = failure.getClass().getSimpleName(); } String message = "Unable to open " + label(target) + ": " + detail; - SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog( + SwingUtilities.invokeLater(() -> { + if (generation != this.runtimeGeneration.get() || CompanionApp.isSwitchingProjects()) return; + JOptionPane.showMessageDialog( this.window, message, "Navigation failed", JOptionPane.ERROR_MESSAGE - )); + ); + }); } private static String label(NavigationTarget target) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java index aa268e4a..67003d45 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java @@ -156,6 +156,14 @@ public void restore(Path dataDirectory) { } } + public void clear() { + synchronized (this.lifecycleLock) { + this.pending = null; + this.activeInventoryId = null; + this.activeDataDirectory = null; + } + } + public void accept(Path dataDirectory, String expectedInventoryId, Path inventoryFile) { synchronized (this.lifecycleLock) { ensureOpen(); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/search/insight/CodeInsightService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/search/insight/CodeInsightService.java index 088564b1..2f945cc4 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/search/insight/CodeInsightService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/search/insight/CodeInsightService.java @@ -137,18 +137,10 @@ private void run() { return; } T result = this.task.run(this.binding); - dispatch(() -> { - if (!this.cancelled.get()) { - this.listener.onCompleted(result); - } - }); + dispatch(() -> this.listener.onCompleted(result)); } catch (RuntimeException failure) { if (!this.cancelled.get() && !Thread.currentThread().isInterrupted()) { - dispatch(() -> { - if (!this.cancelled.get()) { - this.listener.onFailed(failure); - } - }); + dispatch(() -> this.listener.onFailed(failure)); } } finally { operations.remove(this); @@ -160,13 +152,12 @@ public void cancel() { this.cancelled.set(true); } - } - - private static void dispatch(Runnable callback) { - if (SwingUtilities.isEventDispatchThread()) { - callback.run(); - } else { - SwingUtilities.invokeLater(callback); + private void dispatch(Runnable callback) { + SwingUtilities.invokeLater(() -> { + if (!this.cancelled.get() && this.binding == CodeInsightService.this.binding && !executor.isShutdown()) { + callback.run(); + } + }); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionLaunchConfiguration.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionLaunchConfiguration.java index 1962536f..4649caf0 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionLaunchConfiguration.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionLaunchConfiguration.java @@ -28,5 +28,4 @@ public static CompanionLaunchConfiguration parse(String[] arguments, Map state = new AtomicReference<>(State.WAITING_FOR_HELLO); + private ProjectSelectionServer projectSelections; + private AttachmentHandler projectSelectionHandler; public CompanionSession(String expectedToken) { this(expectedToken, hello -> { }, new Listener() { }); @@ -83,10 +85,30 @@ public void bindAndPublish(CompanionLaunchConfiguration configuration) throws IO close(); throw new IOException("Companion transport did not bind to loopback: " + address); } - new CompanionSessionDescriptor(CompanionProtocol.VERSION, address.getPort(), ProcessHandle.current().pid()) + this.projectSelections = new ProjectSelectionServer(this.authenticator, + this.projectSelectionHandler == null ? this.attachmentHandler : this.projectSelectionHandler, this::isConnected); + new CompanionSessionDescriptor(CompanionProtocol.VERSION, address.getPort(), ProcessHandle.current().pid(), this.projectSelections.port()) .writeAtomically(configuration.descriptorFile()); } + public void setProjectSelectionHandler(AttachmentHandler handler) { + if (this.projectSelections != null) throw new IllegalStateException("Session is already published"); + this.projectSelectionHandler = Objects.requireNonNull(handler); + } + + public void disconnect() { + if (!this.server.isClientConnected()) return; + try { + this.server.closeClientAfterPendingWrites().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException failure) { + this.server.closeClient(); + } catch (InterruptedException failure) { + this.server.closeClient(); + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while disconnecting the previous game", failure); + } + } + static InetSocketAddress sessionAddress(int port) { return new InetSocketAddress(CompanionLaunchContract.IPV4_LOOPBACK_HOST, port); } @@ -203,6 +225,7 @@ private void rejectAndClose(String reason) { public void close() { State previous = this.state.getAndSet(State.CLOSED); this.server.close(); + if (this.projectSelections != null) this.projectSelections.close(); if (previous == State.AUTHENTICATED) { this.listener.disconnected(); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectRegistry.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectRegistry.java new file mode 100644 index 00000000..9126f41b --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectRegistry.java @@ -0,0 +1,90 @@ +package com.github.minecraft_ta.totalDebugCompanion.session; + +import com.github.minecraft_ta.totaldebug.storage.AppPaths; +import com.github.minecraft_ta.totaldebug.storage.JsonFiles; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Remembers existing instance directories; it never moves or deletes their contents. */ +public final class ProjectRegistry { + public record Project(String name, CompanionProfile profile) { + public Project { + if (Objects.requireNonNull(name).isBlank()) throw new IllegalArgumentException("Project name is blank"); + Objects.requireNonNull(profile); + } + } + + private final AppPaths paths; + private final Map projects = new LinkedHashMap<>(); + private String selected; + + private ProjectRegistry(AppPaths paths) { this.paths = paths; } + + public static ProjectRegistry open(AppPaths paths) throws IOException { + var registry = new ProjectRegistry(paths); + if (Files.isRegularFile(paths.projects())) { + try { + JsonObject json = JsonFiles.read(paths.projects()); + if (JsonFiles.integer(json, "format") != 1) throw new IllegalArgumentException("Unsupported project registry format"); + registry.selected = JsonFiles.string(json, "selected"); + for (var value : JsonFiles.array(json, "projects")) { + var entry = value.getAsJsonObject(); + var profile = CompanionProfile.fromJson(entry); + if (registry.projects.putIfAbsent(profile.id(), new Project(JsonFiles.string(entry, "name"), profile)) != null) + throw new IllegalArgumentException("Duplicate project id: " + profile.id()); + } + if (!registry.projects.containsKey(registry.selected)) throw new IllegalArgumentException("Selected project is missing"); + } catch (RuntimeException exception) { + throw new IOException("Invalid project registry", exception); + } + } else if (Files.isRegularFile(paths.profile())) { + // Preserve the previously selected instance when first using the registry. + registry.select(CompanionProfile.read(paths.profile())); + Files.delete(paths.profile()); + } + return registry; + } + + public synchronized List projects() { return List.copyOf(this.projects.values()); } + + public synchronized CompanionProfile selected() { + Project project = this.projects.get(this.selected); + return project == null ? null : project.profile(); + } + + public synchronized void select(CompanionProfile profile) throws IOException { + if (profile.equals(selected())) return; + var replacement = new LinkedHashMap<>(this.projects); + Project previous = replacement.get(profile.id()); + String name = previous == null ? defaultName(profile) : previous.name(); + replacement.put(profile.id(), new Project(name, profile)); + JsonObject json = new JsonObject(); + json.addProperty("format", 1); + json.addProperty("selected", profile.id()); + JsonArray entries = new JsonArray(); + for (Project project : replacement.values()) { + JsonObject entry = project.profile().toJson(); + entry.addProperty("name", project.name()); + entries.add(entry); + } + json.add("projects", entries); + JsonFiles.write(this.paths.projects(), json); + this.projects.clear(); + this.projects.putAll(replacement); + this.selected = profile.id(); + } + + private static String defaultName(CompanionProfile profile) { + var path = profile.workspaceDirectory(); + if (path.getFileName() != null && path.getFileName().toString().equals("minecraft") && path.getParent() != null) + path = path.getParent(); + return path.getFileName() == null ? path.toString() : path.getFileName().toString(); + } +} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectSelectionServer.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectSelectionServer.java new file mode 100644 index 00000000..932ef2d9 --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectSelectionServer.java @@ -0,0 +1,64 @@ +package com.github.minecraft_ta.totalDebugCompanion.session; + +import com.github.minecraft_ta.totaldebug.protocol.ProjectSelectionRequest; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** Independent of the occupied game socket and of the optional MCP host. */ +final class ProjectSelectionServer implements AutoCloseable { + private final HttpServer server; + private final ExecutorService worker = Executors.newSingleThreadExecutor( + runnable -> Thread.ofPlatform().daemon().name("companion-project-request").unstarted(runnable)); + + ProjectSelectionServer(SessionAuthenticator authenticator, CompanionSession.AttachmentHandler select, + java.util.function.BooleanSupplier connected) throws IOException { + this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 8); + this.server.setExecutor(this.worker); + this.server.createContext(ProjectSelectionRequest.PATH, exchange -> { + try (exchange) { + int status; + String detail; + if (!exchange.getRequestMethod().equals("POST") + || !exchange.getRequestURI().toString().equals(ProjectSelectionRequest.PATH) + || exchange.getRequestHeaders().containsKey("Origin") + || !"application/octet-stream".equals(exchange.getRequestHeaders().getFirst("Content-Type"))) { + status = 400; + detail = "Expected a local project selection request"; + } else { + try { + var hello = ProjectSelectionRequest.decode(exchange.getRequestBody().readNBytes(ProjectSelectionRequest.MAX_BYTES + 1)); + var authentication = authenticator.authenticate(hello); + if (!authentication.accepted()) { + status = 403; + detail = authentication.rejectionReason(); + } else { + select.attach(hello); + exchange.getResponseHeaders().set(ProjectSelectionRequest.CONNECTED_HEADER, Boolean.toString(connected.getAsBoolean())); + exchange.sendResponseHeaders(204, -1); + return; + } + } catch (IOException | RuntimeException failure) { + status = 409; + detail = failure.getMessage() == null ? "Unable to switch project" : failure.getMessage(); + } + } + byte[] bytes = detail.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + } + }); + this.server.start(); + } + + int port() { return this.server.getAddress().getPort(); } + + @Override public void close() { + this.server.stop(0); + this.worker.shutdownNow(); + } +} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/MainWindow.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/MainWindow.java index 43bb132c..3f9361b5 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/MainWindow.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/MainWindow.java @@ -370,6 +370,27 @@ public void refreshProfile() { refreshActions(); } + public boolean prepareProjectSwitch() { + if (!SwingUtilities.isEventDispatchThread()) throw new IllegalStateException("Project views must close on the EDT"); + if (!this.editorTabs.canCloseAll()) return false; + this.editorTabs.closeMatching(editor -> true); + if (this.editorTabs.getTabCount() != 0) return false; + for (Window window : getOwnedWindows()) window.dispose(); + if (this.debuggerWindow != null) this.debuggerWindow.dispose(); + if (this.breakpointsWindow != null) this.breakpointsWindow.dispose(); + if (this.evaluateExpressionWindow != null) this.evaluateExpressionWindow.dispose(); + if (this.searchEverywherePopup != null) this.searchEverywherePopup.dispose(); + if (this.snippetExecutions != null) this.snippetExecutions.close(); + this.debuggerWindow = null; + this.breakpointsWindow = null; + this.evaluateExpressionWindow = null; + this.searchEverywherePopup = null; + this.snippetExecutions = null; + this.navigationService.projectChanged(); + setEnabled(false); + return true; + } + public void refreshRuntimeSources() { if (SwingUtilities.isEventDispatchThread()) { this.fileTreeView.reloadProfile(); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java new file mode 100644 index 00000000..fdc426bd --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java @@ -0,0 +1,235 @@ +package com.github.minecraft_ta.totalDebugCompanion; + +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.session.ProjectRegistry; +import com.github.minecraft_ta.totaldebug.storage.AppPaths; +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.concurrent.TimeUnit; +import java.util.concurrent.CompletableFuture; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationService; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; + +class ProjectSwitchLifecycleTest { + @TempDir Path directory; + + @Test void switchesTheActualApplicationStateWithoutAWindowOrGame() throws Exception { + // Companion owns process-wide singletons. Exercise its real switch in a fresh JVM. + String classpath = System.getProperty("totaldebug.testClasspath", System.getProperty("java.class.path")); + Path log = directory.resolve("probe.log"); + Process process = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-Djava.awt.headless=false", "-cp", classpath, + getClass().getName(), directory.toString()).redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + assertTrue(process.waitFor(30, TimeUnit.SECONDS), () -> "Switch did not finish: " + read(log)); + assertEquals(0, process.exitValue(), () -> read(log)); + } finally { if (process.isAlive()) process.destroyForcibly(); } + } + + public static void main(String[] args) { + try { + Path root = Path.of(args[0]); + AppPaths paths = new AppPaths(root.resolve("app")); + var registry = ProjectRegistry.open(paths); + set("launchConfiguration", new CompanionLaunchConfiguration(paths.home())); + set("projects", registry); + var createDebugger = CompanionApp.class.getDeclaredMethod("createDebuggerController"); + createDebugger.setAccessible(true); + set("debuggerController", createDebugger.invoke(null)); + var session = new com.github.minecraft_ta.totalDebugCompanion.session.CompanionSession("test-token"); + session.bindAndPublish(new CompanionLaunchConfiguration(paths.home())); + set("session", session); + CompanionApp.SERVER = session.server(); + var jobs = new com.github.minecraft_ta.totalDebugCompanion.mcp.CodeModeJobService( + session.server(), CompanionApp::isConnected, Map::of); + var constructor = com.github.minecraft_ta.totalDebugCompanion.mcp.CompanionMcpServer.class.getDeclaredConstructor( + Path.class, com.github.minecraft_ta.totalDebugCompanion.mcp.CodeModeJobService.class, int.class); + constructor.setAccessible(true); + var mcp = (com.github.minecraft_ta.totalDebugCompanion.mcp.CompanionMcpServer) constructor.newInstance(paths.home(), jobs, 0); + mcp.start(); + set("mcpServer", mcp); + String endpoint = mcp.endpointUrl(); + var transport = io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport + .builder(endpoint.substring(0, endpoint.length() - 4)).endpoint("/mcp").build(); + var client = io.modelcontextprotocol.client.McpClient.sync(transport).build(); + client.initialize(); + var a = profile(root, "A"); + var b = profile(root, "B"); + Files.writeString(a.dataDirectory().resolve("scripts/shared.tdscript"), "A"); + Files.writeString(b.dataDirectory().resolve("scripts/shared.tdscript"), "B"); + CompanionApp.openProject(a).get(10, TimeUnit.SECONDS); + CompanionApp.instanceState().setDebuggerWatches(java.util.List.of("watch A")); + CompanionApp.openProject(b).get(10, TimeUnit.SECONDS); + assertEquals(b, CompanionApp.currentProject()); + assertTrue(CompanionApp.instanceState().debuggerWatches().isEmpty()); + CompanionApp.instanceState().setDebuggerWatches(java.util.List.of("watch B")); + CompanionApp.openProject(a).get(10, TimeUnit.SECONDS); + assertEquals(java.util.List.of("watch A"), CompanionApp.instanceState().debuggerWatches()); + assertEquals("A", Files.readString(CompanionApp.instancePaths().scripts().resolve("shared.tdscript"))); + assertEquals("B", Files.readString(b.dataDirectory().resolve("scripts/shared.tdscript"))); + var missing = new CompanionProfile("missing", root.resolve("absent/total-debug"), root.resolve("absent")); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> CompanionApp.openProject(missing).get(10, TimeUnit.SECONDS)); + assertEquals(a, CompanionApp.currentProject()); + assertFalse(Files.exists(missing.dataDirectory())); + Files.writeString(b.dataDirectory().resolve("state.json"), "invalid state"); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); + assertEquals(a, CompanionApp.currentProject()); + assertEquals(a, ProjectRegistry.open(paths).selected()); + Files.delete(b.dataDirectory().resolve("state.json")); + Files.delete(b.dataDirectory().resolve("scripts/shared.tdscript")); + Files.delete(b.dataDirectory().resolve("scripts")); + Files.writeString(b.dataDirectory().resolve("scripts"), "not a directory"); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); + assertEquals(a, CompanionApp.currentProject()); + assertEquals(a, ProjectRegistry.open(paths).selected()); + assertEquals(2, CompanionApp.projects().size()); + assertFalse(CompanionApp.isSwitchingProjects()); + assertEquals(endpoint, mcp.endpointUrl()); + var status = client.callTool(new io.modelcontextprotocol.spec.McpSchema.CallToolRequest("status", Map.of())); + assertFalse(Boolean.TRUE.equals(status.isError()), "MCP must stay initialized through switches"); + verifyEditorSwitch(a, b, paths); + client.close(); + mcp.close(); + session.close(); + System.exit(0); + } catch (Throwable failure) { + failure.printStackTrace(); + System.exit(1); + } + } + + private static CompanionProfile profile(Path root, String id) throws Exception { + Path game = Files.createDirectories(root.resolve(id)); + Path data = game.resolve("total-debug"); + Files.createDirectories(data.resolve("scripts")); + return new CompanionProfile(id, data, game); + } + + private static void verifyEditorSwitch(CompanionProfile a, CompanionProfile b, AppPaths paths) throws Exception { + Files.delete(b.dataDirectory().resolve("scripts")); + Files.createDirectory(b.dataDirectory().resolve("scripts")); + var allowed = new java.util.concurrent.atomic.AtomicBoolean(); + var disposed = new java.util.concurrent.atomic.AtomicBoolean(); + var panel = new javax.swing.JPanel(); + var editor = new com.github.minecraft_ta.totalDebugCompanion.model.IEditorPanel() { + public String getTitle() { return "Unsaved A"; } + public String getTooltip() { return "A"; } + public javax.swing.Icon getIcon() { return null; } + public java.awt.Component getComponent() { return panel; } + public boolean canClose() { + if (!allowed.get()) return false; + assertEquals(a, CompanionApp.currentProject(), "Save must run before selecting B"); + try { Files.writeString(a.dataDirectory().resolve("scripts/shared.tdscript"), "saved A"); } + catch (java.io.IOException failure) { return false; } + return true; + } + public void dispose() { disposed.set(true); } + }; + GlobalConfig.getInstance().loadFrom(((CompanionLaunchConfiguration) get("launchConfiguration")).appHome()); + CompanionApp.configureLookAndFeel(); + javax.swing.SwingUtilities.invokeAndWait(() -> + com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow.INSTANCE.getEditorTabs().openEditorTab(editor)); + set("uiStarted", true); + long generationBeforeVeto = CompanionApp.projectGeneration(); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); + assertEquals(a, CompanionApp.currentProject()); + assertEquals(generationBeforeVeto, CompanionApp.projectGeneration()); + assertEquals("still A", CompanionApp.inProject(generationBeforeVeto, () -> "still A")); + assertFalse(disposed.get()); + allowed.set(true); + byte[] savedRegistry = Files.readAllBytes(paths.projects()); + Files.delete(paths.projects()); + Files.createDirectory(paths.projects()); + Files.writeString(paths.projects().resolve("occupied"), "x"); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); + assertTrue(failure.getCause().getMessage().contains("Project opened, but its selection could not be saved")); + assertEquals(b, CompanionApp.currentProject()); + assertTrue(CompanionApp.instanceState().debuggerWatches().isEmpty()); + assertTrue(disposed.get()); + assertEquals("saved A", Files.readString(a.dataDirectory().resolve("scripts/shared.tdscript"))); + var window = com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow.INSTANCE; + var treeField = window.getClass().getDeclaredField("fileTreeView"); + treeField.setAccessible(true); + var treeView = (javax.swing.JScrollPane) treeField.get(window); + javax.swing.SwingUtilities.invokeAndWait(() -> { + var tree = (javax.swing.JTree) treeView.getViewport().getView(); + var root = (javax.swing.tree.DefaultMutableTreeNode) tree.getModel().getRoot(); + var scripts = (com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree.LazyTreeNode) root.getChildAt(0); + assertEquals(b.dataDirectory().resolve("scripts").toString(), scripts.getUserObject().getTooltip()); + assertEquals(0, window.getEditorTabs().getTabCount()); + }); + Files.delete(paths.projects().resolve("occupied")); + Files.delete(paths.projects()); + Files.write(paths.projects(), savedRegistry); + assertEquals(a, ProjectRegistry.open(paths).selected()); + CompanionApp.openProject(b).get(10, TimeUnit.SECONDS); + assertEquals(b, ProjectRegistry.open(paths).selected()); + verifyNavigationReset(window); + javax.swing.SwingUtilities.invokeAndWait(window::dispose); + } + + private static void verifyNavigationReset(com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow window) throws Exception { + var pending = new java.util.concurrent.atomic.AtomicReference>(); + var created = new CompletableFuture(); + javax.swing.SwingUtilities.invokeAndWait(() -> { + var tree = new com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.FileTreeView(ignored -> { }) { + @Override public CompletableFuture revealLocalDirectory(Path path) { + var delayed = pending.getAndSet(null); + return delayed == null ? CompletableFuture.completedFuture(true) : delayed; + } + }; + created.complete(new NavigationService(window, + new com.github.minecraft_ta.totalDebugCompanion.ui.components.global.EditorTabs(), tree)); + }); + var navigation = created.join(); + for (String directory : java.util.List.of("A/one", "A/two")) + navigation.navigate(new NavigationTarget.LocalDirectory(Path.of(directory))).get(3, TimeUnit.SECONDS); + var delayedA = new CompletableFuture(); + pending.set(delayedA); + var oldTraversal = navigation.goBack(); + javax.swing.SwingUtilities.invokeAndWait(navigation::projectChanged); + assertFalse(oldTraversal.isDone(), "Old lookup is still awaiting a callback"); + for (String directory : java.util.List.of("B/one", "B/two")) + navigation.navigate(new NavigationTarget.LocalDirectory(Path.of(directory))).get(3, TimeUnit.SECONDS); + javax.swing.SwingUtilities.invokeAndWait(() -> assertTrue(navigation.backAction().isEnabled())); + var delayedB = new CompletableFuture(); + pending.set(delayedB); + var newTraversal = navigation.goBack(); + javax.swing.SwingUtilities.invokeAndWait(() -> { }); + assertFalse(newTraversal.isDone()); + delayedA.complete(true); + oldTraversal.get(3, TimeUnit.SECONDS); + javax.swing.SwingUtilities.invokeAndWait(() -> assertTrue(navigation.goBack().isDone(), + "Completion from A must not admit another traversal while B is still navigating")); + delayedB.complete(true); + newTraversal.get(3, TimeUnit.SECONDS); + javax.swing.SwingUtilities.invokeAndWait(() -> assertTrue(navigation.forwardAction().isEnabled())); + } + + private static Object get(String name) throws Exception { + var field = CompanionApp.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(null); + } + + private static void set(String name, Object value) throws Exception { + var field = CompanionApp.class.getDeclaredField(name); + field.setAccessible(true); + field.set(null, value); + } + + private static String read(Path file) { + try { return Files.readString(file); } catch (Exception failure) { return failure.toString(); } + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/jdt/semanticHighlighting/SemanticHighlightingPublicationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/jdt/semanticHighlighting/SemanticHighlightingPublicationTest.java index 38dde98a..0fd9857c 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/jdt/semanticHighlighting/SemanticHighlightingPublicationTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/jdt/semanticHighlighting/SemanticHighlightingPublicationTest.java @@ -175,6 +175,35 @@ private static RSyntaxTextArea editor(String key, String source) { return area; } + @Test + void clearingAProjectPreventsOldCallbacksFromReachingAReusedKey() throws Exception { + String key = newKey(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ASTCache.addChangeListener(key, (unit, version) -> { + entered.countDown(); + awaitCallbackRelease(release); + }); + var old = ASTCache.update(key, "Target", SOURCE); + try { + await(entered, "Old parse did not enter its callback"); + ASTCache.clear(); + var received = new java.util.concurrent.CopyOnWriteArrayList(); + ASTCache.addChangeListener(key, (unit, version) -> received.add(unit)); + String next = "// next project\n" + SOURCE; + ASTCache.update(key, "Target", next).get(10, TimeUnit.SECONDS); + release.countDown(); + old.get(10, TimeUnit.SECONDS); + assertEquals(1, received.size()); + assertEquals(next, ASTCache.getContents(key)); + assertEquals(ASTCache.getFromCache(key), received.getFirst()); + } finally { + release.countDown(); + old.get(10, TimeUnit.SECONDS); + ASTCache.removeFromCache(key); + } + } + private static void parseAndAwaitDelivery(String key, String source) throws InterruptedException { CompilationUnit previous = ASTCache.getFromCache(key); CountDownLatch delivered = new CountDownLatch(1); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CodeModeJobServiceTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CodeModeJobServiceTest.java index 1b0274b8..611a1788 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CodeModeJobServiceTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CodeModeJobServiceTest.java @@ -224,6 +224,21 @@ void pendingCancellationKeepsWaitingForActualCompletion() { } } + @Test + void switchDisconnectsCompilingJobsBeforeSynchronousCancellationFailure() { + FakeTransport transport = new FakeTransport(); + try (CodeModeJobService service = service(transport, true)) { + var submitted = service.submit("return 42;", List.of(), CodeModeJobService.ExecutionSide.CLIENT, + CodeModeJobService.ExecutionEnvironment.THREAD); + transport.onCancel = scriptId -> service.acceptResult(scriptId, + new ExecutionResult(ExecutionStatus.COMPILATION_FAILED, ExecutionText.empty(), null, + ExecutionText.complete("Compilation cancelled"))); + service.prepareProjectSwitch(); + assertEquals(List.of(submitted.scriptId()), transport.cancelledScriptIds); + assertEquals(CodeModeJobService.JobState.DISCONNECTED, service.get(submitted.jobId()).orElseThrow().state()); + } + } + @Test void cancellationTransportFailureDoesNotLoseTheLiveJob() { FakeTransport transport = new FakeTransport(); @@ -392,6 +407,7 @@ private static final class FakeTransport implements CodeModeJobService.Transport private final List executions = new ArrayList<>(); private final List cancelledScriptIds = new ArrayList<>(); private RuntimeException cancelFailure; + private java.util.function.IntConsumer onCancel = ignored -> { }; @Override public void execute( @@ -407,6 +423,7 @@ public void execute( public void cancel(int scriptId) { if (this.cancelFailure != null) throw this.cancelFailure; this.cancelledScriptIds.add(scriptId); + this.onCancel.accept(scriptId); } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpProjectGuardTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpProjectGuardTest.java new file mode 100644 index 00000000..9ba0a651 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpProjectGuardTest.java @@ -0,0 +1,32 @@ +package com.github.minecraft_ta.totalDebugCompanion.mcp; + +import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; +import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; +import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerSessionController; +import org.junit.jupiter.api.Test; +import java.net.URI; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class DebuggerMcpProjectGuardTest { + @Test void sourceLoadingCannotCarryABreakpointIntoTheNextProject() throws Exception { + long original = CompanionApp.projectGeneration(); + var generation = CompanionApp.class.getDeclaredField("projectGeneration"); + generation.setAccessible(true); + var source = new DebugEngine.Source(URI.create("file:///old-project/Target.java"), "Target", "class Target {}"); + try (var controller = new DebuggerSessionController(name -> source)) { + var service = new DebuggerMcpService(() -> controller, name -> { + synchronized (CompanionApp.class) { generation.setLong(null, original + 1); } + return source; + }); + var failure = assertThrows(IllegalStateException.class, + () -> service.call("debugger_breakpoint_set", Map.of("binary_name", "Target", "line", 1), original)); + assertTrue(failure.getMessage().contains("Project changed")); + assertTrue(controller.breakpointEntries().isEmpty()); + assertThrows(IllegalStateException.class, + () -> service.call("debugger_control", Map.of("action", "attach"), original)); + } finally { + synchronized (CompanionApp.class) { generation.setLong(null, original); } + } + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/search/insight/CodeInsightServiceTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/search/insight/CodeInsightServiceTest.java index 9200ee00..791c4435 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/search/insight/CodeInsightServiceTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/search/insight/CodeInsightServiceTest.java @@ -15,9 +15,42 @@ import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class CodeInsightServiceTest { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void dropsCompletedQueriesStillQueuedOnTheEdtAfterRebindOrClose(boolean close) throws Exception { + var enteredEdt = new java.util.concurrent.CountDownLatch(1); + var releaseEdt = new java.util.concurrent.CountDownLatch(1); + try (ClassIndex index = ClassIndex.fromSources(List.of(IndexSource.classFile(0, classBytes(String.class)))); + CodeInsightService service = new CodeInsightService(() -> index, RuntimeSourceCatalog.empty())) { + javax.swing.SwingUtilities.invokeLater(() -> { + enteredEdt.countDown(); + try { releaseEdt.await(5, TimeUnit.SECONDS); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); } + }); + try { + assertTrue(enteredEdt.await(3, TimeUnit.SECONDS)); + var result = new CompletableFuture(); + service.locateClass("java.lang.String", listener(result)); + var executorField = CodeInsightService.class.getDeclaredField("executor"); + executorField.setAccessible(true); + var executor = (java.util.concurrent.ThreadPoolExecutor) executorField.get(service); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (executor.getCompletedTaskCount() == 0 && System.nanoTime() < deadline) Thread.sleep(1); + assertEquals(1, executor.getCompletedTaskCount(), "Query must finish before changing the binding"); + if (close) service.close(); + else service.rebind(() -> index, RuntimeSourceCatalog.empty()); + releaseEdt.countDown(); + javax.swing.SwingUtilities.invokeAndWait(() -> { }); + assertFalse(result.isDone(), "An old query must not reach its listener after rebinding or closing"); + } finally { releaseEdt.countDown(); } + } + } + @Test void locatesTheSingleSourceOwningAQualifiedClass() throws Exception { var module = new RuntimeInventory.RuntimeModule( diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProfileTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProfileTest.java index 9328bfd5..624e8070 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProfileTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProfileTest.java @@ -12,7 +12,7 @@ class CompanionProfileTest { Path temporaryDirectory; @Test - void persistsTheOfflineProfileSnapshot() throws Exception { + void readsThePreviouslyRememberedProfile() throws Exception { CompanionProfile profile = new CompanionProfile( "atm10", this.temporaryDirectory.resolve("data"), @@ -20,7 +20,7 @@ void persistsTheOfflineProfileSnapshot() throws Exception { ); Path profileFile = this.temporaryDirectory.resolve("profile.properties"); - profile.writeAtomically(profileFile); + com.github.minecraft_ta.totaldebug.storage.JsonFiles.write(profileFile, profile.toJson()); assertEquals(profile, CompanionProfile.read(profileFile)); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProjectAttachmentTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProjectAttachmentTest.java new file mode 100644 index 00000000..3925248e --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProjectAttachmentTest.java @@ -0,0 +1,91 @@ +package com.github.minecraft_ta.totalDebugCompanion.session; + +import com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol; +import com.github.minecraft_ta.totaldebug.protocol.ProjectSelectionRequest; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerHelloMessage; +import com.github.minecraft_ta.totaldebug.storage.CompanionSessionDescriptor; +import com.github.tth05.scnet.util.ByteBufferInputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.*; + +class CompanionProjectAttachmentTest { + @TempDir Path directory; + + @Test void explicitSwitchReplacesAnAttachedGameButAnOrdinaryHandshakeCannot() throws Exception { + var selected = new AtomicReference<>("a"); + try (var session = new CompanionSession("secret", hello -> { + if (!hello.profileId().equals(selected.get())) throw new IOException("Select the project first"); + }, new CompanionSession.Listener() {})) { + session.setProjectSelectionHandler(hello -> { + if (!hello.profileId().equals(selected.get())) { + session.disconnect(); + selected.set(hello.profileId()); + } + }); + var config = new CompanionLaunchConfiguration(directory); + session.bindAndPublish(config); + var descriptor = CompanionSessionDescriptor.read(config.descriptorFile(), com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol.VERSION); + try (var a = connect(descriptor.port())) { + assertTrue(handshake(a, "a")); + assertTrue(ProjectSelectionRequest.send(descriptor.projectPort(), hello("a"))); + assertFalse(ProjectSelectionRequest.send(descriptor.projectPort(), hello("b"))); + assertEquals(-1, a.getInputStream().read()); + assertEquals("b", selected.get()); + try (var rejected = connect(descriptor.port())) { + assertFalse(handshake(rejected, "a")); + assertEquals(-1, rejected.getInputStream().read()); + } + try (var b = connect(descriptor.port())) { + assertTrue(handshake(b, "b")); + assertTrue(session.isConnected()); + assertTrue(ProjectSelectionRequest.send(descriptor.projectPort(), hello("b"))); + // Do not read B's EOF before selecting it again: its local ready state may still be stale. + assertFalse(ProjectSelectionRequest.send(descriptor.projectPort(), hello("a"))); + assertFalse(ProjectSelectionRequest.send(descriptor.projectPort(), hello("b"))); + try (var reconnected = connect(descriptor.port())) { + assertTrue(handshake(reconnected, "b")); + assertTrue(ProjectSelectionRequest.send(descriptor.projectPort(), hello("b"))); + } + assertEquals(descriptor, CompanionSessionDescriptor.read(config.descriptorFile(), com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol.VERSION)); + } + } + } + } + + private static Socket connect(int port) throws IOException { + var socket = new Socket("127.0.0.1", port); + socket.setSoTimeout(5000); + return socket; + } + + private static boolean handshake(Socket socket, String project) throws IOException { + byte[] bytes = ProjectSelectionRequest.encode(hello(project)); + var output = new DataOutputStream(socket.getOutputStream()); + output.writeShort(CompanionProtocol.CLIENT_HELLO); + output.writeInt(bytes.length); + output.write(bytes); + output.flush(); + var input = new DataInputStream(socket.getInputStream()); + assertEquals(CompanionProtocol.SERVER_HELLO, input.readShort()); + var response = new ServerHelloMessage(); + response.read(new ByteBufferInputStream(ByteBuffer.wrap(input.readNBytes(input.readInt())))); + if (response.accepted()) { + assertEquals(CompanionProtocol.READY, input.readShort()); + input.readNBytes(input.readInt()); + } + return response.accepted(); + } + + private static ClientHelloMessage hello(String project) { + return new ClientHelloMessage(CompanionProtocol.VERSION, "secret", project, "data", "game"); + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionSessionDescriptorTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionSessionDescriptorTest.java index 985dcb4d..cd3c1371 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionSessionDescriptorTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionSessionDescriptorTest.java @@ -18,15 +18,15 @@ class CompanionSessionDescriptorTest { Path temporaryDirectory; @Test - void atomicallyPublishesOnlyProtocolPortAndPid() throws Exception { + void atomicallyPublishesPortsAndIdentityWithoutSecrets() throws Exception { Path descriptorFile = this.temporaryDirectory.resolve(CompanionLaunchContract.INSTANCE_DESCRIPTOR_FILE_NAME); - CompanionSessionDescriptor expected = new CompanionSessionDescriptor(3, 41731, 9912); + CompanionSessionDescriptor expected = new CompanionSessionDescriptor(3, 41731, 9912, 41732); expected.writeAtomically(descriptorFile); - assertEquals(expected, CompanionSessionDescriptor.read(descriptorFile)); + assertEquals(expected, CompanionSessionDescriptor.read(descriptorFile, 3)); String contents = Files.readString(descriptorFile); - assertEquals("protocol=3\nport=41731\npid=9912\n", contents.replace("\r\n", "\n")); + assertEquals("protocol=3\nport=41731\npid=9912\nprojectPort=41732\n", contents.replace("\r\n", "\n")); assertFalse(contents.contains("token")); try (var files = Files.list(this.temporaryDirectory)) { assertEquals(1, files.count()); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionSessionRejectionTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionSessionRejectionTest.java index 30200e6f..41f01572 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionSessionRejectionTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionSessionRejectionTest.java @@ -40,7 +40,7 @@ void unauthenticatedResultsNeverReachApplicationListeners() throws Exception { client.getMessageProcessor().registerMessage(CompanionProtocol.EXECUTION_RESULT, TestExecutionResult.class); session.bindAndPublish(configuration); CompletableFuture rejection = connect(client, - CompanionSessionDescriptor.read(configuration.descriptorFile())); + CompanionSessionDescriptor.read(configuration.descriptorFile(), com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol.VERSION)); client.getMessageProcessor().enqueueMessage(new TestExecutionResult()); @@ -71,7 +71,7 @@ void rejectedSendReturnsFalseWhileTheTransportEndsTheSession() throws Exception Client client = configuredClient(token)) { try { session.bindAndPublish(configuration); - connect(client, CompanionSessionDescriptor.read(configuration.descriptorFile())); + connect(client, CompanionSessionDescriptor.read(configuration.descriptorFile(), com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol.VERSION)); assertTrue(authenticated.await(2, TimeUnit.SECONDS)); session.server().getMessageProcessor().beginOutboundDrain(); assertFalse(session.send(new com.github.tth05.scnet.message.impl.EmptyMessage())); @@ -90,7 +90,7 @@ void wrongTokenIsRejectedWithoutStoppingTheServer() throws Exception { Client rejectedClient = configuredClient("wrong-token-value-1234567890abcdef"); Client acceptedClient = configuredClient(token)) { session.bindAndPublish(configuration); - CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(configuration.descriptorFile()); + CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(configuration.descriptorFile(), com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol.VERSION); TestServerHello rejected = connect(rejectedClient, descriptor).get(2, TimeUnit.SECONDS); assertFalse(rejected.accepted); @@ -119,7 +119,7 @@ public void disconnected() { Client first = configuredClient(token); Client second = configuredClient(token)) { session.bindAndPublish(configuration); - CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(configuration.descriptorFile()); + CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(configuration.descriptorFile(), com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol.VERSION); assertTrue(connect(first, descriptor).get(2, TimeUnit.SECONDS).accepted); first.close(); @@ -139,7 +139,7 @@ void authenticatedHandshakePublishesReadyAfterServerHello() throws Exception { try (CompanionSession session = new CompanionSession(token); Client client = configuredClient(token)) { session.bindAndPublish(configuration); - CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(configuration.descriptorFile()); + CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(configuration.descriptorFile(), com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol.VERSION); CompletableFuture ready = new CompletableFuture<>(); client.getMessageBus().listenAlways(TestReady.class, ready::complete); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectRegistryTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectRegistryTest.java new file mode 100644 index 00000000..3fa3772b --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectRegistryTest.java @@ -0,0 +1,50 @@ +package com.github.minecraft_ta.totalDebugCompanion.session; + +import com.github.minecraft_ta.totaldebug.storage.AppPaths; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectRegistryTest { + @TempDir Path directory; + + @Test void preservesTheRememberedProjectAndKeepsInstanceFilesInPlace() throws Exception { + AppPaths paths = new AppPaths(directory.resolve("app")); + CompanionProfile a = profile("a"); + CompanionProfile b = profile("b"); + Path aScript = Files.writeString(a.dataDirectory().resolve("script.txt"), "A"); + Path bScript = Files.writeString(b.dataDirectory().resolve("script.txt"), "B"); + com.github.minecraft_ta.totaldebug.storage.JsonFiles.write(paths.profile(), a.toJson()); + var registry = ProjectRegistry.open(paths); + assertEquals(a, registry.selected()); + assertFalse(Files.exists(paths.profile())); + registry.select(b); + registry.select(a); + var reopened = ProjectRegistry.open(paths); + assertEquals(a, reopened.selected()); + assertEquals(2, reopened.projects().size()); + assertEquals("A", Files.readString(aScript)); + assertEquals("B", Files.readString(bScript)); + } + + @Test void failedRegistryWriteDoesNotChangeTheSelectedProject() throws Exception { + AppPaths paths = new AppPaths(directory.resolve("app")); + var registry = ProjectRegistry.open(paths); + var a = profile("a"); + registry.select(a); + Files.delete(paths.projects()); + Files.createDirectory(paths.projects()); + Files.writeString(paths.projects().resolve("occupied"), "x"); + assertThrows(java.io.IOException.class, () -> registry.select(profile("b"))); + assertEquals(a, registry.selected()); + assertEquals(1, registry.projects().size()); + } + + private CompanionProfile profile(String name) throws Exception { + Path game = Files.createDirectories(directory.resolve(name).resolve("minecraft")); + return new CompanionProfile(name, Files.createDirectories(game.resolve("total-debug")), game); + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectSelectionServerTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectSelectionServerTest.java new file mode 100644 index 00000000..c0d6df25 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/ProjectSelectionServerTest.java @@ -0,0 +1,45 @@ +package com.github.minecraft_ta.totalDebugCompanion.session; + +import com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol; +import com.github.minecraft_ta.totaldebug.protocol.ProjectSelectionRequest; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectSelectionServerTest { + @Test void requiresAuthenticationAndAnExplicitNonBrowserRequest() throws Exception { + var selected = new AtomicReference("a"); + try (var server = new ProjectSelectionServer(new SessionAuthenticator("secret"), hello -> selected.set(hello.profileId()), () -> false); + var client = HttpClient.newHttpClient()) { + assertThrows(IOException.class, () -> ProjectSelectionRequest.send(server.port(), hello("wrong", "b"))); + assertEquals("a", selected.get()); + var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.port() + ProjectSelectionRequest.PATH)) + .header("Content-Type", "application/octet-stream").header("Origin", "https://example.org") + .POST(HttpRequest.BodyPublishers.ofByteArray(ProjectSelectionRequest.encode(hello("secret", "b")))).build(); + assertEquals(400, client.send(request, HttpResponse.BodyHandlers.discarding()).statusCode()); + assertEquals("a", selected.get()); + assertFalse(ProjectSelectionRequest.send(server.port(), hello("secret", "b"))); + assertEquals("b", selected.get()); + } + } + + @Test void propagatesSaveVetoWithoutChangingSelection() throws Exception { + try (var server = new ProjectSelectionServer(new SessionAuthenticator("secret"), hello -> { + throw new IOException("An editor could not be saved"); + }, () -> false)) { + var failure = assertThrows(IOException.class, () -> ProjectSelectionRequest.send(server.port(), hello("secret", "b"))); + assertTrue(failure.getMessage().contains("could not be saved")); + } + } + + private static ClientHelloMessage hello(String token, String project) { + return new ClientHelloMessage(CompanionProtocol.VERSION, token, project, "data", "game"); + } +} diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 1a8047db..80aec3a0 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -51,7 +51,7 @@ Default Windows home: `%LOCALAPPDATA%/TotalDebugCompanion`. Override it with `-- ```text TotalDebugCompanion/ settings.json - profile.json + projects.json run/ companion/ instance.lock @@ -76,7 +76,8 @@ TotalDebugCompanion/ ``` - `settings.json` contains appearance, fonts, debugger window geometry and presentation preferences. It contains no watches, breakpoints or expression history. -- `profile.json` remembers the current instance home and actual game directory. Companion reopens this profile on standalone startup. +- `projects.json` remembers known projects by instance identity, display name, TotalDebug data directory and game directory, plus the selected project. Companion restores its cached data on standalone startup. The previously remembered `profile.json` is imported once and removed after the registry is saved; instance files stay in place. +- Instance identity is derived from the normalized game-directory path. Moving a directory requires reopening that location and may require rebuilding its cache; this registry does not relocate installations. - The `run/companion` files coordinate the existing single Companion process. Credentials are published with user-only POSIX permissions or Windows ACLs. Lock ownership, not the existence of a lock file, determines liveness. - Immutable launch copies retain the three most recently used builds, plus any older build still pinned by a launching or running process. Publishers and pruning share a cache lock; the launcher pins the JAR through process exit, and Companion also pins its running copy. Authored scripts and installed executables are outside this cleanup scope. - The JDT directory holds embedded Eclipse plugin metadata. The dummy bundle is part of the JDT adapter, not a Minecraft plugin. diff --git a/docs/USAGE.md b/docs/USAGE.md index 2dd987a7..95e1ad91 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -12,6 +12,18 @@ Companion stays open when Minecraft exits and reconnects when a matching TotalDe The index describes selected runtime archives and prepared class files. It does not reconstruct every transformation inside the running JVM. Decompiled source can differ from original source, and generated local names do not replace missing debugger metadata. +## Projects + +Companion remembers one project per Minecraft instance and keeps one selected at a time. Scripts, watches, history and caches remain in that instance's existing `total-debug` directory. Standalone startup reopens the selected project, including cached source access while Minecraft is offline. + +F6 or an explicit source-open request from another game selects its project before connecting. An ordinary handshake cannot replace the selected project. Selection uses an authenticated loopback request separate from the occupied game socket; it does not depend on the optional MCP host. Companion protocol 15 requires a matching mod/Companion pair. + +Switching saves and closes project editors; a failed save prevents the switch. It detaches the debugger, requests cancellation of owned execution jobs, clears project views and pending results, and restores the selected instance's state. Minecraft processes remain running, and the existing MCP endpoint stays available. Disconnection does not prove arbitrary target code has stopped. + +If remembering the selection fails, the new project remains open and Companion reports the save error. Selecting it again retries persistence; until then, restarting reopens the last successfully remembered project. + +The application API supports listing known projects and opening a `CompanionProfile`, including one created with `CompanionProfile.forGame(path)`. Project-selector UI, project MCP tools, launcher controls and restoring open tabs are subsequent work. + ## Scripts and evaluation 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. 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 cc8c3cdf..591260ee 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 @@ -1,5 +1,7 @@ package com.github.minecraft_ta.totaldebug.client.companion; +import com.github.minecraft_ta.totaldebug.storage.InstancePaths; + import com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol; import com.github.minecraft_ta.totaldebug.protocol.scnet.ProtocolBindings; import com.github.minecraft_ta.totaldebug.storage.CompanionSessionDescriptor; @@ -36,11 +38,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.util.ArrayList; -import java.util.HexFormat; import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; @@ -149,7 +148,7 @@ public CompanionAppClient(Path totalDebugDirectory, String developmentJar) { this.appHome = appPaths.home(); this.instanceDescriptorFile = appPaths.instanceDescriptor(); this.instanceKeyFile = appPaths.instanceKey(); - this.profileId = profileId(this.workspaceDirectory); + this.profileId = InstancePaths.profileId(this.workspaceDirectory); this.installer = new CompanionAppInstaller(this.appDirectory, developmentJar); this.runtimeInventoryPublisher = new RuntimeInventoryPublisher(this.dataDirectory); this.timeouts = Objects.requireNonNull(timeouts, "timeouts"); @@ -411,14 +410,19 @@ private boolean isAuthenticated() { private void ensureConnectedAndReady() throws IOException { try { + CompanionSessionDescriptor descriptor = discoverOrStartCompanion(); + String token = readInstanceKey(); + boolean connectionRetained = com.github.minecraft_ta.totaldebug.protocol.ProjectSelectionRequest.send(descriptor.projectPort(), + new ClientHelloMessage(CompanionProtocol.VERSION, token, this.profileId, + this.dataDirectory.toString(), this.workspaceDirectory.toString())); CompletableFuture readiness = this.ready; - if (this.client.isConnected() && readiness.isDone() && !readiness.isCompletedExceptionally()) { + if (connectionRetained && descriptor.equals(this.activeDescriptor) + && this.client.isConnected() && readiness.isDone() && !readiness.isCompletedExceptionally()) { return; } resetConnection(); - CompanionSessionDescriptor descriptor = discoverOrStartCompanion(); this.activeDescriptor = descriptor; - this.sessionToken = readInstanceKey(); + this.sessionToken = token; InetSocketAddress address = sessionAddress(descriptor.port()); reportProgress(CompanionStartupProgress.connecting()); if (!this.client.connect(address)) { @@ -538,21 +542,15 @@ private CompanionSessionDescriptor readLiveDescriptor() throws IOException { if (!Files.isRegularFile(this.instanceDescriptorFile)) { return null; } - CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(this.instanceDescriptorFile); - boolean processAlive = ProcessHandle.of(descriptor.processId()).map(ProcessHandle::isAlive).orElse(false); - if (!processAlive || !isInstanceLockHeld()) { - TotalDebug.LOGGER.info( - "Discarding stale TotalDebugCompanion descriptor for process {}", - descriptor.processId() - ); + if (!isInstanceLockHeld()) { + TotalDebug.LOGGER.info("Discarding stale TotalDebugCompanion descriptor without an instance lock"); Files.deleteIfExists(this.instanceDescriptorFile); Files.deleteIfExists(this.instanceKeyFile); return null; } - if (descriptor.protocolVersion() != CompanionProtocol.VERSION) { - throw new IOException( - "Close the running Companion before using protocol " + CompanionProtocol.VERSION - ); + CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(this.instanceDescriptorFile, CompanionProtocol.VERSION); + if (!ProcessHandle.of(descriptor.processId()).map(ProcessHandle::isAlive).orElse(false)) { + throw new IOException("Companion descriptor names a stopped process while its instance lock is held"); } if (!Files.isRegularFile(this.instanceKeyFile)) { throw new IOException("Companion instance key is missing"); @@ -611,13 +609,7 @@ private CompanionSessionDescriptor awaitDescriptor(Path descriptorFile) throws I long timeoutNanos = this.timeouts.processStart().toNanos(); while (System.nanoTime() - startedAt < timeoutNanos) { if (Files.isRegularFile(descriptorFile)) { - CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(descriptorFile); - if (descriptor.protocolVersion() != CompanionProtocol.VERSION) { - throw new IOException( - "Companion descriptor protocol mismatch: expected " + CompanionProtocol.VERSION - + ", got " + descriptor.protocolVersion() - ); - } + CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(descriptorFile, CompanionProtocol.VERSION); if (!ProcessHandle.of(descriptor.processId()).map(ProcessHandle::isAlive).orElse(false)) { throw new IOException("Companion descriptor names a stopped process"); } @@ -690,16 +682,4 @@ public synchronized void close() { } } - private static String profileId(Path workspaceDirectory) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - String identity = workspaceDirectory.toAbsolutePath().normalize().toString(); - if (System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).startsWith("windows")) { - identity = identity.toLowerCase(java.util.Locale.ROOT); - } - return HexFormat.of().formatHex(digest.digest(identity.getBytes(StandardCharsets.UTF_8))); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 is unavailable", exception); - } - } } diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionJavaRuntime.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionJavaRuntime.java index 1c15680c..3e290fe0 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionJavaRuntime.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionJavaRuntime.java @@ -23,6 +23,7 @@ final class CompanionJavaRuntime { "java.xml", "jdk.attach", "jdk.compiler", + "jdk.httpserver", "jdk.jdi", "jdk.unsupported", "jdk.zipfs" diff --git a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionJavaRuntimeTest.java b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionJavaRuntimeTest.java index 8384ec23..af57a910 100644 --- a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionJavaRuntimeTest.java +++ b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionJavaRuntimeTest.java @@ -27,6 +27,7 @@ class CompanionJavaRuntimeTest { "java.xml", "jdk.attach", "jdk.compiler", + "jdk.httpserver", "jdk.jdi", "jdk.unsupported", "jdk.zipfs" diff --git a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionSessionDescriptorTest.java b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionSessionDescriptorTest.java index a90dbd56..4261c7a8 100644 --- a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionSessionDescriptorTest.java +++ b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionSessionDescriptorTest.java @@ -18,17 +18,18 @@ class CompanionSessionDescriptorTest { Path temporaryDirectory; @Test - void readsTheExactThreeFieldDescriptor() throws Exception { + void readsBothPortsAndTheProcessIdentity() throws Exception { Path descriptorFile = Files.writeString( this.temporaryDirectory.resolve("session.properties"), - "protocol=3\nport=41731\npid=9912\n" + "protocol=3\nport=41731\npid=9912\nprojectPort=41732\n" ); - CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(descriptorFile); + CompanionSessionDescriptor descriptor = CompanionSessionDescriptor.read(descriptorFile, 3); assertEquals(3, descriptor.protocolVersion()); assertEquals(41731, descriptor.port()); assertEquals(9912, descriptor.processId()); + assertEquals(41732, descriptor.projectPort()); assertFalse(Files.readString(descriptorFile).contains("token")); } @@ -41,7 +42,7 @@ void rejectsUnknownFieldsInsteadOfGuessing() throws Exception { IOException exception = assertThrows( IOException.class, - () -> CompanionSessionDescriptor.read(descriptorFile) + () -> CompanionSessionDescriptor.read(descriptorFile, 3) ); assertEquals("Unknown companion session descriptor field: token", exception.getMessage()); } diff --git a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionStaleDescriptorTest.java b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionStaleDescriptorTest.java index e5b0671a..4157ae9a 100644 --- a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionStaleDescriptorTest.java +++ b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionStaleDescriptorTest.java @@ -30,7 +30,7 @@ void discardsAStaleDescriptorAfterItsPidWasReused() throws Exception { Path keyFile = paths.instanceKey(); Files.writeString( descriptorFile, - "protocol=4\nport=41731\npid=" + ProcessHandle.current().pid() + "\n", + "protocol=14\nport=41731\npid=" + ProcessHandle.current().pid() + "\n", StandardCharsets.UTF_8 ); Files.writeString(keyFile, "a".repeat(64), StandardCharsets.US_ASCII); @@ -68,7 +68,7 @@ void keepsRejectingARealRunningCompanionWithAnotherProtocol() throws Exception { Path keyFile = paths.instanceKey(); Files.writeString( descriptorFile, - "protocol=4\nport=41731\npid=" + ProcessHandle.current().pid() + "\n", + "protocol=14\nport=41731\npid=" + ProcessHandle.current().pid() + "\n", StandardCharsets.UTF_8 ); Files.writeString(keyFile, "a".repeat(64), StandardCharsets.US_ASCII); 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 82967f49..c3778fe5 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 = 14; + public static final int VERSION = 15; public static final short READY = 1; public static final short OPEN_CLASS = 2; diff --git a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/ProjectSelectionRequest.java b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/ProjectSelectionRequest.java new file mode 100644 index 00000000..5278bc14 --- /dev/null +++ b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/ProjectSelectionRequest.java @@ -0,0 +1,66 @@ +package com.github.minecraft_ta.totaldebug.protocol; + +import com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage; +import com.github.tth05.scnet.util.ByteBufferInputStream; +import com.github.tth05.scnet.util.ByteBufferOutputStream; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +/** Explicit local activation before attaching the single-game transport. */ +public final class ProjectSelectionRequest { + public static final String PATH = "/select-project"; + public static final int MAX_BYTES = 32_768; + public static final String CONNECTED_HEADER = "X-Companion-Connected"; + + private ProjectSelectionRequest() {} + + public static byte[] encode(ClientHelloMessage hello) { + var output = new ByteBufferOutputStream(256, MAX_BYTES, 8192); + hello.write(output); + ByteBuffer buffer = output.getBuffer(); + byte[] bytes = new byte[buffer.position()]; + buffer.flip(); + buffer.get(bytes); + return bytes; + } + + public static ClientHelloMessage decode(byte[] bytes) { + if (bytes.length > MAX_BYTES) throw new IllegalArgumentException("Project request is too large"); + var buffer = ByteBuffer.wrap(bytes); + var hello = new ClientHelloMessage(); + hello.read(new ByteBufferInputStream(buffer, 8192)); + if (buffer.hasRemaining()) throw new IllegalArgumentException("Trailing project request data"); + return hello; + } + + /** Returns whether Companion still has an authenticated game connection after selection. */ + public static boolean send(int port, ClientHelloMessage hello) throws IOException { + if (port < 1 || port > 65535) throw new IllegalArgumentException("Invalid project request port"); + var connection = (HttpURLConnection) URI.create("http://127.0.0.1:" + port + PATH).toURL().openConnection(java.net.Proxy.NO_PROXY); + connection.setConnectTimeout(5000); + connection.setReadTimeout(60_000); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/octet-stream"); + connection.setDoOutput(true); + byte[] bytes = encode(hello); + connection.setFixedLengthStreamingMode(bytes.length); + try { + try (var output = connection.getOutputStream()) { output.write(bytes); } + if (connection.getResponseCode() != 204) { + String detail = "Companion rejected project selection"; + if (connection.getErrorStream() != null) { + try (var input = connection.getErrorStream()) { + detail = new String(input.readNBytes(4096), StandardCharsets.UTF_8); + } + } + throw new IOException(detail); + } + return Boolean.parseBoolean(connection.getHeaderField(CONNECTED_HEADER)); + } finally { connection.disconnect(); } + } +} 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 9c5c02e4..5ff9de3f 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; -/** Protocol-14 payloads, kept independently of the encoder. */ +/** Protocol-15 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 = "0000000e00000003616263000000017000000001640000000177"; - public static final String SERVER_HELLO = "0000000e0100000000"; + public static final String CLIENT_HELLO = "0000000f00000003616263000000017000000001640000000177"; + public static final String SERVER_HELLO = "0000000f0100000000"; 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/ProjectSelectionRequestTest.java b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/ProjectSelectionRequestTest.java new file mode 100644 index 00000000..67ff47f9 --- /dev/null +++ b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/ProjectSelectionRequestTest.java @@ -0,0 +1,20 @@ +package com.github.minecraft_ta.totaldebug.protocol; + +import com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage; +import org.junit.jupiter.api.Test; +import java.util.Arrays; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectSelectionRequestTest { + @Test void reusesTheBoundedHelloPayloadAndRejectsTrailingData() { + var original = new ClientHelloMessage(CompanionProtocol.VERSION, "secret", "project", "data", "game"); + var bytes = ProjectSelectionRequest.encode(original); + var decoded = ProjectSelectionRequest.decode(bytes); + assertEquals(original.profileId(), decoded.profileId()); + assertEquals(original.token(), decoded.token()); + assertEquals(original.dataDirectory(), decoded.dataDirectory()); + assertEquals(original.workspaceDirectory(), decoded.workspaceDirectory()); + assertThrows(IllegalArgumentException.class, () -> ProjectSelectionRequest.decode(Arrays.copyOf(bytes, bytes.length + 1))); + assertThrows(IllegalArgumentException.class, () -> ProjectSelectionRequest.decode(new byte[ProjectSelectionRequest.MAX_BYTES + 1])); + } +} diff --git a/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/AppPaths.java b/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/AppPaths.java index 628d5f49..941bfa18 100644 --- a/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/AppPaths.java +++ b/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/AppPaths.java @@ -30,6 +30,7 @@ public static AppPaths defaults(Map environment) { public Path settings() { return home.resolve("settings.json"); } public Path profile() { return home.resolve(CompanionLaunchContract.PROFILE_FILE_NAME); } + public Path projects() { return home.resolve("projects.json"); } public Path run() { return home.resolve("run").resolve("companion"); } public Path instanceLock() { return run().resolve(CompanionLaunchContract.INSTANCE_LOCK_FILE_NAME); } public Path instanceDescriptor() { return run().resolve(CompanionLaunchContract.INSTANCE_DESCRIPTOR_FILE_NAME); } diff --git a/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/CompanionSessionDescriptor.java b/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/CompanionSessionDescriptor.java index 05b8916d..01124e6d 100644 --- a/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/CompanionSessionDescriptor.java +++ b/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/CompanionSessionDescriptor.java @@ -9,7 +9,7 @@ import java.util.HashMap; import java.util.Map; -public record CompanionSessionDescriptor(int protocolVersion, int port, long processId) { +public record CompanionSessionDescriptor(int protocolVersion, int port, long processId, int projectPort) { public CompanionSessionDescriptor { if (protocolVersion < 1) { throw new IllegalArgumentException("protocolVersion must be positive"); @@ -20,16 +20,18 @@ public record CompanionSessionDescriptor(int protocolVersion, int port, long pro if (processId < 1) { throw new IllegalArgumentException("processId must be positive"); } + if (projectPort < 1 || projectPort > 65_535) throw new IllegalArgumentException("Invalid project port"); } public void writeAtomically(Path descriptorFile) throws IOException { String contents = CompanionLaunchContract.DESCRIPTOR_PROTOCOL_KEY + "=" + this.protocolVersion + "\n" + CompanionLaunchContract.DESCRIPTOR_PORT_KEY + "=" + this.port + "\n" - + CompanionLaunchContract.DESCRIPTOR_PROCESS_ID_KEY + "=" + this.processId + "\n"; + + CompanionLaunchContract.DESCRIPTOR_PROCESS_ID_KEY + "=" + this.processId + "\n" + + "projectPort=" + this.projectPort + "\n"; AtomicFiles.writeString(descriptorFile, contents); } - public static CompanionSessionDescriptor read(Path descriptorFile) throws IOException { + public static CompanionSessionDescriptor read(Path descriptorFile, int expectedProtocol) throws IOException { Map values = new HashMap<>(); for (String line : Files.readAllLines(descriptorFile, StandardCharsets.UTF_8)) { if (line.isBlank()) { @@ -43,21 +45,32 @@ public static CompanionSessionDescriptor read(Path descriptorFile) throws IOExce String value = line.substring(separator + 1); if (!key.equals(CompanionLaunchContract.DESCRIPTOR_PROTOCOL_KEY) && !key.equals(CompanionLaunchContract.DESCRIPTOR_PORT_KEY) - && !key.equals(CompanionLaunchContract.DESCRIPTOR_PROCESS_ID_KEY)) { + && !key.equals(CompanionLaunchContract.DESCRIPTOR_PROCESS_ID_KEY) && !key.equals("projectPort")) { throw new IOException("Unknown companion session descriptor field: " + key); } if (values.putIfAbsent(key, value) != null) { throw new IOException("Duplicate companion session descriptor field: " + key); } } - if (values.size() != 3) { - throw new IOException("Companion session descriptor must contain protocol, port, and pid"); + int protocol; + try { + protocol = Integer.parseInt(values.get(CompanionLaunchContract.DESCRIPTOR_PROTOCOL_KEY)); + } catch (IllegalArgumentException exception) { + throw new IOException("Companion session descriptor contains an invalid protocol", exception); + } + if (protocol != expectedProtocol) { + throw new IOException("Close the running Companion before using protocol " + expectedProtocol + + "; its descriptor uses protocol " + protocol); + } + if (values.size() != 4) { + throw new IOException("Companion session descriptor must contain protocol, port, pid and projectPort"); } try { return new CompanionSessionDescriptor( - Integer.parseInt(values.get(CompanionLaunchContract.DESCRIPTOR_PROTOCOL_KEY)), + protocol, Integer.parseInt(values.get(CompanionLaunchContract.DESCRIPTOR_PORT_KEY)), - Long.parseLong(values.get(CompanionLaunchContract.DESCRIPTOR_PROCESS_ID_KEY)) + Long.parseLong(values.get(CompanionLaunchContract.DESCRIPTOR_PROCESS_ID_KEY)), + Integer.parseInt(values.get("projectPort")) ); } catch (IllegalArgumentException exception) { throw new IOException("Companion session descriptor contains an invalid value", exception); diff --git a/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/InstancePaths.java b/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/InstancePaths.java index 205c5e38..665205b6 100644 --- a/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/InstancePaths.java +++ b/storage/src/main/java/com/github/minecraft_ta/totaldebug/storage/InstancePaths.java @@ -13,6 +13,16 @@ public static InstancePaths forGame(Path gameDirectory) { return new InstancePaths(gameDirectory.resolve("total-debug")); } + public static String profileId(Path gameDirectory) { + String identity = gameDirectory.toAbsolutePath().normalize().toString(); + if (System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).startsWith("windows")) + identity = identity.toLowerCase(java.util.Locale.ROOT); + try { + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(identity.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException failure) { throw new AssertionError(failure); } + } + /** Installation stays with the game instance. */ public static Path installationDirectory(Path gameDirectory) { return Objects.requireNonNull(gameDirectory).toAbsolutePath().normalize().resolve("total-debug").resolve("companion-app");