Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions companion/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The active endpoint is written to `<companion-app-home>/run/companion/mcp-endpoi

Companion removes the descriptor when it closes. A Minecraft disconnect does not stop MCP.

Project switching also preserves this endpoint and initialized MCP clients. Outstanding code jobs keep their original context and are marked disconnected after cancellation is requested. Project-bound requests reject stale results, and code/debugger mutations are admitted against the project generation in which the request started. Project list/open tools are not yet exposed through MCP.

## Response policy

Each tool returns only the values needed to use that tool. Runtime paths, hashes, profile metadata, timestamps, and artifact locations do not appear in normal status or job responses.
Expand Down
1 change: 1 addition & 0 deletions companion/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ tasks.named('assemble') {

tasks.named('test') {
useJUnitPlatform()
systemProperty 'totaldebug.testClasspath', sourceSets.test.runtimeClasspath.asPath
}

// Brings the UI up without a Minecraft session so themes and icons can be eyeballed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import com.github.minecraft_ta.totalDebugCompanion.search.reference.ReferenceSearchService;
import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration;
import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile;
import com.github.minecraft_ta.totalDebugCompanion.session.ProjectRegistry;
import com.github.minecraft_ta.totalDebugCompanion.session.CompanionSession;
import com.github.minecraft_ta.totalDebugCompanion.session.CompanionTimeouts;
import com.github.minecraft_ta.totalDebugCompanion.resource.FileTypeResolver;
Expand Down Expand Up @@ -102,6 +103,11 @@ public final class CompanionApp {
private static CompanionMcpServer mcpServer;
private static volatile DebuggerSessionController debuggerController;
private static volatile boolean uiStarted;
private static ProjectRegistry projects;
private static volatile boolean switchingProjects;
private static volatile long projectGeneration;
private static final java.util.concurrent.ExecutorService projectWorker = java.util.concurrent.Executors.newSingleThreadExecutor(
runnable -> Thread.ofPlatform().daemon().name("companion-projects").unstarted(runnable));

private record PendingNavigation(NavigationTarget target, NavigationService.Activation activation) {
}
Expand Down Expand Up @@ -185,7 +191,7 @@ static int run(String[] args, Map<String, String> environment, CompanionTimeouts
);
restoreProfile();

session = new CompanionSession(token, CompanionApp::activateSessionProfile, new CompanionSession.Listener() {
session = new CompanionSession(token, CompanionApp::attachSelectedProfile, new CompanionSession.Listener() {
@Override
public void connecting() {
updateGameStatus(new ServiceStatus(
Expand Down Expand Up @@ -235,6 +241,12 @@ public void debugTarget(DebugTargetMessage message) {
}
});
SERVER = session.server();
session.setProjectSelectionHandler(hello -> {
try { openProject(CompanionProfile.fromHello(hello)).join(); }
catch (java.util.concurrent.CompletionException failure) {
throw new IOException(failure.getCause().getMessage(), failure.getCause());
}
});
startUi();
updateGameStatus(new ServiceStatus(
ServiceStatus.State.INACTIVE,
Expand All @@ -256,6 +268,7 @@ public void debugTarget(DebugTargetMessage message) {
} finally {
startup.close();
try (var shutdown = RuntimePhase.start("companion.shutdown")) {
projectWorker.close();
if (runtimeIndexService != null) {
runtimeIndexService.close();
}
Expand Down Expand Up @@ -299,7 +312,7 @@ public void debugTarget(DebugTargetMessage message) {
}
}

private static synchronized void activateSessionProfile(
private static synchronized void attachSelectedProfile(
com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage hello
) throws IOException {
CompanionProfile requested;
Expand All @@ -308,10 +321,13 @@ private static synchronized void activateSessionProfile(
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid Minecraft profile", exception);
}
activateProfile(requested, true);
if (switchingProjects || !requested.equals(profile)) {
throw new IOException("Select this project explicitly before connecting");
}
}

private static void handleDebugTarget(DebugTargetMessage message) {
if (switchingProjects) return;
if (message.targetKind() != DebugTargetMessage.LOCAL_JVM) {
throw new IllegalArgumentException("Unknown debug target kind: " + message.targetKind());
}
Expand All @@ -323,14 +339,86 @@ private static void handleDebugTarget(DebugTargetMessage message) {
}

private static void restoreProfile() throws IOException {
Path profileFile = launchConfiguration.profileFile();
if (!Files.isRegularFile(profileFile)) {
projects = ProjectRegistry.open(launchConfiguration.paths());
CompanionProfile selected = projects.selected();
if (selected != null) {
try { activateProfile(selected); }
catch (IOException failure) { System.err.println("Unable to reopen selected project: " + failure.getMessage()); }
}
}

public static List<ProjectRegistry.Project> projects() {
return projects == null ? List.of() : projects.projects();
}

public static CompanionProfile currentProject() { return profile; }

public static boolean isSwitchingProjects() { return switchingProjects; }

public static long projectGeneration() { return projectGeneration; }

/** Admit mutations/queue submissions atomically with starting a switch; never wait here. */
public static synchronized <T> T inProject(long generation, java.util.function.Supplier<T> action) {
if (switchingProjects || generation != projectGeneration) throw new IllegalStateException("Project changed during the request");
return action.get();
}

/** Application API; selection controls and MCP project tools are added separately. */
public static CompletableFuture<Void> openProject(CompanionProfile requested) {
Objects.requireNonNull(requested);
return CompletableFuture.runAsync(() -> {
try { switchProject(requested); }
catch (IOException failure) { throw new java.util.concurrent.CompletionException(failure); }
}, projectWorker);
}

private static void switchProject(CompanionProfile requested) throws IOException {
validateProfile(requested);
if (requested.equals(profile)) {
projects.select(requested);
return;
}
activateProfile(CompanionProfile.read(profileFile), false);
// Validate state before closing the current project; malformed destination state must not displace it.
try (var checked = InstanceState.open(new InstancePaths(requested.dataDirectory()))) { }
synchronized (CompanionApp.class) {
switchingProjects = true;
}
try {
if (uiStarted) {
boolean[] canSwitch = {false};
SwingUtilities.invokeAndWait(() -> canSwitch[0] = MainWindow.INSTANCE.prepareProjectSwitch());
if (!canSwitch[0]) throw new IOException("Project switch cancelled because an editor could not be saved");
}
synchronized (CompanionApp.class) { projectGeneration++; }
if (mcpServer != null) mcpServer.prepareProjectSwitch();
scriptCompiler.runtimeDisconnected();
if (session != null) session.disconnect();
Comment thread
Pelotrio marked this conversation as resolved.
getDebuggerController().clearTarget().join();
getDebuggerController().replaceBreakpointDefinitions(List.of()).join();
com.github.minecraft_ta.totalDebugCompanion.jdt.diagnostics.ASTCache.clear();
synchronized (CompanionApp.class) {
pendingNavigations.clear();
if (runtimeIndexService != null) runtimeIndexService.clear();
activateProfile(requested);
}
updateGameStatus(new ServiceStatus(ServiceStatus.State.INACTIVE, "Offline", "Selected project is not connected to Minecraft."));
try {
projects.select(requested);
} catch (IOException failure) {
throw new IOException("Project opened, but its selection could not be saved: " + failure.getMessage(), failure);
}
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IOException("Project switch interrupted", failure);
} catch (InvocationTargetException failure) {
throw new IOException("Unable to close project editors", failure.getCause());
} finally {
switchingProjects = false;
if (uiStarted) SwingUtilities.invokeLater(() -> MainWindow.INSTANCE.setEnabled(true));
}
}

private static void activateProfile(CompanionProfile requested, boolean persist) throws IOException {
private static void activateProfile(CompanionProfile requested) throws IOException {
validateProfile(requested);
CompanionProfile current = profile;
boolean profileChanged = !requested.equals(current);
Expand All @@ -357,9 +445,6 @@ private static void activateProfile(CompanionProfile requested, boolean persist)
}
profile = requested;
setupDataDirectories();
if (persist) {
requested.writeAtomically(launchConfiguration.profileFile());
}
if (uiStarted && profileChanged) {
refreshUiProfile();
}
Expand All @@ -369,13 +454,15 @@ private static void activateProfile(CompanionProfile requested, boolean persist)
}

private static void validateProfile(CompanionProfile requested) throws IOException {
Files.createDirectories(requested.dataDirectory());
if (!Files.isDirectory(requested.workspaceDirectory())) {
throw new IOException("Minecraft workspace not found");
}
Files.createDirectories(requested.dataDirectory());
setupDataDirectories(requested.dataDirectory(), true);
}

private static synchronized void handleRuntimeInventory(RuntimeInventoryMessage message) {
if (switchingProjects) return;
CompanionProfile current = profile;
if (current == null || runtimeIndexService == null) {
return;
Expand Down Expand Up @@ -454,7 +541,7 @@ private static synchronized void installRuntimeSnapshot(RuntimeIndexService.Read
static void configureWithoutSession(CompanionProfile developmentProfile) {
debuggerController = createDebuggerController();
try {
activateProfile(Objects.requireNonNull(developmentProfile, "developmentProfile"), false);
activateProfile(Objects.requireNonNull(developmentProfile, "developmentProfile"));
} catch (IOException exception) {
throw new IllegalStateException("Unable to configure the UI profile", exception);
}
Expand Down Expand Up @@ -725,7 +812,7 @@ public static void exit() {
}

public static boolean isConnected() {
return session != null && session.isConnected();
return !switchingProjects && session != null && session.isConnected();
}

public static boolean hasProfile() {
Expand All @@ -737,11 +824,13 @@ public static String getActiveRuntimeSignature() {
}

public static boolean send(AbstractMessage message) {
if (switchingProjects && !(message instanceof StopScriptMessage)) return false;
CompanionSession current = session;
return current != null && current.send(message);
}

public static CompletableFuture<CompilationResult> compileJava(String source, String entryClass) {
if (switchingProjects) return CompletableFuture.failedFuture(new IllegalStateException("Project is switching"));
return scriptCompiler.compile(source, entryClass);
}

Expand All @@ -753,7 +842,7 @@ public static boolean isCurrentRuntimeInventory(String inventoryId) {
public static boolean runScript(int id, String source, boolean serverSide,
ScriptExecutionEnvironment environment, Consumer<ExecutionResult> failureHandler) {
CompanionSession current = session;
if (current == null || !current.isConnected()) return false;
if (switchingProjects || current == null || !current.isConnected()) return false;
scriptCompiler.submit(id, source, serverSide, environment, failureHandler);
return true;
}
Expand All @@ -770,8 +859,10 @@ public static void openClass(String binaryName, int targetType, String targetIde
}

private static void openOrQueue(NavigationTarget target, NavigationService.Activation activation) {
if (switchingProjects) return;
if (decompilationService == null) {
synchronized (CompanionApp.class) {
if (switchingProjects) return;
if (decompilationService == null) {
pendingNavigations.add(new PendingNavigation(target, activation));
return;
Expand Down Expand Up @@ -926,6 +1017,7 @@ private static List<DebuggerSessionController.BreakpointDefinition> restoreBreak
}

private static void persistBreakpoints(DebuggerSessionController controller) {
if (switchingProjects) return;
String runtimeSignature = activeRuntimeSignature;
if (runtimeSignature == null || runtimeSignature.isBlank()) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -485,9 +485,9 @@ public void acceptTarget(DebugTargetDescriptor replacement) {
});
}

public void clearTarget() {
public CompletableFuture<Void> clearTarget() {
this.queue.invalidateAdvisoryWork();
submit(() -> {
return submitFuture(() -> {
DebugTargetDescriptor previous = this.target;
this.target = null;
if (this.engine != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,42 @@ public class ASTCache {
private static final Map<String, CopyOnWriteArrayList<BiConsumer<CompilationUnit, Integer>>> LISTENERS =
new ConcurrentHashMap<>();

public static void update(String key, String className, String contents) {
update(key, className, contents, JavaEditorSource.identity(contents));
public static CompletableFuture<Void> update(String key, String className, String contents) {
return update(key, className, contents, JavaEditorSource.identity(contents));
}

public static void update(String key, String className, String editorContents, JavaEditorSource source) {
int version = 0;
public static CompletableFuture<Void> update(String key, String className, String editorContents, JavaEditorSource source) {
Entry selected;
int version;
synchronized (CACHE) {
var existing = CACHE.get(key);
if (existing != null)
version = ++existing.version;
selected = CACHE.computeIfAbsent(key, ignored -> new Entry());
version = ++selected.version;
}

int finalVersion = version;
CompletableFuture.runAsync(() -> {
return CompletableFuture.runAsync(() -> {
synchronized (CACHE) {
if (CACHE.get(key) != selected || selected.version != finalVersion) return;
}
var ast = rawParse(className, source.text());

List<BiConsumer<CompilationUnit, Integer>> listeners;
synchronized (CACHE) {
var entry = CACHE.computeIfAbsent(key, (k) -> new Entry());
var entry = CACHE.get(key);
//There's already something newer available
if (entry.version > finalVersion)
if (entry != selected || entry.version != finalVersion)
return;

entry.version = finalVersion;
entry.unit = ast;
entry.contents = editorContents;
entry.sourceMap = source.sourceMap();
entry.privilegedAccess = source.privilegedAccess();
listeners = List.copyOf(LISTENERS.getOrDefault(key, new CopyOnWriteArrayList<>()));
}
for (var listener : listeners) {
synchronized (CACHE) { if (CACHE.get(key) != selected) return; }
listener.accept(ast, finalVersion);
}
notifyListeners(key, ast, finalVersion);
});
}

Expand All @@ -67,7 +74,7 @@ public static Runnable addChangeListener(String key, BiConsumer<CompilationUnit,
synchronized (CACHE) {
existing = CACHE.get(key);
}
if (existing != null)
if (existing != null && existing.unit != null)
listener.accept(existing.unit, existing.version);
return () -> {
listeners.remove(listener);
Expand All @@ -84,6 +91,13 @@ public static void removeFromCache(String key) {
LISTENERS.remove(key);
}

public static void clear() {
synchronized (CACHE) {
CACHE.clear();
LISTENERS.clear();
}
}

public static CompilationUnit getFromCache(String key) {
synchronized (CACHE) {
var entry = CACHE.get(key);
Expand All @@ -97,7 +111,7 @@ public static CompilationUnit getFromCache(String key) {
public static Snapshot getSnapshot(String key) {
synchronized (CACHE) {
Entry entry = CACHE.get(key);
return entry == null ? null : new Snapshot(entry.unit, entry.contents, entry.sourceMap);
return entry == null || entry.unit == null ? null : new Snapshot(entry.unit, entry.contents, entry.sourceMap);
}
}

Expand Down Expand Up @@ -132,16 +146,6 @@ public static boolean allowsPrivilegedAccess(String key) {
}
}

private static void notifyListeners(String key, CompilationUnit ast, int version) {
var listeners = LISTENERS.get(key);
if (listeners == null) {
return;
}
for (var listener : listeners) {
listener.accept(ast, version);
}
}

public static class Entry {

public int version;
Expand Down
Loading