From 3a152764931518bb764f830b408a4138a81de6f4 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:25:01 +0200 Subject: [PATCH 1/3] Every task off the Swing thread names its worker; the pipeline owns the write queue WriteQueue, a serial path over the file work, is the project's write queue: the project makes it for its pipeline and closes it before the change record, and ConfigChanges keeps no thread. The async calls that ran on the JVM's shared pool run on Workers.files(); the thumbnail and logo readers use file strands, the resource viewer the file work, and the state writer's flush the timer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CompanionApplication.java | 6 +- .../catalog/ConfigChanges.java | 44 ------ .../catalog/ConfigSettings.java | 4 +- .../change/ChangePipeline.java | 17 ++- .../change/WriteQueue.java | 50 +++++++ .../inspection/InspectionSession.java | 5 +- .../inspection/ItemIconService.java | 4 +- .../totalDebugCompanion/model/CodeView.java | 3 +- .../navigation/NavigationService.java | 5 +- .../project/ProjectScope.java | 9 +- .../script/SnippetExpressionSupport.java | 5 +- .../storage/JsonStateWriter.java | 11 +- .../ui/components/catalog/ModLogoIcons.java | 11 +- .../ui/components/catalog/ModPanel.java | 3 +- .../components/catalog/TextureThumbnails.java | 14 +- .../editors/PackResourceEditor.java | 5 +- .../editors/ResourceTextEditor.java | 3 +- .../components/editors/ResourceViewPanel.java | 11 +- .../ui/components/editors/ScriptPanel.java | 8 +- .../components/global/NotificationWidget.java | 3 +- .../ui/components/global/ProjectSelector.java | 3 +- .../ui/components/inspection/DataView.java | 3 +- .../ui/components/treeView/FileTreeView.java | 3 +- .../treeView/ScriptFileActions.java | 3 +- .../treeView/lazyFileTree/LazyFileJTree.java | 3 +- .../ui/views/PrismInstancePicker.java | 3 +- .../ui/views/debugger/BreakpointsWindow.java | 5 +- .../ui/views/debugger/DebuggerInspector.java | 3 +- .../totalDebugCompanion/util/Workers.java | 5 + .../totalDebugCompanion/SystemsRulesTest.java | 30 +--- .../catalog/ConfigChangesTest.java | 84 ----------- .../change/WriteQueueTest.java | 134 ++++++++++++++++++ docs/SYSTEMS.md | 16 ++- 33 files changed, 285 insertions(+), 231 deletions(-) create mode 100644 companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueue.java create mode 100644 companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java index 98ed02da2..a859c8ef5 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Source; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Severity; @@ -69,7 +70,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutorService; -import java.util.concurrent.ForkJoinPool; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.LinkedHashMap; @@ -799,7 +799,7 @@ private void restoreCatalog(ProjectScope scope) { itemIcons.setItemLookup(itemId -> scope.catalog().index().flatMap(index -> index.itemIcon(itemId))); // Independent tasks: unreadable icon archives must not keep the catalog from loading. itemIcons.restore(scope.paths().previews()); - CompletableFuture.runAsync(scope.catalog()::restore); + CompletableFuture.runAsync(scope.catalog()::restore, Workers.files()); } private void handlePackCatalog(PreparedFilePayload message) { @@ -811,7 +811,7 @@ private void handlePackCatalog(PreparedFilePayload message) { if (scope == null || !scope.isActive()) return; switch (message.state()) { case PREPARING -> scope.catalog().capturing(); - case READY -> scope.catalog().accept(message.inventoryId(), Path.of(message.file()), ForkJoinPool.commonPool()); + case READY -> scope.catalog().accept(message.inventoryId(), Path.of(message.file()), Workers.files()); case FAILED -> scope.catalog().failed(message.detail()); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChanges.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChanges.java index a7a21fba1..431d02d5d 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChanges.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChanges.java @@ -13,14 +13,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; /** * When configuration edits take effect in the game, and the edits the running game has not applied yet. NeoForge @@ -45,9 +38,6 @@ private record Key(Path file, String setting) { private final Path workspace; private final ChangeRecord record; private final Map pending = new ConcurrentHashMap<>(); - /** One write at a time for the project, so writes to the same file never interleave. */ - private final ExecutorService writes = Executors.newSingleThreadExecutor(task -> - Thread.ofPlatform().daemon().name("Configuration writes").unstarted(task)); /** The game process the pending edits wait in, or 0 while it is unknown. */ private long gameProcess; @@ -67,40 +57,6 @@ public GameLocation location() { return this.location; } - /** Runs {@code write} after the project's earlier writes; refused once the project closes. */ - public CompletableFuture write(Supplier write) { - try { - return CompletableFuture.supplyAsync(write, this.writes); - } catch (RejectedExecutionException closed) { - return CompletableFuture.failedFuture(new IOException("The project is closing; the change was not written")); - } - } - - /** - * The project's writes to the game's files, one at a time, which the project finishes before its change record - * closes. Refuses work once the project closes. - */ - public Executor writes() { - return this.writes; - } - - /** - * Stops taking writes and waits until those already taken have finished, so every file written is also recorded - * before the change record closes. An interruption does not cut the wait short; it is kept for the caller. - */ - public void close() { - this.writes.shutdown(); - boolean interrupted = false; - while (true) { - try { - if (this.writes.awaitTermination(1, TimeUnit.MINUTES)) break; - } catch (InterruptedException interruption) { - interrupted = true; - } - } - if (interrupted) Thread.currentThread().interrupt(); - } - /** * The connected game's process. Another process than the one the pending edits wait in started after it, and read * every file when it started. diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigSettings.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigSettings.java index 0b06f54aa..2b5ff9190 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigSettings.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigSettings.java @@ -113,7 +113,7 @@ public ChangeRecord record() { * made, or whatever it holds where {@code expected} is null. */ public CompletableFuture set(Target target, String expected, String literal) { - return this.changes.write(() -> { + return this.pipeline.write(() -> { try { ChangePipeline.Applied applied = this.pipeline.write(this, List.of(new ChangePipeline.Edit<>(target.recorded(), expected, new Value(literal)))).applied().getFirst(); @@ -141,7 +141,7 @@ public CompletableFuture set(Target target, String expected, String liter public CompletableFuture saveText(FileTarget target, List settings, String base, String after, boolean overwrite) { String fileName = target.fileName().substring(target.fileName().lastIndexOf('/') + 1); - return this.changes.write(() -> { + return this.pipeline.write(() -> { try { // The text as a whole is what the edit was made against, and what is written, comments too. String current = Files.readString(target.file(), StandardCharsets.UTF_8); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/ChangePipeline.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/ChangePipeline.java index 3406a0a25..44c1c87ad 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/ChangePipeline.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/ChangePipeline.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.change; +import java.util.function.Supplier; import com.github.minecraft_ta.totalDebugCompanion.game.Access; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; import com.github.minecraft_ta.totalDebugCompanion.game.GameState; @@ -68,7 +69,7 @@ public record Outcome(List> applied, b private final Map> waiting = new ConcurrentHashMap<>(); private final Reloads reloads; - /** Changes the game {@code location} tells of; {@code writes} is the project's write queue. */ + /** Changes the game {@code location} tells of; {@code writes} is the project's write queue ({@link WriteQueue}). */ public ChangePipeline(GameLocation location, ChangeRecord record, Executor writes) { this.location = Objects.requireNonNull(location, "location"); this.record = Objects.requireNonNull(record, "record"); @@ -79,6 +80,20 @@ public ChangePipeline(GameLocation location, ChangeRecord record, Executor write }); } + /** The project's write queue, where every write of the game's and the packs' files runs, one at a time. */ + public Executor writes() { + return this.writes; + } + + /** Runs {@code write} after the project's earlier writes; refused once the project closes. */ + public CompletableFuture write(Supplier write) { + try { + return CompletableFuture.supplyAsync(write, this.writes); + } catch (RejectedExecutionException closed) { + return CompletableFuture.failedFuture(new IOException("The project is closing; the change was not written")); + } + } + public GameLocation location() { return this.location; } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueue.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueue.java new file mode 100644 index 000000000..d096e4abb --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueue.java @@ -0,0 +1,50 @@ +package com.github.minecraft_ta.totalDebugCompanion.change; + +import com.github.minecraft_ta.totalDebugCompanion.util.Strand; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; + +/** + * The project's writes to the game's and the packs' files, one at a time, so writes to the same file never interleave + * (docs/SYSTEMS.md, section 5). The change pipeline writes through it; the project finishes it before its change record + * closes. It refuses work once closed. + */ +public final class WriteQueue implements Executor, AutoCloseable { + private final Strand strand = Workers.fileStrand(); + private boolean closed; + + @Override + public synchronized void execute(Runnable write) { + if (this.closed) throw new RejectedExecutionException("The project is closing"); + this.strand.execute(write); + } + + /** + * Stops taking writes and waits until those already taken have finished, so every file written is also recorded + * before the change record closes. An interruption does not cut the wait short; it is kept for the caller. + */ + @Override + public void close() { + CompletableFuture drained = new CompletableFuture<>(); + synchronized (this) { + this.closed = true; + this.strand.execute(() -> drained.complete(null)); + } + boolean interrupted = false; + while (true) { + try { + drained.get(); + break; + } catch (InterruptedException interruption) { + interrupted = true; + } catch (ExecutionException impossible) { + break; + } + } + if (interrupted) Thread.currentThread().interrupt(); + } +} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/InspectionSession.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/InspectionSession.java index 7fc6f21e5..3a19e6b7c 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/InspectionSession.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/InspectionSession.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.inspection; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.jdt.JavaSnippetSource; import com.github.minecraft_ta.totalDebugCompanion.script.ExecutionTextDisplay; import com.github.minecraft_ta.totalDebugCompanion.script.ScriptCompilationService; @@ -280,7 +281,7 @@ public CompletableFuture createTool(String name) { } catch (Exception exception) { throw new IllegalStateException(exception.getMessage(), exception); } - }).whenComplete((path, failure) -> SwingUtilities.invokeLater(() -> { + }, Workers.files()).whenComplete((path, failure) -> SwingUtilities.invokeLater(() -> { if (failure == null) refresh(); })); } @@ -365,7 +366,7 @@ private CompletableFuture runTools(Side selected) { done.complete(null); return done; } - CompletableFuture.supplyAsync(this::loadTools).whenComplete((loaded, failure) -> SwingUtilities.invokeLater(() -> { + CompletableFuture.supplyAsync(this::loadTools, Workers.files()).whenComplete((loaded, failure) -> SwingUtilities.invokeLater(() -> { if (this.disposed || current != this.toolRevision) { done.complete(null); return; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconService.java index 8c593bd1a..23359fbe1 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconService.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.inspection; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.util.Signal; import com.github.minecraft_ta.totalDebugCompanion.catalog.CatalogIndex; import com.github.minecraft_ta.totalDebugCompanion.itemrender.ItemModelId; @@ -25,7 +26,6 @@ import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.ForkJoinPool; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -89,7 +89,7 @@ public void accept(Path archive) { * newest restore adopts its result, and a snapshot announced meanwhile supersedes it. */ public CompletableFuture restore(Path directory) { - return restore(directory, ForkJoinPool.commonPool()); + return restore(directory, Workers.files()); } CompletableFuture restore(Path directory, Executor reader) { 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 56e266975..d91c4c13e 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.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; import com.github.minecraft_ta.totalDebugCompanion.ui.EditorContext; import com.formdev.flatlaf.util.StringUtils; @@ -56,7 +57,7 @@ public CodeView(EditorContext context, DecompiledSource source, int offset, Edit public void reload(int offset) { CompletableFuture task = CompletableFuture - .supplyAsync(() -> readCode(this.path)) + .supplyAsync(() -> readCode(this.path), Workers.files()) .thenAcceptAsync(code -> { this.codeViewPanel.setCode(code); this.codeViewPanel.navigateToOffset(offset); 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 e1ce42535..c9838a6bc 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,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.navigation; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigSources; import com.github.minecraft_ta.totalDebugCompanion.model.ChangesView; import com.github.minecraft_ta.totalDebugCompanion.model.ConfigFileView; @@ -585,7 +586,7 @@ private CompletableFuture openRuntimeSource( public CompletableFuture relocatePreview(IEditorPanel previous, Path path) { if (path.getFileName().toString().endsWith(ScriptView.FILE_EXTENSION)) { EditorContext context = editors.get(); - return CompletableFuture.supplyAsync(() -> new ScriptView(context, path)).thenComposeAsync(replacement -> { + return CompletableFuture.supplyAsync(() -> new ScriptView(context, path), Workers.files()).thenComposeAsync(replacement -> { // A pending switch may still be vetoed by this active file operation. if (project != context.project() || context.project().phase() == ProjectScope.Phase.RETIRED) { replacement.dispose(); @@ -604,7 +605,7 @@ public CompletableFuture openCreatedScript(ProjectScope expected, Path pat EditorContext context = editors.get(); if (context.project() != expected || project != expected || expected.phase() == ProjectScope.Phase.RETIRED) return CompletableFuture.failedFuture(new CancellationException("Project changed while creating the script")); - return captureCurrentEntry().thenCompose(origin -> CompletableFuture.supplyAsync(() -> new ScriptView(context, path)) + return captureCurrentEntry().thenCompose(origin -> CompletableFuture.supplyAsync(() -> new ScriptView(context, path), Workers.files()) .thenComposeAsync(script -> { if (project != expected || expected.phase() == ProjectScope.Phase.RETIRED) { script.dispose(); 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 index 2e4228710..dcadfc065 100644 --- 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 @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.project; +import com.github.minecraft_ta.totalDebugCompanion.change.WriteQueue; import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReading; import com.github.minecraft_ta.totaldebug.protocol.scnet.RelayFailedMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.PlayingMessage; @@ -71,6 +72,8 @@ public record PendingNavigation(NavigationTarget target, NavigationService.Activ private final ChangeRecord changes; /** What Companion changed in the pack, kept with the instance. */ public ChangeRecord changes() { return changes; } + /** The project's write queue, which its change pipeline writes through. */ + private final WriteQueue writes = new WriteQueue(); private final ConfigChanges configChanges; private final ConfigSettings configSettings; public ConfigSettings configSettings() { return configSettings; } @@ -113,14 +116,14 @@ public ProjectScope(Object lock, CompanionProfile profile, InstanceState state, this.changes = Objects.requireNonNull(changes); this.location = new GameLocation(profile.workspaceDirectory()); this.configChanges = new ConfigChanges(this.location, changes); - this.pipeline = new ChangePipeline(this.location, changes, this.configChanges.writes()); + this.pipeline = new ChangePipeline(this.location, changes, this.writes); this.configSettings = new ConfigSettings(this.configChanges, this.pipeline); this.keyAssignments = new KeyAssignments(profile.workspaceDirectory().resolve("options.txt")); this.keyBindings = new KeyBindingControl(this.pipeline, this.keyAssignments); this.packs = new GamePacks(this.location); this.world = new WorldReading(this.location, this.packs); this.resources = new ResourceEdits(this.pipeline, this.packs, new ResourceOriginals(paths().originals()), - this.configChanges.writes(), state); + this.pipeline.writes(), state); this.packSelections = new PackSelections(this.resources); } @@ -227,7 +230,7 @@ public List drainNavigations() { pending.clear(); } // Writes still queued finish first, so each is recorded before the change record closes. - try { keyAssignments.close(); world.close(); resources.close(); configChanges.close(); closeRuntime(); } finally { try { state.close(); } finally { changes.close(); } } + try { keyAssignments.close(); world.close(); resources.close(); writes.close(); closeRuntime(); } finally { try { state.close(); } finally { changes.close(); } } } public String loadBreakpointScript(String name) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/SnippetExpressionSupport.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/SnippetExpressionSupport.java index 56097da69..e1f288ab7 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/SnippetExpressionSupport.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/script/SnippetExpressionSupport.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.script; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerCompletionProposal; import com.github.minecraft_ta.totalDebugCompanion.jdt.JavaSnippetSource; @@ -46,11 +47,11 @@ public CompletableFuture> complete( int caret, boolean explicit ) { - return CompletableFuture.supplyAsync(() -> completeNow(expression, caret)); + return CompletableFuture.supplyAsync(() -> completeNow(expression, caret), Workers.files()); } public CompletableFuture> tokens(String expression) { - return CompletableFuture.supplyAsync(() -> tokensNow(expression)); + return CompletableFuture.supplyAsync(() -> tokensNow(expression), Workers.files()); } public synchronized void accepted(DebuggerCompletionProposal proposal) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/storage/JsonStateWriter.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/storage/JsonStateWriter.java index a107a10a6..207213c4d 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/storage/JsonStateWriter.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/storage/JsonStateWriter.java @@ -1,20 +1,16 @@ package com.github.minecraft_ta.totalDebugCompanion.storage; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totaldebug.storage.JsonFiles; import com.google.gson.JsonElement; import java.io.IOException; import java.nio.file.Path; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; /** Coalesces snapshots without letting an older background save overwrite a flush. */ public final class JsonStateWriter implements AutoCloseable { private final Path file; - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(task -> - Thread.ofPlatform().daemon().name("Companion state writer").unstarted(task)); private ScheduledFuture scheduled; private JsonElement pending; private boolean closed; @@ -31,13 +27,13 @@ public synchronized void schedule(JsonElement snapshot) { if (this.scheduled != null) { this.scheduled.cancel(false); } - this.scheduled = this.executor.schedule(() -> { + this.scheduled = Workers.later(500, Workers.files(), () -> { try { flush(); } catch (IOException exception) { System.err.println("Unable to save " + this.file + ": " + exception.getMessage()); } - }, 500, TimeUnit.MILLISECONDS); + }); } public synchronized void flush() throws IOException { @@ -55,6 +51,5 @@ public synchronized void flush() throws IOException { public synchronized void close() throws IOException { flush(); this.closed = true; - this.executor.shutdown(); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ModLogoIcons.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ModLogoIcons.java index dce2d8b20..4644c2d98 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ModLogoIcons.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ModLogoIcons.java @@ -1,5 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.catalog; +import com.github.minecraft_ta.totalDebugCompanion.util.Strand; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.catalog.ModFiles; import com.github.minecraft_ta.totalDebugCompanion.catalog.ModSummary; @@ -17,8 +19,6 @@ import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; /** * Mod logos as square row icons. A banner is much wider than tall and unreadable in a square, so mods with one keep @@ -28,11 +28,8 @@ public final class ModLogoIcons { /** The widest (or tallest) logo that still reads as a square icon. */ static final double MAXIMUM_ASPECT = 1.5; private static final int MAXIMUM_LOGO_BYTES = 8 * 1024 * 1024; - private static final ExecutorService LOADER = Executors.newSingleThreadExecutor(runnable -> { - Thread thread = new Thread(runnable, "Mod logo loader"); - thread.setDaemon(true); - return thread; - }); + /** Where logos are read, one at a time. */ + private static final Strand LOADER = Workers.fileStrand(); /** Shared by every list and tree that shows mods; Swing thread only, like each of them. */ private static final IconLoader LOGOS = new IconLoader<>(1_024, 4, ModLogoIcons::load); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ModPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ModPanel.java index d7df9b016..c832357f7 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ModPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ModPanel.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.catalog; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.util.Signal; import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigSettings; import com.github.minecraft_ta.totalDebugCompanion.ui.components.PageLoader; @@ -359,7 +360,7 @@ private Callable> prepareResources() { } private void loadLogo(List logo) { - CompletableFuture.supplyAsync(() -> readLogo(logo)).thenAccept(image -> SwingUtilities.invokeLater(() -> { + CompletableFuture.supplyAsync(() -> readLogo(logo), Workers.files()).thenAccept(image -> SwingUtilities.invokeLater(() -> { if (!this.disposed && this.summary != null && image != null) this.header.setIcon(image); })); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/TextureThumbnails.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/TextureThumbnails.java index 5947a8aaf..dabee600d 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/TextureThumbnails.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/TextureThumbnails.java @@ -1,5 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.catalog; +import com.github.minecraft_ta.totalDebugCompanion.util.Strand; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.catalog.ModResources; import com.github.minecraft_ta.totalDebugCompanion.itemrender.TextureAnimation; import com.github.minecraft_ta.totalDebugCompanion.itemrender.TextureImages; @@ -18,8 +20,6 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; import java.util.zip.ZipFile; @@ -33,12 +33,9 @@ final class TextureThumbnails { static final int MAX_TEXTURE_BYTES = 4 * 1024 * 1024; private final int size; - private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> { - Thread thread = new Thread(runnable, "Texture thumbnails"); - thread.setDaemon(true); - return thread; - }); - /** Loader thread only. */ + /** Where the previews are read, one at a time, as the archives are open there. */ + private final Strand loader = Workers.fileStrand(); + /** On the loader only. */ private final Map archives = new HashMap<>(); private final IconLoader icons; /** Previews requested and not yet finished; the archives close when none remain. */ @@ -106,6 +103,5 @@ private void closeArchives() { void dispose() { this.icons.clear(); this.loader.execute(this::closeArchives); - this.loader.shutdown(); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/PackResourceEditor.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/PackResourceEditor.java index 35726b57e..f2a1738a7 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/PackResourceEditor.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/PackResourceEditor.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.editors; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.change.ChangePipeline; import com.github.minecraft_ta.totalDebugCompanion.catalog.PackFolders; import com.github.minecraft_ta.totalDebugCompanion.Icons; @@ -442,7 +443,7 @@ protected final void saveThen(Consumer after) { } catch (IOException exception) { throw new CompletionException(exception); } - }).whenComplete((held, failure) -> SwingUtilities.invokeLater(() -> { + }, Workers.files()).whenComplete((held, failure) -> SwingUtilities.invokeLater(() -> { // The reads go on afterwards, whatever happens here, unless a save takes the hold over. boolean savingAgain = false; try { @@ -501,7 +502,7 @@ private boolean save(Consumer after) { } catch (IOException exception) { throw new CompletionException(exception); } - }).thenCompose(bytes -> this.edits.save(this.path, into, bytes, alongside, expected)) + }, Workers.files()).thenCompose(bytes -> this.edits.save(this.path, into, bytes, alongside, expected)) .whenComplete((saved, failure) -> SwingUtilities.invokeLater(() -> { // The reads go on afterwards, whatever happens here, unless another save takes the hold over. boolean savingAgain = false; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ResourceTextEditor.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ResourceTextEditor.java index a958c23a9..bd82ac081 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ResourceTextEditor.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ResourceTextEditor.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.editors; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.pack.JsonFormat; import com.github.minecraft_ta.totalDebugCompanion.pack.ResourceEdits; @@ -127,7 +128,7 @@ void reformat() { // Before the working pack's copy is read, the text on screen is not what a save would write over. if (!this.text.editorPane.isEditable() || this.formatting != null) return; String shown = this.text.text(); - CompletableFuture task = CompletableFuture.supplyAsync(() -> JsonFormat.format(shown)); + CompletableFuture task = CompletableFuture.supplyAsync(() -> JsonFormat.format(shown), Workers.files()); this.formatting = task; task.whenComplete((formatted, failure) -> SwingUtilities.invokeLater(() -> { this.formatting = null; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ResourceViewPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ResourceViewPanel.java index 8421cc7c7..b0b1a0bbf 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ResourceViewPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ResourceViewPanel.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.editors; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.pack.ResourceEdits; import com.github.minecraft_ta.totalDebugCompanion.pack.ResourcePaths; import com.github.minecraft_ta.totalDebugCompanion.resource.ArchiveEntrySource; @@ -23,17 +24,9 @@ import java.awt.event.FocusEvent; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; public final class ResourceViewPanel extends JPanel { - private static final ExecutorService LOADER = Executors.newFixedThreadPool(2, runnable -> { - Thread thread = new Thread(runnable, "Resource viewer loader"); - thread.setDaemon(true); - return thread; - }); - private final NavigationService navigation; private final ContentSource source; private final ResourceFileType fileType; @@ -90,7 +83,7 @@ public void reload() { } catch (Exception exception) { throw new CompletionException(exception); } - }, LOADER); + }, Workers.files()); this.loadTask = task; task.whenComplete((opened, failure) -> SwingUtilities.invokeLater(() -> { if (this.disposed || this.loadTask != task) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ScriptPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ScriptPanel.java index cbacb608f..54edd3c62 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ScriptPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/ScriptPanel.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.editors; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.ui.Tooltip; import com.github.minecraft_ta.totalDebugCompanion.script.EditorScriptRunService; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Source; @@ -50,7 +51,6 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.ForkJoinPool; import javax.swing.*; import javax.swing.event.DocumentEvent; import javax.swing.border.CompoundBorder; @@ -166,7 +166,7 @@ public ScriptPanel(EditorContext context, ScriptView scriptView) { setupLogPanel(); setupSaveBehavior(); this.completion = new ScriptCompletionController(editorPane, scriptView.compilationName(), codeCompletionPopup, - ForkJoinPool.commonPool(), analysis::completionAccepted); + Workers.files(), analysis::completionAccepted); setupSignatureHelp(); setupFormatting(); var format = new JButton(this.editorPane.getActionMap().get("formatFile")); @@ -469,7 +469,7 @@ private void requestSignatureHelp() { var generated = JavaSnippetSource.body(scriptView.compilationName(), UIUtils.getText(editorPane)); int caret = generated.sourceMap().toGeneratedOffset(editorPane.getCaretPosition()); if (caret < 0) { hideSignatureHelp(); return; } - CompletableFuture.supplyAsync(() -> SignatureHelp.find(scriptView.compilationName(), generated.source(), caret)) + CompletableFuture.supplyAsync(() -> SignatureHelp.find(scriptView.compilationName(), generated.source(), caret), Workers.files()) .whenComplete((help, failure) -> SwingUtilities.invokeLater(() -> { if (disposed || request != signatureRequest || !signatureHelpActive || !editorPane.isFocusOwner()) return; if (failure != null) LOGGER.log(System.Logger.Level.WARNING, "Unable to load parameter information", failure); @@ -538,7 +538,7 @@ private CompletableFuture saveScript() { var write = saveTail.handle((ignored, failure) -> null).thenRunAsync(() -> { try { scriptView.persist(text); } catch (IOException failure) { throw new CompletionException(failure); } - }); + }, Workers.files()); var published = new CompletableFuture(); saveTail = published; write.whenComplete((ignored, failure) -> SwingUtilities.invokeLater(() -> { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/NotificationWidget.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/NotificationWidget.java index 1e76baf43..503cd4508 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/NotificationWidget.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/NotificationWidget.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.global; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.ui.UiMetrics; import com.github.minecraft_ta.totalDebugCompanion.ui.Tooltip; import com.github.minecraft_ta.totalDebugCompanion.Icons; @@ -358,7 +359,7 @@ private void checkSource(boolean activate) { return; } openSource.setToolTipText("Checking source"); - CompletableFuture.supplyAsync(() -> directory ? Files.isDirectory(path) : Files.isRegularFile(path)).whenComplete((exists, failure) -> UIUtils.onEdt(() -> { + CompletableFuture.supplyAsync(() -> directory ? Files.isDirectory(path) : Files.isRegularFile(path), Workers.files()).whenComplete((exists, failure) -> UIUtils.onEdt(() -> { if (closed || request != sourceRequest || !expanded) return; String currentReason = unavailable.apply(entry.source()); if (currentReason == null && (failure != null || !Boolean.TRUE.equals(exists))) currentReason = "The source is no longer available"; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/ProjectSelector.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/ProjectSelector.java index 11ceba246..be8867698 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/ProjectSelector.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/ProjectSelector.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.global; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.ui.Tooltip; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Source; @@ -182,7 +183,7 @@ private void choosePrism() { prismPicker.setVisible(true); } private void openDirectory(Path selected) { - finish(CompletableFuture.supplyAsync(() -> ProjectDirectories.resolve(selected)) + finish(CompletableFuture.supplyAsync(() -> ProjectDirectories.resolve(selected), Workers.files()) .thenCompose(profile -> projects.openProject(profile, null))); } private void open(CompanionProfile profile) { finish(this.projects.openProject(profile, null)); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/inspection/DataView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/inspection/DataView.java index 1e9641867..7d131fefb 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/inspection/DataView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/inspection/DataView.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.inspection; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.ui.UiMetrics; import com.github.minecraft_ta.totalDebugCompanion.ui.components.GroupedRowCell; import com.github.minecraft_ta.totalDebugCompanion.ui.components.SegmentedToggle; @@ -92,7 +93,7 @@ final class DataView extends JPanel { private final SpeedSearch speedSearch; DataView() { - this(ForkJoinPool.commonPool()); + this(Workers.files()); } /** {@code decoder} decodes the data of each read; a large read must not hold up the Swing thread. */ diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java index afa6b47b9..e9a12d67a 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; @@ -71,7 +72,7 @@ private CompletableFuture ensureScriptsRoot() { var scripts = factory.createFileSystemDirectoryItem(scope.paths().scripts(), true); scripts.setIcon(FileTreeIcons.forRootDirectory("scripts")); return scripts; - }).thenAcceptAsync(scripts -> { + }, Workers.files()).thenAcceptAsync(scripts -> { if (disposed || project.get() != scope || scope.phase() == ProjectScope.Phase.RETIRED) { if (scripts != null) scripts.dispose(); throw new CancellationException("Project changed while preparing Scripts"); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java index 4d434f200..055746673 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Source; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Severity; @@ -381,7 +382,7 @@ private CompletableFuture execute(List roots, boolean deleting, Work previews.stream().filter(view -> !Files.exists(((NavigationTarget.LocalFile) view.getNavigationTarget()).path())).forEach(removedPreviews::add); } } - }).whenComplete((ignored, failure) -> SwingUtilities.invokeLater(() -> { + }, Workers.files()).whenComplete((ignored, failure) -> SwingUtilities.invokeLater(() -> { try { requireOwner(ctx); saved.forEach(view -> view.saved(drafts.get(view))); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTree.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTree.java index ff61669a7..428652031 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTree.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTree.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.util.UIUtils; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.ui.presentation.PrimarySecondaryLabel; @@ -331,7 +332,7 @@ private CompletableFuture loadItemsForNode(LazyTreeNode node) { DirectoryChain.compactChildren(source.loadChildren()).stream() .sorted(LazyFileJTree::compareTreeItems) .toList() - ).thenComposeAsync(items -> { + , Workers.files()).thenComposeAsync(items -> { this.activeLoads.remove(node); if (!isAttached(node)) { this.rowsOnly.remove(node); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/PrismInstancePicker.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/PrismInstancePicker.java index 2358214e0..77b12ecef 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/PrismInstancePicker.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/PrismInstancePicker.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.views; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.formdev.flatlaf.util.UIScale; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; @@ -141,7 +142,7 @@ public PrismInstancePicker(Window owner, Path prismHome, CompanionProfile curren return new Entry(profile, ProjectRegistry.defaultName(profile), description, artwork(details.icon()), selected); }).sorted(Comparator.comparing(Entry::current).reversed().thenComparing(Entry::name, String.CASE_INSENSITIVE_ORDER)).toList(); } catch (IOException failure) { throw new CompletionException(failure); } - }).whenComplete((loaded, failure) -> UIUtils.onEdt(() -> { + }, Workers.files()).whenComplete((loaded, failure) -> UIUtils.onEdt(() -> { if (disposed) return; loading = false; if (failure != null) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/debugger/BreakpointsWindow.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/debugger/BreakpointsWindow.java index 2ab035e24..88b3bf1b7 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/debugger/BreakpointsWindow.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/debugger/BreakpointsWindow.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.views.debugger; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.ui.components.ThinSplitPane; import com.formdev.flatlaf.util.UIScale; @@ -380,7 +381,7 @@ private void loadScripts() { CompletableFuture.supplyAsync(() -> { try { return scripts.listScripts(); } catch (IOException failure) { throw new CompletionException(failure); } - }).whenComplete((names, failure) -> SwingUtilities.invokeLater(() -> { + }, Workers.files()).whenComplete((names, failure) -> SwingUtilities.invokeLater(() -> { if (disposed || revision != scriptLoadRevision) return; scriptsLoading = false; scriptsError = null; @@ -727,7 +728,7 @@ private ExpressionCompletionSupport.CompletionProvider completionProvider( contextOffset += 2 + caret; } return ExpressionScopeAnalyzer.complete(unit, contextOffset, text, caret); - }); + }, Workers.files()); } private static int sourceOffset(String source, int displayedLine) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/debugger/DebuggerInspector.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/debugger/DebuggerInspector.java index 7e3c2ce20..cd5c50987 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/debugger/DebuggerInspector.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/debugger/DebuggerInspector.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.views.debugger; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.ui.UiMetrics; import com.github.minecraft_ta.totalDebugCompanion.ui.Tooltip; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter; @@ -767,7 +768,7 @@ private void navigateToDeclaration( DebugEngine.Source source = frame.sourceUri() == null ? null : this.controller.source(frame.sourceUri()); CompletableFuture.supplyAsync(() -> DebuggerVariableNavigation.declarationTarget(source, frame, variable, parent) - ).whenComplete((target, failure) -> UIUtils.onEdt(() -> { + , Workers.files()).whenComplete((target, failure) -> UIUtils.onEdt(() -> { if (disposed || !Objects.equals(frame, this.frame)) return; if (failure != null) { if (!isCancellation(failure)) notifications.publish(NotificationCenter.Severity.ERROR, "Unable to Jump to Source", diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/Workers.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/Workers.java index 3d2994a95..e6931d291 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/Workers.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/Workers.java @@ -41,6 +41,11 @@ public static Executor files() { return FILES; } + /** A new serial path over the file work, for file work whose state stays on one thread, as open archives. */ + public static Strand fileStrand() { + return new Strand(FILES); + } + /** A new serial path, for an owner whose state changes in one order. */ public static Strand strand() { return new Strand(OWNERS); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java index 34dd5ba68..970ba2198 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java @@ -67,7 +67,7 @@ private record Allowed(int times, String why) { } // Counted per call: an executor made with a thread factory counts twice. Services that own a thread for a reason of their - // own (section 4), and those that move onto Workers. + // own (section 4). private static final Map THREADS = Map.ofEntries( Map.entry("CompanionApplication.java", new Allowed(4, "project switching and the MCP lifecycle")), Map.entry("debugger/DebuggerSessionQueue.java", new Allowed(2, "the debugger")), @@ -83,35 +83,11 @@ private record Allowed(int times, String why) { Map.entry("mcp/CodeModeJobService.java", new Allowed(2, "the MCP job service")), Map.entry("session/ProjectSelectionServer.java", new Allowed(2, "accepts connections")), Map.entry("inspection/ItemIconService.java", new Allowed(2, "the item icon renderer is confined to one thread")), - Map.entry("catalog/ConfigChanges.java", new Allowed(2, "the project's write queue, which the pipeline takes over in PR 8")), - Map.entry("storage/JsonStateWriter.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("ui/components/catalog/TextureThumbnails.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("ui/components/catalog/ModLogoIcons.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("ui/components/editors/ResourceViewPanel.java", new Allowed(2, "moves onto Workers in PR 8")), Map.entry("util/Workers.java", new Allowed(6, "the file work, the owners' strands and the timer everything shares")), Map.entry("util/FileWatch.java", new Allowed(1, "the one watcher of the folders Companion follows"))); - // All move onto Workers' file work in PR 8. - private static final Map SHARED_POOL = Map.ofEntries( - Map.entry("CompanionApplication.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("inspection/ItemIconService.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("ui/components/inspection/DataView.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("inspection/InspectionSession.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("model/CodeView.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("navigation/NavigationService.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("script/SnippetExpressionSupport.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("ui/components/catalog/ModPanel.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("ui/components/editors/PackResourceEditor.java", new Allowed(2, "its saves move onto Workers in PR 8")), - Map.entry("ui/components/editors/ResourceTextEditor.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("ui/components/editors/ScriptPanel.java", new Allowed(3, "moves onto Workers in PR 8")), - Map.entry("ui/components/global/NotificationWidget.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("ui/components/global/ProjectSelector.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("ui/components/treeView/FileTreeView.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("ui/components/treeView/ScriptFileActions.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("ui/components/treeView/lazyFileTree/LazyFileJTree.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("ui/views/PrismInstancePicker.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("ui/views/debugger/BreakpointsWindow.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("ui/views/debugger/DebuggerInspector.java", new Allowed(1, "moves onto Workers in PR 8"))); + // Every async task names its worker. + private static final Map SHARED_POOL = Map.of(); // Signal itself; events inside a subsystem or a control, which are not state (section 1). private static final Map LISTENER_LISTS = Map.ofEntries( diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChangesTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChangesTest.java index 058f0e339..063a460a2 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChangesTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChangesTest.java @@ -54,90 +54,6 @@ void location() { this.location = new GameLocation(this.directory); } - @Test - void closingFinishesTheWritesAlreadyTakenAndRefusesLaterOnes() throws Exception { - ConfigChanges changes = new ConfigChanges(this.location, ChangeRecord.inMemory()); - CountDownLatch release = new CountDownLatch(1); - CompletableFuture taken = changes.write(() -> { - try { - release.await(5, TimeUnit.SECONDS); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - } - return "written"; - }); - - CompletableFuture closing = CompletableFuture.runAsync(changes::close); - release.countDown(); - closing.get(10, TimeUnit.SECONDS); - - assertEquals("written", taken.getNow(null), "a write taken before closing is finished, and so recorded"); - ExecutionException refused = assertThrows(ExecutionException.class, - () -> changes.write(() -> "late").get(5, TimeUnit.SECONDS)); - assertEquals("The project is closing; the change was not written", refused.getCause().getMessage()); - } - - @Test - void closingWaitsThroughAnInterruptionAndKeepsItForTheCaller() throws Exception { - ConfigChanges changes = new ConfigChanges(this.location, ChangeRecord.inMemory()); - CountDownLatch started = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - CompletableFuture taken = changes.write(() -> { - started.countDown(); - try { - release.await(10, TimeUnit.SECONDS); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - } - return "written"; - }); - assertTrue(started.await(5, TimeUnit.SECONDS)); - boolean[] interruptedAfter = new boolean[1]; - Thread closer = new Thread(() -> { - changes.close(); - interruptedAfter[0] = Thread.currentThread().isInterrupted(); - }); - - closer.start(); - closer.interrupt(); - closer.join(200); - assertTrue(closer.isAlive(), "an interruption does not end the wait while a write runs"); - release.countDown(); - closer.join(5_000); - - assertEquals("written", taken.getNow(null)); - assertTrue(interruptedAfter[0], "the interruption is kept for the caller"); - } - - @Test - void anOfflineKeyChangeTakenBeforeClosingIsWrittenAndRecorded() throws Exception { - Path options = this.directory.resolve("options.txt"); - Files.writeString(options, "key_key.jump:key.keyboard.space\n"); - ChangeRecord record = ChangeRecord.inMemory(); - ConfigChanges changes = new ConfigChanges(this.location, record); - KeyBindingControl keys = new KeyBindingControl(new ChangePipeline(this.location, record, changes.writes()), assignments()); - CountDownLatch release = new CountDownLatch(1); - changes.write(() -> { - try { - release.await(10, TimeUnit.SECONDS); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - } - return null; - }); - CompletableFuture key = keys.set(List.of(new KeyBindingControl.Change("key.jump", - new KeyBindings.Assignment("key.keyboard.space", "NONE"), new KeyBindings.Assignment("key.keyboard.g", "NONE")))); - - CompletableFuture closing = CompletableFuture.runAsync(changes::close); - release.countDown(); - closing.get(10, TimeUnit.SECONDS); - - assertTrue(key.isDone()); - assertEquals(List.of("key_key.jump:key.keyboard.g"), Files.readAllLines(options)); - assertEquals(new KeyBindings.Assignment("key.keyboard.space", "NONE"), keys.original("key.jump"), - "the key change is recorded before the record could close"); - } - @Test void theFirstGameToConnectKeepsTheEditsMadeWhileItRan() throws Exception { ConfigChanges changes = new ConfigChanges(this.location, ChangeRecord.inMemory()); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java new file mode 100644 index 000000000..60391ce15 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java @@ -0,0 +1,134 @@ +package com.github.minecraft_ta.totalDebugCompanion.change; + +import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingControl; +import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindings; +import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyAssignments; +import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; +import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The project's write queue, which the pipeline writes through and the project finishes before its record closes. */ +class WriteQueueTest { + @TempDir Path directory; + private GameLocation location; + private final List assignments = new ArrayList<>(); + + @BeforeEach + void location() { + this.location = new GameLocation(this.directory); + } + + @AfterEach + void closeAssignments() { + this.assignments.forEach(KeyAssignments::close); + } + + @Test + void closingFinishesTheWritesAlreadyTakenAndRefusesLaterOnes() throws Exception { + WriteQueue writes = new WriteQueue(); + ChangePipeline changes = new ChangePipeline(this.location, ChangeRecord.inMemory(), writes); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture taken = changes.write(() -> { + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return "written"; + }); + + CompletableFuture closing = CompletableFuture.runAsync(writes::close); + release.countDown(); + closing.get(10, TimeUnit.SECONDS); + + assertEquals("written", taken.getNow(null), "a write taken before closing is finished, and so recorded"); + ExecutionException refused = assertThrows(ExecutionException.class, + () -> changes.write(() -> "late").get(5, TimeUnit.SECONDS)); + assertEquals("The project is closing; the change was not written", refused.getCause().getMessage()); + } + + @Test + void closingWaitsThroughAnInterruptionAndKeepsItForTheCaller() throws Exception { + WriteQueue writes = new WriteQueue(); + ChangePipeline changes = new ChangePipeline(this.location, ChangeRecord.inMemory(), writes); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture taken = changes.write(() -> { + started.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return "written"; + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + boolean[] interruptedAfter = new boolean[1]; + Thread closer = new Thread(() -> { + writes.close(); + interruptedAfter[0] = Thread.currentThread().isInterrupted(); + }); + + closer.start(); + closer.interrupt(); + closer.join(200); + assertTrue(closer.isAlive(), "an interruption does not end the wait while a write runs"); + release.countDown(); + closer.join(5_000); + + assertEquals("written", taken.getNow(null)); + assertTrue(interruptedAfter[0], "the interruption is kept for the caller"); + } + + @Test + void anOfflineKeyChangeTakenBeforeClosingIsWrittenAndRecorded() throws Exception { + Path options = this.directory.resolve("options.txt"); + Files.writeString(options, "key_key.jump:key.keyboard.space\n"); + ChangeRecord record = ChangeRecord.inMemory(); + WriteQueue writes = new WriteQueue(); + ChangePipeline changes = new ChangePipeline(this.location, record, writes); + KeyBindingControl keys = new KeyBindingControl(changes, assignments()); + CountDownLatch release = new CountDownLatch(1); + changes.write(() -> { + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return null; + }); + CompletableFuture key = keys.set(List.of(new KeyBindingControl.Change("key.jump", + new KeyBindings.Assignment("key.keyboard.space", "NONE"), new KeyBindings.Assignment("key.keyboard.g", "NONE")))); + + CompletableFuture closing = CompletableFuture.runAsync(writes::close); + release.countDown(); + closing.get(10, TimeUnit.SECONDS); + + assertTrue(key.isDone()); + assertEquals(List.of("key_key.jump:key.keyboard.g"), Files.readAllLines(options)); + assertEquals(new KeyBindings.Assignment("key.keyboard.space", "NONE"), keys.original("key.jump"), + "the key change is recorded before the record could close"); + } + + private KeyAssignments assignments() { + KeyAssignments assignments = new KeyAssignments(this.directory.resolve("options.txt")); + this.assignments.add(assignments); + return assignments; + } +} diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index de5e2f43a..2b25b1e7b 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -143,20 +143,20 @@ The Swing thread runs Swing, and nothing that waits or grows with the data: no f | Worker | For | Instead of | |---|---|---| -| File work (a bounded pool of platform threads) | Reads for pages and readings | The shared pool in `PageLoader` and the one-argument `supplyAsync` calls; `ResourceViewPanel`'s pool | -| Serial workers (one thread each, by name) | Components whose state is confined to one thread, or whose order matters | The own executors of `TextureThumbnails`, `ModLogoIcons`, the item icon renderer, `JsonStateWriter` | -| The project's write queue (a serial worker the pipeline owns) | Every write of the pipeline, adoptions, and owners noticing values put back | `ConfigChanges`' executor, which every category borrows today | -| Timers (one scheduler) | Settle delays, retries, timeouts; a timer only hands work to another worker | `KeyAssignments`' and `ExternalEdits`' schedulers, `JsonStateWriter`'s scheduled flush | +| File work (`Workers.files()`, a bounded pool of platform threads) | Reads for pages and readings, and other short work off the Swing thread | The shared pool in `PageLoader` and the one-argument `supplyAsync` calls; `ResourceViewPanel`'s pool | +| Serial paths (`Workers.strand()` for owners, `Workers.fileStrand()` for file work), each a `Strand` over the shared threads | Owners, and components whose state stays on one thread or whose order matters, as open archives | The own executors of `TextureThumbnails` and `ModLogoIcons` | +| The project's write queue (`WriteQueue`, a serial path over the file work that the pipeline writes through) | Every write of the pipeline, adoptions, and owners noticing values put back | `ConfigChanges`' executor, which every category borrowed | +| Timers (`Workers.later`, one scheduler) | Settle delays, retries, timeouts; a timer only hands work to another worker | `KeyAssignments`' and `ExternalEdits`' schedulers, `JsonStateWriter`'s scheduled flush | - Platform threads, not virtual ones: on Java 21 a virtual thread is pinned inside `synchronized` and by `ZipFile`, which most reads use. - Swing timers stay for delays and animations on the Swing thread. -- **Allowed own threads**, each owned by a service that closes it: the debugger (`DebuggerSessionQueue`, `DebuggerEvaluationRunner`), the editor's Java analysis, script compilation, decompilation, search (also `SearchEverywherePopup`'s) and the runtime index, the MCP job service, `CompanionApplication`'s project switching and MCP lifecycle, and `ProjectSelectionServer`. The list lives in the architecture test; adding to it is a decision recorded here. +- **Allowed own threads**, each owned by a service that closes it: the debugger (`DebuggerSessionQueue`, `DebuggerEvaluationRunner`), the editor's Java analysis, script compilation, decompilation, search (also `SearchEverywherePopup`'s) and the runtime index, the MCP job service, `CompanionApplication`'s project switching and MCP lifecycle, `ProjectSelectionServer`, and the item icon renderer, which its graphics library confines to one thread. The list lives in the architecture test; adding to it is a decision recorded here. ## 5. Writes The change pipeline ([CHANGE_PIPELINE.md](CHANGE_PIPELINE.md)) stays the one way to change the game's and the packs' files and what the running game keeps. -- **The pipeline owns the project's write queue.** `ConfigChanges` stops creating it; `ResourceEdits` gets it from the pipeline. +- **The pipeline owns the project's write queue.** The project makes one `WriteQueue` for its pipeline and closes it before its change record; `ConfigChanges` makes none, and `ResourceEdits` and configuration settings write through the pipeline's. - **A category may write beside its value inside its own write task**, as `ResourceEdits` enables the managed pack in `options.txt` and writes its `pack.mcmeta`. Nothing writes the game's or the packs' files outside a write task. - **Companion's own files** (its state, script files, originals, decompiled sources) are not the pipeline's; they are written by their owners on serial workers. - **After a write, the category's owner fires its signal** if the value differs. @@ -206,7 +206,9 @@ PRs on 1.21.1, stacked, each reviewed until clean. A shared mechanism comes with | 5 | The owners still telling through their own listeners on signals: `GameLocation`, `ItemIconService`, `RuntimeIndexService` | `GameLocation.Change` and the three listener lists | | 6 | The last watchers onto `FileWatch`: the Project tree's folders, told of entries only, and `ExternalEdits`, whose settle runs on the timer; `FileUtils`' pause for Companion's own moves becomes `FileWatch.pausing` | `FileUtils`, the watchers and schedulers of `ExternalEdits` | | 7 | All remaining pages on `page` and `follows`, the logs, configuration and resource packs pages reading whenever shown; the World tab's title from its owner; reads on the file workers | `ShownUpdates`, `whenShown`, `waitsWhileHidden` and `follow`, the pages' own subscriptions, the reads in constructors, the navigation refreshes | -| 8 | The current project as state, which the Project tree and the main window follow; connection numbers for waiting requests; the pipeline owning the write queue, where owners notice values put back outside Companion; no file checks on the Swing thread; the remaining executors onto `Workers` | `ConfigChanges`' executor, the scope checks, the `CompanionUi` relays, `ChangeRecord.observed` from page reads and the Changes page's read whenever shown, the UI classes' executors | +| 8a | The pipeline owning the write queue; the remaining executors and one-argument async calls onto `Workers` | `ConfigChanges`' executor, the UI classes' and `JsonStateWriter`'s executors, every use of the shared pool | +| 8b | The current project as state, which the Project tree and the main window follow; no file checks on the Swing thread | the scope checks, the `CompanionUi` relays | +| 8c | Owners noticing values put back outside Companion, on the write queue; connection numbers for waiting requests | `ChangeRecord.observed` from page reads and the Changes page's read whenever shown | After the messages, A4 continues with categories registering their pages and Modpack rows. Splitting `CompanionApplication` (the game connection and the MCP server into their own classes) and the mod's `CompanionAppClient` (launching Companion) is easier then and is decided at that point. From 3af59051a95ea88e3b15bc4915a42a4bc05eb316 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:38:15 +0200 Subject: [PATCH 2/3] Keep project writes independent of shared file reads --- .../change/WriteQueue.java | 17 ++++------ .../totalDebugCompanion/util/Workers.java | 8 +++++ .../totalDebugCompanion/SystemsRulesTest.java | 2 +- .../change/WriteQueueTest.java | 32 +++++++++++++++++++ docs/SYSTEMS.md | 4 +-- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueue.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueue.java index d096e4abb..6719ddf11 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueue.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueue.java @@ -1,12 +1,11 @@ package com.github.minecraft_ta.totalDebugCompanion.change; -import com.github.minecraft_ta.totalDebugCompanion.util.Strand; import com.github.minecraft_ta.totalDebugCompanion.util.Workers; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; /** * The project's writes to the game's and the packs' files, one at a time, so writes to the same file never interleave @@ -14,13 +13,13 @@ * closes. It refuses work once closed. */ public final class WriteQueue implements Executor, AutoCloseable { - private final Strand strand = Workers.fileStrand(); + private final ExecutorService worker = Workers.projectWrites(); private boolean closed; @Override public synchronized void execute(Runnable write) { if (this.closed) throw new RejectedExecutionException("The project is closing"); - this.strand.execute(write); + this.worker.execute(write); } /** @@ -29,20 +28,16 @@ public synchronized void execute(Runnable write) { */ @Override public void close() { - CompletableFuture drained = new CompletableFuture<>(); synchronized (this) { this.closed = true; - this.strand.execute(() -> drained.complete(null)); + this.worker.shutdown(); } boolean interrupted = false; while (true) { try { - drained.get(); - break; + if (this.worker.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS)) break; } catch (InterruptedException interruption) { interrupted = true; - } catch (ExecutionException impossible) { - break; } } if (interrupted) Thread.currentThread().interrupt(); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/Workers.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/Workers.java index e6931d291..2b828ac84 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/Workers.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/Workers.java @@ -46,6 +46,14 @@ public static Strand fileStrand() { return new Strand(FILES); } + /** A project's serial write worker, independent of unrelated reads and closed by its write queue. */ + public static ExecutorService projectWrites() { + return Executors.newSingleThreadExecutor(task -> Thread.ofPlatform() + .daemon() + .name("Companion writes") + .unstarted(task)); + } + /** A new serial path, for an owner whose state changes in one order. */ public static Strand strand() { return new Strand(OWNERS); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java index 970ba2198..9e3f97455 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java @@ -83,7 +83,7 @@ private record Allowed(int times, String why) { Map.entry("mcp/CodeModeJobService.java", new Allowed(2, "the MCP job service")), Map.entry("session/ProjectSelectionServer.java", new Allowed(2, "accepts connections")), Map.entry("inspection/ItemIconService.java", new Allowed(2, "the item icon renderer is confined to one thread")), - Map.entry("util/Workers.java", new Allowed(6, "the file work, the owners' strands and the timer everything shares")), + Map.entry("util/Workers.java", new Allowed(8, "the shared file work, owners and timer, and each project's write worker")), Map.entry("util/FileWatch.java", new Allowed(1, "the one watcher of the folders Companion follows"))); // Every async task names its worker. diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java index 60391ce15..0c086d67f 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java @@ -5,6 +5,7 @@ import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyAssignments; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -39,6 +40,37 @@ void closeAssignments() { this.assignments.forEach(KeyAssignments::close); } + @Test + void writesAndClosingDoNotWaitForUnrelatedFileReads() throws Exception { + CountDownLatch entered = new CountDownLatch(4); + CountDownLatch release = new CountDownLatch(1); + WriteQueue empty = new WriteQueue(); + WriteQueue writes = new WriteQueue(); + for (int index = 0; index < 4; index++) { + Workers.files().execute(() -> { + entered.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }); + } + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + CompletableFuture.runAsync(empty::close).get(5, TimeUnit.SECONDS); + ChangePipeline changes = new ChangePipeline(this.location, ChangeRecord.inMemory(), writes); + CompletableFuture taken = changes.write(() -> "written"); + CompletableFuture closing = CompletableFuture.runAsync(writes::close); + assertEquals("written", taken.get(5, TimeUnit.SECONDS)); + closing.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + empty.close(); + writes.close(); + } + } + @Test void closingFinishesTheWritesAlreadyTakenAndRefusesLaterOnes() throws Exception { WriteQueue writes = new WriteQueue(); diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index 2b25b1e7b..590b8bd57 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -145,7 +145,7 @@ The Swing thread runs Swing, and nothing that waits or grows with the data: no f |---|---|---| | File work (`Workers.files()`, a bounded pool of platform threads) | Reads for pages and readings, and other short work off the Swing thread | The shared pool in `PageLoader` and the one-argument `supplyAsync` calls; `ResourceViewPanel`'s pool | | Serial paths (`Workers.strand()` for owners, `Workers.fileStrand()` for file work), each a `Strand` over the shared threads | Owners, and components whose state stays on one thread or whose order matters, as open archives | The own executors of `TextureThumbnails` and `ModLogoIcons` | -| The project's write queue (`WriteQueue`, a serial path over the file work that the pipeline writes through) | Every write of the pipeline, adoptions, and owners noticing values put back | `ConfigChanges`' executor, which every category borrowed | +| The project's write queue (`WriteQueue`, with its own serial worker from `Workers.projectWrites()`) | Every write of the pipeline, independent of unrelated reads, so closing can finish its writes | `ConfigChanges`' executor, which every category borrowed | | Timers (`Workers.later`, one scheduler) | Settle delays, retries, timeouts; a timer only hands work to another worker | `KeyAssignments`' and `ExternalEdits`' schedulers, `JsonStateWriter`'s scheduled flush | - Platform threads, not virtual ones: on Java 21 a virtual thread is pinned inside `synchronized` and by `ZipFile`, which most reads use. @@ -156,7 +156,7 @@ The Swing thread runs Swing, and nothing that waits or grows with the data: no f The change pipeline ([CHANGE_PIPELINE.md](CHANGE_PIPELINE.md)) stays the one way to change the game's and the packs' files and what the running game keeps. -- **The pipeline owns the project's write queue.** The project makes one `WriteQueue` for its pipeline and closes it before its change record; `ConfigChanges` makes none, and `ResourceEdits` and configuration settings write through the pipeline's. +- **The pipeline owns the project's write queue.** The project makes one `WriteQueue` for its pipeline and closes it before its change record; `ConfigChanges` makes none, and `ResourceEdits` and configuration settings write through the pipeline's. Its worker is separate from the shared file pool, so unrelated reads cannot delay writes or closing an empty queue. - **A category may write beside its value inside its own write task**, as `ResourceEdits` enables the managed pack in `options.txt` and writes its `pack.mcmeta`. Nothing writes the game's or the packs' files outside a write task. - **Companion's own files** (its state, script files, originals, decompiled sources) are not the pipeline's; they are written by their owners on serial workers. - **After a write, the category's owner fires its signal** if the value differs. From e5e3da3e1437b5f63d894cb0ca703c3f66b5009c Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:01:18 +0200 Subject: [PATCH 3/3] Compose script moves after pending saves without blocking their worker --- .../treeView/ScriptFileActions.java | 6 ++- .../treeView/ScriptFileActionsTest.java | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java index 055746673..bb085e301 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java @@ -369,11 +369,13 @@ private CompletableFuture execute(List roots, boolean deleting, Work var removed = new ArrayList(); var changes = new ArrayList(); var result = new CompletableFuture(); - CompletableFuture.runAsync(() -> { + CompletableFuture pendingSaves = CompletableFuture.allOf( + views.stream().map(ScriptView::pendingSave).toArray(CompletableFuture[]::new)); + pendingSaves.thenRunAsync(() -> { try { ctx.project().requireActive(); for (Path root : roots) ctx.project().scriptFiles().mutable(root); - for (var entry : drafts.entrySet()) { entry.getKey().pendingSave().join(); entry.getKey().persist(entry.getValue()); saved.add(entry.getKey()); } + for (var entry : drafts.entrySet()) { entry.getKey().persist(entry.getValue()); saved.add(entry.getKey()); } FileWatch.shared().pausing(roots, () -> work.run(ctx.project().scriptFiles(), changes)); } catch (IOException failure) { throw new CompletionException(failure); } finally { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActionsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActionsTest.java index 87a44db85..02da64c55 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActionsTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActionsTest.java @@ -1,6 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView; import com.github.minecraft_ta.totalDebugCompanion.testui.UiTest; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import javax.swing.JMenuItem; import javax.swing.Action; import javax.swing.JPopupMenu; @@ -41,6 +42,7 @@ import java.nio.file.Path; import java.util.List; import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.CompletableFuture; import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; @@ -54,6 +56,55 @@ @UiTest class ScriptFileActionsTest { + @Test + void movingWaitsForPendingSavesWithoutHoldingTheirWorker() throws Exception { + Path home = Files.createDirectories(directory.resolve("home")); + GlobalConfig.getInstance().loadFrom(home); + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(home), "test-token")) { + app.openProject(CompanionProfile.forGame(Files.createDirectories(directory.resolve("game")))).get(10, TimeUnit.SECONDS); + MainWindow window = edt(app::createWindow); + Path script = window.editorContext().project().scriptFiles().create( + window.editorContext().project().scriptFiles().root(), "Pending", false, "return 1;"); + CompletableFuture pending = new CompletableFuture<>(); + CountDownLatch observed = new CountDownLatch(1); + ScriptView view = new ScriptView(window.editorContext(), script) { + @Override public CompletableFuture pendingSave() { + observed.countDown(); + return pending; + } + }; + edt(() -> window.getEditorTabs().openEditorTab(view)).get(10, TimeUnit.SECONDS); + CountDownLatch busy = new CountDownLatch(3); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture moving = null; + try { + for (int index = 0; index < 3; index++) { + Workers.files().execute(() -> { + busy.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }); + } + assertTrue(busy.await(5, TimeUnit.SECONDS)); + Path renamed = script.resolveSibling("Renamed.tdscript"); + moving = edt(() -> window.scriptFileActions().rename(script, renamed)); + assertTrue(observed.await(5, TimeUnit.SECONDS)); + Workers.files().execute(() -> pending.complete(null)); + moving.get(5, TimeUnit.SECONDS); + assertEquals("return 1;", Files.readString(renamed)); + assertEquals(renamed, edt(view::getPath)); + } finally { + pending.complete(null); + release.countDown(); + if (moving != null) moving.handle((ignored, failure) -> null).get(10, TimeUnit.SECONDS); + edt(() -> { window.dispose(); return null; }); + } + } + } + @ParameterizedTest @ValueSource(strings = {"create", "duplicate"}) void recreatingAnExternallyDeletedOpenScriptPreservesItsDraft(String command) throws Exception { Path home = Files.createDirectories(directory.resolve("home"));