From 2fb24e0d0b01f0226f3f4a8a9c4d59ba98f320de Mon Sep 17 00:00:00 2001 From: supersaiyansubtlety Date: Mon, 15 Sep 2025 18:26:00 -0700 Subject: [PATCH 1/8] make stat icon updates sequencial; fixes 271 --- .../org/quiltmc/enigma/gui/ClassSelector.java | 10 +++--- .../main/java/org/quiltmc/enigma/gui/Gui.java | 21 +++++++++++- .../gui/node/ClassSelectorClassNode.java | 14 +++++--- .../java/org/quiltmc/enigma/util/Utils.java | 33 +++++++++++++++++++ 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java index d613ce749..c58dedf86 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java @@ -6,6 +6,7 @@ import org.quiltmc.enigma.gui.node.SortedMutableTreeNode; import org.quiltmc.enigma.gui.util.GuiUtil; import org.quiltmc.enigma.api.translation.representation.entry.ClassEntry; +import org.quiltmc.enigma.util.Utils; import javax.annotation.Nullable; import javax.swing.JTree; @@ -18,6 +19,7 @@ import java.util.Collection; import java.util.Comparator; import java.util.List; +import java.util.concurrent.RunnableFuture; public class ClassSelector extends JTree { public static final Comparator DEOBF_CLASS_COMPARATOR = Comparator.comparing(ClassEntry::getFullName); @@ -296,11 +298,11 @@ public void reload() { * * @param classEntry the class to reload stats for */ - public void reloadStats(ClassEntry classEntry) { + public RunnableFuture reloadStats(ClassEntry classEntry) { ClassSelectorClassNode node = this.packageManager.getClassNode(classEntry); - if (node != null) { - node.reloadStats(this.controller.getGui(), this, true); - } + return node == null + ? Utils.DUMMY_RUNNABLE_FUTURE + : node.reloadStats(this.controller.getGui(), this, true); } public interface ClassSelectionListener { diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java index ce2b41c7c..08d963b48 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java @@ -41,6 +41,7 @@ import org.quiltmc.enigma.api.translation.representation.entry.ClassEntry; import org.quiltmc.enigma.api.translation.representation.entry.Entry; import org.quiltmc.enigma.util.I18n; +import org.quiltmc.enigma.util.Utils; import org.quiltmc.enigma.util.validation.Message; import org.quiltmc.enigma.util.validation.ParameterizedMessage; import org.quiltmc.enigma.util.validation.ValidationContext; @@ -68,6 +69,8 @@ import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RunnableFuture; import java.util.function.IntFunction; public class Gui { @@ -106,6 +109,9 @@ public class Gui { private final boolean testEnvironment; + @Nullable + private CompletableFuture priorReloads; + public Gui(EnigmaProfile profile, Set editableTypes, boolean testEnvironment) { this.dockerManager = new DockerManager(this); this.mainWindow = new MainWindow(this, Enigma.NAME); @@ -620,13 +626,26 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { toUpdate.addAll(parents); } + if (this.priorReloads == null) { + this.priorReloads = CompletableFuture.completedFuture(null); + } + for (Docker value : this.dockerManager.getDockers()) { if (value instanceof ClassesDocker docker) { for (ClassEntry entry : toUpdate) { - docker.getClassSelector().reloadStats(entry); + this.priorReloads = this.priorReloads.thenRunAsync(() -> { + try { + docker.getClassSelector().reloadStats(entry).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + }); } } } + + // discard once done to avoid taking up memory + this.priorReloads = this.priorReloads.thenRun(() -> this.priorReloads = null); } public SearchDialog getSearchDialog() { diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java index 92d26ec3f..57e02dd7c 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java @@ -7,12 +7,14 @@ import org.quiltmc.enigma.gui.util.GuiUtil; import org.quiltmc.enigma.api.stats.StatsGenerator; import org.quiltmc.enigma.api.translation.representation.entry.ClassEntry; +import org.quiltmc.enigma.util.Utils; import javax.swing.SwingUtilities; import javax.swing.SwingWorker; import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.TreeNode; import java.util.Comparator; +import java.util.concurrent.RunnableFuture; public class ClassSelectorClassNode extends SortedMutableTreeNode { private final ClassEntry obfEntry; @@ -41,15 +43,15 @@ public ClassEntry getDeobfEntry() { * @param selector the class selector to reload on * @param updateIfPresent whether to update the stats if they have already been generated for this node */ - public void reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent) { + public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent) { StatsGenerator generator = gui.getController().getStatsGenerator(); if (generator == null) { - return; + return Utils.DUMMY_RUNNABLE_FUTURE; } - SwingWorker iconUpdateWorker = new SwingWorker<>() { + SwingWorker iconUpdateWorker = new SwingWorker<>() { @Override - protected ClassSelectorClassNode doInBackground() { + protected Void doInBackground() { var parameters = Config.stats().createIconGenParameters(gui.getEditableStatTypes()); if (generator.getResultNullable(parameters) == null && generator.getOverallProgress() == null) { @@ -58,7 +60,7 @@ protected ClassSelectorClassNode doInBackground() { generator.generate(ProgressListener.createEmpty(), ClassSelectorClassNode.this.getObfEntry(), parameters); } - return ClassSelectorClassNode.this; + return null; } @Override @@ -78,6 +80,8 @@ public void done() { if (Config.main().features.enableClassTreeStatIcons.value()) { SwingUtilities.invokeLater(iconUpdateWorker::execute); } + + return iconUpdateWorker; } @Override diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java index 905555335..da4368406 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java +++ b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java @@ -2,6 +2,7 @@ import com.google.common.io.CharStreams; +import javax.annotation.Nonnull; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -16,12 +17,44 @@ import java.util.List; import java.util.Locale; import java.util.Properties; +import java.util.concurrent.RunnableFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; public class Utils { + public static final RunnableFuture DUMMY_RUNNABLE_FUTURE = new RunnableFuture<>() { + @Override + public void run() { } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public boolean isDone() { + return true; + } + + @Override + public Void get() { + return null; + } + + @Override + public Void get(long timeout, @Nonnull TimeUnit unit) { + return null; + } + }; + public static String readStreamToString(InputStream in) throws IOException { return CharStreams.toString(new InputStreamReader(in, StandardCharsets.UTF_8)); } From 10031b21ba90a781fd0c551b1010b4bd164f4201 Mon Sep 17 00:00:00 2001 From: supersaiyansubtlety Date: Mon, 15 Sep 2025 19:16:17 -0700 Subject: [PATCH 2/8] allow many concurrerent asynchronous calls per call to Gui::reloadStats, but batch by call and wait for prior call batches before starting the next call batch --- .../main/java/org/quiltmc/enigma/gui/Gui.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java index 08d963b48..e46da590d 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java @@ -72,8 +72,11 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.RunnableFuture; import java.util.function.IntFunction; +import java.util.stream.Stream; public class Gui { + private static final CompletableFuture COMPLETED_DUMMY = CompletableFuture.completedFuture(null); + private final MainWindow mainWindow; private final GuiController controller; @@ -109,8 +112,7 @@ public class Gui { private final boolean testEnvironment; - @Nullable - private CompletableFuture priorReloads; + private CompletableFuture priorReloads = COMPLETED_DUMMY; public Gui(EnigmaProfile profile, Set editableTypes, boolean testEnvironment) { this.dockerManager = new DockerManager(this); @@ -626,14 +628,16 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { toUpdate.addAll(parents); } - if (this.priorReloads == null) { - this.priorReloads = CompletableFuture.completedFuture(null); + if (this.priorReloads.isDone()) { + // discard prior completed futures to avoid taking up memory + this.priorReloads = COMPLETED_DUMMY; } + final List currentReloads = new ArrayList<>(); for (Docker value : this.dockerManager.getDockers()) { if (value instanceof ClassesDocker docker) { for (ClassEntry entry : toUpdate) { - this.priorReloads = this.priorReloads.thenRunAsync(() -> { + currentReloads.add(() -> { try { docker.getClassSelector().reloadStats(entry).get(); } catch (InterruptedException | ExecutionException e) { @@ -644,8 +648,14 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { } } - // discard once done to avoid taking up memory - this.priorReloads = this.priorReloads.thenRun(() -> this.priorReloads = null); + this.priorReloads = this.priorReloads.thenRunAsync(() -> CompletableFuture + .allOf( + currentReloads.stream() + .map(CompletableFuture::runAsync) + .toArray(CompletableFuture[]::new) + ) + .join() + ); } public SearchDialog getSearchDialog() { From 07641dc27831f8addd3d81cbd8b5cabcd812baaf Mon Sep 17 00:00:00 2001 From: supersaiyansubtlety Date: Tue, 16 Sep 2025 16:14:52 -0700 Subject: [PATCH 3/8] allow Gui::reloadStats to cancel ClassSelectorClassNode::reloadStats' iconUpdateWorker --- .../org/quiltmc/enigma/gui/ClassSelector.java | 5 +- .../main/java/org/quiltmc/enigma/gui/Gui.java | 14 ++++- .../gui/node/ClassSelectorClassNode.java | 62 +++++++++++++------ 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java index c58dedf86..520544567 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java @@ -20,6 +20,7 @@ import java.util.Comparator; import java.util.List; import java.util.concurrent.RunnableFuture; +import java.util.concurrent.atomic.AtomicBoolean; public class ClassSelector extends JTree { public static final Comparator DEOBF_CLASS_COMPARATOR = Comparator.comparing(ClassEntry::getFullName); @@ -298,11 +299,11 @@ public void reload() { * * @param classEntry the class to reload stats for */ - public RunnableFuture reloadStats(ClassEntry classEntry) { + public RunnableFuture reloadStats(ClassEntry classEntry, AtomicBoolean canceller) { ClassSelectorClassNode node = this.packageManager.getClassNode(classEntry); return node == null ? Utils.DUMMY_RUNNABLE_FUTURE - : node.reloadStats(this.controller.getGui(), this, true); + : node.reloadStats(this.controller.getGui(), this, true, canceller); } public interface ClassSelectionListener { diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java index e46da590d..ec0a9e91f 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java @@ -71,6 +71,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.RunnableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.IntFunction; import java.util.stream.Stream; @@ -112,7 +113,14 @@ public class Gui { private final boolean testEnvironment; + /** + * Possibly-incomplete work from prior calls to {@link #reloadStats(ClassEntry, boolean)}. + */ private CompletableFuture priorReloads = COMPLETED_DUMMY; + /** + * Setting this to true cancels incomplete work from the last call to {@link #reloadStats(ClassEntry, boolean)}. + */ + private AtomicBoolean priorReloadCanceler = new AtomicBoolean(false); public Gui(EnigmaProfile profile, Set editableTypes, boolean testEnvironment) { this.dockerManager = new DockerManager(this); @@ -631,15 +639,19 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { if (this.priorReloads.isDone()) { // discard prior completed futures to avoid taking up memory this.priorReloads = COMPLETED_DUMMY; + } else { + this.priorReloadCanceler.set(true); } + final AtomicBoolean currentReloadCanceler = new AtomicBoolean(false); + this.priorReloadCanceler = currentReloadCanceler; final List currentReloads = new ArrayList<>(); for (Docker value : this.dockerManager.getDockers()) { if (value instanceof ClassesDocker docker) { for (ClassEntry entry : toUpdate) { currentReloads.add(() -> { try { - docker.getClassSelector().reloadStats(entry).get(); + docker.getClassSelector().reloadStats(entry, currentReloadCanceler).get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java index 57e02dd7c..a92554d4b 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java @@ -1,6 +1,7 @@ package org.quiltmc.enigma.gui.node; import org.quiltmc.enigma.api.ProgressListener; +import org.quiltmc.enigma.api.stats.ProjectStatsResult; import org.quiltmc.enigma.gui.ClassSelector; import org.quiltmc.enigma.gui.Gui; import org.quiltmc.enigma.gui.config.Config; @@ -14,9 +15,16 @@ import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.TreeNode; import java.util.Comparator; +import java.util.concurrent.ExecutionException; import java.util.concurrent.RunnableFuture; +import java.util.concurrent.atomic.AtomicBoolean; public class ClassSelectorClassNode extends SortedMutableTreeNode { + /** + * Used by {@link #reloadStats(Gui, ClassSelector, boolean)}; never change its value. + */ + private static final AtomicBoolean DUMMY_CANCELER = new AtomicBoolean(false); + private final ClassEntry obfEntry; private ClassEntry deobfEntry; @@ -43,37 +51,55 @@ public ClassEntry getDeobfEntry() { * @param selector the class selector to reload on * @param updateIfPresent whether to update the stats if they have already been generated for this node */ - public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent) { + public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent) { + return this.reloadStats(gui, selector, updateIfPresent, DUMMY_CANCELER); + } + + public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent, AtomicBoolean canceller) { StatsGenerator generator = gui.getController().getStatsGenerator(); if (generator == null) { return Utils.DUMMY_RUNNABLE_FUTURE; } - SwingWorker iconUpdateWorker = new SwingWorker<>() { + SwingWorker iconUpdateWorker = new SwingWorker<>() { @Override - protected Void doInBackground() { - var parameters = Config.stats().createIconGenParameters(gui.getEditableStatTypes()); + protected ProjectStatsResult doInBackground() { + if (canceller.get()) { + return null; + } else { + var parameters = Config.stats().createIconGenParameters(gui.getEditableStatTypes()); - if (generator.getResultNullable(parameters) == null && generator.getOverallProgress() == null) { - generator.generate(ProgressListener.createEmpty(), parameters); - } else if (updateIfPresent) { - generator.generate(ProgressListener.createEmpty(), ClassSelectorClassNode.this.getObfEntry(), parameters); + if (generator.getResultNullable(parameters) == null && generator.getOverallProgress() == null) { + return generator.generate(ProgressListener.createEmpty(), parameters); + } else if (updateIfPresent) { + return generator.generate(ProgressListener.createEmpty(), ClassSelectorClassNode.this.getObfEntry(), parameters); + } else { + return null; + } } - - return null; } @Override public void done() { - try { - var parameters = Config.stats().createIconGenParameters(gui.getEditableStatTypes()); - ((DefaultTreeCellRenderer) selector.getCellRenderer()).setIcon(GuiUtil.getDeobfuscationIcon(generator.getResultNullable(parameters), ClassSelectorClassNode.this.getObfEntry())); - } catch (NullPointerException ignored) { - // do nothing. this seems to be a race condition, likely a bug in FlatLAF caused by us suppressing the default tree icons - // ignoring this error should never cause issues since it only occurs at startup + if (!canceller.get()) { + final ProjectStatsResult result; + try { + result = this.get(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + + if (result != null) { + try { + ((DefaultTreeCellRenderer) selector.getCellRenderer()).setIcon(GuiUtil.getDeobfuscationIcon(result, ClassSelectorClassNode.this.getObfEntry())); + } catch (NullPointerException ignored) { + // do nothing. this seems to be a race condition, likely a bug in FlatLAF caused by us suppressing the default tree icons + // ignoring this error should never cause issues since it only occurs at startup + } + + SwingUtilities.invokeLater(() -> selector.reload(ClassSelectorClassNode.this, false)); + } } - - SwingUtilities.invokeLater(() -> selector.reload(ClassSelectorClassNode.this, false)); } }; From 5bcba8306ff98a9bbe4669426a541f1cc3bbee1a Mon Sep 17 00:00:00 2001 From: supersaiyansubtlety Date: Tue, 16 Sep 2025 19:54:15 -0700 Subject: [PATCH 4/8] use stream to create currentReloads --- .../main/java/org/quiltmc/enigma/gui/Gui.java | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java index ec0a9e91f..0d2f25c4f 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java @@ -41,7 +41,6 @@ import org.quiltmc.enigma.api.translation.representation.entry.ClassEntry; import org.quiltmc.enigma.api.translation.representation.entry.Entry; import org.quiltmc.enigma.util.I18n; -import org.quiltmc.enigma.util.Utils; import org.quiltmc.enigma.util.validation.Message; import org.quiltmc.enigma.util.validation.ParameterizedMessage; import org.quiltmc.enigma.util.validation.ValidationContext; @@ -70,7 +69,6 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.concurrent.RunnableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.IntFunction; import java.util.stream.Stream; @@ -645,20 +643,16 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { final AtomicBoolean currentReloadCanceler = new AtomicBoolean(false); this.priorReloadCanceler = currentReloadCanceler; - final List currentReloads = new ArrayList<>(); - for (Docker value : this.dockerManager.getDockers()) { - if (value instanceof ClassesDocker docker) { - for (ClassEntry entry : toUpdate) { - currentReloads.add(() -> { - try { - docker.getClassSelector().reloadStats(entry, currentReloadCanceler).get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - }); + final List currentReloads = this.dockerManager.getDockers().stream() + .flatMap(docker -> docker instanceof ClassesDocker classes ? Stream.of(classes) : Stream.empty()) + .flatMap(classes -> toUpdate.stream().map(updating -> () -> { + try { + classes.getClassSelector().reloadStats(updating, currentReloadCanceler).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); } - } - } + })) + .toList(); this.priorReloads = this.priorReloads.thenRunAsync(() -> CompletableFuture .allOf( From 675720da3f79d06185ecd61feed9473f6e53cc52 Mon Sep 17 00:00:00 2001 From: supersaiyansubtlety Date: Tue, 16 Sep 2025 20:03:31 -0700 Subject: [PATCH 5/8] AtomicBoolean params -> Supplier --- .../java/org/quiltmc/enigma/gui/ClassSelector.java | 5 +++-- .../src/main/java/org/quiltmc/enigma/gui/Gui.java | 2 +- .../enigma/gui/node/ClassSelectorClassNode.java | 14 +++++--------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java index 520544567..3557df1cd 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.concurrent.RunnableFuture; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; public class ClassSelector extends JTree { public static final Comparator DEOBF_CLASS_COMPARATOR = Comparator.comparing(ClassEntry::getFullName); @@ -299,11 +300,11 @@ public void reload() { * * @param classEntry the class to reload stats for */ - public RunnableFuture reloadStats(ClassEntry classEntry, AtomicBoolean canceller) { + public RunnableFuture reloadStats(ClassEntry classEntry, Supplier shouldCancel) { ClassSelectorClassNode node = this.packageManager.getClassNode(classEntry); return node == null ? Utils.DUMMY_RUNNABLE_FUTURE - : node.reloadStats(this.controller.getGui(), this, true, canceller); + : node.reloadStats(this.controller.getGui(), this, true, shouldCancel); } public interface ClassSelectionListener { diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java index 0d2f25c4f..66e5a125f 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java @@ -647,7 +647,7 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { .flatMap(docker -> docker instanceof ClassesDocker classes ? Stream.of(classes) : Stream.empty()) .flatMap(classes -> toUpdate.stream().map(updating -> () -> { try { - classes.getClassSelector().reloadStats(updating, currentReloadCanceler).get(); + classes.getClassSelector().reloadStats(updating, currentReloadCanceler::get).get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java index a92554d4b..17fdd7066 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java @@ -18,13 +18,9 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.RunnableFuture; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; public class ClassSelectorClassNode extends SortedMutableTreeNode { - /** - * Used by {@link #reloadStats(Gui, ClassSelector, boolean)}; never change its value. - */ - private static final AtomicBoolean DUMMY_CANCELER = new AtomicBoolean(false); - private final ClassEntry obfEntry; private ClassEntry deobfEntry; @@ -52,10 +48,10 @@ public ClassEntry getDeobfEntry() { * @param updateIfPresent whether to update the stats if they have already been generated for this node */ public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent) { - return this.reloadStats(gui, selector, updateIfPresent, DUMMY_CANCELER); + return this.reloadStats(gui, selector, updateIfPresent, () -> false); } - public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent, AtomicBoolean canceller) { + public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent, Supplier shouldCancel) { StatsGenerator generator = gui.getController().getStatsGenerator(); if (generator == null) { return Utils.DUMMY_RUNNABLE_FUTURE; @@ -64,7 +60,7 @@ public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean up SwingWorker iconUpdateWorker = new SwingWorker<>() { @Override protected ProjectStatsResult doInBackground() { - if (canceller.get()) { + if (shouldCancel.get()) { return null; } else { var parameters = Config.stats().createIconGenParameters(gui.getEditableStatTypes()); @@ -81,7 +77,7 @@ protected ProjectStatsResult doInBackground() { @Override public void done() { - if (!canceller.get()) { + if (!shouldCancel.get()) { final ProjectStatsResult result; try { result = this.get(); From 8e18afac6a7c082cd213947848da167b1c0b7234 Mon Sep 17 00:00:00 2001 From: supersaiyansubtlety Date: Wed, 17 Sep 2025 16:10:02 -0700 Subject: [PATCH 6/8] javadoc return Future instead of RunnableFuture add overload for a method to avoid breakage --- .../org/quiltmc/enigma/gui/ClassSelector.java | 24 ++++++++++++--- .../main/java/org/quiltmc/enigma/gui/Gui.java | 28 ++++++++--------- .../gui/node/ClassSelectorClassNode.java | 30 ++++++++++++++----- .../java/org/quiltmc/enigma/util/Utils.java | 9 +++--- 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java index 3557df1cd..bc1a43453 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/ClassSelector.java @@ -19,8 +19,7 @@ import java.util.Collection; import java.util.Comparator; import java.util.List; -import java.util.concurrent.RunnableFuture; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.Future; import java.util.function.Supplier; public class ClassSelector extends JTree { @@ -299,11 +298,28 @@ public void reload() { * On completion, the class's stats icon will be updated. * * @param classEntry the class to reload stats for + * + * @return a future whose completion indicates that all asynchronous work has finished + */ + public Future reloadStats(ClassEntry classEntry) { + return this.reloadStats(classEntry, Utils.SUPPLY_FALSE); + } + + /** + * Requests an asynchronous reload of the stats for the given class. + * On completion, the class's stats icon will be updated. + * + * @param classEntry the class to reload stats for + * @param shouldCancel a supplier that may be used to cancel asynchronous work if it returns + * {@code true} before the work has started + * + * @return a future whose completion indicates that no asynchronous work remains, whether + * because it was canceled using the passed {@code shouldCancel} method or because it finished normally */ - public RunnableFuture reloadStats(ClassEntry classEntry, Supplier shouldCancel) { + public Future reloadStats(ClassEntry classEntry, Supplier shouldCancel) { ClassSelectorClassNode node = this.packageManager.getClassNode(classEntry); return node == null - ? Utils.DUMMY_RUNNABLE_FUTURE + ? Utils.DUMMY_FUTURE : node.reloadStats(this.controller.getGui(), this, true, shouldCancel); } diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java index 66e5a125f..8e45c9140 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java @@ -74,8 +74,6 @@ import java.util.stream.Stream; public class Gui { - private static final CompletableFuture COMPLETED_DUMMY = CompletableFuture.completedFuture(null); - private final MainWindow mainWindow; private final GuiController controller; @@ -114,7 +112,7 @@ public class Gui { /** * Possibly-incomplete work from prior calls to {@link #reloadStats(ClassEntry, boolean)}. */ - private CompletableFuture priorReloads = COMPLETED_DUMMY; + private CompletableFuture priorReloads = CompletableFuture.completedFuture(null); /** * Setting this to true cancels incomplete work from the last call to {@link #reloadStats(ClassEntry, boolean)}. */ @@ -623,6 +621,7 @@ public void moveClassTree(ClassEntry classEntry, boolean updateSwingState, boole /** * Reloads stats for the provided class in all selectors. + * * @param classEntry the class to reload * @param propagate whether to also reload ancestors of the class */ @@ -630,13 +629,14 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { List toUpdate = new ArrayList<>(); toUpdate.add(classEntry); if (propagate) { - Collection parents = this.controller.getProject().getJarIndex().getIndex(InheritanceIndex.class).getAncestors(classEntry); + Collection parents = this.controller.getProject().getJarIndex().getIndex(InheritanceIndex.class) + .getAncestors(classEntry); toUpdate.addAll(parents); } if (this.priorReloads.isDone()) { // discard prior completed futures to avoid taking up memory - this.priorReloads = COMPLETED_DUMMY; + this.priorReloads = CompletableFuture.completedFuture(null); } else { this.priorReloadCanceler.set(true); } @@ -644,15 +644,15 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { final AtomicBoolean currentReloadCanceler = new AtomicBoolean(false); this.priorReloadCanceler = currentReloadCanceler; final List currentReloads = this.dockerManager.getDockers().stream() - .flatMap(docker -> docker instanceof ClassesDocker classes ? Stream.of(classes) : Stream.empty()) - .flatMap(classes -> toUpdate.stream().map(updating -> () -> { - try { - classes.getClassSelector().reloadStats(updating, currentReloadCanceler::get).get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - })) - .toList(); + .flatMap(docker -> docker instanceof ClassesDocker classes ? Stream.of(classes) : Stream.empty()) + .flatMap(docker -> toUpdate.stream().map(updating -> () -> { + try { + docker.getClassSelector().reloadStats(updating, currentReloadCanceler::get).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + })) + .toList(); this.priorReloads = this.priorReloads.thenRunAsync(() -> CompletableFuture .allOf( diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java index 17fdd7066..bda7a2d9c 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java @@ -16,8 +16,7 @@ import javax.swing.tree.TreeNode; import java.util.Comparator; import java.util.concurrent.ExecutionException; -import java.util.concurrent.RunnableFuture; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.Future; import java.util.function.Supplier; public class ClassSelectorClassNode extends SortedMutableTreeNode { @@ -43,18 +42,33 @@ public ClassEntry getDeobfEntry() { * Reloads the stats for this class node and updates the icon in the provided class selector. * Exits if no project is open. * - * @param gui the current gui instance - * @param selector the class selector to reload on + * @param gui the current gui instance + * @param selector the class selector to reload on * @param updateIfPresent whether to update the stats if they have already been generated for this node + * + * @return a future whose completion indicates that all asynchronous work has finished */ - public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent) { - return this.reloadStats(gui, selector, updateIfPresent, () -> false); + public Future reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent) { + return this.reloadStats(gui, selector, updateIfPresent, Utils.SUPPLY_FALSE); } - public RunnableFuture reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent, Supplier shouldCancel) { + /** + * Reloads the stats for this class node and updates the icon in the provided class selector. + * Exits if no project is open. + * + * @param gui the current gui instance + * @param selector the class selector to reload on + * @param updateIfPresent whether to update the stats if they have already been generated for this node + * @param shouldCancel a supplier that may be used to cancel asynchronous work if it returns + * {@code true} before the work has started + * + * @return a future whose completion indicates that no asynchronous work remains, whether + * because it was canceled using the passed {@code shouldCancel} method or because it finished normally + */ + public Future reloadStats(Gui gui, ClassSelector selector, boolean updateIfPresent, Supplier shouldCancel) { StatsGenerator generator = gui.getController().getStatsGenerator(); if (generator == null) { - return Utils.DUMMY_RUNNABLE_FUTURE; + return Utils.DUMMY_FUTURE; } SwingWorker iconUpdateWorker = new SwingWorker<>() { diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java index da4368406..0cfc931d8 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java +++ b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java @@ -17,7 +17,7 @@ import java.util.List; import java.util.Locale; import java.util.Properties; -import java.util.concurrent.RunnableFuture; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.function.Supplier; @@ -25,10 +25,7 @@ import java.util.zip.ZipFile; public class Utils { - public static final RunnableFuture DUMMY_RUNNABLE_FUTURE = new RunnableFuture<>() { - @Override - public void run() { } - + public static final Future DUMMY_FUTURE = new Future<>() { @Override public boolean cancel(boolean mayInterruptIfRunning) { return false; @@ -55,6 +52,8 @@ public Void get(long timeout, @Nonnull TimeUnit unit) { } }; + public static final Supplier SUPPLY_FALSE = () -> false; + public static String readStreamToString(InputStream in) throws IOException { return CharStreams.toString(new InputStreamReader(in, StandardCharsets.UTF_8)); } From 7887f2c5534dae627438adfd0820ba6d66d28bbb Mon Sep 17 00:00:00 2001 From: supersaiyansubtlety Date: Wed, 17 Sep 2025 16:12:43 -0700 Subject: [PATCH 7/8] make ProjectStatsResult.stats a ConcurrentHashMap because of a rare ConcurrentModificationException --- .../java/org/quiltmc/enigma/api/stats/ProjectStatsResult.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/stats/ProjectStatsResult.java b/enigma/src/main/java/org/quiltmc/enigma/api/stats/ProjectStatsResult.java index 3891eb9ea..6fa1f5c61 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/api/stats/ProjectStatsResult.java +++ b/enigma/src/main/java/org/quiltmc/enigma/api/stats/ProjectStatsResult.java @@ -9,12 +9,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class ProjectStatsResult implements StatsProvider { private final EnigmaProject project; private final Map> packageToClasses = new HashMap<>(); - private final Map stats = new HashMap<>(); + private final Map stats = new ConcurrentHashMap<>(); private final Map packageStats = new HashMap<>(); private StatsResult overall; From 8963fe0c3b6ad684ca0af574ffb42078a9204145 Mon Sep 17 00:00:00 2001 From: supersaiyansubtlety Date: Sun, 28 Sep 2025 08:13:10 -0700 Subject: [PATCH 8/8] use newSingleThreadExecutor instead of chaining CompletableFutures --- .../main/java/org/quiltmc/enigma/gui/Gui.java | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java index 8e45c9140..f0270a1e1 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java @@ -69,6 +69,8 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.IntFunction; import java.util.stream.Stream; @@ -110,13 +112,18 @@ public class Gui { private final boolean testEnvironment; /** - * Possibly-incomplete work from prior calls to {@link #reloadStats(ClassEntry, boolean)}. + * Executor for {@link #reloadStats(ClassEntry, boolean) reloadStats} work. + * + *

Executes all work from one call to {@link #reloadStats(ClassEntry, boolean) reloadStats} + * before starting work for the next call. + * Fixes #271. */ - private CompletableFuture priorReloads = CompletableFuture.completedFuture(null); + private final Executor reloadStatsExecutor = Executors.newSingleThreadExecutor(); /** - * Setting this to true cancels incomplete work from the last call to {@link #reloadStats(ClassEntry, boolean)}. + * Setting this to true cancels unstarted work from the last call to + * {@link #reloadStats(ClassEntry, boolean) reloadStats}. */ - private AtomicBoolean priorReloadCanceler = new AtomicBoolean(false); + private AtomicBoolean priorReloadStatsCanceler = new AtomicBoolean(false); public Gui(EnigmaProfile profile, Set editableTypes, boolean testEnvironment) { this.dockerManager = new DockerManager(this); @@ -626,6 +633,10 @@ public void moveClassTree(ClassEntry classEntry, boolean updateSwingState, boole * @param propagate whether to also reload ancestors of the class */ public void reloadStats(ClassEntry classEntry, boolean propagate) { + this.priorReloadStatsCanceler.set(true); + final AtomicBoolean currentReloadCanceler = new AtomicBoolean(false); + this.priorReloadStatsCanceler = currentReloadCanceler; + List toUpdate = new ArrayList<>(); toUpdate.add(classEntry); if (propagate) { @@ -634,15 +645,6 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { toUpdate.addAll(parents); } - if (this.priorReloads.isDone()) { - // discard prior completed futures to avoid taking up memory - this.priorReloads = CompletableFuture.completedFuture(null); - } else { - this.priorReloadCanceler.set(true); - } - - final AtomicBoolean currentReloadCanceler = new AtomicBoolean(false); - this.priorReloadCanceler = currentReloadCanceler; final List currentReloads = this.dockerManager.getDockers().stream() .flatMap(docker -> docker instanceof ClassesDocker classes ? Stream.of(classes) : Stream.empty()) .flatMap(docker -> toUpdate.stream().map(updating -> () -> { @@ -654,7 +656,7 @@ public void reloadStats(ClassEntry classEntry, boolean propagate) { })) .toList(); - this.priorReloads = this.priorReloads.thenRunAsync(() -> CompletableFuture + this.reloadStatsExecutor.execute(() -> CompletableFuture .allOf( currentReloads.stream() .map(CompletableFuture::runAsync)