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..6719ddf11 --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueue.java @@ -0,0 +1,45 @@ +package com.github.minecraft_ta.totalDebugCompanion.change; + +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; + +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 + * (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 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.worker.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() { + synchronized (this) { + this.closed = true; + this.worker.shutdown(); + } + boolean interrupted = false; + while (true) { + try { + if (this.worker.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS)) break; + } catch (InterruptedException interruption) { + interrupted = true; + } + } + 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..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 @@ -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; @@ -368,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 { @@ -381,7 +384,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..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 @@ -41,6 +41,19 @@ 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 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 34dd5ba68..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 @@ -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/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"))); - // 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..0c086d67f --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java @@ -0,0 +1,166 @@ +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 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; +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 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(); + 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/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")); diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index de5e2f43a..590b8bd57 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`, 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. - 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. 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. @@ -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.