diff --git a/src/main/java/com/legacyminecraft/poseidon/PluginLoadPlanner.java b/src/main/java/com/legacyminecraft/poseidon/PluginLoadPlanner.java new file mode 100644 index 000000000..5bf900f18 --- /dev/null +++ b/src/main/java/com/legacyminecraft/poseidon/PluginLoadPlanner.java @@ -0,0 +1,285 @@ +package com.legacyminecraft.poseidon; + +import org.bukkit.Server; +import org.bukkit.plugin.InvalidDescriptionException; +import org.bukkit.plugin.InvalidPluginException; +import org.bukkit.plugin.PluginDescriptionFile; +import org.yaml.snakeyaml.error.YAMLException; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.*; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.logging.Level; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class PluginLoadPlanner { + private final Server server; + private final Set fileFilters; + private final File updateDirectory; + + public PluginLoadPlanner(Server server, Set fileFilters, File updateDirectory) { + this.server = server; + this.fileFilters = fileFilters; + this.updateDirectory = updateDirectory; + } + + // Generate a load order for plugins in a given directory based on dependencies. + public List plan(File directory, File[] files) { + if (files == null || files.length == 0) { + return Collections.emptyList(); + } + + // Normalize filesystem enumeration so plugin order is not platform-dependent. + // Issue identified by RobertWesner + Arrays.sort(files, (left, right) -> left.getName().compareToIgnoreCase(right.getName())); + + // Index plugin metadata up front so dependency decisions can be made before any plugin code runs. + LinkedHashMap candidates = new LinkedHashMap<>(); + + // Loop through files in the directory and parse plugin descriptions, skipping duplicates and invalid plugins. + //TODO: We should figure out how we want to handle dupe plugins in Poseidon in the future. No reason exists for them and they just cause hard to trobleshoot issues + for (File file : files) { + PluginCandidate candidate = createCandidate(file); + if (candidate == null) { + continue; + } + + PluginCandidate existing = candidates.get(candidate.name); + if (existing != null) { + server.getLogger().log(Level.SEVERE, "Could not load '" + file.getPath() + "' in folder '" + directory.getPath() + "': duplicate plugin name '" + candidate.name + "' also found in '" + existing.file.getPath() + "'"); + continue; + } + + candidates.put(candidate.name, candidate); + } + + List plan = new ArrayList<>(); + + + // Create a deterministic load order + Set loadedNames = new LinkedHashSet<>(); + LinkedHashSet remaining = new LinkedHashSet<>(candidates.values()); + + // Iterate until no plugins remain or no progress can be made due to missing or circular dependencies. + while (!remaining.isEmpty()) { + List ready = new ArrayList<>(); + + // First try to satisfy both hard dependencies + for (PluginCandidate candidate : remaining) { + if (hasMissingHardDependencies(candidate, candidates)) { + continue; + } + + // Prefer loading after both hard and present soft dependencies when the graph allows it. + if (dependenciesLoaded(candidate.getHardDependencies(), loadedNames) + && dependenciesLoaded(candidate.getPresentSoftDependencies(candidates), loadedNames)) { + ready.add(candidate); + } + } + + // If no plugin can satisfy every soft dependency, fall back to hard-dependency order. + boolean relaxedSoftDependencies = false; + if (ready.isEmpty()) { + for (PluginCandidate candidate : remaining) { + if (hasMissingHardDependencies(candidate, candidates)) { + continue; + } + + // This preserves startup progress when soft dependencies form cycles or long chains. + if (dependenciesLoaded(candidate.getHardDependencies(), loadedNames)) { + ready.add(candidate); + } + } + relaxedSoftDependencies = !ready.isEmpty(); + } + + if (ready.isEmpty()) { + // Anything left here either references a missing hard dependency or is part of a cycle. + break; + } + + // Sort deterministically to ensure a stable load order + Collections.sort(ready, (left, right) -> { + int loadOrder = left.description.getLoad().compareTo(right.description.getLoad()); + if (loadOrder != 0) { + return loadOrder; + } + + int nameOrder = left.name.compareToIgnoreCase(right.name); + if (nameOrder != 0) { + return nameOrder; + } + + return left.file.getName().compareToIgnoreCase(right.file.getName()); + }); + + for (PluginCandidate candidate : ready) { + // Tell the legacy loader to ignore soft dependencies only when the planner already relaxed them. + boolean ignoreSoftDependencies = relaxedSoftDependencies || candidate.hasMissingSoftDependencies(candidates); + plan.add(new PlannedPlugin(candidate.file, candidate.name, ignoreSoftDependencies)); + loadedNames.add(candidate.name); + remaining.remove(candidate); + } + } + + // Print errors + for (PluginCandidate candidate : remaining) { + // If the candidate has missing hard dependencies, report them. Otherwise, report a circular or unresolved dependency chain. + if (hasMissingHardDependencies(candidate, candidates)) { + for (String dependency : candidate.getMissingHardDependencies(candidates)) { + server.getLogger().log(Level.SEVERE, "Could not load '" + candidate.file.getPath() + "' in folder '" + directory.getPath() + "': Unknown dependency " + dependency); + } + } else { + server.getLogger().log(Level.SEVERE, "Could not load '" + candidate.file.getPath() + "' in folder '" + directory.getPath() + "': circular or unresolved dependency chain"); + } + } + + return plan; + } + + private PluginCandidate createCandidate(File file) { + PluginDescriptionFile description; + + try { + description = getPluginDescription(file); + } catch (InvalidPluginException | InvalidDescriptionException ex) { + server.getLogger().log(Level.SEVERE, "Could not load '" + file.getPath() + "'.", ex); + return null; + } + + if (description == null) { + // Non-plugin files in the directory are ignored by the registered file filters. + return null; + } + + return new PluginCandidate(file, description); + } + + private PluginDescriptionFile getPluginDescription(File file) throws InvalidPluginException, InvalidDescriptionException { + // Read plugin.yml first so ordering can be computed without instantiating plugin classes. + File descriptionSource = getEffectivePluginFile(file); // If plugin has an update, read description from the update file instead as it might have new dependencies. + + for (Pattern filter : fileFilters) { + Matcher match = filter.matcher(descriptionSource.getName()); + if (!match.find()) { + continue; + } + + try (JarFile jar = new JarFile(descriptionSource)) { + JarEntry entry = jar.getJarEntry("plugin.yml"); + + if (entry == null) { + throw new InvalidPluginException(new IOException("Jar does not contain plugin.yml")); + } + + try (InputStream stream = jar.getInputStream(entry)) { + return new PluginDescriptionFile(stream); + } + } catch (IOException ex) { + throw new InvalidPluginException(ex); + } catch (YAMLException ex) { + throw new InvalidPluginException(ex); + } + } + + return null; + } + + private File getEffectivePluginFile(File file) { + if (updateDirectory == null || !updateDirectory.isDirectory()) { + return file; + } + + File updateFile = new File(updateDirectory, file.getName()); + if (updateFile.isFile()) { + // Return the update file instead for processing + return updateFile; + } + + return file; + } + + private boolean hasMissingHardDependencies(PluginCandidate candidate, Map candidates) { + return !candidate.getMissingHardDependencies(candidates).isEmpty(); + } + + private boolean dependenciesLoaded(Collection dependencies, Set loadedNames) { + for (String dependency : dependencies) { + if (!loadedNames.contains(dependency)) { + return false; + } + } + + return true; + } + + public static final class PlannedPlugin { + public final File file; + public final String name; + public final boolean ignoreSoftDependencies; + + PlannedPlugin(File file, String name, boolean ignoreSoftDependencies) { + this.file = file; + this.name = name; + this.ignoreSoftDependencies = ignoreSoftDependencies; + } + } + + private static final class PluginCandidate { + private final File file; + private final PluginDescriptionFile description; + private final String name; + private final List hardDependencies; + private final List softDependencies; + + private PluginCandidate(File file, PluginDescriptionFile description) { + this.file = file; + this.description = description; + this.name = description.getName(); + this.hardDependencies = copyDependencies(description.getDepend()); + this.softDependencies = copyDependencies(description.getSoftDepend()); + } + + private List getHardDependencies() { + return hardDependencies; + } + + private List getMissingHardDependencies(Map candidates) { + List missing = new ArrayList<>(); + for (String dependency : hardDependencies) { + if (!candidates.containsKey(dependency)) { + missing.add(dependency); + } + } + return missing; + } + + private List getPresentSoftDependencies(Map candidates) { + List present = new ArrayList<>(); + for (String dependency : softDependencies) { + if (candidates.containsKey(dependency)) { + present.add(dependency); + } + } + return present; + } + + private boolean hasMissingSoftDependencies(Map candidates) { + // Missing soft dependencies should not block load, but present ones still influence ordering. + return getPresentSoftDependencies(candidates).size() != softDependencies.size(); + } + + @SuppressWarnings("unchecked") + private static List copyDependencies(Object dependencies) { + if (dependencies == null) { + return Collections.emptyList(); + } + + return new ArrayList<>((Collection) dependencies); + } + } +} diff --git a/src/main/java/org/bukkit/craftbukkit/CraftServer.java b/src/main/java/org/bukkit/craftbukkit/CraftServer.java index 387cc692c..cd1e1bde8 100644 --- a/src/main/java/org/bukkit/craftbukkit/CraftServer.java +++ b/src/main/java/org/bukkit/craftbukkit/CraftServer.java @@ -146,9 +146,13 @@ public void loadPlugins() { public void enablePlugins(PluginLoadOrder type) { Plugin[] plugins = pluginManager.getPlugins(); + // Enable startup plugins first, then postworld plugins later. + // If a plugin depends on another plugin from the current phase, enable that dependency first. + // Dependencies from a later phase stay disabled until that phase runs. for (Plugin plugin : plugins) { - if ((!plugin.isEnabled()) && (plugin.getDescription().getLoad() == type)) { - loadPlugin(plugin); + // Re-evaluate every disabled plugin on each phase so deferred dependencies can come alive later. + if (!plugin.isEnabled() && shouldAttemptEnable(plugin, type)) { + enablePlugin(plugin, type, new LinkedHashSet()); } } @@ -163,6 +167,72 @@ public void disablePlugins() { pluginManager.disablePlugins(); } + private boolean shouldAttemptEnable(Plugin plugin, PluginLoadOrder type) { + // Startup plugins are eligible during both passes. Postworld plugins only become eligible later. + //TODO Reevaluate if this should be allowed + return plugin.getDescription().getLoad().ordinal() <= type.ordinal(); + } + + private boolean enablePlugin(Plugin plugin, PluginLoadOrder type, Set enabling) { + if (plugin.isEnabled()) { + return true; + } + + if (!shouldAttemptEnable(plugin, type)) { + return false; + } + + String pluginName = plugin.getDescription().getName(); + // Guard against recursive dependency loops during the current enable chain. + if (!enabling.add(pluginName)) { + getLogger().log(Level.SEVERE, "Circular plugin dependency detected while enabling " + plugin.getDescription().getFullName()); + return false; + } + + try { + Object dependObject = plugin.getDescription().getDepend(); + if (dependObject instanceof Collection) { + for (Object dependencyNameObject : (Collection) dependObject) { + String dependencyName = String.valueOf(dependencyNameObject); + Plugin dependency = pluginManager.getPlugin(dependencyName); + + if (dependency == null) { + getLogger().log(Level.SEVERE, "Could not enable " + plugin.getDescription().getFullName() + ": missing required dependency " + dependencyName); + return false; + } + + // A dependency scheduled for a later phase will be retried when that phase runs. + if (!shouldAttemptEnable(dependency, type)) { + return false; + } + + // Hard dependencies must be fully enabled before this plugin can start. + if (!enablePlugin(dependency, type, enabling)) { + return false; + } + } + } + + Object softDependObject = plugin.getDescription().getSoftDepend(); + if (softDependObject instanceof Collection) { + for (Object dependencyNameObject : (Collection) softDependObject) { + String dependencyName = String.valueOf(dependencyNameObject); + Plugin dependency = pluginManager.getPlugin(dependencyName); + // Soft dependencies are enabled first when possible, but do not block startup. + if (dependency != null && !dependency.isEnabled() && shouldAttemptEnable(dependency, type)) { + enablePlugin(dependency, type, enabling); + } + } + } + + // The actual enable call stays in one place so permission registration behavior is unchanged. + loadPlugin(plugin); + return plugin.isEnabled(); + } finally { + enabling.remove(pluginName); + } + } + private void loadPlugin(Plugin plugin) { try { pluginManager.enablePlugin(plugin); diff --git a/src/main/java/org/bukkit/plugin/SimplePluginManager.java b/src/main/java/org/bukkit/plugin/SimplePluginManager.java index e4627fcba..990c37492 100644 --- a/src/main/java/org/bukkit/plugin/SimplePluginManager.java +++ b/src/main/java/org/bukkit/plugin/SimplePluginManager.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.MapMaker; +import com.legacyminecraft.poseidon.PluginLoadPlanner; import com.legacyminecraft.poseidon.Poseidon; import com.legacyminecraft.poseidon.event.PoseidonCustomListener; import com.legacyminecraft.poseidon.utility.PerformanceStatistic; @@ -39,6 +40,7 @@ public final class SimplePluginManager implements PluginManager { private final Map> defaultPerms = new LinkedHashMap>(); private final Map> permSubs = new HashMap>(); private final Map> defSubs = new HashMap>(); + private final List enableOrder = new ArrayList<>(); private final Comparator comparer = new Comparator() { public int compare(RegisteredListener i, RegisteredListener j) { int result = i.getPriority().compareTo(j.getPriority()); @@ -147,51 +149,20 @@ public Plugin[] loadPlugins(File directory) { List result = new ArrayList(); File[] files = directory.listFiles(); - boolean allFailed = false; - boolean finalPass = false; - - LinkedList filesList = new LinkedList(Arrays.asList(files)); - if (!(server.getUpdateFolder().equals(""))) { updateDirectory = new File(directory, server.getUpdateFolder()); } - while (!allFailed || finalPass) { - allFailed = true; - Iterator itr = filesList.iterator(); - - while (itr.hasNext()) { - File file = itr.next(); - Plugin plugin = null; - - try { - plugin = loadPlugin(file, finalPass); - itr.remove(); - } catch (UnknownDependencyException ex) { - if (finalPass) { - server.getLogger().log(Level.SEVERE, "Could not load '" + file.getPath() + "' in folder '" + directory.getPath() + "': " + ex.getMessage(), ex); - itr.remove(); - } else { - plugin = null; - } - } catch (InvalidPluginException ex) { - server.getLogger().log(Level.SEVERE, "Could not load '" + file.getPath() + "' in folder '" + directory.getPath() + "': ", ex.getCause()); - itr.remove(); - } catch (InvalidDescriptionException ex) { - server.getLogger().log(Level.SEVERE, "Could not load '" + file.getPath() + "' in folder '" + directory.getPath() + "': " + ex.getMessage(), ex); - itr.remove(); - } + PluginLoadPlanner planner = new PluginLoadPlanner(server, fileAssociations.keySet(), updateDirectory); + for (PluginLoadPlanner.PlannedPlugin plannedPlugin : planner.plan(directory, files)) { + try { + Plugin plugin = loadPlugin(plannedPlugin.file, plannedPlugin.ignoreSoftDependencies); if (plugin != null) { result.add(plugin); - allFailed = false; - finalPass = false; } - } - if (finalPass) { - break; - } else if (allFailed) { - finalPass = true; + } catch (UnknownDependencyException | InvalidPluginException | InvalidDescriptionException ex) { + server.getLogger().log(Level.SEVERE, "Could not load '" + plannedPlugin.file.getPath() + "' in folder '" + directory.getPath() + "'.", ex); } } @@ -318,6 +289,10 @@ public void enablePlugin(final Plugin plugin) { try { plugin.getPluginLoader().enablePlugin(plugin); + // Record successful enables so shutdown can run in strict reverse dependency order. + if (plugin.isEnabled()) { + enableOrder.add(plugin); + } } catch (Throwable ex) { server.getLogger().log(Level.SEVERE, "Error occurred (in the plugin loader) while enabling " + plugin.getDescription().getFullName() + " (Is it up to date?): " + ex.getMessage(), ex); } @@ -325,8 +300,18 @@ public void enablePlugin(final Plugin plugin) { } public void disablePlugins() { + // Tear down plugins in reverse successful enable order so dependents stop before their dependencies. + List enabledPlugins = new ArrayList<>(enableOrder); // Create a snapshot as disablePlugin is responsible for removing plugins + ListIterator iterator = enabledPlugins.listIterator(enabledPlugins.size()); + while (iterator.hasPrevious()) { + disablePlugin(iterator.previous()); + } + + // Fall back to any enabled plugin that was not recorded, so shutdown remains complete. This should only be needed if people are using stuff like PlugMan for (Plugin plugin : getPlugins()) { - disablePlugin(plugin); + if (plugin.isEnabled()) { + disablePlugin(plugin); + } } } @@ -349,6 +334,8 @@ public void disablePlugin(final Plugin plugin) { } catch (Throwable ex) { server.getLogger().log(Level.SEVERE, "Error occurred (in the plugin loader) while unregistering services for " + plugin.getDescription().getFullName() + " (Is it up to date?): " + ex.getMessage(), ex); } + + enableOrder.remove(plugin); } } @@ -359,6 +346,7 @@ public void clearPlugins() { lookupNames.clear(); listeners.clear(); fileAssociations.clear(); + enableOrder.clear(); permissions.clear(); defaultPerms.get(true).clear(); defaultPerms.get(false).clear();