diff --git a/companion/MCP.md b/companion/MCP.md index c9481052..3306be72 100644 --- a/companion/MCP.md +++ b/companion/MCP.md @@ -18,7 +18,7 @@ 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. +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 through the active project scope in which the request started. Project list/open tools are not yet exposed through MCP. ## Response policy @@ -134,3 +134,5 @@ The preferred design is a persistent profiling service inside TotalDebug's Minec JFR is the right first backend for broad CPU hotspot sampling and sampled allocation estimates. Exact invocation counts or timings for named methods are a separate feature and may justify targeted instrumentation later. A Java agent is not needed for the initial sampler. Spark or async-profiler ingestion can remain optional if native stack profiling or flame graphs become necessary. Before implementation, settle the result shape, whether blocked-thread events belong in the first version, and whether one active profile is sufficient. No profiling MCP tools are shipped yet. + +Project-bound requests capture the opened project scope. During a project switch, new mutations are rejected, including while an editor save dialog is open. Retry after the switch or save veto completes; results from a retired project are rejected. diff --git a/companion/README.md b/companion/README.md index 3cf0a697..1b37c01c 100644 --- a/companion/README.md +++ b/companion/README.md @@ -47,10 +47,12 @@ Contact sheets and individual captures are written under `companion/build/ui-scr The [MCP API](MCP.md) exposes source queries, Java execution and debugger operations to trusted local clients. The [storage guide](https://github.com/Minecraft-TA/TotalDebug/blob/1.21.1/docs/STORAGE.md) describes scripts, settings, persisted debugger state and generated caches. -## Runtime ownership +## Ownership -`CompanionApp` publishes one `RuntimeBinding` for the installed inventory. The binding groups its identity, source catalog, classpath, decompiler and reference search, and owns the native index after installation succeeds. `CompanionClassIndex` is only JDT's process-wide lookup hook; setting or clearing it never closes an index. +`CompanionApp` owns the session, debugger, compiler, index loader and project worker. One `ProjectScope` owns the selected profile, instance state, navigation history, pending navigation and nullable `RuntimeBinding`. A scope admits work while ACTIVE; SWITCHING rejects new work but can be cancelled after an editor veto or failed state flush; RETIRED is terminal. Check-and-submit uses the same lifecycle lock as runtime installation. Swing hops and debugger waits run outside that lock. + +The scope publishes one `RuntimeBinding` for the installed inventory. The binding groups its identity, source catalog, classpath, decompiler and reference search, and owns the native index after installation succeeds. `CompanionClassIndex` is only JDT's process-wide lookup hook; setting or clearing it never closes an index. The index loader retains ownership while a candidate is prepared. The application detaches the previous runtime, attaches the new compiler/insight bindings, and completes publication under the existing lifecycle lock. Debugger and UI follow-up runs afterward and cannot return an installed index to the loader's failure cleanup. Closing a runtime detaches its consumers before releasing the index, and is idempotent. A rejected candidate closes its own prepared consumers while leaving index disposal to the loader. -The script compiler and code-insight worker remain application-lived. Open local editors retain the code-insight service, so a runtime changes its binding rather than replacing that service instance. Project admission and navigation invalidation retain their existing guards; the planned project-scope migration is a separate slice. Stateless JDT parsing is in `JavaAst`; editor analysis/listeners remain in `ASTCache`. +The script compiler and code-insight worker remain application-lived. Open local editors retain the code-insight service, so a runtime changes its binding rather than replacing that service instance. MCP tool declarations identify project-bound requests. Mutations use the captured scope's atomic admission gate; late results check that scope, and navigation also checks runtime identity. Instance state is flushed before retirement and detach. Stateless JDT parsing is in `JavaAst`; editor analysis/listeners remain in `ASTCache`. 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 48e9d417..17a95c34 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 @@ -1,6 +1,8 @@ package com.github.minecraft_ta.totalDebugCompanion; import com.github.minecraft_ta.totaldebug.storage.AppPaths; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope.PendingNavigation; import com.github.minecraft_ta.totaldebug.storage.InstancePaths; import com.github.minecraft_ta.totaldebug.storage.AtomicFiles; import com.github.minecraft_ta.totaldebug.storage.RuntimePhase; @@ -89,26 +91,21 @@ public final class CompanionApp { public static Server SERVER; private static CompanionSession session; private static CompanionLaunchConfiguration launchConfiguration; - private static volatile CompanionProfile profile; - private static volatile InstanceState instanceState = InstanceState.inMemory(); - private static volatile RuntimeBinding runtime; + private static final Object lifecycleLock = new Object(); + private static volatile ProjectScope current; + private static final InstanceState emptyState = InstanceState.inMemory(); private static final CodeInsightService codeInsightService = new CodeInsightService( () -> { throw new IllegalStateException("Runtime class index is not ready"); }, RuntimeSourceCatalog.empty()); private static RuntimeIndexService runtimeIndexService; private static final ScriptCompilationService scriptCompiler = new ScriptCompilationService(CompanionApp::send, CompanionApp::send); - private static final List pendingNavigations = new ArrayList<>(); 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 volatile boolean switching; 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) { - } - private CompanionApp() { } @@ -179,7 +176,7 @@ static int run(String[] args, Map environment, CompanionTimeouts GlobalConfig.getInstance().loadFrom(launchConfiguration.appHome()); configureLookAndFeel(); - runtimeIndexService = new RuntimeIndexService(CompanionApp.class, CompanionApp::installRuntimeSnapshot); + runtimeIndexService = new RuntimeIndexService(lifecycleLock, CompanionApp::installRuntimeSnapshot); runtimeIndexService.addStatusListener(CompanionApp::updateRuntimeIndexUi); debuggerController = createDebuggerController(); debuggerController.setExceptionBreakpoints( @@ -282,7 +279,7 @@ public void debugTarget(DebugTargetMessage message) { RuntimePhase.run("close.ui", CompanionApp::stopUiAfterFailure); try (var state = RuntimePhase.start("close.state")) { GlobalConfig.getInstance().saveNow(); - instanceState.close(); + instanceState().close(); } catch (IOException exception) { exception.printStackTrace(System.err); } @@ -307,22 +304,24 @@ public void debugTarget(DebugTargetMessage message) { } } - private static synchronized void attachSelectedProfile( + private static void attachSelectedProfile( com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage hello ) throws IOException { - CompanionProfile requested; - try { - requested = CompanionProfile.fromHello(hello); - } catch (IllegalArgumentException exception) { - throw new IOException("Invalid Minecraft profile", exception); - } - if (switchingProjects || !requested.equals(profile)) { - throw new IOException("Select this project explicitly before connecting"); + synchronized (lifecycleLock) { + CompanionProfile requested; + try { + requested = CompanionProfile.fromHello(hello); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid Minecraft profile", exception); + } + if (switching || !requested.equals(currentProject())) { + throw new IOException("Select this project explicitly before connecting"); + } } } private static void handleDebugTarget(DebugTargetMessage message) { - if (switchingProjects) return; + if (switching) return; if (message.targetKind() != DebugTargetMessage.LOCAL_JVM) { throw new IllegalArgumentException("Unknown debug target kind: " + message.targetKind()); } @@ -346,16 +345,17 @@ public static List projects() { return projects == null ? List.of() : projects.projects(); } - public static CompanionProfile currentProject() { return profile; } + public static CompanionProfile currentProject() { var scope = current; return scope == null ? null : scope.profile(); } - public static boolean isSwitchingProjects() { return switchingProjects; } + public static boolean isSwitching() { return switching; } - public static long projectGeneration() { return projectGeneration; } + public static ProjectScope currentScope() { return current; } - /** 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(); + public static ProjectScope requireProject() { + ProjectScope scope = current; + if (scope == null) throw new IllegalStateException("No Minecraft project is loaded"); + scope.requireActive(); + return scope; } /** Application API; selection controls and MCP project tools are added separately. */ @@ -369,37 +369,54 @@ public static CompletableFuture openProject(CompanionProfile requested) { private static void switchProject(CompanionProfile requested) throws IOException { validateProfile(requested); - if (requested.equals(profile)) { - projects.select(requested); - return; - } - // 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; + if (requested.equals(currentProject())) { projects.select(requested); return; } + // Prepare the actual replacement before disturbing the current project. + ProjectScope replacement = ProjectScope.open(lifecycleLock, requested); + replacement.beginSwitch(); + ProjectScope old; + synchronized (lifecycleLock) { + old = current; + switching = true; + if (old != null) old.beginSwitch(); } + boolean installed = false; 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 (old != null) old.state().saveNow(); + if (uiStarted) { + boolean[] closed = {false}; + SwingUtilities.invokeAndWait(() -> closed[0] = MainWindow.INSTANCE.closeProjectViews()); + if (!closed[0]) throw new IOException("Project switch cancelled because an editor could not be closed"); + } + synchronized (lifecycleLock) { + if (old != null) old.retire(); + current = null; if (runtimeIndexService != null) runtimeIndexService.clear(); - activateProfile(requested); } + // Retirement is terminal. Attempt every detach and install the prepared replacement even if + // a broken debugger/connection cannot detach cleanly. + if (mcpServer != null) finishTransitionStep("Disconnect execution jobs", mcpServer::prepareProjectSwitch); + finishTransitionStep("Disconnect script compiler", scriptCompiler::runtimeDisconnected); + if (session != null) finishTransitionStep("Disconnect Minecraft", session::disconnect); + finishTransitionStep("Clear debugger target", () -> getDebuggerController().clearTarget().join()); + finishTransitionStep("Clear debugger breakpoints", () -> getDebuggerController().replaceBreakpointDefinitions(List.of()).join()); + CompanionClassIndex.clear(); + if (old != null) { + try { old.close(); } + catch (IOException | RuntimeException failure) { reportTransitionFailure("Close retired project", failure); } + } + com.github.minecraft_ta.totalDebugCompanion.jdt.diagnostics.ASTCache.clear(); + synchronized (lifecycleLock) { current = replacement; } + installed = true; + finishTransitionStep("Restore debugger preferences", () -> restoreProjectState(replacement)); + if (uiStarted) refreshUiProfile(); updateGameStatus(new ServiceStatus(ServiceStatus.State.INACTIVE, "Offline", "Selected project is not connected to Minecraft.")); - try { - projects.select(requested); - } catch (IOException failure) { + 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) { @@ -408,37 +425,45 @@ private static void switchProject(CompanionProfile requested) throws IOException } catch (InvocationTargetException failure) { throw new IOException("Unable to close project editors", failure.getCause()); } finally { - switchingProjects = false; - if (uiStarted) SwingUtilities.invokeLater(() -> MainWindow.INSTANCE.setEnabled(true)); + try { + if (!installed) { replacement.retire(); replacement.close(); } + } finally { + synchronized (lifecycleLock) { + if (old != null) old.cancelSwitch(); + try { + if (installed && runtimeIndexService != null) runtimeIndexService.restore(requested.dataDirectory()); + } finally { + if (installed) replacement.cancelSwitch(); + switching = false; + } + } + if (uiStarted) SwingUtilities.invokeLater(() -> MainWindow.INSTANCE.setEnabled(true)); + } } } + private static void finishTransitionStep(String description, Runnable action) { + try { action.run(); } + catch (RuntimeException failure) { reportTransitionFailure(description, failure); } + } + + private static void reportTransitionFailure(String description, Exception failure) { + System.getLogger(CompanionApp.class.getName()).log(System.Logger.Level.WARNING, + description + " failed while switching projects", failure); + } + private static void activateProfile(CompanionProfile requested) throws IOException { validateProfile(requested); - CompanionProfile current = profile; - boolean profileChanged = !requested.equals(current); - if (profileChanged) { - InstanceState replacementState = InstanceState.open(new InstancePaths(requested.dataDirectory())); - try { - instanceState.close(); - } catch (IOException exception) { - replacementState.close(); - throw exception; - } - instanceState = replacementState; - getDebuggerController().setBreakpointsMuted(instanceState.debuggerBreakpointsMuted()).join(); - getDebuggerController().setExceptionBreakpoints(instanceState.breakOnCaughtExceptions(), - instanceState.breakOnUncaughtExceptions()).join(); - closeRuntime(); - } - profile = requested; - setupDataDirectories(); - if (uiStarted && profileChanged) { - refreshUiProfile(); - } - if (profileChanged && runtimeIndexService != null) { - runtimeIndexService.restore(requested.dataDirectory()); - } + ProjectScope replacement = ProjectScope.open(lifecycleLock, requested); + synchronized (lifecycleLock) { current = replacement; } + restoreProjectState(replacement); + if (runtimeIndexService != null) runtimeIndexService.restore(requested.dataDirectory()); + } + + private static void restoreProjectState(ProjectScope scope) { + getDebuggerController().setBreakpointsMuted(scope.state().debuggerBreakpointsMuted()).join(); + getDebuggerController().setExceptionBreakpoints(scope.state().breakOnCaughtExceptions(), + scope.state().breakOnUncaughtExceptions()).join(); } private static void validateProfile(CompanionProfile requested) throws IOException { @@ -449,24 +474,26 @@ private static void validateProfile(CompanionProfile requested) throws IOExcepti setupDataDirectories(requested.dataDirectory(), true); } - private static synchronized void handleRuntimeInventory(RuntimeInventoryMessage message) { - if (switchingProjects) return; - CompanionProfile current = profile; - if (current == null || runtimeIndexService == null) { - return; - } - switch (message.state()) { - case RuntimeInventoryMessage.PREPARING -> { - runtimeIndexService.waiting( - message.detail().isBlank() ? "Minecraft is preparing runtime sources" : message.detail()); + private static void handleRuntimeInventory(RuntimeInventoryMessage message) { + synchronized (lifecycleLock) { + if (switching) return; + CompanionProfile current = currentProject(); + if (current == null || runtimeIndexService == null) { + return; + } + switch (message.state()) { + case RuntimeInventoryMessage.PREPARING -> { + runtimeIndexService.waiting( + message.detail().isBlank() ? "Minecraft is preparing runtime sources" : message.detail()); + } + case RuntimeInventoryMessage.AVAILABLE -> runtimeIndexService.accept( + current.dataDirectory(), + message.inventoryId(), + Path.of(message.inventoryFile()) + ); + case RuntimeInventoryMessage.FAILED -> runtimeIndexService.failedBeforeBuild(message.detail()); + default -> runtimeIndexService.failedBeforeBuild("Minecraft sent an unknown runtime inventory state"); } - case RuntimeInventoryMessage.AVAILABLE -> runtimeIndexService.accept( - current.dataDirectory(), - message.inventoryId(), - Path.of(message.inventoryFile()) - ); - case RuntimeInventoryMessage.FAILED -> runtimeIndexService.failedBeforeBuild(message.detail()); - default -> runtimeIndexService.failedBeforeBuild("Minecraft sent an unknown runtime inventory state"); } } @@ -475,54 +502,59 @@ private static void installRuntimeSnapshot(RuntimeIndexService.ReadySnapshot sna snapshot.indexFile().getParent().resolve("inventory.json"), snapshot.inventoryId())); } - private static synchronized void installRuntimeSnapshot(RuntimeIndexService.ReadySnapshot snapshot, + private static void installRuntimeSnapshot(RuntimeIndexService.ReadySnapshot snapshot, RuntimeSnapshotBytecodeSource bytecodeSource) { - if (switchingProjects) throw new IllegalStateException("Project is switching"); - CompanionProfile current = requireProfile(); - RuntimeBinding replacement; - try { - replacement = new RuntimeBinding(snapshot, current.dataDirectory(), bytecodeSource, scriptCompiler, codeInsightService); - } catch (IOException exception) { - throw new IllegalStateException("Unable to prepare the runtime class index", exception); - } - try { - closeRuntime(); - replacement.attach(); - // Queue before publication: rejected scheduling still leaves ownership with the loader. - // The follow-up acquires this lock after the loader finishes its installation callback. - projectWorker.execute(() -> finishRuntimeInstallation(replacement, current)); - CompanionClassIndex.set(snapshot.index()); - runtime = replacement; - replacement.acceptOwnership(); - } catch (RuntimeException failure) { - replacement.close(); - throw failure; + synchronized (lifecycleLock) { + if (switching) throw new IllegalStateException("Project is switching"); + ProjectScope scope = requireProject(); + CompanionProfile current = scope.profile(); + RuntimeBinding replacement; + try { + replacement = new RuntimeBinding(snapshot, current.dataDirectory(), bytecodeSource, scriptCompiler, codeInsightService); + } catch (IOException exception) { + throw new IllegalStateException("Unable to prepare the runtime class index", exception); + } + try { + closeRuntime(); + replacement.attach(); + // Queue before publication: rejected scheduling still leaves ownership with the loader. + // The follow-up acquires this lock after the loader finishes its installation callback. + projectWorker.execute(() -> finishRuntimeInstallation(replacement, scope)); + CompanionClassIndex.set(snapshot.index()); + scope.bindRuntime(replacement); + replacement.acceptOwnership(); + } catch (RuntimeException failure) { + replacement.close(); + throw failure; + } } } - private static void finishRuntimeInstallation(RuntimeBinding installed, CompanionProfile selected) { + private static void finishRuntimeInstallation(RuntimeBinding installed, ProjectScope selected) { + SwingUtilities.invokeLater(() -> { + if (!selected.isActive() || selected.runtime() != installed || current != selected) return; + if (uiStarted) { + MainWindow.INSTANCE.navigation().runtimeChanged(); + MainWindow.INSTANCE.refreshRuntimeSources(); + } + List queued; + synchronized (lifecycleLock) { + if (!selected.isActive() || selected.runtime() != installed || current != selected) return; + queued = selected.drainNavigations(); + } + for (PendingNavigation pending : queued) { + MainWindow.INSTANCE.navigation().navigate(pending.target(), pending.activation()); + } + }); try { CompletableFuture breakpoints; - synchronized (CompanionApp.class) { - if (switchingProjects || runtime != installed || profile != selected) return; + synchronized (lifecycleLock) { + if (!selected.isActive() || selected.runtime() != installed || current != selected) return; breakpoints = getDebuggerController().replaceBreakpointDefinitions( - restoreBreakpoints(installed.snapshot().signature())); + selected.restoreBreakpoints(installed.snapshot().signature())); } // A failed debugger/UI refresh must never return ownership of an installed index to its loader. breakpoints.join(); - SwingUtilities.invokeLater(() -> { - if (switchingProjects || runtime != installed || profile != selected) return; - if (uiStarted) MainWindow.INSTANCE.refreshRuntimeSources(); - List queued; - synchronized (CompanionApp.class) { - if (switchingProjects || runtime != installed || profile != selected) return; - queued = List.copyOf(pendingNavigations); - pendingNavigations.clear(); - } - for (PendingNavigation pending : queued) { - MainWindow.INSTANCE.navigation().navigate(pending.target(), pending.activation()); - } - }); prewarmJavaParser(); } catch (RuntimeException failure) { System.getLogger(CompanionApp.class.getName()).log(System.Logger.Level.WARNING, @@ -688,14 +720,14 @@ private static void closeMcpServer() { } private static Map runtimeContext() { - CompanionProfile current = profile; + CompanionProfile current = currentProject(); if (current == null) { return Map.of(); } Map context = new java.util.LinkedHashMap<>(); context.put("profile_id", current.id()); context.put("workspace_directory", current.workspaceDirectory().toString()); - RuntimeBinding installed = runtime; + RuntimeBinding installed = currentRuntime(); if (installed != null) { context.put("runtime_signature", installed.snapshot().signature()); context.put("index_file", installed.snapshot().indexFile().toString()); @@ -792,7 +824,7 @@ public static void exit() { } try (var state = RuntimePhase.start("close.request-save")) { GlobalConfig.getInstance().saveNow(); - instanceState.saveNow(); + instanceState().saveNow(); } catch (IOException exception) { JOptionPane.showMessageDialog(MainWindow.INSTANCE, exception.getMessage(), "Unable to save state", JOptionPane.ERROR_MESSAGE); @@ -803,27 +835,27 @@ public static void exit() { } public static boolean isConnected() { - return !switchingProjects && session != null && session.isConnected(); + return !switching && session != null && session.isConnected(); } public static boolean hasProfile() { - return profile != null; + return current != null; } public static String getActiveRuntimeSignature() { - RuntimeBinding current = runtime; + RuntimeBinding current = currentRuntime(); return current == null ? null : current.snapshot().signature(); } public static boolean send(AbstractMessage message) { - if (switchingProjects && !(message instanceof StopScriptMessage)) return false; + if (switching && !(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); + try { return requireProject().admit(() -> scriptCompiler.compile(source, entryClass)); } + catch (IllegalStateException failure) { return CompletableFuture.failedFuture(failure); } } /** A pre-send check; the receiving runtime must still validate the result's inventory identity. */ @@ -833,10 +865,14 @@ public static boolean isCurrentRuntimeInventory(String inventoryId) { public static boolean runScript(int id, String source, boolean serverSide, ScriptExecutionEnvironment environment, Consumer failureHandler) { - CompanionSession current = session; - if (switchingProjects || current == null || !current.isConnected()) return false; - scriptCompiler.submit(id, source, serverSide, environment, failureHandler); - return true; + synchronized (lifecycleLock) { + ProjectScope scope = current; + if (scope == null || !scope.isActive() || !isConnected()) return false; + return scope.admit(() -> { + scriptCompiler.submit(id, source, serverSide, environment, failureHandler); + return true; + }); + } } public static boolean stopScript(int id) { @@ -851,12 +887,13 @@ public static void openClass(String binaryName, int targetType, String targetIde } private static void openOrQueue(NavigationTarget target, NavigationService.Activation activation) { - if (switchingProjects) return; - if (runtime == null) { - synchronized (CompanionApp.class) { - if (switchingProjects) return; - if (runtime == null) { - pendingNavigations.add(new PendingNavigation(target, activation)); + if (switching) return; + if (currentRuntime() == null) { + synchronized (lifecycleLock) { + if (switching) return; + if (currentRuntime() == null) { + ProjectScope scope = current; + if (scope != null && scope.isActive()) scope.queueNavigation(target, activation); return; } } @@ -880,7 +917,7 @@ public static void openDebugFrame( } private static DebugEngine.Source loadDebugSource(String binaryName) throws IOException { - RuntimeBinding current = runtime; + RuntimeBinding current = currentRuntime(); CompanionDecompilationService service = current == null ? null : current.decompiler(); return service == null ? null : service.loadDebugSource(binaryName); } @@ -894,7 +931,8 @@ public static InstancePaths instancePaths() { } public static InstanceState instanceState() { - return instanceState; + ProjectScope scope = current; + return scope == null ? emptyState : scope.state(); } public static Path getRootPath() { @@ -906,7 +944,7 @@ public static Path getWorkspaceDirectory() { } public static ReferenceSearchService getReferenceSearchService() { - RuntimeBinding current = runtime; + RuntimeBinding current = currentRuntime(); ReferenceSearchService service = current == null ? null : current.references(); if (service == null) { throw new IllegalStateException("Reference search is unavailable"); @@ -916,19 +954,19 @@ public static ReferenceSearchService getReferenceSearchService() { public static CodeInsightService getCodeInsightService() { CodeInsightService service = codeInsightService; - if (runtime == null) { + if (currentRuntime() == null) { throw new IllegalStateException("Code insight is unavailable"); } return service; } public static RuntimeSourceCatalog getRuntimeSourceCatalog() { - RuntimeBinding current = runtime; + RuntimeBinding current = currentRuntime(); return current == null ? RuntimeSourceCatalog.empty() : current.sources(); } public static CompanionDecompilationService getDecompilationService() { - RuntimeBinding current = runtime; + RuntimeBinding current = currentRuntime(); CompanionDecompilationService service = current == null ? null : current.decompiler(); if (service == null) { throw new IllegalStateException("Decompilation is unavailable"); @@ -955,7 +993,7 @@ public static boolean isDebuggerConnected() { } private static DebuggerSessionController createDebuggerController() { - DebuggerSessionController controller = new DebuggerSessionController(CompanionApp::loadDebugSource, () -> { RuntimeBinding current = runtime; return current == null ? null : current.classpath(); }, CompanionApp::loadBreakpointScript); + DebuggerSessionController controller = new DebuggerSessionController(CompanionApp::loadDebugSource, () -> { RuntimeBinding current = currentRuntime(); return current == null ? null : current.classpath(); }, name -> requireProject().loadBreakpointScript(name)); controller.setBreakpointsMuted(instanceState().debuggerBreakpointsMuted()).join(); controller.addListener(new DebuggerSessionController.Listener() { @Override @@ -963,82 +1001,19 @@ public void breakpointsChanged( URI sourceUri, List breakpoints ) { - persistBreakpoints(controller); + ProjectScope scope = current; + if (scope != null) scope.persistBreakpoints(controller); } @Override public void breakpointsMutedChanged(boolean muted) { - instanceState().setDebuggerBreakpointsMuted(muted); + ProjectScope scope = current; + if (scope != null && scope.isActive()) scope.state().setDebuggerBreakpointsMuted(muted); } }); return controller; } - private static String loadBreakpointScript(String name) { - Path relative = Path.of(name); - Path root = instancePaths().scripts().toAbsolutePath().normalize(); - Path file = root.resolve(relative).normalize(); - if (relative.isAbsolute() || !file.startsWith(root) || file.equals(root)) { - throw new IllegalArgumentException("Breakpoint script must be relative to the scripts directory"); - } - try { return java.nio.file.Files.readString(file); } - catch (IOException failure) { throw new IllegalStateException("Unable to read breakpoint script " + name, failure); } - } - - private static List restoreBreakpoints(String runtimeSignature) { - return instanceState().debuggerBreakpoints(runtimeSignature).stream() - .map(persisted -> { - DebugEngine.MethodTarget method = persisted.methodOwner() == null - ? null - : new DebugEngine.MethodTarget( - persisted.methodOwner(), - persisted.methodName(), - persisted.methodDescriptor() - ); - DebugEngine.SourceBreakpoint request = new DebugEngine.SourceBreakpoint( - persisted.line(), - persisted.debuggerLine(), - method, - persisted.condition(), - persisted.hitCondition(), persisted.action() - ); - return new DebuggerSessionController.BreakpointDefinition( - URI.create(persisted.sourceUri()), - persisted.binaryName(), - request, - persisted.enabled() - ); - }) - .toList(); - } - - private static void persistBreakpoints(DebuggerSessionController controller) { - if (switchingProjects) return; - String runtimeSignature = getActiveRuntimeSignature(); - if (runtimeSignature == null || runtimeSignature.isBlank()) { - return; - } - List persisted = controller.breakpointDefinitions().stream() - .map(definition -> { - DebugEngine.SourceBreakpoint request = definition.request(); - DebugEngine.MethodTarget method = request.method(); - return new InstanceState.PersistedBreakpoint( - definition.sourceUri().toString(), - definition.binaryName(), - request.line(), - request.debuggerLine(), - method == null ? null : method.ownerClassName(), - method == null ? null : method.name(), - method == null ? null : method.descriptor(), - request.condition(), - request.hitCondition(), - definition.enabled(), request.action() - ); - }) - .toList(); - instanceState().setDebuggerBreakpoints(runtimeSignature, persisted); - } - public static RuntimeIndexService.Status getRuntimeIndexStatus() { RuntimeIndexService service = runtimeIndexService; return service == null @@ -1069,23 +1044,22 @@ public static void retryRuntimeIndex() { } private static CompanionProfile requireProfile() { - CompanionProfile current = profile; + CompanionProfile current = currentProject(); if (current == null) { throw new IllegalStateException("No Minecraft profile is loaded"); } return current; } + public static RuntimeBinding currentRuntime() { + ProjectScope scope = current; + return scope == null ? null : scope.runtime(); + } + private static void closeRuntime() { - RuntimeBinding previous = runtime; - runtime = null; + ProjectScope scope = current; CompanionClassIndex.clear(); - if (previous != null) { - try { previous.close(); } - finally { - if (uiStarted) MainWindow.INSTANCE.navigation().runtimeChanged(); - } - } + if (scope != null) scope.closeRuntime(); } private static String newInstanceToken() { 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 dbfbaad5..6b91fe5e 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 @@ -1,6 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.mcp; import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; import com.github.minecraft_ta.totalDebugCompanion.jdt.CompanionClassIndex; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.server.McpServer; @@ -22,6 +23,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.function.Supplier; /** Loopback MCP host for Companion code mode. */ public final class CompanionMcpServer implements AutoCloseable { @@ -36,6 +38,7 @@ public final class CompanionMcpServer implements AutoCloseable { private final CompanionMcpSearchService search; private final DebuggerMcpService debugger; private final int port; + private final Supplier project; private HttpServletStreamableServerTransportProvider transportProvider; private McpSyncServer mcpServer; private Tomcat tomcat; @@ -48,10 +51,11 @@ public CompanionMcpServer(Path dataDirectory, CodeModeJobService jobs) { CompanionMcpServer(Path dataDirectory, CodeModeJobService jobs, int port) { this(dataDirectory, jobs, port, new DebuggerMcpService(CompanionApp::getDebuggerController, - name -> CompanionApp.getDecompilationService().loadDebugSource(name))); + name -> CompanionApp.getDecompilationService().loadDebugSource(name)), CompanionApp::requireProject); } - CompanionMcpServer(Path dataDirectory, CodeModeJobService jobs, int port, DebuggerMcpService debugger) { + CompanionMcpServer(Path dataDirectory, CodeModeJobService jobs, int port, DebuggerMcpService debugger, Supplier project) { + this.project = Objects.requireNonNull(project); this.dataDirectory = normalize(dataDirectory); this.endpointDescriptor = new com.github.minecraft_ta.totaldebug.storage.AppPaths(this.dataDirectory).mcpEndpoint(); this.jobs = Objects.requireNonNull(jobs, "jobs"); @@ -152,9 +156,7 @@ public void runtimeDisconnected() { 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"); + ProjectScope project = CompanionMcpToolCatalog.projectBound(request.name()) ? this.project.get() : null; Map result = switch (request.name()) { case "status" -> status(); case "client_code_execute" -> execute(request.arguments(), CodeModeJobService.ExecutionSide.CLIENT, project); @@ -189,8 +191,7 @@ private McpSchema.CallToolResult callTool(McpSchema.CallToolRequest request) { 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"); + if (project != null) project.requireActive(); return CompanionMcpToolCatalog.result(result, false); } catch (RuntimeException | IOException exception) { return CompanionMcpToolCatalog.result( @@ -210,7 +211,7 @@ private Map status() { private Map execute( Map arguments, - CodeModeJobService.ExecutionSide side, long project + CodeModeJobService.ExecutionSide side, ProjectScope project ) { String code = requiredString(arguments, "code"); List imports = optionalStringList(arguments, "imports"); @@ -218,7 +219,7 @@ private Map execute( CodeModeJobService.ExecutionEnvironment.class, Objects.requireNonNullElse(optionalString(arguments, "environment"), "thread") ); - CodeModeJobService.JobSnapshot submitted = CompanionApp.inProject(project, () -> this.jobs.submit(code, imports, side, environment)); + CodeModeJobService.JobSnapshot submitted = project.admit(() -> 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/CompanionMcpToolCatalog.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpToolCatalog.java index f2e1fb33..26ff98d4 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpToolCatalog.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpToolCatalog.java @@ -40,27 +40,32 @@ final class CompanionMcpToolCatalog { ), List.of("code") ); - private static final List TOOLS = List.of( - tool( + private record ToolSpec(McpSchema.Tool tool, boolean projectBound) { } + private static final List TOOLS = List.of( + spec( "status", + false, "Report Companion, Minecraft, and debugger connectivity.", emptySchema(), statusOutputSchema() ), - tool( + spec( "client_code_execute", + true, "Execute a value-returning Java body in the connected Minecraft client JVM.", EXECUTE_INPUT_SCHEMA, jobOutputSchema() ), - tool( + spec( "server_code_execute", + true, "Execute a value-returning Java body with server authority.", EXECUTE_INPUT_SCHEMA, jobOutputSchema() ), - tool( + spec( "job_wait", + false, "Wait for one code job to finish or return its current state at the timeout.", objectSchema( Map.of( @@ -75,20 +80,23 @@ final class CompanionMcpToolCatalog { ), jobOutputSchema() ), - tool( + spec( "job_cancel", + false, "Request cancellation of one code job.", jobIdSchema(), jobCancellationOutputSchema() ), - tool( + spec( "job_source", + false, "Return the exact generated Java source for one code job.", jobIdSchema(), sourceOutputSchema() ), - tool( + spec( "search_classes", + true, "Resolve an exact binary name first, otherwise search full binary names by literal text.", objectSchema( Map.of("query", stringSchema("Exact binary name or case-insensitive name fragment.")), @@ -96,8 +104,9 @@ final class CompanionMcpToolCatalog { ), boundedListOutputSchema("classes", classOutputSchema()) ), - tool( + spec( "runtime_source", + true, "Return one exact class or member source scope from a runtime class.", objectSchema( Map.of("target", runtimeSourceTargetSchema()), @@ -105,14 +114,16 @@ final class CompanionMcpToolCatalog { ), runtimeSourceOutputSchema() ), - tool( + spec( "search_symbols", + true, "Search field and method declarations, or list declarations owned by one class.", searchSymbolsSchema(), boundedListOutputSchema("symbols", symbolOutputSchema()) ), - tool( + spec( "find_usages", + true, "Find declaration sites that reference one exact class, field, or method.", objectSchema( Map.of("target", usageTargetSchema()), @@ -120,8 +131,9 @@ final class CompanionMcpToolCatalog { ), boundedListOutputSchema("usages", usageOutputSchema()) ), - tool( + spec( "search_literals", + true, "Search indexed Java string literal values by case-sensitive text.", objectSchema(Map.of("query", stringSchema("Literal text fragment.")), List.of("query")), boundedListOutputSchema("literals", literalOutputSchema()) @@ -171,6 +183,16 @@ static McpSchema.CallToolResult companionUnavailable(String tool, ConnectionFail return result(Map.of("error", failure.asMap()), true); } + private static ToolSpec spec(String name, boolean projectBound, String description, + Map inputSchema, Map outputSchema) { + return new ToolSpec(tool(name, description, inputSchema, outputSchema), projectBound); + } + + static boolean projectBound(String name) { + return TOOLS.stream().filter(spec -> spec.tool().name().equals(name)) + .findFirst().map(ToolSpec::projectBound).orElse(true); + } + static McpSchema.Tool tool( String name, String description, @@ -480,7 +502,7 @@ private static Map nonNegativeIntegerSchema(String description) } private static List allTools() { - return java.util.stream.Stream.concat(TOOLS.stream(), DebuggerMcpToolCatalog.tools().stream()).toList(); + return java.util.stream.Stream.concat(TOOLS.stream().map(ToolSpec::tool), DebuggerMcpToolCatalog.tools().stream()).toList(); } private static Map indexTools() { 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 f367a5a0..fa6e3ccb 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,6 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.mcp; import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerBreakpointResolver; @@ -27,7 +28,7 @@ final class DebuggerMcpService { this.sources = Objects.requireNonNull(sources, "sources"); } - Map call(String tool, Map args, long project) throws IOException { + Map call(String tool, Map args, ProjectScope project) throws IOException { DebuggerSessionController session = this.controller.get(); try { return switch (tool) { @@ -47,12 +48,12 @@ Map call(String tool, Map args, long project) th 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(CompanionApp.inProject(project, () -> session.startEvaluation(text(args, "pause_id"), + case "debugger_evaluate" -> operation(session, await(project.admit(() -> 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(CompanionApp.inProject(project, () -> session.cancelEvaluation(text(args, "operation_id")))); + var requested = await(project.admit(() -> session.cancelEvaluation(text(args, "operation_id")))); yield operation(session, requested, 0); } default -> throw new IllegalArgumentException("Unknown debugger tool: " + tool); @@ -116,10 +117,10 @@ private static Map operation(DebuggerSessionController session, return result; } - private Map control(DebuggerSessionController session, Map args, long project) + private Map control(DebuggerSessionController session, Map args, ProjectScope project) throws InterruptedException { String action = text(args, "action"); - CompletableFuture operation = CompanionApp.inProject(project, () -> switch (action) { + CompletableFuture operation = project.admit(() -> switch (action) { case "attach" -> session.attach(); case "detach" -> session.detach(); case "pause" -> session.pause(((Number) args.get("thread_id")).longValue()); @@ -132,7 +133,7 @@ private Map control(DebuggerSessionController session, Map setBreakpoint(DebuggerSessionController session, Map args, long project) + private Map setBreakpoint(DebuggerSessionController session, Map args, ProjectScope project) throws IOException { String binaryName = text(args, "binary_name"); DebugEngine.Source source; @@ -144,7 +145,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); + project.requireActive(); int line = integer(args, "line", 0); DebugEngine.SourceBreakpoint request = DebuggerBreakpointResolver.resolve(source, line, (String) args.get("condition"), (String) args.get("hit_condition")) @@ -154,19 +155,19 @@ private Map setBreakpoint(DebuggerSessionController session, Map (String) action.get("script"), "continue_on_success".equals(action.get("completion")))); } DebugEngine.SourceBreakpoint resolvedRequest = request; - await(CompanionApp.inProject(project, () -> session.putBreakpoint(source, resolvedRequest, (Boolean) args.getOrDefault("enabled", true)))); + await(project.admit(() -> 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, long project) { + private Map removeBreakpoint(DebuggerSessionController session, Map args, ProjectScope 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(CompanionApp.inProject(project, () -> session.removeBreakpoint(entry.sourceUri(), line))); + for (var entry : matches) await(project.admit(() -> session.removeBreakpoint(entry.sourceUri(), line))); return Map.of("removed", !matches.isEmpty()); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/CodeView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/CodeView.java index ad356c00..93c9c3b6 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/CodeView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/CodeView.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.model; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; import com.formdev.flatlaf.util.StringUtils; import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; import com.github.minecraft_ta.totalDebugCompanion.Icons; @@ -19,6 +20,9 @@ import java.util.Optional; public class CodeView implements IEditorPanel { + private final RuntimeBinding runtimeBinding; + @Override public RuntimeBinding runtimeBinding() { return runtimeBinding; } + private final Path path; private final EditorLocation location; @@ -32,6 +36,7 @@ public CodeView(Path path, int offset) { } public CodeView(Path path, int offset, EditorLocation location) { + this.runtimeBinding = null; this.path = path; this.location = location; this.debugSource = null; @@ -40,7 +45,8 @@ public CodeView(Path path, int offset, EditorLocation location) { reload(offset); } - public CodeView(DecompiledSource source, int offset, EditorLocation location) { + public CodeView(DecompiledSource source, int offset, EditorLocation location, RuntimeBinding runtimeBinding) { + this.runtimeBinding = runtimeBinding; this.path = source.path(); this.location = location; this.debugSource = source.debugSource(); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/IEditorPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/IEditorPanel.java index c18a28a3..9c75245e 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/IEditorPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/IEditorPanel.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.model; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; import com.github.minecraft_ta.totalDebugCompanion.ui.components.global.BottomInformationBar; import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationViewState; @@ -30,6 +31,9 @@ default BottomInformationBar getInformationBar() { return null; } + /** The runtime that supplied this view; local editors have no runtime owner. */ + default RuntimeBinding runtimeBinding() { return null; } + default NavigationTarget getNavigationTarget() { return null; } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/LiteralUsagesView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/LiteralUsagesView.java index 09714db6..e2d1f078 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/LiteralUsagesView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/LiteralUsagesView.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.model; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceQuery; @@ -11,16 +12,21 @@ import java.util.Objects; public final class LiteralUsagesView implements IEditorPanel { + private final RuntimeBinding runtimeBinding; + @Override public RuntimeBinding runtimeBinding() { return runtimeBinding; } + private final String literal; private final UsagesViewPanel panel; - public LiteralUsagesView(String literal) { + public LiteralUsagesView(String literal, RuntimeBinding runtimeBinding) { + if (runtimeBinding == null) throw new IllegalStateException("Reference search is unavailable"); + this.runtimeBinding = runtimeBinding; this.literal = Objects.requireNonNull(literal, "literal"); this.panel = new UsagesViewPanel( ReferenceQuery.stringLiteral(literal), quotedPreview(literal), Icons.VALUE, - CompanionApp.getReferenceSearchService() + runtimeBinding.references() ); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/ResourceView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/ResourceView.java index 6923ae2d..6bcd8dfe 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/ResourceView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/ResourceView.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.model; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; import com.github.minecraft_ta.totalDebugCompanion.resource.ContentSource; import com.github.minecraft_ta.totalDebugCompanion.resource.ArchiveEntrySource; import com.github.minecraft_ta.totalDebugCompanion.resource.LocalFileSource; @@ -15,12 +16,16 @@ import java.util.Objects; public final class ResourceView implements IEditorPanel { + private final RuntimeBinding runtimeBinding; + @Override public RuntimeBinding runtimeBinding() { return runtimeBinding; } + private final ContentSource source; private final ResourceFileType fileType; private final ResourceViewPanel panel; - public ResourceView(ContentSource source) { + public ResourceView(ContentSource source, RuntimeBinding runtimeBinding) { + this.runtimeBinding = runtimeBinding; this.source = Objects.requireNonNull(source, "source"); this.fileType = FileTypeResolver.resolve(source.displayName()); this.panel = new ResourceViewPanel(source, this.fileType); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/UsagesView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/UsagesView.java index 8f31a5d9..1d115f26 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/UsagesView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/UsagesView.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.model; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; @@ -11,12 +12,17 @@ import java.util.Objects; public final class UsagesView implements IEditorPanel { + private final RuntimeBinding runtimeBinding; + @Override public RuntimeBinding runtimeBinding() { return runtimeBinding; } + private final CodeSymbol symbol; private final UsagesViewPanel panel; - public UsagesView(CodeSymbol symbol) { + public UsagesView(CodeSymbol symbol, RuntimeBinding runtimeBinding) { + if (runtimeBinding == null) throw new IllegalStateException("Reference search is unavailable"); + this.runtimeBinding = runtimeBinding; this.symbol = Objects.requireNonNull(symbol, "symbol"); - this.panel = new UsagesViewPanel(symbol, CompanionApp.getReferenceSearchService()); + this.panel = new UsagesViewPanel(symbol, runtimeBinding.references()); } public CodeSymbol symbol() { 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 1c0463cd..63366efb 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 @@ -1,6 +1,9 @@ package com.github.minecraft_ta.totalDebugCompanion.navigation; +import java.util.function.Predicate; import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; import com.github.minecraft_ta.totalDebugCompanion.decompile.DecompiledSource; import com.github.minecraft_ta.totalDebugCompanion.decompile.SourceFileNavigation; @@ -29,7 +32,7 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.CancellationException; import java.util.function.Supplier; /** Resolves semantic destinations into the current Companion UI. */ @@ -42,10 +45,16 @@ public enum Activation { private final MainWindow window; private final EditorTabs tabs; private final FileTreeView fileTree; - private final NavigationHistory history = new NavigationHistory(100); - private final AtomicBoolean traversingHistory = new AtomicBoolean(); - private volatile NavigationEntry currentEntry; - private final java.util.concurrent.atomic.AtomicLong runtimeGeneration = new java.util.concurrent.atomic.AtomicLong(); + private volatile ProjectScope project; + private final NavigationState emptyNavigation = new NavigationState(); + private NavigationState state() { var scope = project; return scope == null ? emptyNavigation : scope.navigation(); } + private record Context(ProjectScope project, RuntimeBinding runtime) { } + private Context captureContext() { var scope = project; return new Context(scope, scope == null ? null : scope.runtime()); } + private boolean isCurrent(Context captured) { + return captured.project() == project && (project == null + ? !CompanionApp.isSwitching() + : project.isActive() && project.runtime() == captured.runtime()); + } private final Action backAction = new AbstractAction("Back") { @Override public void actionPerformed(ActionEvent event) { @@ -60,6 +69,7 @@ public void actionPerformed(ActionEvent event) { }; public NavigationService(MainWindow window, EditorTabs tabs, FileTreeView fileTree) { + this.project = CompanionApp.currentScope(); this.window = Objects.requireNonNull(window, "window"); this.tabs = Objects.requireNonNull(tabs, "tabs"); this.fileTree = Objects.requireNonNull(fileTree, "fileTree"); @@ -74,15 +84,15 @@ 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(); + Context context = captureContext(); CompletableFuture navigation = captureCurrentEntry().thenCompose(origin -> - (generation == this.runtimeGeneration.get() ? performNavigation(target, activation) - : CompletableFuture.failedFuture(new java.util.concurrent.CancellationException("Project changed"))) + (isCurrent(context) ? performNavigation(target, activation) + : CompletableFuture.failedFuture(new CancellationException("Project changed"))) .thenCompose(ignored -> captureDestination(target)) .thenAccept(destination -> { - if (generation != this.runtimeGeneration.get()) return; - this.currentEntry = destination; - this.history.recordNewNavigation(origin); + if (!isCurrent(context)) return; + state().currentEntry = destination; + state().history.recordNewNavigation(origin); refreshHistoryActions(); }) ); @@ -99,19 +109,23 @@ public Action forwardAction() { } public void runtimeChanged() { - this.runtimeGeneration.incrementAndGet(); - SwingUtilities.invokeLater(() -> this.tabs.closeMatching(editor -> - editor instanceof CodeView view && view.getNavigationTarget() instanceof NavigationTarget.RuntimeClass - || editor instanceof ResourceView resource && resource.getNavigationTarget() instanceof NavigationTarget.ArchiveEntry - || editor instanceof UsagesView || editor instanceof LiteralUsagesView)); + Context context = captureContext(); + var navigationState = state(); + var traversal = navigationState.traversal.get(); + if (traversal != null && traversal.runtime() != context.runtime()) navigationState.traversal.compareAndSet(traversal, null); + SwingUtilities.invokeLater(() -> { + if (!isCurrent(context)) return; + this.tabs.closeMatching(editor -> editor.runtimeBinding() != context.runtime() + && (editor.getNavigationTarget() instanceof NavigationTarget.RuntimeClass + || editor.getNavigationTarget() instanceof NavigationTarget.ArchiveEntry + || editor.getNavigationTarget() instanceof NavigationTarget.SymbolUsages + || editor.getNavigationTarget() instanceof NavigationTarget.LiteralUsages)); + }); refreshHistoryActions(); } - public void projectChanged() { - this.runtimeGeneration.incrementAndGet(); - this.traversingHistory.set(false); - this.currentEntry = null; - this.history.clear(); + public void projectChanged(ProjectScope project) { + this.project = project; refreshHistoryActions(); } @@ -126,6 +140,7 @@ public CompletableFuture goForward() { private CompletableFuture performNavigation(NavigationTarget target, Activation activation) { CompletableFuture navigation; try { + RuntimeBinding requestedRuntime = CompanionApp.currentRuntime(); navigation = switch (target) { case NavigationTarget.RuntimeClass runtimeClass -> openRuntimeSource( runtimeClass.binaryName(), @@ -162,15 +177,15 @@ private CompletableFuture performNavigation(NavigationTarget target, Activ -1, activation ); - case NavigationTarget.SymbolUsages usages -> onEdt(() -> this.tabs.focusOrCreateIfAbsent( + case NavigationTarget.SymbolUsages usages -> onEdt(() -> openRuntimeEditor(requestedRuntime, UsagesView.class, view -> view.symbol().equals(usages.symbol()), - () -> new UsagesView(usages.symbol()) + () -> new UsagesView(usages.symbol(), requestedRuntime) ).thenAccept(UsagesView::restartSearch), activation); - case NavigationTarget.LiteralUsages usages -> onEdt(() -> this.tabs.focusOrCreateIfAbsent( + case NavigationTarget.LiteralUsages usages -> onEdt(() -> openRuntimeEditor(requestedRuntime, LiteralUsagesView.class, view -> view.literal().equals(usages.literal()), - () -> new LiteralUsagesView(usages.literal()) + () -> new LiteralUsagesView(usages.literal(), requestedRuntime) ).thenAccept(LiteralUsagesView::restartSearch), activation); case NavigationTarget.RuntimePackage runtimePackage -> revealPackage(runtimePackage); case NavigationTarget.ModuleSearch search -> onEdt(() -> { @@ -185,36 +200,38 @@ private CompletableFuture performNavigation(NavigationTarget target, Activ } private CompletableFuture traverseHistory(NavigationHistory.Direction direction) { - long generation = this.runtimeGeneration.get(); - if (!this.traversingHistory.compareAndSet(false, true)) { + Context context = captureContext(); + NavigationState navigationState = state(); + var traversal = new NavigationState.Traversal(context.runtime()); + if (!navigationState.traversal.compareAndSet(null, traversal)) { return CompletableFuture.completedFuture(null); } - NavigationEntry destination = this.history.destination( + NavigationEntry destination = state().history.destination( direction, CompanionApp.getActiveRuntimeSignature() ); if (destination == null) { - this.traversingHistory.set(false); + state().traversal.set(null); refreshHistoryActions(); return CompletableFuture.completedFuture(null); } CompletableFuture navigation = captureCurrentEntry().thenCompose(origin -> - (generation == this.runtimeGeneration.get() ? performNavigation(destination.target(), Activation.ACTIVATE_WINDOW) - : CompletableFuture.failedFuture(new java.util.concurrent.CancellationException("Project changed"))) - .thenCompose(ignored -> restoreSelectedEntry(destination)) + (isCurrent(context) ? performNavigation(destination.target(), Activation.ACTIVATE_WINDOW) + : CompletableFuture.failedFuture(new CancellationException("Project changed"))) + .thenCompose(ignored -> restoreSelectedEntry(destination, context)) .thenRun(() -> { - if (generation != this.runtimeGeneration.get()) return; - this.currentEntry = destination; - this.history.complete(direction, destination, origin); + if (!isCurrent(context)) return; + state().currentEntry = destination; + state().history.complete(direction, destination, origin); }) ); navigation.whenComplete((ignored, failure) -> { - if (generation != this.runtimeGeneration.get()) return; - if (failure != null && !(unwrap(failure) instanceof java.util.concurrent.CancellationException)) { - this.history.discard(direction, destination); + if (isCurrent(context) && failure != null && !(unwrap(failure) instanceof CancellationException)) { + navigationState.history.discard(direction, destination); } - this.traversingHistory.set(false); + // A reversible switch must not strand the old traversal; a new one has a different token. + navigationState.traversal.compareAndSet(traversal, null); refreshHistoryActions(); }); reportFailure(navigation, destination.target()); @@ -226,7 +243,7 @@ private CompletableFuture captureCurrentEntry() { SwingUtilities.invokeLater(() -> { try { IEditorPanel editor = this.tabs.getSelectedEditor(); - NavigationEntry current = this.currentEntry; + NavigationEntry current = state().currentEntry; if (current != null && (!isEditorDestination(current.target()) || editor == null || sameEditorDestination( @@ -265,8 +282,9 @@ private CompletableFuture captureDestination(NavigationTarget t return result; } - private CompletableFuture restoreSelectedEntry(NavigationEntry entry) { + private CompletableFuture restoreSelectedEntry(NavigationEntry entry, Context context) { return onEdt(() -> { + if (!isCurrent(context)) return CompletableFuture.failedFuture(new CancellationException("Project changed")); IEditorPanel editor = this.tabs.getSelectedEditor(); if (!isEditorDestination(entry.target())) { return CompletableFuture.completedFuture(null); @@ -282,7 +300,7 @@ private CompletableFuture restoreSelectedEntry(NavigationEntry entry) { } private void selectedEditorChanged(IEditorPanel editor) { - this.currentEntry = entryForEditor(editor); + state().currentEntry = entryForEditor(editor); } private NavigationEntry entryForEditor(IEditorPanel editor) { @@ -330,9 +348,9 @@ private static boolean sameEditorDestination(NavigationTarget requested, Navigat } private void reportFailure(CompletableFuture navigation, NavigationTarget target) { - long generation = this.runtimeGeneration.get(); + Context context = captureContext(); navigation.whenComplete((ignored, failure) -> { - if (failure != null && generation == this.runtimeGeneration.get() && !CompanionApp.isSwitchingProjects()) { + if (failure != null && isCurrent(context) && !CompanionApp.isSwitching()) { showFailure(target, unwrap(failure)); } }); @@ -341,12 +359,12 @@ private void reportFailure(CompletableFuture navigation, NavigationTarget private void refreshHistoryActions() { SwingUtilities.invokeLater(() -> { String runtimeSignature = CompanionApp.getActiveRuntimeSignature(); - boolean available = !this.traversingHistory.get(); - this.backAction.setEnabled(available && this.history.canNavigate( + boolean available = state().traversal.get() == null; + this.backAction.setEnabled(available && state().history.canNavigate( NavigationHistory.Direction.BACK, runtimeSignature )); - this.forwardAction.setEnabled(available && this.history.canNavigate( + this.forwardAction.setEnabled(available && state().history.canNavigate( NavigationHistory.Direction.FORWARD, runtimeSignature )); @@ -359,18 +377,20 @@ private CompletableFuture openRuntimeSource( int executionLine, Activation activation ) { - var service = CompanionApp.getDecompilationService(); - long generation = this.runtimeGeneration.get(); + Context context = captureContext(); + RuntimeBinding installed = context.runtime(); + if (installed == null) throw new IllegalStateException("Decompilation is unavailable"); + var service = installed.decompiler(); return service.load(binaryName).thenCompose(source -> { int offset = offsetResolver.applyAsInt(source); return onEdt(() -> { - if (generation != this.runtimeGeneration.get() || service != CompanionApp.getDecompilationService()) { - return CompletableFuture.failedFuture(new java.util.concurrent.CancellationException("Runtime changed during source navigation")); + if (!isCurrent(context) || service != CompanionApp.getDecompilationService()) { + return CompletableFuture.failedFuture(new CancellationException("Runtime changed during source navigation")); } - return this.tabs.focusOrCreateIfAbsent( + return openRuntimeEditor(installed, CodeView.class, view -> view.getPath().equals(source.path()), - () -> new CodeView(source, offset, SourceFileNavigation.location(source)) + () -> new CodeView(source, offset, SourceFileNavigation.location(source), installed) ).thenAccept(view -> { view.navigateToOffset(offset); if (executionLine > 0) { @@ -409,18 +429,32 @@ private CompletableFuture openLocalFile(NavigationTarget.LocalFile target, } private CompletableFuture openResource(ContentSource source, Activation activation) { - return onEdt(() -> this.tabs.focusOrCreateIfAbsent( + RuntimeBinding installed = source instanceof ArchiveEntrySource ? captureContext().runtime() : null; + return onEdt(() -> openRuntimeEditor(installed, ResourceView.class, view -> view.source().identity().equals(source.identity()), - () -> new ResourceView(source) + () -> new ResourceView(source, installed) ).thenApply(ignored -> null), activation); } + private CompletableFuture openRuntimeEditor( + RuntimeBinding runtime, Class type, Predicate matches, Supplier create) { + if (runtime != null && runtime != captureContext().runtime()) { + return CompletableFuture.failedFuture(new CancellationException("Runtime changed")); + } + // Dispose a stale same-file editor before the new one installs its AST listeners. + tabs.closeMatching(editor -> type.isInstance(editor) && matches.test(type.cast(editor)) + && editor.runtimeBinding() != runtime); + return tabs.focusOrCreateIfAbsent(type, editor -> editor.runtimeBinding() == runtime && matches.test(editor), create); + } + private CompletableFuture revealPackage(NavigationTarget.RuntimePackage target) { var result = new CompletableFuture(); + Context context = captureContext(); CompanionApp.getCodeInsightService().locateClass(target.ownerClassName(), new CodeInsightService.Listener<>() { @Override public void onCompleted(RuntimeSnapshotBytecodeSource.Source source) { + if (!isCurrent(context)) { result.cancel(false); return; } if (source == null) { result.completeExceptionally(new IllegalStateException( "Class " + target.ownerClassName() + " is not present in the runtime index" @@ -477,14 +511,15 @@ private CompletableFuture onEdt( Activation activation ) { var result = new CompletableFuture(); - long generation = this.runtimeGeneration.get(); + Context context = captureContext(); SwingUtilities.invokeLater(() -> { try { - if (generation != this.runtimeGeneration.get() || CompanionApp.isSwitchingProjects()) { - result.completeExceptionally(new java.util.concurrent.CancellationException("Project changed")); + if (!isCurrent(context) || CompanionApp.isSwitching()) { + result.completeExceptionally(new CancellationException("Project changed")); return; } operation.get().whenComplete((ignored, failure) -> { + if (!isCurrent(context)) { result.cancel(false); return; } if (failure != null) { result.completeExceptionally(failure); return; @@ -502,8 +537,8 @@ private CompletableFuture onEdt( } private void showFailure(NavigationTarget target, Throwable failure) { - if (failure instanceof java.util.concurrent.CancellationException) return; - long generation = this.runtimeGeneration.get(); + if (failure instanceof CancellationException) return; + Context context = captureContext(); failure.printStackTrace(System.err); String detail = failure.getMessage(); if (detail == null || detail.isBlank()) { @@ -511,7 +546,7 @@ private void showFailure(NavigationTarget target, Throwable failure) { } String message = "Unable to open " + label(target) + ": " + detail; SwingUtilities.invokeLater(() -> { - if (generation != this.runtimeGeneration.get() || CompanionApp.isSwitchingProjects()) return; + if (!isCurrent(context) || CompanionApp.isSwitching()) return; JOptionPane.showMessageDialog( this.window, message, diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationState.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationState.java new file mode 100644 index 00000000..97b8867c --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/navigation/NavigationState.java @@ -0,0 +1,12 @@ +package com.github.minecraft_ta.totalDebugCompanion.navigation; + +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; +import java.util.concurrent.atomic.AtomicReference; + +/** History belongs to a project, including while its runtime is temporarily unavailable. */ +public final class NavigationState { + final NavigationHistory history = new NavigationHistory(100); + record Traversal(RuntimeBinding runtime) { } + final AtomicReference traversal = new AtomicReference<>(); + volatile NavigationEntry currentEntry; +} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java new file mode 100644 index 00000000..8e3791cd --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java @@ -0,0 +1,165 @@ +package com.github.minecraft_ta.totalDebugCompanion.project; + +import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; +import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerSessionController; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationService; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationState; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.storage.InstanceState; +import com.github.minecraft_ta.totaldebug.storage.InstancePaths; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +/** Resources and request admission for one opened project. */ +public final class ProjectScope implements AutoCloseable { + public enum Phase { ACTIVE, SWITCHING, RETIRED } + public record PendingNavigation(NavigationTarget target, NavigationService.Activation activation) { } + + private final NavigationState navigation = new NavigationState(); + public NavigationState navigation() { return navigation; } + + private final Object lock; + private final CompanionProfile profile; + private final InstanceState state; + private final List pending = new ArrayList<>(); + private volatile Phase phase = Phase.ACTIVE; + private volatile RuntimeBinding runtime; + private boolean closed; + + public ProjectScope(Object lock, CompanionProfile profile, InstanceState state) { + this.lock = Objects.requireNonNull(lock); + this.profile = Objects.requireNonNull(profile); + this.state = Objects.requireNonNull(state); + } + + public static ProjectScope open(Object lock, CompanionProfile profile) throws IOException { + return new ProjectScope(lock, profile, InstanceState.open(new InstancePaths(profile.dataDirectory()))); + } + + public CompanionProfile profile() { return profile; } + public InstanceState state() { return state; } + public InstancePaths paths() { return new InstancePaths(profile.dataDirectory()); } + public Phase phase() { return phase; } + public boolean isActive() { return phase == Phase.ACTIVE; } + public void requireActive() { + if (!isActive()) throw new IllegalStateException("Project changed during the request"); + } + + /** Check and submit under the shared lifecycle lock; actions must never wait. */ + public T admit(Supplier action) { + synchronized (lock) { requireActive(); return action.get(); } + } + public void beginSwitch() { + synchronized (lock) { requireActive(); phase = Phase.SWITCHING; } + } + public void cancelSwitch() { + synchronized (lock) { + if (phase == Phase.SWITCHING) phase = Phase.ACTIVE; + } + } + public void retire() { synchronized (lock) { phase = Phase.RETIRED; } } + public RuntimeBinding runtime() { return runtime; } + public String runtimeSignature() { var value = runtime; return value == null ? null : value.snapshot().signature(); } + + /** Called by the loader under the shared lifecycle lock, before its ownership handoff. */ + public void bindRuntime(RuntimeBinding value) { requireActive(); runtime = value; } + public void closeRuntime() { + RuntimeBinding previous = runtime; + runtime = null; + if (previous != null) previous.close(); + } + public void queueNavigation(NavigationTarget target, NavigationService.Activation activation) { + synchronized (lock) { requireActive(); pending.add(new PendingNavigation(target, activation)); } + } + public List drainNavigations() { + synchronized (lock) { + requireActive(); + var result = List.copyOf(pending); + pending.clear(); + return result; + } + } + @Override public void close() throws IOException { + synchronized (lock) { + if (phase != Phase.RETIRED) throw new IllegalStateException("Retire the project before closing it"); + if (closed) return; + closed = true; + pending.clear(); + } + try { closeRuntime(); } finally { state.close(); } + } + + public String loadBreakpointScript(String name) { + Path relative = Path.of(name); + Path root = paths().scripts().toAbsolutePath().normalize(); + Path file = root.resolve(relative).normalize(); + if (relative.isAbsolute() || !file.startsWith(root) || file.equals(root)) { + throw new IllegalArgumentException("Breakpoint script must be relative to the scripts directory"); + } + try { return Files.readString(file); } + catch (IOException failure) { throw new IllegalStateException("Unable to read breakpoint script " + name, failure); } + } + + public List restoreBreakpoints(String runtimeSignature) { + return state.debuggerBreakpoints(runtimeSignature).stream() + .map(persisted -> { + DebugEngine.MethodTarget method = persisted.methodOwner() == null + ? null + : new DebugEngine.MethodTarget( + persisted.methodOwner(), + persisted.methodName(), + persisted.methodDescriptor() + ); + DebugEngine.SourceBreakpoint request = new DebugEngine.SourceBreakpoint( + persisted.line(), + persisted.debuggerLine(), + method, + persisted.condition(), + persisted.hitCondition(), persisted.action() + ); + return new DebuggerSessionController.BreakpointDefinition( + URI.create(persisted.sourceUri()), + persisted.binaryName(), + request, + persisted.enabled() + ); + }) + .toList(); + } + + public void persistBreakpoints(DebuggerSessionController controller) { + if (!isActive()) return; + String runtimeSignature = runtimeSignature(); + if (runtimeSignature == null || runtimeSignature.isBlank()) { + return; + } + List persisted = controller.breakpointDefinitions().stream() + .map(definition -> { + DebugEngine.SourceBreakpoint request = definition.request(); + DebugEngine.MethodTarget method = request.method(); + return new InstanceState.PersistedBreakpoint( + definition.sourceUri().toString(), + definition.binaryName(), + request.line(), + request.debuggerLine(), + method == null ? null : method.ownerClassName(), + method == null ? null : method.name(), + method == null ? null : method.descriptor(), + request.condition(), + request.hitCondition(), + definition.enabled(), request.action() + ); + }) + .toList(); + state.setDebuggerBreakpoints(runtimeSignature, persisted); + } + +} 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 3f9361b5..23476306 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 @@ -366,6 +366,7 @@ private void reportNavigationFailure(String message) { } public void refreshProfile() { + this.navigationService.projectChanged(CompanionApp.currentScope()); this.fileTreeView.reloadProfile(); refreshActions(); } @@ -373,6 +374,12 @@ public void refreshProfile() { public boolean prepareProjectSwitch() { if (!SwingUtilities.isEventDispatchThread()) throw new IllegalStateException("Project views must close on the EDT"); if (!this.editorTabs.canCloseAll()) return false; + setEnabled(false); + return true; + } + + public boolean closeProjectViews() { + if (!SwingUtilities.isEventDispatchThread()) throw new IllegalStateException("Project views must close on the EDT"); this.editorTabs.closeMatching(editor -> true); if (this.editorTabs.getTabCount() != 0) return false; for (Window window : getOwnedWindows()) window.dispose(); @@ -386,7 +393,7 @@ public boolean prepareProjectSwitch() { this.evaluateExpressionWindow = null; this.searchEverywherePopup = null; this.snippetExecutions = null; - this.navigationService.projectChanged(); + this.statusBar.setEditor(null); setEnabled(false); return true; } 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 index fdc426bd..ca9c5678 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java @@ -1,6 +1,8 @@ package com.github.minecraft_ta.totalDebugCompanion; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; +import com.github.minecraft_ta.totalDebugCompanion.storage.InstanceState; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; import com.github.minecraft_ta.totalDebugCompanion.session.ProjectRegistry; import com.github.minecraft_ta.totaldebug.storage.AppPaths; @@ -8,13 +10,20 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; +import javax.swing.SwingUtilities; import java.nio.file.Path; import java.util.concurrent.TimeUnit; import java.util.concurrent.CompletableFuture; import java.util.Map; +import com.github.minecraft_ta.totalDebugCompanion.mcp.ProjectSwitchJobs; +import com.github.minecraft_ta.totalDebugCompanion.mcp.CodeModeJobService; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.CancellationException; import static org.junit.jupiter.api.Assertions.*; import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationService; import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; +import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; class ProjectSwitchLifecycleTest { @TempDir Path directory; @@ -46,8 +55,7 @@ public static void main(String[] args) { 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 jobs = ProjectSwitchJobs.create(); 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); @@ -64,22 +72,26 @@ public static void main(String[] args) { 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.instanceState().setDebuggerWatches(List.of("watch A")); + var admitted = CompanionApp.requireProject().admit(() -> jobs.submit("return 42;", List.of(), + CodeModeJobService.ExecutionSide.CLIENT, CodeModeJobService.ExecutionEnvironment.THREAD)); + assertEquals(CodeModeJobService.JobState.COMPILING, admitted.state()); CompanionApp.openProject(b).get(10, TimeUnit.SECONDS); + assertEquals(CodeModeJobService.JobState.DISCONNECTED, jobs.get(admitted.jobId()).orElseThrow().state()); assertEquals(b, CompanionApp.currentProject()); assertTrue(CompanionApp.instanceState().debuggerWatches().isEmpty()); - CompanionApp.instanceState().setDebuggerWatches(java.util.List.of("watch B")); + CompanionApp.instanceState().setDebuggerWatches(List.of("watch B")); CompanionApp.openProject(a).get(10, TimeUnit.SECONDS); - assertEquals(java.util.List.of("watch A"), CompanionApp.instanceState().debuggerWatches()); + assertEquals(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, + assertThrows(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, + assertThrows(ExecutionException.class, () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); assertEquals(a, CompanionApp.currentProject()); assertEquals(a, ProjectRegistry.open(paths).selected()); @@ -87,16 +99,45 @@ public static void main(String[] args) { 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, + assertThrows(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()); + assertFalse(CompanionApp.isSwitching()); 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"); + // A failed state flush is reversible, just like the editor save veto. + Files.delete(b.dataDirectory().resolve("scripts")); + Files.createDirectory(b.dataDirectory().resolve("scripts")); + var scopeBeforeFlush = CompanionApp.requireProject(); + CompanionApp.instanceState().saveNow(); + Path stateFile = a.dataDirectory().resolve("state.json"); + byte[] savedState = Files.readAllBytes(stateFile); + Files.delete(stateFile); + Files.createDirectory(stateFile); + Files.writeString(stateFile.resolve("occupied"), "x"); + CompanionApp.instanceState().setDebuggerWatches(List.of("pending A")); + assertThrows(ExecutionException.class, + () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); + assertSame(scopeBeforeFlush, CompanionApp.requireProject()); + assertTrue(scopeBeforeFlush.admit(() -> true)); + assertFalse(CompanionApp.isSwitching()); + Files.delete(stateFile.resolve("occupied")); + Files.delete(stateFile); + Files.write(stateFile, savedState); + CompanionApp.instanceState().saveNow(); + Files.delete(b.dataDirectory().resolve("scripts")); + Files.writeString(b.dataDirectory().resolve("scripts"), "restore fixture"); verifyEditorSwitch(a, b, paths); + set("uiStarted", false); + var retired = CompanionApp.requireProject(); + CompanionApp.getDebuggerController().close(); + CompanionApp.openProject(a).get(10, TimeUnit.SECONDS); + assertEquals(a, CompanionApp.currentProject(), "A broken debugger must not strand project selection"); + assertEquals(ProjectScope.Phase.RETIRED, retired.phase()); + assertThrows(IllegalStateException.class, () -> retired.state().setDebuggerWatches(List.of("closed"))); client.close(); mcp.close(); session.close(); @@ -139,19 +180,39 @@ public boolean canClose() { 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, + var scopeBeforeVeto = CompanionApp.requireProject(); + assertThrows(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")); + assertSame(scopeBeforeVeto, CompanionApp.requireProject()); + assertEquals("still A", scopeBeforeVeto.admit(() -> "still A")); assertFalse(disposed.get()); allowed.set(true); + CompanionApp.instanceState().saveNow(); + Path stateFile = a.dataDirectory().resolve("state.json"); + byte[] savedState = Files.readAllBytes(stateFile); + Files.delete(stateFile); + Files.createDirectory(stateFile); + Files.writeString(stateFile.resolve("occupied"), "x"); + CompanionApp.instanceState().setDebuggerWatches(List.of("pending view state")); + try { + assertThrows(ExecutionException.class, () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); + assertSame(scopeBeforeVeto, CompanionApp.requireProject()); + assertFalse(disposed.get(), "A failed state flush must preserve open views"); + SwingUtilities.invokeAndWait(() -> { + assertSame(editor, MainWindow.INSTANCE.getEditorTabs().getSelectedEditor()); + assertTrue(MainWindow.INSTANCE.isEnabled()); + }); + } finally { + Files.delete(stateFile.resolve("occupied")); + Files.delete(stateFile); + Files.write(stateFile, savedState); + } 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, + var failure = assertThrows(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()); @@ -193,14 +254,18 @@ private static void verifyNavigationReset(com.github.minecraft_ta.totalDebugComp 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")) + var scopeA = new ProjectScope(new Object(), CompanionApp.currentProject(), InstanceState.inMemory()); + var scopeB = new ProjectScope(new Object(), CompanionApp.currentProject(), InstanceState.inMemory()); + navigation.projectChanged(scopeA); + for (String directory : 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); + scopeA.retire(); + javax.swing.SwingUtilities.invokeAndWait(() -> navigation.projectChanged(scopeB)); assertFalse(oldTraversal.isDone(), "Old lookup is still awaiting a callback"); - for (String directory : java.util.List.of("B/one", "B/two")) + for (String directory : 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(); @@ -208,13 +273,28 @@ private static void verifyNavigationReset(com.github.minecraft_ta.totalDebugComp var newTraversal = navigation.goBack(); javax.swing.SwingUtilities.invokeAndWait(() -> { }); assertFalse(newTraversal.isDone()); + javax.swing.SwingUtilities.invokeAndWait(() -> { + navigation.runtimeChanged(); + assertTrue(navigation.goBack().isDone(), "A late refresh of the same runtime must not unlock its active traversal"); + }); delayedA.complete(true); - oldTraversal.get(3, TimeUnit.SECONDS); + assertInstanceOf(CancellationException.class, assertThrows(ExecutionException.class, + () -> oldTraversal.get(3, TimeUnit.SECONDS)).getCause()); 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())); + var duringVeto = new CompletableFuture(); + pending.set(duringVeto); + var vetoTraversal = navigation.goForward(); + javax.swing.SwingUtilities.invokeAndWait(() -> { }); + scopeB.beginSwitch(); + duringVeto.complete(true); + assertThrows(ExecutionException.class, () -> vetoTraversal.get(3, TimeUnit.SECONDS)); + scopeB.cancelSwitch(); + navigation.goForward().get(3, TimeUnit.SECONDS); + javax.swing.SwingUtilities.invokeAndWait(() -> assertTrue(navigation.backAction().isEnabled())); } private static Object get(String name) throws Exception { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/RuntimeInstallationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/RuntimeInstallationTest.java index 1f4ef8dd..643507ab 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/RuntimeInstallationTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/RuntimeInstallationTest.java @@ -1,5 +1,11 @@ package com.github.minecraft_ta.totalDebugCompanion; +import com.github.minecraft_ta.totalDebugCompanion.model.ResourceView; +import com.github.minecraft_ta.totalDebugCompanion.resource.ArchiveEntrySource; +import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; +import javax.swing.SwingUtilities; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService.ReadySnapshot; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; @@ -41,6 +47,13 @@ public static void main(String[] args) { config.set(null, new CompanionLaunchConfiguration(Files.createDirectories(root.resolve("app")))); CompanionApp.configureWithoutSession(new CompanionProfile("test", Files.createDirectories(root.resolve("data")), Files.createDirectories(root.resolve("game")))); + GlobalConfig.getInstance().loadFrom(root.resolve("app")); + CompanionApp.configureLookAndFeel(); + SwingUtilities.invokeAndWait(() -> MainWindow.INSTANCE.getEditorTabs().openEditorTab( + new ResourceView(new ArchiveEntrySource(root.resolve("old.jar"), "old.txt", -1), null))); + var uiStarted = CompanionApp.class.getDeclaredField("uiStarted"); + uiStarted.setAccessible(true); + uiStarted.set(null, true); // Force debugger restoration to fail after the index is published. CompanionApp.getDebuggerController().close(); var install = CompanionApp.class.getDeclaredMethod("installRuntimeSnapshot", ReadySnapshot.class, @@ -52,8 +65,29 @@ public static void main(String[] args) { close.setAccessible(true); try (var accepted = snapshot(root, "accepted"); var rejected = snapshot(root, "")) { var bytes = RuntimeSnapshotBytecodeSource.fromIndexedSources(accepted.sources(), accepted.index()); - install.invoke(null, accepted, bytes); + var releaseRefresh = new CountDownLatch(1); + var refreshBlocked = new CountDownLatch(1); + ((ExecutorService) queue.get(null)).submit(() -> { + refreshBlocked.countDown(); + try { assertTrue(releaseRefresh.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + }); + assertTrue(refreshBlocked.await(3, TimeUnit.SECONDS)); + var newView = new AtomicReference(); + try { + install.invoke(null, accepted, bytes); + SwingUtilities.invokeAndWait(() -> { + var view = new ResourceView(new ArchiveEntrySource(root.resolve("old.jar"), "old.txt", -1), CompanionApp.currentRuntime()); + newView.set(view); + MainWindow.INSTANCE.getEditorTabs().openEditorTab(view); + }); + } finally { releaseRefresh.countDown(); } ((ExecutorService) queue.get(null)).submit(() -> {}).get(10, TimeUnit.SECONDS); + SwingUtilities.invokeAndWait(() -> {}); + SwingUtilities.invokeAndWait(() -> { + assertEquals(1, MainWindow.INSTANCE.getEditorTabs().getTabCount(), "Late invalidation must close only the old runtime tab"); + assertSame(newView.get(), MainWindow.INSTANCE.getEditorTabs().getSelectedEditor()); + }); assertFalse(accepted.index().isDestroyed(), "Post-publication failure must not close the installed index"); var decompiler = CompanionApp.getDecompilationService(); var candidateBytes = RuntimeSnapshotBytecodeSource.fromIndexedSources(rejected.sources(), rejected.index()); @@ -65,6 +99,7 @@ public static void main(String[] args) { close.invoke(null); assertTrue(accepted.index().isDestroyed()); } + SwingUtilities.invokeAndWait(MainWindow.INSTANCE::dispose); System.exit(0); } catch (Throwable failure) { failure.printStackTrace(); System.exit(1); } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java index a20025dc..6fbaf945 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java @@ -930,19 +930,19 @@ public static void main(String[] args) throws Exception { EditorLocation.forRuntimeClass( "com.github.minecraft_ta.totaldebug.ThemeSample", sampleClasses.toUri().toASCIIString() - ) + ), CompanionApp.currentRuntime() )); MainWindow.INSTANCE.getEditorTabs().openEditorTab(new ResourceView( - new ArchiveEntrySource(sampleArchive, "META-INF/MANIFEST.MF", -1) + new ArchiveEntrySource(sampleArchive, "META-INF/MANIFEST.MF", -1), CompanionApp.currentRuntime() )); MainWindow.INSTANCE.getEditorTabs().openEditorTab(new ResourceView( - new ArchiveEntrySource(sampleArchive, "docs/NOTICE.custom", -1) + new ArchiveEntrySource(sampleArchive, "docs/NOTICE.custom", -1), CompanionApp.currentRuntime() )); MainWindow.INSTANCE.getEditorTabs().openEditorTab(new ResourceView( - new ArchiveEntrySource(sampleArchive, "config/defaults.toml", -1) + new ArchiveEntrySource(sampleArchive, "config/defaults.toml", -1), CompanionApp.currentRuntime() )); MainWindow.INSTANCE.getEditorTabs().openEditorTab(new ResourceView( - new ArchiveEntrySource(sampleArchive, "assets/sample/textures/gui/debug.png", -1) + new ArchiveEntrySource(sampleArchive, "assets/sample/textures/gui/debug.png", -1), CompanionApp.currentRuntime() )); boolean interactionVerification = Arrays.asList(args).stream() .anyMatch(argument -> argument.startsWith("--verify-")); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java index 5dbe3d15..122b4063 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java @@ -157,7 +157,7 @@ private static void advance(UiRenderScenario scenario, ScenarioContext context) case IMPLEMENTATION_CHOOSER -> advanceImplementationChooser(context); case SEARCH_EMPTY, SEARCH_RESULTS, MODULE_FILTER -> advanceSearch(scenario, context); case USAGES_RESULTS -> context.once("open-usages", () -> { - UsagesView view = new UsagesView(new CodeSymbol.ClassSymbol("sample.ThemeSample")); + UsagesView view = new UsagesView(new CodeSymbol.ClassSymbol("sample.ThemeSample"), CompanionApp.currentRuntime()); MainWindow.INSTANCE.getEditorTabs().openEditorTab(view) .thenRun(() -> SwingUtilities.invokeLater(view::restartSearch)); }); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServerTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServerTest.java index e0cf5d4e..b6b34f77 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServerTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServerTest.java @@ -3,6 +3,9 @@ import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionResult; import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.storage.InstanceState; import org.junit.jupiter.api.io.TempDir; import java.net.URI; @@ -125,7 +128,7 @@ void servesMcpInitializeAndToolDiscoveryOnTheStableLoopbackEndpoint() throws Exc "\"code\":\"return 1;\",\"wait_ms\":0}}}" ); assertEquals(200, unavailableExecution.statusCode()); - assertTrue(unavailableExecution.body().contains("not available")); + assertTrue(unavailableExecution.body().contains("No Minecraft project")); assertTrue(unavailableExecution.body().contains("\"isError\":true")); } assertFalse(Files.exists(server.endpointDescriptor())); @@ -146,7 +149,8 @@ void keepsJobResponsesLimitedToExecutionOutcome() throws Exception { CompanionMcpServer server = new CompanionMcpServer( this.temporaryDirectory.resolve("data"), jobs, - 0 + 0, new DebuggerMcpService(() -> null, name -> null), + () -> new ProjectScope(new Object(), new CompanionProfile("test", temporaryDirectory, temporaryDirectory), InstanceState.inMemory()) ); try (server; HttpClient client = HttpClient.newHttpClient()) { server.start(); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpSidecarTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpSidecarTest.java index 4a34efba..f8be62c3 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpSidecarTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpSidecarTest.java @@ -5,6 +5,9 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.junit.jupiter.api.Test; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.storage.InstanceState; import org.junit.jupiter.api.io.TempDir; import java.io.BufferedReader; @@ -170,10 +173,11 @@ private CompanionMcpServer companion(int port, String instance, CodeModeJobServi transport, Clock.systemUTC() ); + var scope = new ProjectScope(new Object(), new CompanionProfile(instance, temporaryDirectory, temporaryDirectory), InstanceState.inMemory()); return new CompanionMcpServer( this.temporaryDirectory.resolve(instance).resolve("data"), jobs, - port + port, new DebuggerMcpService(() -> null, name -> null), () -> scope ); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpIntegrationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpIntegrationTest.java index a5bb6874..8ad5e71c 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpIntegrationTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpIntegrationTest.java @@ -15,6 +15,9 @@ import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.spec.McpSchema; import org.junit.jupiter.api.Test; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.storage.InstanceState; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Timeout; @@ -87,7 +90,8 @@ void controlsAndInspectsTheSharedDebuggerOverMcp() throws Exception { CodeModeJobService jobs = new CodeModeJobService(() -> false, new NoOpTransport(), Clock.systemUTC()); DebuggerMcpService debugger = new DebuggerMcpService(() -> controller, name -> source); - try (CompanionMcpServer server = new CompanionMcpServer(temporaryDirectory.resolve("data"), jobs, 0, debugger)) { + var scope = new ProjectScope(new Object(), new CompanionProfile("test", temporaryDirectory, temporaryDirectory), InstanceState.inMemory()); + try (CompanionMcpServer server = new CompanionMcpServer(temporaryDirectory.resolve("data"), jobs, 0, debugger, () -> scope)) { server.start(); String base = server.endpointUrl().substring(0, server.endpointUrl().length() - 4); var transport = HttpClientStreamableHttpTransport.builder(base).endpoint("/mcp").build(); 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 index 9ba0a651..d8b18000 100644 --- 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 @@ -1,6 +1,9 @@ package com.github.minecraft_ta.totalDebugCompanion.mcp; -import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.storage.InstanceState; +import java.nio.file.Path; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerSessionController; import org.junit.jupiter.api.Test; @@ -10,23 +13,22 @@ class DebuggerMcpProjectGuardTest { @Test void sourceLoadingCannotCarryABreakpointIntoTheNextProject() throws Exception { - long original = CompanionApp.projectGeneration(); - var generation = CompanionApp.class.getDeclaredField("projectGeneration"); - generation.setAccessible(true); + var scope = new ProjectScope(new Object(), new CompanionProfile("test", Path.of("data"), Path.of("game")), InstanceState.inMemory()); 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); } + scope.retire(); return source; }); var failure = assertThrows(IllegalStateException.class, - () -> service.call("debugger_breakpoint_set", Map.of("binary_name", "Target", "line", 1), original)); + () -> service.call("debugger_breakpoint_set", Map.of("binary_name", "Target", "line", 1), scope)); assertTrue(failure.getMessage().contains("Project changed")); assertTrue(controller.breakpointEntries().isEmpty()); assertThrows(IllegalStateException.class, - () -> service.call("debugger_control", Map.of("action", "attach"), original)); + () -> service.call("debugger_control", Map.of("action", "attach"), scope)); } finally { - synchronized (CompanionApp.class) { generation.setLong(null, original); } + scope.retire(); + scope.close(); } } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/ProjectSwitchJobs.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/ProjectSwitchJobs.java new file mode 100644 index 00000000..a461243d --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/ProjectSwitchJobs.java @@ -0,0 +1,28 @@ +package com.github.minecraft_ta.totalDebugCompanion.mcp; + +import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionResult; +import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionStatus; +import com.github.minecraft_ta.totaldebug.protocol.execution.ExecutionText; +import java.time.Clock; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +/** Holds compilation until application teardown cancels it. */ +public final class ProjectSwitchJobs { + private ProjectSwitchJobs() { } + public static CodeModeJobService create() { + return new CodeModeJobService(() -> true, new CodeModeJobService.Transport() { + private final Map> compiling = new HashMap<>(); + @Override public void execute(int id, String source, CodeModeJobService.ExecutionSide side, + CodeModeJobService.ExecutionEnvironment environment, Consumer failure) { + compiling.put(id, failure); + } + @Override public void cancel(int id) { + var failure = compiling.remove(id); + if (failure != null) failure.accept(new ExecutionResult(ExecutionStatus.COMPILATION_FAILED, + ExecutionText.empty(), null, ExecutionText.complete("Compilation cancelled"))); + } + }, Clock.systemUTC()); + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScopeTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScopeTest.java new file mode 100644 index 00000000..3915e3eb --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScopeTest.java @@ -0,0 +1,61 @@ +package com.github.minecraft_ta.totalDebugCompanion.project; + +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.storage.InstanceState; +import org.junit.jupiter.api.Test; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectScopeTest { + private ProjectScope scope() { + return new ProjectScope(new Object(), new CompanionProfile("test", Path.of("data"), Path.of("game")), InstanceState.inMemory()); + } + + @Test void vetoReopensAdmissionButRetirementIsTerminal() throws Exception { + var scope = scope(); + assertEquals("accepted", scope.admit(() -> "accepted")); + assertThrows(IllegalStateException.class, scope::close); + scope.beginSwitch(); + assertFalse(scope.isActive()); + assertThrows(IllegalStateException.class, () -> scope.admit(() -> true)); + scope.cancelSwitch(); + assertTrue(scope.admit(() -> true)); + scope.retire(); + scope.cancelSwitch(); + assertEquals(ProjectScope.Phase.RETIRED, scope.phase()); + assertThrows(IllegalStateException.class, () -> scope.admit(() -> true)); + scope.close(); + scope.close(); + } + + @Test void switchCannotPassAnAdmittedSubmission() throws Exception { + var scope = scope(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var submitted = new CompletableFuture(); + var admission = CompletableFuture.runAsync(() -> scope.admit(() -> { + entered.countDown(); + try { assertTrue(release.await(3, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + submitted.complete(null); + return null; + })); + assertTrue(entered.await(3, TimeUnit.SECONDS)); + var switchStarted = new CountDownLatch(1); + var switching = CompletableFuture.runAsync(() -> { + switchStarted.countDown(); + scope.beginSwitch(); + assertTrue(submitted.isDone(), "The admitted job must reach its queue before switching"); + }); + assertTrue(switchStarted.await(3, TimeUnit.SECONDS)); + release.countDown(); + admission.get(3, TimeUnit.SECONDS); + switching.get(3, TimeUnit.SECONDS); + assertThrows(IllegalStateException.class, () -> scope.admit(() -> true)); + scope.retire(); + scope.close(); + } +} diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 80aec3a0..ea13c364 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -30,7 +30,7 @@ total-debug/ Files are created when needed; an empty instance does not need every directory. - `scripts` contains authored methodless Java scripts. -- `state.json` holds watches, breakpoint definitions, mute/exception choices and the last 50 distinct evaluator inputs with imports and execution side. One instance-state owner writes the whole file. Breakpoint resolution is partitioned by runtime signature. +- `state.json` holds watches, breakpoint definitions, mute/exception choices and the last 50 distinct evaluator inputs with imports and execution side. The [project scope](../companion/README.md#ownership) owns instance state and flushes it before retiring the project. One instance-state owner writes the whole file. Breakpoint resolution is partitioned by runtime signature. - The one replaceable inventory describes the Java runtime, production mode, ordered physical class sources, logical origins and module ownership. Game and Companion use the same Java record, JSON format and validator. - The format-2 source manifest lists generated JAR names, effective-content fingerprints, sizes and output SHA-256 hashes. Physical directories and JARs are referenced in place. A virtual root reuses its original archive only after its class entries and manifest match the effective loader view. Nested archives are copied as bytes; filtered or merged views are packed with buffered, compressed ZIP output. Filenames use the artifact or Java module name; collisions receive a numeric suffix. Only changed or damaged files are regenerated, and obsolete generated JARs are removed. - `index.jindex` is the only index file. Its ZIP contains the native Zstd `index` entry first, followed by `manifest.json` with inventory identity and source-id mappings. There is no index directory, generation selector or `current.json`. diff --git a/docs/USAGE.md b/docs/USAGE.md index 95e1ad91..16f04ea2 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -18,7 +18,7 @@ Companion remembers one project per Minecraft instance and keeps one selected at 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. +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. See [ownership](../companion/README.md#ownership) for the switch phases and resource lifetimes. 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.