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
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@
import com.github.minecraft_ta.totalDebugCompanion.script.ScriptCompilationService;
import com.github.minecraft_ta.totalDebugCompanion.script.ScriptExecutionService;
import com.github.minecraft_ta.totaldebug.protocol.scnet.InspectSubjectMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ChangeResultMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.DatapacksMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.PackStackMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ReloadResultMessage;
import com.github.minecraft_ta.totalDebugCompanion.inspection.ItemIconService;
import com.github.minecraft_ta.totaldebug.protocol.scnet.StopScriptMessage;
import com.github.minecraft_ta.totalDebugCompanion.mcp.CompanionMcpServer;
Expand Down Expand Up @@ -94,6 +90,8 @@ public final class CompanionApplication implements AutoCloseable, ProjectControl
private final CompanionLaunchConfiguration launchConfiguration;
private final Object lifecycleLock = new Object();
private volatile ProjectScope current;
/** Removes the current project's routes of the game's messages; under the lifecycle lock. */
private Runnable currentMessages = () -> { };
private final InstanceState emptyState = InstanceState.inMemory();
private final CodeInsightService codeInsightService = new CodeInsightService(
() -> { throw new IllegalStateException("Runtime class index is not ready"); }, RuntimeSourceCatalog.empty());
Expand Down Expand Up @@ -216,37 +214,12 @@ else if (gameStatus == null || gameStatus.state() != ServiceStatus.State.FAILED)
if (pendingLaunch != null) queueLaunch(pendingLaunch);
}

@Override
public void changeResult(ChangeResultMessage message) {
ProjectScope scope = current;
if (scope != null) scope.pipeline().answered(message.payload());
}

@Override
public void packStack(PackStackMessage message) {
ProjectScope scope = current;
if (scope != null) scope.packs().named(message.payload());
}

@Override
public void datapacks(DatapacksMessage message) {
ProjectScope scope = current;
if (scope != null) scope.packs().datapacks(message.world(), message.payload(), message.refusal());
}

@Override
public void playing(PlayingMessage message) {
ProjectScope scope = current;
if (scope != null) scope.location().playing(message.payload());
// The current project's game location takes it too, through its own route.
requestServerScripts(message.payload());
}

@Override
public void reloadResult(ReloadResultMessage message) {
ProjectScope scope = current;
if (scope != null) scope.pipeline().reloads().answered(message.payload());
}

@Override public void failed(String detail, ClientHelloMessage hello) {
synchronized (lifecycleLock) {
if (closed || switching) return;
Expand All @@ -267,14 +240,7 @@ public void relayFailed(RelayFailedMessage message) {
case CompanionProtocol.RUN_SCRIPT, CompanionProtocol.STOP_SCRIPT -> {
if (executionRuns != null) executionRuns.relayFailed(message.correlation(), message.reason());
}
case CompanionProtocol.RELOAD -> {
ProjectScope scope = current;
if (scope != null) scope.pipeline().reloads().relayFailed(message.correlation(), message.reason());
}
case CompanionProtocol.CHANGE -> {
ProjectScope scope = current;
if (scope != null) scope.pipeline().relayFailed(message.correlation(), message.reason());
}
// A change's or reload's reaches the current project's pipeline through its own route.
default -> { }
}
}
Expand All @@ -293,6 +259,8 @@ public void debugTarget(DebugTargetMessage message) {
handleDebugTarget(message);
}
});
// The project reopened above came before the session: its messages reach it from now on.
synchronized (lifecycleLock) { makeCurrent(current); }
scriptExecutions = new ScriptExecutionService(session, scriptCompiler, this::isConnected);
executionRuns = new ExecutionRuns(session, scriptExecutions);
editorRuns = new EditorScriptRunService(executionRuns, notifications);
Expand Down Expand Up @@ -361,7 +329,7 @@ public void start() throws IOException {
synchronized (lifecycleLock) {
scope = current;
if (scope != null) scope.retire();
current = null;
makeCurrent(null);
}
if (scope != null) {
if (scope.runtime() != null) CompanionClassIndex.clear();
Expand Down Expand Up @@ -725,7 +693,7 @@ private void switchProject(CompanionProfile requested) throws IOException {
}
synchronized (lifecycleLock) {
if (old != null) old.retire();
current = null;
makeCurrent(null);
if (runtimeIndexService != null) runtimeIndexService.clear();
}
// Retirement is terminal. Attempt every detach and install the prepared replacement even if
Expand All @@ -742,7 +710,7 @@ private void switchProject(CompanionProfile requested) throws IOException {
try { old.close(); }
catch (IOException | RuntimeException failure) { reportCleanupFailure("Close retired project", failure); }
}
synchronized (lifecycleLock) { current = replacement; }
synchronized (lifecycleLock) { makeCurrent(replacement); }
installed = true;
restoreCatalog(replacement);
runCleanup("Restore debugger preferences", () -> restoreProjectState(replacement));
Expand Down Expand Up @@ -793,6 +761,17 @@ private void runCleanup(String description, Runnable action) {
catch (RuntimeException failure) { reportCleanupFailure(description, failure); }
}

/**
* Makes {@code scope} the current project, or none: the game's messages for a project reach only the current one's
* owners. Under the lifecycle lock.
*/
private void makeCurrent(ProjectScope scope) {
this.currentMessages.run();
this.currentMessages = () -> { };
current = scope;
if (scope != null && session != null) this.currentMessages = scope.listen(session);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Register restored projects after constructing the session

When Companion starts with a project already selected, restoreProfile() calls activateProfile() before session is assigned, so this condition skips scope.listen(session) and no later initialization step registers those routes. Consequently, after every normal restart the restored project silently misses pack-stack, datapack, playing, change/reload-result, and relay-failure messages until the user switches projects; construct the session first or explicitly attach the restored scope afterward.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}

private void reportCleanupFailure(String description, Exception failure) {
System.getLogger(CompanionApplication.class.getName()).log(System.Logger.Level.WARNING,
description + " failed", failure);
Expand All @@ -801,7 +780,7 @@ private void reportCleanupFailure(String description, Exception failure) {
private void activateProfile(CompanionProfile requested) throws IOException {
validateProfile(requested);
ProjectScope replacement = ProjectScope.open(lifecycleLock, requested);
synchronized (lifecycleLock) { current = replacement; }
synchronized (lifecycleLock) { makeCurrent(replacement); }
restoreCatalog(replacement);
restoreProjectState(replacement);
if (runtimeIndexService != null) runtimeIndexService.restore(requested.dataDirectory(), requested.workspaceDirectory());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
package com.github.minecraft_ta.totalDebugCompanion.project;

import com.github.minecraft_ta.totaldebug.protocol.scnet.RelayFailedMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.PlayingMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.DatapacksMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.PackStackMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ReloadResultMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ChangeResultMessage;
import com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol;
import com.github.minecraft_ta.totalDebugCompanion.session.MessageRoutes;
import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigLabels;
import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigSettings;
import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingLabels;
Expand Down Expand Up @@ -115,6 +123,28 @@ public ProjectScope(Object lock, CompanionProfile profile, InstanceState state,
this.packSelections = new PackSelections(this.resources);
}

/**
* Takes the game's messages this project's owners handle, until the returned removal runs, as when another project
* becomes the current one: a project that is not current receives nothing.
*/
public Runnable listen(MessageRoutes routes) {
List<Runnable> removals = List.of(
routes.on(ChangeResultMessage.class, message -> this.pipeline.answered(message.payload())),
routes.on(ReloadResultMessage.class, message -> this.pipeline.reloads().answered(message.payload())),
routes.on(PackStackMessage.class, message -> this.packs.named(message.payload())),
routes.on(DatapacksMessage.class, message -> this.packs.datapacks(message.world(), message.payload(), message.refusal())),
routes.on(PlayingMessage.class, message -> this.location.playing(message.payload())),
// The refused message and its correlation name the request together.
routes.on(RelayFailedMessage.class, message -> {
switch (message.messageId()) {
case CompanionProtocol.CHANGE -> this.pipeline.relayFailed(message.correlation(), message.reason());
case CompanionProtocol.RELOAD -> this.pipeline.reloads().relayFailed(message.correlation(), message.reason());
default -> { }
}
}));
return () -> removals.forEach(Runnable::run);
}

public static ProjectScope open(Object lock, CompanionProfile profile) throws IOException {
var sources = RuntimeSourceCatalog.empty();
try { sources = new RuntimeSourceCatalog(LocalModSources.discover(profile.workspaceDirectory())); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.github.minecraft_ta.totaldebug.protocol.relay.RelayedMessages;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ExecutionResultMessage;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import com.github.minecraft_ta.totaldebug.protocol.scnet.FromServerMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ProtocolBindings;
import com.github.minecraft_ta.totaldebug.protocol.scnet.RelayFailedMessage;
Expand All @@ -14,10 +15,6 @@
import com.github.minecraft_ta.totaldebug.protocol.scnet.FocusWindowMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ReadyMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.InspectSubjectMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ChangeResultMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.DatapacksMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.PackStackMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ReloadResultMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.DebugTargetMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerHelloMessage;
Expand All @@ -41,10 +38,11 @@
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;

public final class CompanionSession implements AutoCloseable {
public final class CompanionSession implements AutoCloseable, MessageRoutes {
private final List<BiConsumer<Side, ExecutionResultMessage>> serverResultListeners = new CopyOnWriteArrayList<>();
private enum State {
WAITING_FOR_HELLO,
Expand All @@ -63,16 +61,6 @@ public interface Listener {

default void inspectSubject(InspectSubjectMessage message) { }

/** The game answered a change of values it keeps. */
default void changeResult(ChangeResultMessage message) { }

default void packStack(PackStackMessage message) { }

/** The server of the world the game plays named its datapacks. */
default void datapacks(DatapacksMessage message) { }

default void reloadResult(ReloadResultMessage message) { }

default void focusWindow() { }

default void connecting() {
Expand Down Expand Up @@ -164,6 +152,21 @@ public void removeExecutionResultListener(BiConsumer<Side, ExecutionResultMessag
this.serverResultListeners.remove(listener);
}

@Override
public <M extends AbstractMessage> Runnable on(Class<M> type, Consumer<M> handler) {
// A key of its own, so removing it never removes another registration of the same handler.
Object key = new Object();
// A message the bus is handing out while the route is removed is not handed to it any more.
AtomicBoolean active = new AtomicBoolean(true);
this.server.getMessageBus().listenAlways(type, key, message -> {
if (active.get()) handler.accept(message);
});
return () -> {
active.set(false);
this.server.getMessageBus().unregister(type, key);
};
}

public void setProjectSelectionHandler(AttachmentHandler handler) {
if (this.projectSelections != null) throw new IllegalStateException("Session is already published");
this.projectSelectionHandler = Objects.requireNonNull(handler);
Expand Down Expand Up @@ -278,10 +281,6 @@ private void registerHandlers() {
this.server.getMessageBus().listenAlways(PlayingMessage.class, this.listener::playing);
this.server.getMessageBus().listenAlways(DebugTargetMessage.class, this.listener::debugTarget);
this.server.getMessageBus().listenAlways(InspectSubjectMessage.class, this.listener::inspectSubject);
this.server.getMessageBus().listenAlways(ChangeResultMessage.class, this.listener::changeResult);
this.server.getMessageBus().listenAlways(PackStackMessage.class, this.listener::packStack);
this.server.getMessageBus().listenAlways(DatapacksMessage.class, this.listener::datapacks);
this.server.getMessageBus().listenAlways(ReloadResultMessage.class, this.listener::reloadResult);
this.server.getMessageBus().listenAlways(FocusWindowMessage.class, message ->
SwingUtilities.invokeLater(this.listener::focusWindow));
this.server.addConnectionListener(new IConnectionListener() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.github.minecraft_ta.totalDebugCompanion.session;

import com.github.tth05.scnet.message.AbstractMessage;

import java.util.function.Consumer;

/**
* Where the game's messages are delivered (docs/SYSTEMS.md, section 6): an owner registers the messages it handles, and
* removes them when it closes, as a project that is no longer the current one.
*/
public interface MessageRoutes {
/**
* Runs {@code handler} for each message of {@code type} from the game, also one the server sent through it, on the
* connection's thread in arrival order; returns what removes it.
*/
<M extends AbstractMessage> Runnable on(Class<M> type, Consumer<M> handler);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.github.minecraft_ta.totalDebugCompanion;

import com.github.minecraft_ta.totaldebug.protocol.scnet.PackStackMessage;
import com.github.minecraft_ta.totaldebug.protocol.message.PackStackPayload;
import com.github.minecraft_ta.totaldebug.protocol.message.ClientPacksPayload;
import com.github.minecraft_ta.totalDebugCompanion.ui.HtmlText;
import com.github.minecraft_ta.totalDebugCompanion.testui.UiTest;
import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource;
Expand All @@ -19,6 +22,7 @@
import com.github.minecraft_ta.totalDebugCompanion.resource.LocalFileSource;
import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow;
import org.junit.jupiter.api.io.TempDir;
import java.util.List;
import java.nio.file.Files;
import java.net.Socket;
import java.awt.Container;
Expand Down Expand Up @@ -232,6 +236,36 @@ private static void advanceRuntimeFollowUpBeforePublication(CompanionApplication
}
}

@Test void theProjectReopenedAtStartupTakesTheGamesMessages() throws Exception {
var configuration = new CompanionLaunchConfiguration(directory);
var profile = CompanionProfile.forGame(Files.createDirectories(directory.resolve("game")));
try (var first = new CompanionApplication(configuration, "test-token")) {
first.openProject(profile).get(3, TimeUnit.SECONDS);
}
var game = new Client();
try (var app = new CompanionApplication(configuration, "test-token")) {
assertEquals(profile.id(), app.currentProject().id(), "Companion reopens the project it had open");
app.session().bindAndPublish(configuration);
ProtocolBindings.registerMod(game.getMessageProcessor());
var ready = new CompletableFuture<Void>();
game.getMessageBus().listenAlways(ReadyMessage.class, message -> ready.complete(null));
int port = CompanionSessionDescriptor.read(configuration.descriptorFile(), CompanionProtocol.VERSION).port();
assertTrue(game.connect(new InetSocketAddress("127.0.0.1", port)));
game.getMessageProcessor().enqueueMessage(new ClientHelloMessage(CompanionProtocol.VERSION, "test-token", profile.id(),
profile.dataDirectory().toString(), profile.workspaceDirectory().toString()));
ready.get(3, TimeUnit.SECONDS);

// The game names its resource packs once it connected.
var stack = new PackStackPayload(34, List.of(new PackStackPayload.Pack("file/Faithful", "Faithful", "")));
game.getMessageProcessor().enqueueMessage(new PackStackMessage(new ClientPacksPayload(stack, 48)));
long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (app.currentScope().packs().resourcePacks() == null && System.nanoTime() < until) Thread.sleep(10);
assertEquals(stack, app.currentScope().packs().resourcePacks(), "the reopened project takes the game's messages");
} finally {
game.close();
}
}

@Test void reconnectPendingKeepsRunsUntilTheTransportActuallyDisconnects() throws Exception {
var configuration = new CompanionLaunchConfiguration(directory);
var game = new Client();
Expand Down
Loading