diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java index 1f15dacf..c98bd4c7 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java @@ -1059,6 +1059,11 @@ public static void addRuntimeIndexStatusListener(Consumer listener) { + RuntimeIndexService service = runtimeIndexService; + if (service != null) service.removeStatusListener(listener); + } + public static void retryRuntimeIndex() { RuntimeIndexService service = runtimeIndexService; if (service != null) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/LiteralUsagesView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/LiteralUsagesView.java index 0f689abe..09714db6 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/LiteralUsagesView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/LiteralUsagesView.java @@ -32,6 +32,11 @@ public void restartSearch() { this.panel.restartSearch(); } + @Override + public void dispose() { + this.panel.dispose(); + } + @Override public String getTitle() { return "Text: " + quotedPreview(this.literal); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/ScriptView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/ScriptView.java index c5111ab8..1b19eed7 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/ScriptView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/ScriptView.java @@ -3,7 +3,6 @@ import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.jdt.JavaSnippetSource; -import com.github.minecraft_ta.totaldebug.protocol.scnet.ExecutionResultMessage; import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationViewState; import com.github.minecraft_ta.totalDebugCompanion.ui.components.editors.ScriptPanel; @@ -121,9 +120,6 @@ public void restoreNavigationViewState(NavigationViewState state) { @Override public void dispose() { if (this.scriptPanel != null) { - if (CompanionApp.SERVER != null) { - CompanionApp.SERVER.getMessageBus().unregister(ExecutionResultMessage.class, this.scriptPanel); - } this.scriptPanel.dispose(); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/UsagesView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/UsagesView.java index fc83f61b..8f31a5d9 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/UsagesView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/model/UsagesView.java @@ -27,6 +27,11 @@ public void restartSearch() { this.panel.restartSearch(); } + @Override + public void dispose() { + this.panel.dispose(); + } + @Override public String getTitle() { return "Usages: " + shortName(this.symbol); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java index 67003d45..b9b2d0bf 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java @@ -136,6 +136,10 @@ public void waiting(String detail) { } } + public void removeStatusListener(Consumer listener) { + this.listeners.remove(listener); + } + public void restore(Path dataDirectory) { synchronized (this.lifecycleLock) { ensureOpen(); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeVisionController.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeVisionController.java index b279c81f..5a95f184 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeVisionController.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeVisionController.java @@ -22,6 +22,7 @@ final class CodeVisionController implements AutoCloseable { private final JLayer layer; private final HierarchyGutterMarkers gutterMarkers; + private final Runnable unsubscribeAst; private CodeInsightService.SearchHandle activeAnalysis; private long generation; private boolean closed; @@ -38,7 +39,7 @@ final class CodeVisionController implements AutoCloseable { this.layerUI = Objects.requireNonNull(layerUI, "layerUI"); this.layer = Objects.requireNonNull(layer, "layer"); this.gutterMarkers = Objects.requireNonNull(gutterMarkers, "gutterMarkers"); - ASTCache.addChangeListener(this.editorIdentifier, (unit, version) -> { + this.unsubscribeAst = ASTCache.addChangeListener(this.editorIdentifier, (unit, version) -> { String source = ASTCache.getContents(this.editorIdentifier); if (source != null) { analyze(SourceDeclarationAnalyzer.analyze(unit, source)); @@ -106,6 +107,7 @@ public synchronized void close() { return; } this.closed = true; + this.unsubscribeAst.run(); if (this.activeAnalysis != null) { this.activeAnalysis.cancel(); this.activeAnalysis = null; 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 5f25cdd0..2d1dcdc7 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 @@ -50,8 +50,8 @@ public class ScriptPanel extends AbstractCodeViewPanel { private final int scriptId = SCRIPT_ID++; private final ScriptView scriptView; - private static final CodeCompletionPopup codeCompletionPopup = new CodeCompletionPopup(MainWindow.INSTANCE); - private static final SignatureHelpPopup signatureHelpPopup = new SignatureHelpPopup(MainWindow.INSTANCE); + private final CodeCompletionPopup codeCompletionPopup = new CodeCompletionPopup(MainWindow.INSTANCE); + private final SignatureHelpPopup signatureHelpPopup = new SignatureHelpPopup(MainWindow.INSTANCE); private final FlatIconButton runButton = new FlatIconButton(Icons.RUN, false); private final FlatIconButton runServerButton = new FlatIconButton(Icons.RUN_SERVER, false); @@ -108,6 +108,8 @@ public Dimension getPreferredSize() { private final SnippetCompletionAdapter snippetCompletionAdapter = new SnippetCompletionAdapter(this.editorPane); private CustomCompletionRequestor completionRequestor; + private boolean disposed; + private final Runnable unsubscribeResults; private boolean didTypeBeforeCaretMove; private int lastCaretPos; private JavaSnippetSource.GeneratedSource lastGeneratedSource; @@ -144,12 +146,14 @@ public ScriptPanel(ScriptView scriptView) { setupAutocompletion(); setupFormatting(); - CompanionApp.SERVER.getMessageBus().listenAlways(ExecutionResultMessage.class, this, this::acceptResult); + var messageBus = CompanionApp.SERVER.getMessageBus(); + messageBus.listenAlways(ExecutionResultMessage.class, this, this::acceptResult); + this.unsubscribeResults = () -> messageBus.unregister(ExecutionResultMessage.class, this); } private void acceptResult(ExecutionResultMessage m) { SwingUtilities.invokeLater(() -> { - if (m.scriptId() != this.scriptId) + if (this.disposed || m.scriptId() != this.scriptId) return; ExecutionStatus status = m.result().status(); @@ -578,10 +582,14 @@ private void hideCompletionPopup() { @Override public void dispose() { + if (this.disposed) return; + this.disposed = true; + this.unsubscribeResults.run(); this.saveTimer.stop(); if (this.completionRequestor != null) this.completionRequestor.setCanceled(true); - hideCompletionPopup(); + this.codeCompletionPopup.dispose(); + this.signatureHelpPopup.dispose(); super.dispose(); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/UsagesViewPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/UsagesViewPanel.java index 8499c66c..cacf4ce3 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/UsagesViewPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/UsagesViewPanel.java @@ -69,6 +69,7 @@ public final class UsagesViewPanel extends JPanel { private ReferenceSearchService.SearchHandle activeSearch; private long searchGeneration; private boolean detached; + private boolean disposed; private int resultLimit = INITIAL_RESULT_LIMIT; private boolean resultTruncated; private List currentUsages = List.of(); @@ -110,8 +111,16 @@ public UsagesViewPanel( }); } + public void dispose() { + requireEdt(); + this.disposed = true; + this.detached = true; + cancelActiveSearch(); + } + public void restartSearch() { requireEdt(); + if (this.disposed) return; this.detached = false; this.resultLimit = INITIAL_RESULT_LIMIT; this.resultTruncated = false; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopup.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopup.java index 82322687..e7db8034 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopup.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopup.java @@ -90,6 +90,16 @@ public void actionPerformed(ActionEvent event) { private final ModuleFilterPopup moduleFilterPopup; private final Consumer themeListener = theme -> applyTheme(); + private final Consumer indexStatusListener = status -> SwingUtilities.invokeLater(() -> { + if (!isDisplayable()) return; + if (CompanionClassIndex.isOpen()) { + syncRuntimeModules(); + refreshResults(); + } else { + showIndexStatus(status); + } + }); + private RuntimeSourceCatalog sourceCatalog = RuntimeSourceCatalog.empty(); private List modules = List.of(); private Category category = Category.ALL; @@ -130,14 +140,7 @@ public void actionPerformed(ActionEvent event) { JComponent.WHEN_IN_FOCUSED_WINDOW ); - CompanionApp.addRuntimeIndexStatusListener(status -> SwingUtilities.invokeLater(() -> { - if (CompanionClassIndex.isOpen()) { - syncRuntimeModules(); - refreshResults(); - } else { - showIndexStatus(status); - } - })); + CompanionApp.addRuntimeIndexStatusListener(this.indexStatusListener); ((JPanel) getContentPane()).setBorder(PopupChrome.border()); setUndecorated(true); @@ -182,6 +185,7 @@ void open(Set moduleIds, String query) { @Override public void dispose() { + CompanionApp.removeRuntimeIndexStatusListener(this.indexStatusListener); ThemeManager.removeThemeChangeListener(this.themeListener); this.searchGeneration.incrementAndGet(); if (this.pendingSearch != null) { @@ -423,6 +427,7 @@ private void moveSelection(int delta) { } private void refreshResults() { + if (this.searchExecutor.isShutdown()) return; long generation = this.searchGeneration.incrementAndGet(); if (this.pendingSearch != null) { this.pendingSearch.cancel(false); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ScriptPanelDisposalTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ScriptPanelDisposalTest.java new file mode 100644 index 00000000..54276161 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ScriptPanelDisposalTest.java @@ -0,0 +1,107 @@ +package com.github.minecraft_ta.totalDebugCompanion; + +import com.github.minecraft_ta.totalDebugCompanion.model.ScriptView; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.ui.components.editors.ScriptPanel; +import com.github.tth05.scnet.Server; +import com.github.tth05.scnet.message.AbstractMessage; +import com.github.tth05.scnet.message.impl.DefaultMessageBus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.swing.SwingUtilities; +import java.awt.Window; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; + +class ScriptPanelDisposalTest { + @TempDir Path directory; + + @Test + void panelsOwnTheirPopupsAndUnsubscribeOnDirectDisposal() throws Exception { + String classpath = System.getProperty("totaldebug.testClasspath", System.getProperty("java.class.path")); + Path log = directory.resolve("probe.log"); + Process process = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-Djava.awt.headless=false", "-cp", classpath, getClass().getName(), directory.toString()) + .redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + assertTrue(process.waitFor(25, TimeUnit.SECONDS), () -> read(log)); + assertEquals(0, process.exitValue(), () -> read(log)); + } finally { if (process.isAlive()) process.destroyForcibly(); } + } + + public static void main(String[] args) { + try { + Path root = Path.of(args[0]); + Path appHome = Files.createDirectories(root.resolve("app")); + var launch = CompanionApp.class.getDeclaredField("launchConfiguration"); + launch.setAccessible(true); + launch.set(null, new CompanionLaunchConfiguration(appHome)); + GlobalConfig.getInstance().loadFrom(appHome); + CompanionApp.configureWithoutSession(CompanionProfile.forGame(Files.createDirectories(root.resolve("game")))); + CompanionApp.configureLookAndFeel(); + CompanionApp.configureTokenMakers(); + try (var server = new Server()) { + var bus = new TrackingBus(); + server.setMessageBus(bus); + CompanionApp.SERVER = server; + SwingUtilities.invokeAndWait(() -> { + var first = (ScriptPanel) new ScriptView("First").getComponent(); + var second = (ScriptPanel) new ScriptView("Second").getComponent(); + Window firstCompletion = popup(first, "codeCompletionPopup"); + Window firstSignature = popup(first, "signatureHelpPopup"); + Window secondCompletion = popup(second, "codeCompletionPopup"); + assertNotSame(firstCompletion, secondCompletion); + firstCompletion.pack(); + firstSignature.pack(); + secondCompletion.pack(); + assertEquals(Set.of(first, second), bus.owners); + try (var replacement = new Server()) { + CompanionApp.SERVER = replacement; + first.dispose(); + first.dispose(); + assertFalse(firstCompletion.isDisplayable()); + assertFalse(firstSignature.isDisplayable()); + assertTrue(secondCompletion.isDisplayable()); + assertEquals(Set.of(second), bus.owners); + second.dispose(); + assertTrue(bus.owners.isEmpty()); + } + }); + } + System.exit(0); + } catch (Throwable failure) { failure.printStackTrace(); System.exit(1); } + } + + private static Window popup(ScriptPanel panel, String name) { + try { + var field = ScriptPanel.class.getDeclaredField(name); + field.setAccessible(true); + return (Window) field.get(panel); + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + } + + private static String read(Path file) { + try { return Files.readString(file); } + catch (Exception failure) { return failure.toString(); } + } + + private static final class TrackingBus extends DefaultMessageBus { + private final Set owners = new HashSet<>(); + @Override public void listenAlways(Class type, Object owner, Consumer listener) { + super.listenAlways(type, owner, listener); + owners.add(owner); + } + @Override public void unregister(Class type, Object owner) { + super.unregister(type, owner); + owners.remove(owner); + } + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/search/reference/ReferenceSearchServiceTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/search/reference/ReferenceSearchServiceTest.java index c2b053cb..d8663d09 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/search/reference/ReferenceSearchServiceTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/search/reference/ReferenceSearchServiceTest.java @@ -3,6 +3,8 @@ import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceLocation; import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceUsagePage; import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceQuery; +import com.github.minecraft_ta.totalDebugCompanion.ui.components.editors.UsagesViewPanel; +import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; import com.github.minecraft_ta.totalDebugCompanion.bytecode.reference.ReferenceUsage; import com.github.tth05.jindex.ReferenceKind; import org.junit.jupiter.api.Test; @@ -22,6 +24,46 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class ReferenceSearchServiceTest { + @Test + void disposingAnUnattachedUsagePanelCancelsItsSearch() throws Exception { + var started = new CountDownLatch(1); + var release = new CountDownLatch(1); + var calls = new AtomicInteger(); + try (var service = new ReferenceSearchService((query, limit) -> { + calls.incrementAndGet(); + started.countDown(); + try { release.await(5, TimeUnit.SECONDS); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); } + return new ReferenceUsagePage(List.of(), false); + })) { + var panel = new AtomicReference(); + var handle = new AtomicReference(); + SwingUtilities.invokeAndWait(() -> { + panel.set(new UsagesViewPanel( + new CodeSymbol.ClassSymbol("example.Target"), service)); + panel.get().restartSearch(); + try { + var field = panel.get().getClass().getDeclaredField("activeSearch"); + field.setAccessible(true); + handle.set((ReferenceSearchService.SearchHandle) field.get(panel.get())); + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + SwingUtilities.invokeAndWait(() -> { + panel.get().dispose(); + // Cancellation stops delivery; it does not pretend the running query has finished. + assertFalse(handle.get().isDone()); + try { + var cancelled = handle.get().getClass().getDeclaredField("cancelled"); + cancelled.setAccessible(true); + assertTrue(((AtomicBoolean) cancelled.get(handle.get())).get()); + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + panel.get().restartSearch(); + }); + assertEquals(1, calls.get()); + } finally { release.countDown(); } + } + @Test void deliversBoundedResultsOnTheSwingEventThread() throws Exception { CountDownLatch completed = new CountDownLatch(1); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeVisionDisposalTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeVisionDisposalTest.java new file mode 100644 index 00000000..a0a74d3a --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/editors/CodeVisionDisposalTest.java @@ -0,0 +1,52 @@ +package com.github.minecraft_ta.totalDebugCompanion.ui.components.editors; + +import com.github.minecraft_ta.totalDebugCompanion.bytecode.insight.HierarchyRelation; +import com.github.minecraft_ta.totalDebugCompanion.jdt.diagnostics.ASTCache; +import com.github.minecraft_ta.totalDebugCompanion.jdt.insight.SourceDeclaration; +import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeSourceCatalog; +import com.github.minecraft_ta.totalDebugCompanion.search.insight.CodeInsightService; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rtextarea.RTextScrollPane; +import org.junit.jupiter.api.Test; + +import javax.swing.JComponent; +import javax.swing.JLayer; +import javax.swing.SwingUtilities; +import java.util.Collection; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CodeVisionDisposalTest { + @Test + void closingControllerUnsubscribesWithoutRemovingOtherEditorListeners() throws Exception { + var field = ASTCache.class.getDeclaredField("LISTENERS"); + field.setAccessible(true); + var listeners = (Map) field.get(null); + String key = "code-vision-disposal-test"; + Runnable unsubscribeOther = ASTCache.addChangeListener(key, (unit, version) -> {}); + try (var service = new CodeInsightService(() -> { throw new AssertionError("No analysis expected"); }, RuntimeSourceCatalog.empty())) { + SwingUtilities.invokeAndWait(() -> { + var editor = new RSyntaxTextArea(); + var scroll = new RTextScrollPane(editor); + var layerUI = new CodeVisionLayerUI(editor, new CodeVisionLayerUI.Handler() { + public void showUsages(CodeSymbol symbol) {} + public void showHierarchy(CodeSymbol symbol, HierarchyRelation relation, int count, int offset) {} + public void showDebuggerValue(DebuggerInlineValueHints.ValueHint value) {} + }); + var layer = new JLayer(scroll, layerUI); + var gutter = new HierarchyGutterMarkers(new EditorGutter(scroll.getGutter()), new HierarchyGutterMarkers.Handler() { + public void navigate(SourceDeclaration declaration, HierarchyRelation relation, int count) {} + public void preview(SourceDeclaration declaration, HierarchyRelation relation, int count, boolean mixed) {} + public void hidePreview() {} + }); + var controller = new CodeVisionController(key, service, layerUI, layer, gutter); + assertEquals(2, ((Collection) listeners.get(key)).size()); + controller.close(); + controller.close(); + assertEquals(1, ((Collection) listeners.get(key)).size()); + }); + } finally { unsubscribeOther.run(); ASTCache.removeFromCache(key); } + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopupProcessTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopupProcessTest.java index 8de0bc7d..eb376c2d 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopupProcessTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopupProcessTest.java @@ -1,6 +1,11 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.views; import com.github.minecraft_ta.totalDebugCompanion.UiDevHarness; +import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService; +import javax.swing.JLabel; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import javax.swing.AbstractButton; @@ -61,6 +66,14 @@ void tabCyclesSearchCategoriesInBothDirections() throws Exception { assertTrue(result.output().contains("Search category cycling verification passed"), result.output()); } + @Test + void disposedPopupUnsubscribesAndIgnoresQueuedRuntimeStatus() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless()); + ProcessResult result = runProcess(Duration.ofSeconds(10), Probe.class.getName(), "verify-disposal"); + assertTrue(result.finished(), result.output()); + assertEquals(0, result.exitCode(), result.output()); + } + private static ProcessResult runProcess(Duration timeout, String mainClass, String... arguments) throws Exception { String javaExecutable = Path.of( @@ -92,6 +105,10 @@ private Probe() { } public static void main(String[] arguments) throws Exception { + if (java.util.Arrays.asList(arguments).contains("verify-disposal")) { + verifyDisposal(); + System.exit(0); + } SwingUtilities.invokeAndWait(() -> { SearchEverywherePopup popup = new SearchEverywherePopup(); try { @@ -106,6 +123,35 @@ public static void main(String[] arguments) throws Exception { System.exit(0); } + private static void verifyDisposal() throws Exception { + var serviceField = CompanionApp.class.getDeclaredField("runtimeIndexService"); + serviceField.setAccessible(true); + var listenersField = RuntimeIndexService.class.getDeclaredField("listeners"); + listenersField.setAccessible(true); + var messageField = SearchEverywherePopup.class.getDeclaredField("messageLabel"); + messageField.setAccessible(true); + try (var service = new RuntimeIndexService(new Object(), snapshot -> snapshot.close())) { + serviceField.set(null, service); + for (int cycle = 0; cycle < 3; cycle++) { + var label = new AtomicReference(); + SwingUtilities.invokeAndWait(() -> { + var popup = new SearchEverywherePopup(); + try { + assertEquals(1, ((Collection) listenersField.get(service)).size()); + label.set((JLabel) messageField.get(popup)); + label.get().setText("unchanged after disposal"); + service.waiting("queued before disposal"); + } catch (IllegalAccessException failure) { throw new AssertionError(failure); } + finally { popup.dispose(); } + }); + SwingUtilities.invokeAndWait(() -> assertEquals("unchanged after disposal", label.get().getText())); + assertTrue(((Collection) listenersField.get(service)).isEmpty()); + service.waiting("sent after disposal"); + SwingUtilities.invokeAndWait(() -> assertEquals("unchanged after disposal", label.get().getText())); + } + } finally { serviceField.set(null, null); } + } + private static void verifyCategoryCycling(SearchEverywherePopup popup) { JComponent query = requireNamedComponent(popup, "searchEverywhere.query", JComponent.class); if (query.getFocusTraversalKeysEnabled()) {