diff --git a/recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginClassLoaderImpl.java b/recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginClassLoaderImpl.java index c20d448d2..c9a5dbde6 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginClassLoaderImpl.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginClassLoaderImpl.java @@ -7,6 +7,10 @@ import java.io.IOException; import java.io.InputStream; import java.net.*; +import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; /** * Classloader for plugin content. @@ -14,17 +18,24 @@ * @author xDark */ final class PluginClassLoaderImpl extends ClassLoader implements PluginClassLoader { - private final PluginGraph graph; private final PluginSource source; private final String id; + private volatile List dependencyLoaders = List.of(); - PluginClassLoaderImpl(@Nonnull ClassLoader classLoader, @Nonnull PluginGraph graph, @Nonnull PluginSource source, @Nonnull String id) { + static { + registerAsParallelCapable(); + } + + PluginClassLoaderImpl(@Nonnull ClassLoader classLoader, @Nonnull PluginSource source, @Nonnull String id) { super(classLoader); - this.graph = graph; this.source = source; this.id = id; } + void setDependencyClassLoaders(@Nonnull Collection dependencyLoaders) { + this.dependencyLoaders = List.copyOf(dependencyLoaders); + } + @Override protected URL findResource(String name) { ByteSource source = this.source.findResource(name); @@ -32,12 +43,12 @@ protected URL findResource(String name) { return null; } try { - URI uri = new URI("recaf", "/", name); + String resourcePath = name.startsWith("/") ? name.substring(1) : name; + URI uri = new URI("recaf", id, "/" + resourcePath, null); return URL.of(uri, new URLStreamHandler() { @Override protected URLConnection openConnection(URL u) { return new URLConnection(u) { - InputStream in; @Override public void connect() { @@ -46,19 +57,23 @@ public void connect() { @Override public InputStream getInputStream() throws IOException { - InputStream in = this.in; - if (in == null) { - in = source.openStream(); - this.in = in; - } - return in; + return source.openStream(); } }; } }); } catch (MalformedURLException | URISyntaxException ex) { - throw new IllegalStateException(ex); + throw new IllegalStateException("Failed to create plugin resource URL for: " + name, ex); + } + } + + @Override + protected Enumeration findResources(String name) { + URL resource = findResource(name); + if (resource == null) { + return Collections.emptyEnumeration(); } + return Collections.enumeration(List.of(resource)); } @Nullable @@ -70,22 +85,28 @@ public ByteSource lookupResource(@Nonnull String name) { @Nonnull @Override public Class lookupClass(@Nonnull String name) throws ClassNotFoundException { - Class cls = lookupClassImpl(name); - if (cls == null) { - throw new ClassNotFoundException(name); + synchronized (getClassLoadingLock(name)) { + Class cls = lookupClassImpl(name); + if (cls == null) { + throw new ClassNotFoundException(name); + } + return cls; } - return cls; } @Override protected Class findClass(String name) throws ClassNotFoundException { - Class cls = lookupClassImpl(name); - if (cls != null) - return cls; - var dependencyLoaders = graph.getDependencyClassloaders(id); - while (dependencyLoaders.hasNext()) { - if ((cls = dependencyLoaders.next().findClass(name)) != null) + Class cls; + synchronized (getClassLoadingLock(name)) { + cls = lookupClassImpl(name); + if (cls != null) { return cls; + } + } + for (PluginClassLoaderImpl dependencyLoader : dependencyLoaders) { + if ((cls = dependencyLoader.loadClass(name)) != null) { + return cls; + } } throw new ClassNotFoundException(name); } diff --git a/recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginGraph.java b/recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginGraph.java index 47650e68c..781d351d3 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginGraph.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginGraph.java @@ -1,10 +1,9 @@ package software.coley.recaf.services.plugin; import com.google.common.collect.Collections2; -import com.google.common.collect.Iterators; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import software.coley.recaf.plugin.*; +import software.coley.recaf.plugin.Plugin; import java.util.*; import java.util.stream.Stream; @@ -40,6 +39,7 @@ final class PluginGraph { @Nonnull Collection> apply(@Nonnull List preparedPlugins) throws PluginException { Map temp = LinkedHashMap.newLinkedHashMap(preparedPlugins.size()); + List initializedPlugins = new ArrayList<>(preparedPlugins.size()); var plugins = this.plugins; for (var preparedPlugin : preparedPlugins) { String id = preparedPlugin.info().id(); @@ -48,52 +48,20 @@ Collection> apply(@Nonnull List preparedPlugi } var threadContextClassLoader = Thread.currentThread().getContextClassLoader(); var parentLoader = threadContextClassLoader != null ? threadContextClassLoader : ClassLoader.getSystemClassLoader(); - var classLoader = new PluginClassLoaderImpl(parentLoader, this, preparedPlugin.pluginSource(), id); + var classLoader = new PluginClassLoaderImpl(parentLoader, preparedPlugin.pluginSource(), id); LoadedPlugin loadedPlugin = new LoadedPlugin(new PluginContainerImpl<>(preparedPlugin, classLoader)); if (temp.putIfAbsent(id, loadedPlugin) != null) { throw new PluginException("Duplicate plugin %s".formatted(id)); } } - for (LoadedPlugin plugin : temp.values()) { - PluginInfo info = plugin.getContainer().info(); - for (String dependencyId : info.dependencies()) { - LoadedPlugin dep = temp.get(dependencyId); - if (dep == null) { - dep = plugins.get(dependencyId); - } - if (dep == null) { - throw new PluginException("Plugin %s is missing dependency %s".formatted(info.id(), dependencyId)); - } - plugin.getDependencies().add(dep); - } - for (String dependencyId : info.softDependencies()) { - LoadedPlugin dep = temp.get(dependencyId); - if (dep == null && (dep = plugins.get(dependencyId)) == null) { - continue; - } - plugin.getDependencies().add(dep); - } - } + resolveDependencies(temp); + wireDependencyClassLoaders(temp.values()); for (LoadedPlugin loadedPlugin : temp.values()) { try { - enable(loadedPlugin); + enable(loadedPlugin, initializedPlugins); } catch (PluginException ex) { - for (LoadedPlugin pl : temp.values()) { - PluginContainerImpl container = pl.getContainer(); - try { - try { - Plugin maybeEnabled = container.plugin; - if (maybeEnabled != null) { - maybeEnabled.onDisable(); - } - } finally { - container.preparedPlugin.reject(); - } - } catch (Exception ex1) { - ex.addSuppressed(ex1); - } - } + rollbackFailedApply(temp, initializedPlugins, ex); throw ex; } } @@ -118,7 +86,7 @@ PluginUnloader unloaderFor(@Nonnull String id) { return new PluginUnloader() { @Override public void commit() throws PluginException { - PluginException ex = unload(plugin, dependants); + PluginException ex = unload(plugin, dependants, new HashSet<>()); if (ex != null) throw ex; } @@ -140,6 +108,89 @@ public Stream dependants() { }; } + /** + * Resolves hard and soft dependencies for all staged plugins. + * + * @param temp + * Staged plugins. + * + * @throws PluginException + * When a hard dependency is missing. + */ + private void resolveDependencies(@Nonnull Map temp) throws PluginException { + for (LoadedPlugin plugin : temp.values()) { + PluginInfo info = plugin.getContainer().info(); + + for (String dependencyId : info.dependencies()) { + if (info.id().equals(dependencyId)) { + throw new PluginException("Plugin %s cannot depend of itself".formatted(info.id())); + } + LoadedPlugin dependency = resolveDependency(dependencyId, temp); + if (dependency == null) { + throw new PluginException("Plugin %s is missing dependency %s".formatted(info.id(), dependencyId)); + } + addDependency(plugin, dependency); + } + + for (String dependencyId : info.softDependencies()) { + if (info.id().equals(dependencyId)) { + continue; + } + LoadedPlugin dependency = resolveDependency(dependencyId, temp); + if (dependency != null) { + addDependency(plugin, dependency); + } + } + } + } + + /** + * Wires already resolved dependencies into plugin classloaders. + * + * @param loadedPlugins + * Plugins to wire. + */ + private static void wireDependencyClassLoaders(@Nonnull Collection loadedPlugins) { + for (LoadedPlugin loadedPlugin : loadedPlugins) { + PluginClassLoaderImpl classLoader = classLoaderOf(loadedPlugin); + List dependencyLoaders = loadedPlugin.getDependencies() + .stream() + .map(PluginGraph::classLoaderOf) + .toList(); + + classLoader.setDependencyClassLoaders(dependencyLoaders); + } + } + + /** + * @param id + * Dependency plugin identifier. + * @param temp + * Staged plugins. + * + * @return Resolved dependency from staged or already loaded plugins. + */ + @Nullable + private LoadedPlugin resolveDependency(@Nonnull String id, @Nonnull Map temp) { + LoadedPlugin dependency = temp.get(id); + if (dependency != null) { + return dependency; + } + return plugins.get(id); + } + + /** + * Adds dependency once. + * + * @param plugin + * Plugin that owns the dependency. + * @param dependency + * Dependency to add. + */ + private static void addDependency(@Nonnull LoadedPlugin plugin, @Nonnull LoadedPlugin dependency) { + plugin.getDependencies().add(dependency); + } + /** * Attempts to unload the given plugin, along with its dependants. * @@ -147,18 +198,23 @@ public Stream dependants() { * Plugin to unload. * @param dependants * Map of plugin dependents. + * @param unloaded + * Plugins already unloaded in this operation. * * @return Exception to be thrown if the plugin could not be unloaded. */ @Nullable - private PluginException unload(@Nonnull LoadedPlugin plugin, @Nonnull Map> dependants) { + private PluginException unload(@Nonnull LoadedPlugin plugin, @Nonnull Map> dependants, @Nonnull Set unloaded) { + if (!unloaded.add(plugin)) { + return null; + } String id = plugin.getContainer().info().id(); if (!plugins.remove(id, plugin)) { throw new IllegalStateException("Plugin %s was already removed, recursion?".formatted(id)); } PluginException exception = null; - for (LoadedPlugin dependant : dependants.get(plugin)) { - PluginException inner = unload(dependant, dependants); + for (LoadedPlugin dependant : dependants.getOrDefault(plugin, Collections.emptySet())) { + PluginException inner = unload(dependant, dependants, unloaded); if (inner != null) { if (exception == null) { exception = inner; @@ -194,8 +250,31 @@ private PluginException unload(@Nonnull LoadedPlugin plugin, @Nonnull Map> dependants) { + collectDependants(plugin, dependants, new LinkedHashSet<>()); + } + + /** + * Recursively collects dependants while protecting against circular dependencies. + * + * @param plugin + * Plugin to collect dependants of in the current step of recursion. + * @param dependants + * Map to store results in. + * @param visiting + * Set of plugins visited in the current traversal path to detect circular dependencies. + * + * @throws IllegalStateException + * If a circular dependency is detected during traversal. + */ + private void collectDependants(@Nonnull LoadedPlugin plugin, @Nonnull Map> dependants, @Nonnull Set visiting) { + if (!visiting.add(plugin)) { + throw new IllegalStateException("Circular dependency detected at plugin: %s".formatted(plugin.getContainer().info().id())); + } Set dependantsSet = dependants.computeIfAbsent(plugin, _ -> HashSet.newHashSet(4)); for (LoadedPlugin pl : plugins.values()) { if (plugin == pl) continue; @@ -204,6 +283,7 @@ private void collectDependants(@Nonnull LoadedPlugin plugin, @Nonnull Map> plugins() { } /** - * @param id - * Plugin identifier. + * Enables the given plugin, initializing if necessary. * - * @return Iterator of dependency classloaders. + * @param loadedPlugin + * Plugin to enable. + * @param initializedPlugins + * Plugins initialized during the current apply operation. + * + * @throws PluginException + * If the plugin could not be initialized or enabled. */ - @Nonnull - Iterator getDependencyClassloaders(@Nonnull String id) { - var loaded = plugins.get(id); - if (loaded == null) { - return Collections.emptyIterator(); - } - return Iterators.transform(loaded.getDependencies().iterator(), input -> ((PluginClassLoaderImpl) input.getContainer().plugin().getClass().getClassLoader())); + private void enable(@Nonnull LoadedPlugin loadedPlugin, @Nonnull List initializedPlugins) throws PluginException { + enable(loadedPlugin, initializedPlugins, new LinkedHashSet<>()); } /** @@ -248,29 +328,93 @@ Iterator getDependencyClassloaders(@Nonnull String id) { * * @param loadedPlugin * Plugin to enable. + * @param initializedPlugins + * Plugins initialized during the current apply operation. + * @param visiting + * Set of plugins currently being initialized in the current dependency chain + * to detect circular dependencies. * * @throws PluginException * If the plugin could not be initialized or enabled. */ @SuppressWarnings({"rawtypes", "unchecked"}) - private void enable(@Nonnull LoadedPlugin loadedPlugin) throws PluginException { - // Enable dependent plugins - for (LoadedPlugin dependency : loadedPlugin.getDependencies()) - enable(dependency); - + private void enable(@Nonnull LoadedPlugin loadedPlugin, @Nonnull List initializedPlugins, @Nonnull Set visiting) throws PluginException { // Check if the plugin is already initialized. PluginContainerImpl container = loadedPlugin.getContainer(); Plugin plugin = container.plugin; if (plugin != null) return; // Already initialized, skip. - + if (!visiting.add(loadedPlugin)) { + throw new PluginException("Circular dependency detected during enablement at: %s".formatted(container.info().id())); + } // Initialize and enable the plugin. try { + // Enable dependent plugins + for (LoadedPlugin dependency : loadedPlugin.getDependencies()) + enable(dependency, initializedPlugins, visiting); Class pluginClass = (Class) container.classLoader.lookupClass(container.preparedPlugin.pluginClassName()); plugin = classAllocator.instance(pluginClass); - plugin.onEnable(); container.plugin = plugin; + initializedPlugins.add(loadedPlugin); + plugin.onEnable(); } catch (Throwable t) { throw new PluginException(t); + } finally { + visiting.remove(loadedPlugin); + } + } + + /** + * Rolls back plugins initialized during a failed apply operation. + * + * @param temp + * Staged plugins. + * @param initializedPlugins + * Plugins initialized before the failure. + * @param failure + * Failure to attach rollback errors to. + */ + private static void rollbackFailedApply(@Nonnull Map temp, @Nonnull List initializedPlugins, @Nonnull PluginException failure) { + for (int i = initializedPlugins.size() - 1; i >= 0; i--) { + LoadedPlugin loadedPlugin = initializedPlugins.get(i); + PluginContainerImpl container = loadedPlugin.getContainer(); + + try { + Plugin plugin = container.plugin; + if (plugin != null) { + plugin.onDisable(); + } + } catch (Exception ex) { + failure.addSuppressed(ex); + } finally { + container.plugin = null; + + if (container.classLoader instanceof AutoCloseable closeable) { + try { + closeable.close(); + } catch (Exception ex) { + failure.addSuppressed(ex); + } + } + } + } + + for (LoadedPlugin loadedPlugin : temp.values()) { + try { + loadedPlugin.getContainer().preparedPlugin.reject(); + } catch (Exception ex) { + failure.addSuppressed(ex); + } } } + + /** + * @param plugin + * Plugin to get classloader from. + * + * @return Plugin classloader. + */ + @Nonnull + private static PluginClassLoaderImpl classLoaderOf(@Nonnull LoadedPlugin plugin) { + return (PluginClassLoaderImpl) plugin.getContainer().classLoader; + } } diff --git a/recaf-core/src/test/java/software/coley/recaf/services/plugin/PluginManagerTest.java b/recaf-core/src/test/java/software/coley/recaf/services/plugin/PluginManagerTest.java index 77b2380e7..94162b318 100644 --- a/recaf-core/src/test/java/software/coley/recaf/services/plugin/PluginManagerTest.java +++ b/recaf-core/src/test/java/software/coley/recaf/services/plugin/PluginManagerTest.java @@ -20,12 +20,15 @@ import software.coley.recaf.util.io.ByteSources; import java.io.IOException; +import java.io.InputStream; import java.lang.annotation.Annotation; import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLConnection; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; import static org.objectweb.asm.Opcodes.*; @@ -35,6 +38,8 @@ */ public class PluginManagerTest extends TestBase { static PluginManager pluginManager; + private static final Map ENABLE_COUNTS = new ConcurrentHashMap<>(); + private static final Map DISABLE_COUNTS = new ConcurrentHashMap<>(); @BeforeAll static void setup() { @@ -46,6 +51,510 @@ void verifyCleanSlate() { assertEquals(0, pluginManager.getPlugins().size(), "Plugins still loaded after test case"); } + @Test + void testBatchLoadedPluginCanReferenceDependencyClasses() throws IOException { + String apiId = "api-plugin"; + String implId = "impl-plugin"; + + String apiPluginClass = "test/ApiPlugin"; + String apiTypeClass = "test/ApiType"; + String implPluginClass = "test/ImplPlugin"; + + byte[] apiZip = createPluginZip(apiPluginClass, createPluginClass(apiPluginClass, apiId, new String[0]), Map.of( + apiTypeClass + ".class", createStaticStringProviderClass(apiTypeClass, "message", "dependency-ok") + )); + + byte[] implZip = createPluginZip(implPluginClass, createPluginClassCallingDependencyStringProvider( + implPluginClass, + implId, + new String[]{apiId}, + apiTypeClass, + "message", + "dependency-ok" + ), Map.of()); + + PluginDiscoverer discoverer = () -> List.of( + () -> ByteSources.wrap(apiZip), + () -> ByteSources.wrap(implZip) + ); + + try { + Collection> containers = pluginManager.loadPlugins(discoverer); + + assertEquals(2, containers.size()); + assertNotNull(pluginManager.getPlugin(apiId)); + assertNotNull(pluginManager.getPlugin(implId)); + + pluginManager.unloaderFor(apiId).commit(); + + assertNull(pluginManager.getPlugin(apiId)); + assertNull(pluginManager.getPlugin(implId)); + } catch (PluginException ex) { + fail("Failed to load plugins with same-batch dependency class visibility", ex); + } + } + + @Test + void testPluginResourcesAreVisibleThroughGetResources() throws IOException { + String id = "resource-enumeration-plugin"; + String pluginClass = "test/ResourceEnumerationPlugin"; + String resourceName = "META-INF/services/example.Service"; + String resourceContent = "example.Implementation"; + + byte[] zip = createPluginZip(pluginClass, createPluginClassCallingEnumeratedResourceAssertion( + pluginClass, + id, + resourceName, + resourceContent + ), Map.of( + resourceName, resourceContent.getBytes(StandardCharsets.UTF_8) + )); + + PluginDiscoverer discoverer = () -> List.of(() -> ByteSources.wrap(zip)); + + try { + pluginManager.loadPlugins(discoverer); + pluginManager.unloaderFor(id).commit(); + } catch (PluginException ex) { + fail("Failed to enumerate plugin resources", ex); + } + } + + @Test + void testPluginResourceUrlsIncludePluginId() throws IOException { + String id = "scoped-resource-plugin"; + String pluginClass = "test/ScopedResourcePlugin"; + String resourceName = "plugin-resource.txt"; + + byte[] zip = createPluginZip(pluginClass, createPluginClassCallingResourceUrlScopeAssertion( + pluginClass, + id, + resourceName, + id + ), Map.of( + resourceName, "scoped".getBytes(StandardCharsets.UTF_8) + )); + + PluginDiscoverer discoverer = () -> List.of(() -> ByteSources.wrap(zip)); + + try { + pluginManager.loadPlugins(discoverer); + pluginManager.unloaderFor(id).commit(); + } catch (PluginException ex) { + fail("Failed to validate plugin resource URL scope", ex); + } + } + + @Test + void testPluginResourceUrlReturnsFreshStreams() throws IOException { + String id = "fresh-stream-plugin"; + String pluginClass = "test/FreshStreamPlugin"; + String resourceName = "fresh-resource.txt"; + String resourceContent = "fresh-content"; + + byte[] zip = createPluginZip(pluginClass, createPluginClassCallingFreshResourceStreamAssertion( + pluginClass, + id, + resourceName, + resourceContent + ), Map.of( + resourceName, resourceContent.getBytes(StandardCharsets.UTF_8) + )); + + PluginDiscoverer discoverer = () -> List.of(() -> ByteSources.wrap(zip)); + + try { + pluginManager.loadPlugins(discoverer); + pluginManager.unloaderFor(id).commit(); + } catch (PluginException ex) { + fail("Failed to validate fresh plugin resource streams", ex); + } + } + + @Test + void testFailedEnableCallsDisableDuringRollback() throws IOException { + String id = "failing-enable-plugin"; + String pluginClass = "test/FailingEnablePlugin"; + + ENABLE_COUNTS.clear(); + DISABLE_COUNTS.clear(); + + byte[] zip = createPluginZip(pluginClass, createPluginClassFailingOnEnable(pluginClass, id), Map.of()); + + PluginDiscoverer discoverer = () -> List.of(() -> ByteSources.wrap(zip)); + + assertThrows(PluginException.class, () -> pluginManager.loadPlugins(discoverer), + "Plugin loading should fail when onEnable throws"); + + assertEquals(1, countOf(ENABLE_COUNTS, id), "onEnable should have been called once"); + assertEquals(1, countOf(DISABLE_COUNTS, id), "onDisable should have been called during rollback"); + assertEquals(0, pluginManager.getPlugins().size(), "Failed plugin should not remain loaded"); + } + + @Test + void testDiamondDependencyUnloadDoesNotUnloadSamePluginTwice() throws IOException { + String pluginA = "diamond-a"; + String pluginB = "diamond-b"; + String pluginC = "diamond-c"; + String pluginD = "diamond-d"; + + String classA = "test/DiamondA"; + String classB = "test/DiamondB"; + String classC = "test/DiamondC"; + String classD = "test/DiamondD"; + + ENABLE_COUNTS.clear(); + DISABLE_COUNTS.clear(); + + byte[] zipA = createPluginZip(classA, createLifecycleCountingPluginClass(classA, pluginA, new String[0]), Map.of()); + byte[] zipB = createPluginZip(classB, createLifecycleCountingPluginClass(classB, pluginB, new String[]{pluginA}), Map.of()); + byte[] zipC = createPluginZip(classC, createLifecycleCountingPluginClass(classC, pluginC, new String[]{pluginA}), Map.of()); + byte[] zipD = createPluginZip(classD, createLifecycleCountingPluginClass(classD, pluginD, new String[]{pluginB, pluginC}), Map.of()); + + PluginDiscoverer discoverer = () -> List.of( + () -> ByteSources.wrap(zipA), + () -> ByteSources.wrap(zipB), + () -> ByteSources.wrap(zipC), + () -> ByteSources.wrap(zipD) + ); + + try { + pluginManager.loadPlugins(discoverer); + + assertEquals(4, pluginManager.getPlugins().size()); + + pluginManager.unloaderFor(pluginA).commit(); + + assertEquals(0, pluginManager.getPlugins().size()); + + assertEquals(1, countOf(DISABLE_COUNTS, pluginA)); + assertEquals(1, countOf(DISABLE_COUNTS, pluginB)); + assertEquals(1, countOf(DISABLE_COUNTS, pluginC)); + assertEquals(1, countOf(DISABLE_COUNTS, pluginD)); + } catch (PluginException ex) { + fail("Failed to unload diamond dependency graph", ex); + } + } + + @Test + void testCircularDependencyFailsDuringEnable() throws IOException { + String pluginA = "circular-a"; + String pluginB = "circular-b"; + + String classA = "test/CircularA"; + String classB = "test/CircularB"; + + byte[] zipA = createPluginZip(classA, createPluginClass(classA, pluginA, new String[]{pluginB}), Map.of()); + byte[] zipB = createPluginZip(classB, createPluginClass(classB, pluginB, new String[]{pluginA}), Map.of()); + + PluginDiscoverer discoverer = () -> List.of( + () -> ByteSources.wrap(zipA), + () -> ByteSources.wrap(zipB) + ); + + assertThrows(PluginException.class, () -> pluginManager.loadPlugins(discoverer), + "Circular plugin dependencies should fail during enablement"); + + assertEquals(0, pluginManager.getPlugins().size(), "Circular dependency failure should not leave plugins loaded"); + } + + public static void assertSameText(String expected, String actual) { + assertEquals(expected, actual); + } + + public static void assertEnumeratedResource(ClassLoader classLoader, String name, String expectedContent) throws IOException { + Enumeration resources = classLoader.getResources(name); + + assertTrue(resources.hasMoreElements(), "Expected resource to be visible through ClassLoader#getResources: " + name); + + URL resource = resources.nextElement(); + assertNotNull(resource, "Enumerated resource URL must not be null"); + + try (InputStream inputStream = resource.openStream()) { + assertEquals(expectedContent, new String(inputStream.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + public static void assertResourceUrlContains(ClassLoader classLoader, String name, String expectedUrlPart) { + URL resource = classLoader.getResource(name); + + assertNotNull(resource, "Expected resource URL: " + name); + assertTrue(resource.toExternalForm().contains(expectedUrlPart), + "Expected resource URL to contain '%s', got: %s".formatted(expectedUrlPart, resource)); + } + + public static void assertFreshResourceStreams(ClassLoader classLoader, String name, String expectedContent) throws IOException { + URL resource = classLoader.getResource(name); + + assertNotNull(resource, "Expected resource URL: " + name); + + URLConnection connection = resource.openConnection(); + + String firstRead; + try (InputStream inputStream = connection.getInputStream()) { + firstRead = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + + String secondRead; + try (InputStream inputStream = connection.getInputStream()) { + secondRead = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertEquals(expectedContent, firstRead); + assertEquals(expectedContent, secondRead); + } + + public static void recordEnable(String id) { + ENABLE_COUNTS.computeIfAbsent(id, ignored -> new AtomicInteger()).incrementAndGet(); + } + + public static void recordDisable(String id) { + DISABLE_COUNTS.computeIfAbsent(id, ignored -> new AtomicInteger()).incrementAndGet(); + } + + public static void recordEnableThenFail(String id) { + recordEnable(id); + throw new IllegalStateException("Intentional enable failure for test plugin: " + id); + } + + private static int countOf(Map counts, String id) { + AtomicInteger count = counts.get(id); + return count == null ? 0 : count.get(); + } + + private static byte[] createPluginZip(String pluginInternalName, byte[] pluginClassBytes, Map additionalEntries) throws IOException { + Map entries = new LinkedHashMap<>(); + entries.put(pluginInternalName + ".class", pluginClassBytes); + entries.put(ZipPluginLoader.SERVICE_PATH, pluginInternalName.replace('/', '.').getBytes(StandardCharsets.UTF_8)); + entries.putAll(additionalEntries); + return ZipCreationUtils.createZip(entries); + } + + private static byte[] createPluginClass(String internalName, String id, String[] dependencies) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(V22, ACC_PUBLIC | ACC_SUPER, internalName, null, "java/lang/Object", new String[]{"software/coley/recaf/plugin/Plugin"}); + visitPluginInformation(cw, id, dependencies); + writeDefaultConstructor(cw); + writeEmptyMethod(cw, "onEnable"); + writeEmptyMethod(cw, "onDisable"); + cw.visitEnd(); + return cw.toByteArray(); + } + + private static byte[] createLifecycleCountingPluginClass(String internalName, String id, String[] dependencies) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(V22, ACC_PUBLIC | ACC_SUPER, internalName, null, "java/lang/Object", new String[]{"software/coley/recaf/plugin/Plugin"}); + visitPluginInformation(cw, id, dependencies); + writeDefaultConstructor(cw); + writeLifecycleCounterMethod(cw, "onEnable", "recordEnable", id); + writeLifecycleCounterMethod(cw, "onDisable", "recordDisable", id); + cw.visitEnd(); + return cw.toByteArray(); + } + + + private static byte[] createPluginClassFailingOnEnable(String internalName, String id) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(V22, ACC_PUBLIC | ACC_SUPER, internalName, null, "java/lang/Object", new String[]{"software/coley/recaf/plugin/Plugin"}); + visitPluginInformation(cw, id, new String[0]); + writeDefaultConstructor(cw); + writeLifecycleCounterMethod(cw, "onEnable", "recordEnableThenFail", id); + writeLifecycleCounterMethod(cw, "onDisable", "recordDisable", id); + cw.visitEnd(); + return cw.toByteArray(); + } + + private static byte[] createPluginClassCallingDependencyStringProvider( + String internalName, + String id, + String[] dependencies, + String providerInternalName, + String providerMethodName, + String expectedValue + ) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(V22, ACC_PUBLIC | ACC_SUPER, internalName, null, "java/lang/Object", new String[]{"software/coley/recaf/plugin/Plugin"}); + visitPluginInformation(cw, id, dependencies); + writeDefaultConstructor(cw); + + MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "onEnable", "()V", null, null); + mv.visitCode(); + mv.visitLdcInsn(expectedValue); + mv.visitMethodInsn(INVOKESTATIC, providerInternalName, providerMethodName, "()Ljava/lang/String;", false); + mv.visitMethodInsn(INVOKESTATIC, + "software/coley/recaf/services/plugin/PluginManagerTest", + "assertSameText", + "(Ljava/lang/String;Ljava/lang/String;)V", + false); + mv.visitInsn(RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + + writeEmptyMethod(cw, "onDisable"); + cw.visitEnd(); + return cw.toByteArray(); + } + + private static byte[] createPluginClassCallingEnumeratedResourceAssertion( + String internalName, + String id, + String resourceName, + String expectedContent + ) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(V22, ACC_PUBLIC | ACC_SUPER, internalName, null, "java/lang/Object", new String[]{"software/coley/recaf/plugin/Plugin"}); + visitPluginInformation(cw, id, new String[0]); + writeDefaultConstructor(cw); + + MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "onEnable", "()V", null, null); + mv.visitCode(); + writeCurrentClassLoader(mv); + mv.visitLdcInsn(resourceName); + mv.visitLdcInsn(expectedContent); + mv.visitMethodInsn(INVOKESTATIC, + "software/coley/recaf/services/plugin/PluginManagerTest", + "assertEnumeratedResource", + "(Ljava/lang/ClassLoader;Ljava/lang/String;Ljava/lang/String;)V", + false); + mv.visitInsn(RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + + writeEmptyMethod(cw, "onDisable"); + cw.visitEnd(); + return cw.toByteArray(); + } + + private static byte[] createPluginClassCallingResourceUrlScopeAssertion( + String internalName, + String id, + String resourceName, + String expectedUrlPart + ) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(V22, ACC_PUBLIC | ACC_SUPER, internalName, null, "java/lang/Object", new String[]{"software/coley/recaf/plugin/Plugin"}); + visitPluginInformation(cw, id, new String[0]); + writeDefaultConstructor(cw); + + MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "onEnable", "()V", null, null); + mv.visitCode(); + writeCurrentClassLoader(mv); + mv.visitLdcInsn(resourceName); + mv.visitLdcInsn(expectedUrlPart); + mv.visitMethodInsn(INVOKESTATIC, + "software/coley/recaf/services/plugin/PluginManagerTest", + "assertResourceUrlContains", + "(Ljava/lang/ClassLoader;Ljava/lang/String;Ljava/lang/String;)V", + false); + mv.visitInsn(RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + + writeEmptyMethod(cw, "onDisable"); + cw.visitEnd(); + return cw.toByteArray(); + } + + private static byte[] createPluginClassCallingFreshResourceStreamAssertion( + String internalName, + String id, + String resourceName, + String expectedContent + ) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(V22, ACC_PUBLIC | ACC_SUPER, internalName, null, "java/lang/Object", new String[]{"software/coley/recaf/plugin/Plugin"}); + visitPluginInformation(cw, id, new String[0]); + writeDefaultConstructor(cw); + + MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "onEnable", "()V", null, null); + mv.visitCode(); + writeCurrentClassLoader(mv); + mv.visitLdcInsn(resourceName); + mv.visitLdcInsn(expectedContent); + mv.visitMethodInsn(INVOKESTATIC, + "software/coley/recaf/services/plugin/PluginManagerTest", + "assertFreshResourceStreams", + "(Ljava/lang/ClassLoader;Ljava/lang/String;Ljava/lang/String;)V", + false); + mv.visitInsn(RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + + writeEmptyMethod(cw, "onDisable"); + cw.visitEnd(); + return cw.toByteArray(); + } + + private static byte[] createStaticStringProviderClass(String internalName, String methodName, String value) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(V22, ACC_PUBLIC | ACC_SUPER, internalName, null, "java/lang/Object", null); + writeDefaultConstructor(cw); + + MethodVisitor mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, methodName, "()Ljava/lang/String;", null, null); + mv.visitCode(); + mv.visitLdcInsn(value); + mv.visitInsn(ARETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + + cw.visitEnd(); + return cw.toByteArray(); + } + + private static void visitPluginInformation(ClassWriter cw, String id, String[] dependencies) { + AnnotationVisitor av = cw.visitAnnotation("Lsoftware/coley/recaf/plugin/PluginInformation;", true); + av.visit("id", id); + av.visit("name", id); + av.visit("version", "1.0"); + + if (dependencies.length > 0) { + AnnotationVisitor dependenciesVisitor = av.visitArray("dependencies"); + for (String dependency : dependencies) + dependenciesVisitor.visit(null, dependency); + dependenciesVisitor.visitEnd(); + } + + av.visitEnd(); + } + + private static void writeDefaultConstructor(ClassWriter cw) { + MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "", "()V", null, null); + mv.visitCode(); + mv.visitVarInsn(ALOAD, 0); + mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "", "()V", false); + mv.visitInsn(RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + } + + private static void writeEmptyMethod(ClassWriter cw, String name) { + MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, name, "()V", null, null); + mv.visitCode(); + mv.visitInsn(RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + } + + private static void writeLifecycleCounterMethod(ClassWriter cw, String methodName, String counterMethodName, String id) { + MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, methodName, "()V", null, null); + mv.visitCode(); + mv.visitLdcInsn(id); + mv.visitMethodInsn(INVOKESTATIC, + "software/coley/recaf/services/plugin/PluginManagerTest", + counterMethodName, + "(Ljava/lang/String;)V", + false); + mv.visitInsn(RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + } + + private static void writeCurrentClassLoader(MethodVisitor mv) { + mv.visitVarInsn(ALOAD, 0); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Object", "getClass", "()Ljava/lang/Class;", false); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Class", "getClassLoader", "()Ljava/lang/ClassLoader;", false); + } + @Test void testSingleLoadAndUnload() throws IOException { String id = "test-plugin";