From 9dffc05082a4985058807c2e3b6c19da1c188d97 Mon Sep 17 00:00:00 2001 From: huangdihd Date: Wed, 10 Jun 2026 14:59:33 +0800 Subject: [PATCH 1/6] chore: bump version to 2.3.2-SNAPSHOT Co-Authored-By: Claude Opus 4.8 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 92c7a74..8cc89d9 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ xin.bbtt xinbot - 2.3.1-RELEASE + 2.3.2-SNAPSHOT jar From 71cbe1508884576df6877a338f8fe6dc20eaed0e Mon Sep 17 00:00:00 2001 From: huangdihd Date: Wed, 10 Jun 2026 15:03:11 +0800 Subject: [PATCH 2/6] fix(plugin): wire (soft)dependency classloader chain on runtime load The single-JAR runtime load path (loadPlugin(File), used by pm load/reload) built the plugin classloader with only the core classloader as parent and never wired up its dependencies, so a plugin loaded at runtime could not see its (soft)dependency classes. Only the startup batch loader handled this. Delegate loadPlugin(File) to the shared instantiateAndLoad() so runtime loads get the same effective-dependency classloader chain as startup loading. Co-Authored-By: Claude Opus 4.8 --- .../xin/bbtt/mcbot/plugin/PluginManager.java | 27 +---- .../plugin/PluginRuntimeSoftDependTest.java | 108 ++++++++++++++++++ 2 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java diff --git a/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java b/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java index a3a3bd7..e431204 100644 --- a/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java +++ b/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java @@ -138,28 +138,11 @@ public void loadPlugin(File pluginFile) throws Exception { if (plugins.containsKey(info.name)) return; - PluginClassLoader pluginClassLoader = new PluginClassLoader(new URL[]{url}, PluginManager.class.getClassLoader()); - pluginLoaders.put(info.name, pluginClassLoader); - pluginDependencies.put(info.name, info.depends); - - Plugin plugin; - try { - Class clazz = Class.forName(info.mainClass, true, pluginClassLoader); - plugin = (Plugin) clazz.getDeclaredConstructor().newInstance(); - } catch (Exception e) { - pluginLoaders.remove(info.name); - pluginDependencies.remove(info.name); - try { pluginClassLoader.close(); } catch (IOException ignored) {} - throw e; - } - - RegisteredPlugin rp; - if (info.type == PluginType.META_PLUGIN && plugin instanceof MetaPlugin) { - rp = new RegisteredMetaPlugin(info.name, info.version, info.mainClass, info.depends, info.file, url, (MetaPlugin) plugin); - } else { - rp = new RegisteredPlugin(info.name, info.version, info.mainClass, info.depends, info.file, url, plugin, PluginType.PLUGIN); - } - loadPlugin(rp); + info.file = pluginFile; + info.url = url; + // Delegate to the shared loader so runtime loads (e.g. `pm load`/`pm reload`) + // wire up the (soft)dependency classloader chain just like startup batch loading. + instantiateAndLoad(info); } public void loadPlugins(String pluginsDirectory) { diff --git a/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java b/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java new file mode 100644 index 0000000..08fb9ba --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java @@ -0,0 +1,108 @@ +package xin.bbtt.mcbot.plugin; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import xin.bbtt.mcbot.Bot; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression test for runtime plugin loading (e.g. the {@code pm load} / {@code pm reload} + * commands, which call {@link PluginManager#loadPlugin(File)} one JAR at a time). + * + *

The startup batch loader wires every plugin's classloader to the loaders of its + * (soft)dependencies, but the single-file runtime path used to skip that wiring entirely, + * so a plugin loaded at runtime could not see its (soft)dependency's classes. This test + * pins down that the runtime path now builds the same classloader chain. + */ +class PluginRuntimeSoftDependTest { + + private PluginManager pluginManager; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() throws Exception { + DummyPlugin.reset(); + pluginManager = new PluginManager(); + + // Ensure no session/running state leaks in from other tests so loading does not + // try to auto-enable the plugins (we only care about the classloader wiring here). + Field sessionField = Bot.class.getDeclaredField("session"); + sessionField.setAccessible(true); + sessionField.set(Bot.INSTANCE, null); + Field runningField = Bot.class.getDeclaredField("running"); + runningField.setAccessible(true); + runningField.set(Bot.INSTANCE, false); + } + + private File createPluginJar(String fileName, String yml) throws Exception { + File file = tempDir.resolve(fileName).toFile(); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(file))) { + jos.putNextEntry(new JarEntry("plugin.yml")); + jos.write(yml.getBytes()); + jos.closeEntry(); + + String className = DummyPlugin.class.getName().replace('.', '/') + ".class"; + jos.putNextEntry(new JarEntry(className)); + try (InputStream is = DummyPlugin.class.getClassLoader().getResourceAsStream(className)) { + assertThat(is).isNotNull(); + byte[] buffer = new byte[1024]; + int len; + while ((len = is.read(buffer)) > 0) { + jos.write(buffer, 0, len); + } + } + jos.closeEntry(); + } + return file; + } + + @Test + void runtimeLoadWiresPresentSoftDependency() throws Exception { + File libJar = createPluginJar("lib-plugin.jar", + "name: LibPlugin\nmain: xin.bbtt.mcbot.plugin.DummyPlugin\nversion: 1.0.0\n"); + File appJar = createPluginJar("app-plugin.jar", + "name: AppPlugin\nmain: xin.bbtt.mcbot.plugin.DummyPlugin\nversion: 1.0.0\n" + + "softdepend: [LibPlugin]\n"); + + // Load the soft dependency first, then load the consumer at runtime. + pluginManager.loadPlugin(libJar); + pluginManager.loadPlugin(appJar); + + PluginClassLoader libLoader = pluginManager.getPluginLoader("LibPlugin"); + PluginClassLoader appLoader = pluginManager.getPluginLoader("AppPlugin"); + assertThat(libLoader).isNotNull(); + assertThat(appLoader).isNotNull(); + + // The present soft dependency must become part of the consumer's classloader chain + // (the single soft dep is wired in as the parent) and be recorded as an effective + // dependency so unload ordering still works. + assertThat(appLoader.getParent()).isSameAs(libLoader); + assertThat(pluginManager.getPluginDependencies().get("AppPlugin")).contains("LibPlugin"); + } + + @Test + void runtimeLoadIgnoresAbsentSoftDependency() throws Exception { + File appJar = createPluginJar("app-plugin.jar", + "name: AppPlugin\nmain: xin.bbtt.mcbot.plugin.DummyPlugin\nversion: 1.0.0\n" + + "softdepend: [MissingPlugin]\n"); + + // A soft dependency that is not present must not block the load and must not be + // recorded as an effective dependency. + pluginManager.loadPlugin(appJar); + + assertThat(pluginManager.getPlugin("AppPlugin")).isNotNull(); + assertThat(pluginManager.getPluginDependencies().get("AppPlugin")).doesNotContain("MissingPlugin"); + } +} From 31b7d1fea027073c2031d46069129c3c1aab2f75 Mon Sep 17 00:00:00 2001 From: huangdihd Date: Thu, 11 Jun 2026 08:21:07 +0800 Subject: [PATCH 3/6] feat(plugin): support MetaPlugin dependencies and protect them from runtime unload Plugins a MetaPlugin (transitively) depends on now share its lifecycle restriction: they cannot be unloaded during runtime, since that would tear down the MetaPlugin's classloader chain. enableAll() also enables the MetaPlugin's dependencies (in dependency order) before the MetaPlugin itself. Co-Authored-By: Claude Fable 5 --- .../xin/bbtt/mcbot/plugin/PluginManager.java | 39 +++++ src/main/resources/lang/de_de.lang | 1 + src/main/resources/lang/en_us.lang | 1 + src/main/resources/lang/fr_fr.lang | 1 + src/main/resources/lang/ja_jp.lang | 1 + src/main/resources/lang/ru_ru.lang | 1 + src/main/resources/lang/zh_cn.lang | 1 + src/main/resources/lang/zh_tw.lang | 1 + .../xin/bbtt/mcbot/plugin/DummyLibPlugin.java | 24 ++++ .../bbtt/mcbot/plugin/DummyMetaPlugin.java | 48 +++++++ .../plugin/MetaPluginDependencyTest.java | 136 ++++++++++++++++++ 11 files changed, 254 insertions(+) create mode 100644 src/test/java/xin/bbtt/mcbot/plugin/DummyLibPlugin.java create mode 100644 src/test/java/xin/bbtt/mcbot/plugin/DummyMetaPlugin.java create mode 100644 src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java diff --git a/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java b/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java index e431204..8504a3c 100644 --- a/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java +++ b/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java @@ -432,6 +432,10 @@ public void unloadPlugin(RegisteredPlugin rp) { return; } if (!plugins.containsKey(rp.getName())) return; + if (Bot.INSTANCE.isRunning() && isMetaPluginDependency(rp.getName())) { + log.error(LangManager.get("xinbot.metaplugin.error.unload_dependency_runtime", rp.getName())); + return; + } String pluginName = rp.getName(); unloadDependents(pluginName); @@ -458,6 +462,36 @@ private void unloadDependents(String pluginName) { } } + // A plugin the meta plugin (transitively) depends on shares the meta plugin's + // lifecycle restrictions: unloading it would tear down the meta plugin's + // classloader chain, so it must not be unloaded during runtime either. + private boolean isMetaPluginDependency(String pluginName) { + for (RegisteredPlugin p : plugins.values()) { + if (p instanceof RegisteredMetaPlugin + && getTransitiveDependencies(p.getName()).contains(pluginName)) { + return true; + } + } + return false; + } + + private List getTransitiveDependencies(String pluginName) { + List ordered = new ArrayList<>(); + Set visited = new HashSet<>(); + visited.add(pluginName); + collectDependencies(pluginName, visited, ordered); + return ordered; + } + + private void collectDependencies(String pluginName, Set visited, List ordered) { + for (String dep : pluginDependencies.getOrDefault(pluginName, Collections.emptyList())) { + if (visited.add(dep)) { + collectDependencies(dep, visited, ordered); + ordered.add(dep); + } + } + } + private List getDependents(String pluginName) { List dependents = new ArrayList<>(); for (RegisteredPlugin p : plugins.values()) { @@ -498,6 +532,11 @@ public void unloadPlugins() { public void enableAll() { RegisteredMetaPlugin meta = getMetaPlugin(); if (meta != null) { + // Dependencies must be enabled before the meta plugin itself. + for (String depName : getTransitiveDependencies(meta.getName())) { + RegisteredPlugin dep = plugins.get(depName); + if (dep != null) enablePlugin(dep); + } enablePlugin(meta); } for (RegisteredPlugin rp : plugins.values()) { diff --git a/src/main/resources/lang/de_de.lang b/src/main/resources/lang/de_de.lang index 8998ca8..736601e 100644 --- a/src/main/resources/lang/de_de.lang +++ b/src/main/resources/lang/de_de.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=MetaPlugin kann während der Laufzeit nicht xinbot.metaplugin.error.enable_runtime=MetaPlugin kann während der Laufzeit nicht aktiviert werden! xinbot.metaplugin.error.disable_runtime=MetaPlugin kann während der Laufzeit nicht deaktiviert werden! xinbot.metaplugin.error.unload_runtime=MetaPlugin kann während der Laufzeit nicht entladen werden! +xinbot.metaplugin.error.unload_dependency_runtime=Plugin %s kann während der Laufzeit nicht entladen werden: das MetaPlugin hängt davon ab! xinbot.metaplugin.error.count=Der Bot muss genau ein MetaPlugin zum Starten haben! (Gefunden: %d) # LangManager messages diff --git a/src/main/resources/lang/en_us.lang b/src/main/resources/lang/en_us.lang index efa7aa5..98e1c2d 100644 --- a/src/main/resources/lang/en_us.lang +++ b/src/main/resources/lang/en_us.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=Cannot load MetaPlugin during runtime! xinbot.metaplugin.error.enable_runtime=Cannot enable MetaPlugin during runtime! xinbot.metaplugin.error.disable_runtime=Cannot disable MetaPlugin during runtime! xinbot.metaplugin.error.unload_runtime=Cannot unload MetaPlugin during runtime! +xinbot.metaplugin.error.unload_dependency_runtime=Cannot unload plugin %s during runtime: the MetaPlugin depends on it! xinbot.metaplugin.error.count=Bot must have exactly one MetaPlugin to start! (Found: %d) # LangManager messages diff --git a/src/main/resources/lang/fr_fr.lang b/src/main/resources/lang/fr_fr.lang index e24e68b..68480a5 100644 --- a/src/main/resources/lang/fr_fr.lang +++ b/src/main/resources/lang/fr_fr.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=Impossible de charger le MetaPlugin pendant xinbot.metaplugin.error.enable_runtime=Impossible d'activer le MetaPlugin pendant l'exécution ! xinbot.metaplugin.error.disable_runtime=Impossible de désactiver le MetaPlugin pendant l'exécution ! xinbot.metaplugin.error.unload_runtime=Impossible de décharger le MetaPlugin pendant l'exécution ! +xinbot.metaplugin.error.unload_dependency_runtime=Impossible de décharger le plugin %s pendant l'exécution : le MetaPlugin en dépend ! xinbot.metaplugin.error.count=Le bot doit avoir exactement un MetaPlugin pour démarrer ! (Trouvé : %d) # LangManager messages diff --git a/src/main/resources/lang/ja_jp.lang b/src/main/resources/lang/ja_jp.lang index d695572..4ba7e56 100644 --- a/src/main/resources/lang/ja_jp.lang +++ b/src/main/resources/lang/ja_jp.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=実行中に MetaPlugin を読み込むこ xinbot.metaplugin.error.enable_runtime=実行中に MetaPlugin を有効にすることはできません! xinbot.metaplugin.error.disable_runtime=実行中に MetaPlugin を無効にすることはできません! xinbot.metaplugin.error.unload_runtime=実行中に MetaPlugin をアンロードすることはできません! +xinbot.metaplugin.error.unload_dependency_runtime=実行中にプラグイン %s をアンロードすることはできません。MetaPlugin が依存しています! xinbot.metaplugin.error.count=ボットを開始するには、正確に1つの MetaPlugin が必要です!(見つかった数: %d) # LangManager messages diff --git a/src/main/resources/lang/ru_ru.lang b/src/main/resources/lang/ru_ru.lang index f1c9a6d..9e5f997 100644 --- a/src/main/resources/lang/ru_ru.lang +++ b/src/main/resources/lang/ru_ru.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=Невозможно загрузить Met xinbot.metaplugin.error.enable_runtime=Невозможно включить MetaPlugin во время работы! xinbot.metaplugin.error.disable_runtime=Невозможно отключить MetaPlugin во время работы! xinbot.metaplugin.error.unload_runtime=Невозможно выгрузить MetaPlugin во время работы! +xinbot.metaplugin.error.unload_dependency_runtime=Невозможно выгрузить плагин %s во время работы: от него зависит MetaPlugin! xinbot.metaplugin.error.count=Для запуска бота должен быть ровно один MetaPlugin! (Найдено: %d) # LangManager messages diff --git a/src/main/resources/lang/zh_cn.lang b/src/main/resources/lang/zh_cn.lang index b451012..1df3209 100644 --- a/src/main/resources/lang/zh_cn.lang +++ b/src/main/resources/lang/zh_cn.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=不允许在运行过程中加载元插件 xinbot.metaplugin.error.enable_runtime=不允许在运行过程中启用元插件! xinbot.metaplugin.error.disable_runtime=不允许在运行过程中禁用元插件! xinbot.metaplugin.error.unload_runtime=不允许在运行过程中卸载元插件! +xinbot.metaplugin.error.unload_dependency_runtime=不允许在运行过程中卸载插件 %s:该插件被元插件依赖! xinbot.metaplugin.error.count=机器人启动必须有且仅有一个元插件!(当前找到:%d 个) # LangManager messages diff --git a/src/main/resources/lang/zh_tw.lang b/src/main/resources/lang/zh_tw.lang index 5322ffc..f00c1e2 100644 --- a/src/main/resources/lang/zh_tw.lang +++ b/src/main/resources/lang/zh_tw.lang @@ -31,6 +31,7 @@ xinbot.metaplugin.error.load_runtime=不允許在運行過程中載入元外掛 xinbot.metaplugin.error.enable_runtime=不允許在運行過程中啟用元外掛程式! xinbot.metaplugin.error.disable_runtime=不允許在運行過程中停用元外掛程式! xinbot.metaplugin.error.unload_runtime=不允許在運行過程中卸載元外掛程式! +xinbot.metaplugin.error.unload_dependency_runtime=不允許在運行過程中卸載外掛程式 %s:該外掛程式被元外掛程式依賴! xinbot.metaplugin.error.count=機器人啟動必須有且僅有一個元外掛程式!(當前找到:%d 個) xinbot.plugin.enabled=已啟用外掛程式:%s xinbot.plugin.disabled=已停用外掛程式:%s diff --git a/src/test/java/xin/bbtt/mcbot/plugin/DummyLibPlugin.java b/src/test/java/xin/bbtt/mcbot/plugin/DummyLibPlugin.java new file mode 100644 index 0000000..472d07a --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/plugin/DummyLibPlugin.java @@ -0,0 +1,24 @@ +package xin.bbtt.mcbot.plugin; + +public class DummyLibPlugin implements Plugin { + + @Override + public void onLoad() { + DummyMetaPlugin.events.add("lib-load"); + } + + @Override + public void onEnable() { + DummyMetaPlugin.events.add("lib-enable"); + } + + @Override + public void onDisable() { + DummyMetaPlugin.events.add("lib-disable"); + } + + @Override + public void onUnload() { + DummyMetaPlugin.events.add("lib-unload"); + } +} diff --git a/src/test/java/xin/bbtt/mcbot/plugin/DummyMetaPlugin.java b/src/test/java/xin/bbtt/mcbot/plugin/DummyMetaPlugin.java new file mode 100644 index 0000000..94cd31b --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/plugin/DummyMetaPlugin.java @@ -0,0 +1,48 @@ +package xin.bbtt.mcbot.plugin; + +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.ClientboundLoginPacket; +import xin.bbtt.mcbot.Server; + +import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.List; + +public class DummyMetaPlugin implements MetaPlugin { + + // Shared lifecycle event log so tests can assert ordering across plugins. + public static final List events = new ArrayList<>(); + + @Override + public void onLoad() { + events.add("meta-load"); + } + + @Override + public void onEnable() { + events.add("meta-enable"); + } + + @Override + public void onDisable() { + events.add("meta-disable"); + } + + @Override + public void onUnload() { + events.add("meta-unload"); + } + + @Override + public SocketAddress getServerSocketAddress() { + return null; + } + + @Override + public Server getServer(ClientboundLoginPacket loginPacket) { + return null; + } + + public static void reset() { + events.clear(); + } +} diff --git a/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java b/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java new file mode 100644 index 0000000..11a4e1f --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java @@ -0,0 +1,136 @@ +package xin.bbtt.mcbot.plugin; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import xin.bbtt.mcbot.Bot; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests that a MetaPlugin can depend on regular plugins, and that those + * dependencies inherit the MetaPlugin's lifecycle restriction: they cannot be + * unloaded during runtime, because that would tear down the MetaPlugin's + * classloader chain. + */ +class MetaPluginDependencyTest { + + private PluginManager pluginManager; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() throws Exception { + DummyMetaPlugin.reset(); + pluginManager = new PluginManager(); + setBotField("session", null); + setBotField("running", false); + } + + @AfterEach + void tearDown() throws Exception { + setBotField("running", false); + } + + private void setBotField(String name, Object value) throws Exception { + Field field = Bot.class.getDeclaredField(name); + field.setAccessible(true); + field.set(Bot.INSTANCE, value); + } + + private void createPluginJar(String fileName, String yml, Class mainClass) throws Exception { + File file = tempDir.resolve(fileName).toFile(); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(file))) { + jos.putNextEntry(new JarEntry("plugin.yml")); + jos.write(yml.getBytes()); + jos.closeEntry(); + + String className = mainClass.getName().replace('.', '/') + ".class"; + jos.putNextEntry(new JarEntry(className)); + try (InputStream is = mainClass.getClassLoader().getResourceAsStream(className)) { + assertThat(is).isNotNull(); + byte[] buffer = new byte[1024]; + int len; + while ((len = is.read(buffer)) > 0) { + jos.write(buffer, 0, len); + } + } + jos.closeEntry(); + } + } + + private void loadMetaWithDependency() throws Exception { + createPluginJar("lib-plugin.jar", + "name: LibPlugin\nmain: xin.bbtt.mcbot.plugin.DummyLibPlugin\nversion: 1.0.0\n", + DummyLibPlugin.class); + createPluginJar("meta-plugin.jar", + "name: TestMeta\nmain: xin.bbtt.mcbot.plugin.DummyMetaPlugin\nversion: 1.0.0\n" + + "type: META_PLUGIN\ndepend: [LibPlugin]\n", + DummyMetaPlugin.class); + pluginManager.loadPlugins(tempDir.toString()); + } + + @Test + void metaPluginCanDependOnAnotherPlugin() throws Exception { + loadMetaWithDependency(); + + RegisteredPlugin meta = pluginManager.getPlugin("TestMeta"); + assertThat(meta).isInstanceOf(RegisteredMetaPlugin.class); + assertThat(pluginManager.getPlugin("LibPlugin")).isNotNull(); + assertThat(pluginManager.getPluginDependencies().get("TestMeta")).contains("LibPlugin"); + + // The dependency must be loaded first and be part of the meta plugin's + // classloader chain. + assertThat(DummyMetaPlugin.events.indexOf("lib-load")) + .isLessThan(DummyMetaPlugin.events.indexOf("meta-load")); + assertThat(pluginManager.getPluginLoader("TestMeta").getParent()) + .isSameAs(pluginManager.getPluginLoader("LibPlugin")); + } + + @Test + void enableAllEnablesMetaDependenciesFirst() throws Exception { + loadMetaWithDependency(); + + pluginManager.enableAll(); + + assertThat(pluginManager.isPluginEnabled("LibPlugin")).isTrue(); + assertThat(pluginManager.isPluginEnabled("TestMeta")).isTrue(); + assertThat(DummyMetaPlugin.events.indexOf("lib-enable")) + .isLessThan(DummyMetaPlugin.events.indexOf("meta-enable")); + } + + @Test + void metaPluginDependencyCannotBeUnloadedDuringRuntime() throws Exception { + loadMetaWithDependency(); + setBotField("running", true); + + pluginManager.unloadPlugin(pluginManager.getPlugin("LibPlugin")); + + assertThat(pluginManager.isPluginLoaded("LibPlugin")).isTrue(); + assertThat(pluginManager.isPluginLoaded("TestMeta")).isTrue(); + assertThat(DummyMetaPlugin.events).doesNotContain("lib-unload", "meta-unload"); + } + + @Test + void metaPluginDependencyUnloadsWhenNotRunning() throws Exception { + loadMetaWithDependency(); + + pluginManager.unloadPlugin(pluginManager.getPlugin("LibPlugin")); + + // The meta plugin depends on the lib, so it is unloaded first as a dependent. + assertThat(pluginManager.isPluginLoaded("LibPlugin")).isFalse(); + assertThat(pluginManager.isPluginLoaded("TestMeta")).isFalse(); + assertThat(DummyMetaPlugin.events.indexOf("meta-unload")) + .isLessThan(DummyMetaPlugin.events.indexOf("lib-unload")); + } +} From 02b06923343f2dc1ed9a65e85b520bafcfb6b70f Mon Sep 17 00:00:00 2001 From: huangdihd Date: Thu, 11 Jun 2026 20:11:29 +0800 Subject: [PATCH 4/6] fix(plugin): reject runtime load when a hard dependency is missing The startup batch loader excludes plugins with missing hard dependencies via the topological sort, but the runtime path (pm load/reload) silently loaded them with the core classloader as parent, leaving a broken classloader chain. Refuse the load with the same dependency.missing message instead. Co-Authored-By: Claude Fable 5 --- .../xin/bbtt/mcbot/plugin/PluginManager.java | 9 +++++++++ .../plugin/PluginRuntimeSoftDependTest.java | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java b/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java index 8504a3c..d9ab090 100644 --- a/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java +++ b/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java @@ -138,6 +138,15 @@ public void loadPlugin(File pluginFile) throws Exception { if (plugins.containsKey(info.name)) return; + // The startup batch loader refuses plugins with missing hard dependencies + // (topological sort excludes them); the runtime path must do the same instead + // of silently loading with a broken classloader chain. + for (String dep : info.depends) { + if (!plugins.containsKey(dep)) { + throw new IllegalArgumentException(LangManager.get("xinbot.plugin.dependency.missing", dep, info.name)); + } + } + info.file = pluginFile; info.url = url; // Delegate to the shared loader so runtime loads (e.g. `pm load`/`pm reload`) diff --git a/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java b/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java index 08fb9ba..9c65bd4 100644 --- a/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java +++ b/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java @@ -14,6 +14,7 @@ import java.util.jar.JarOutputStream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Regression test for runtime plugin loading (e.g. the {@code pm load} / {@code pm reload} @@ -105,4 +106,20 @@ void runtimeLoadIgnoresAbsentSoftDependency() throws Exception { assertThat(pluginManager.getPlugin("AppPlugin")).isNotNull(); assertThat(pluginManager.getPluginDependencies().get("AppPlugin")).doesNotContain("MissingPlugin"); } + + @Test + void runtimeLoadRejectsMissingHardDependency() throws Exception { + File appJar = createPluginJar("app-plugin.jar", + "name: AppPlugin\nmain: xin.bbtt.mcbot.plugin.DummyPlugin\nversion: 1.0.0\n" + + "depend: [MissingPlugin]\n"); + + // The startup batch loader refuses plugins with missing hard dependencies, + // so the runtime path must refuse them too instead of silently loading + // without the dependency wired in. + assertThatThrownBy(() -> pluginManager.loadPlugin(appJar)) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(pluginManager.getPlugin("AppPlugin")).isNull(); + assertThat(pluginManager.getPluginLoader("AppPlugin")).isNull(); + } } From 6e695a4e92706f64bc045eb26eb8dfcb099d35de Mon Sep 17 00:00:00 2001 From: huangdihd Date: Thu, 11 Jun 2026 20:17:13 +0800 Subject: [PATCH 5/6] test: confine Bot state reflection to a BotTestState helper Codacy (PMD AvoidAccessibilityAlteration) flags setAccessible() calls scattered in test classes. Move the reflective access to Bot's private running/session fields into a single suppressed helper. Co-Authored-By: Claude Fable 5 --- .../java/xin/bbtt/mcbot/BotTestState.java | 47 +++++++++++++++++++ .../plugin/MetaPluginDependencyTest.java | 17 ++----- .../plugin/PluginRuntimeSoftDependTest.java | 11 ++--- 3 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 src/test/java/xin/bbtt/mcbot/BotTestState.java diff --git a/src/test/java/xin/bbtt/mcbot/BotTestState.java b/src/test/java/xin/bbtt/mcbot/BotTestState.java new file mode 100644 index 0000000..d484e6a --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/BotTestState.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024-2026 huangdihd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package xin.bbtt.mcbot; + +import java.lang.reflect.Field; + +/** + * Test-only access to {@link Bot}'s private state ({@code running}, {@code session}), + * so plugin tests can simulate startup/runtime conditions on the Bot singleton. + * Production code intentionally exposes no setters for these fields, so the + * reflection is confined to this single helper. + */ +public final class BotTestState { + + private BotTestState() { + } + + public static void setRunning(boolean running) throws ReflectiveOperationException { + set("running", running); + } + + public static void clearSession() throws ReflectiveOperationException { + set("session", null); + } + + @SuppressWarnings("PMD.AvoidAccessibilityAlteration") + private static void set(String fieldName, Object value) throws ReflectiveOperationException { + Field field = Bot.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(Bot.INSTANCE, value); + } +} diff --git a/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java b/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java index 11a4e1f..b6ed652 100644 --- a/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java +++ b/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java @@ -4,12 +4,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import xin.bbtt.mcbot.Bot; +import xin.bbtt.mcbot.BotTestState; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; -import java.lang.reflect.Field; import java.nio.file.Path; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; @@ -33,19 +32,13 @@ class MetaPluginDependencyTest { void setUp() throws Exception { DummyMetaPlugin.reset(); pluginManager = new PluginManager(); - setBotField("session", null); - setBotField("running", false); + BotTestState.clearSession(); + BotTestState.setRunning(false); } @AfterEach void tearDown() throws Exception { - setBotField("running", false); - } - - private void setBotField(String name, Object value) throws Exception { - Field field = Bot.class.getDeclaredField(name); - field.setAccessible(true); - field.set(Bot.INSTANCE, value); + BotTestState.setRunning(false); } private void createPluginJar(String fileName, String yml, Class mainClass) throws Exception { @@ -112,7 +105,7 @@ void enableAllEnablesMetaDependenciesFirst() throws Exception { @Test void metaPluginDependencyCannotBeUnloadedDuringRuntime() throws Exception { loadMetaWithDependency(); - setBotField("running", true); + BotTestState.setRunning(true); pluginManager.unloadPlugin(pluginManager.getPlugin("LibPlugin")); diff --git a/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java b/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java index 9c65bd4..c79add0 100644 --- a/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java +++ b/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java @@ -3,12 +3,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import xin.bbtt.mcbot.Bot; +import xin.bbtt.mcbot.BotTestState; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; -import java.lang.reflect.Field; import java.nio.file.Path; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; @@ -39,12 +38,8 @@ void setUp() throws Exception { // Ensure no session/running state leaks in from other tests so loading does not // try to auto-enable the plugins (we only care about the classloader wiring here). - Field sessionField = Bot.class.getDeclaredField("session"); - sessionField.setAccessible(true); - sessionField.set(Bot.INSTANCE, null); - Field runningField = Bot.class.getDeclaredField("running"); - runningField.setAccessible(true); - runningField.set(Bot.INSTANCE, false); + BotTestState.clearSession(); + BotTestState.setRunning(false); } private File createPluginJar(String fileName, String yml) throws Exception { From 1c6dac2c83322e98501e2b831f9b5b8a6cf31e76 Mon Sep 17 00:00:00 2001 From: huangdihd Date: Thu, 11 Jun 2026 20:19:38 +0800 Subject: [PATCH 6/6] ci: add missing test classes to the parallel test matrix LoginFlowTest, LoginFlowRealWorldTest, ModpackTest and the two new plugin-dependency test classes were not in the hand-maintained matrix, so CI never ran them. Co-Authored-By: Claude Fable 5 --- .github/workflows/maven-test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/maven-test.yml b/.github/workflows/maven-test.yml index de03503..b6c86de 100644 --- a/.github/workflows/maven-test.yml +++ b/.github/workflows/maven-test.yml @@ -29,7 +29,12 @@ jobs: "xin.bbtt.mcbot.listeners.CommandsRecorderTest", "xin.bbtt.mcbot.plugin.PluginClassloaderLeakTest", "xin.bbtt.mcbot.config.BotConfigDataTest", - "xin.bbtt.mcbot.plugin.PluginYmlValidationTest" + "xin.bbtt.mcbot.plugin.PluginYmlValidationTest", + "xin.bbtt.mcbot.LoginFlow.LoginFlowTest", + "xin.bbtt.mcbot.LoginFlow.LoginFlowRealWorldTest", + "xin.bbtt.mcbot.modpack.ModpackTest", + "xin.bbtt.mcbot.plugin.MetaPluginDependencyTest", + "xin.bbtt.mcbot.plugin.PluginRuntimeSoftDependTest" ] steps: