Remove dynamic extension unload/reload (dynamic load from file still exists)

This commit is contained in:
Matt Worzala 2021-11-16 11:21:09 -05:00 committed by TheMode
parent 0c6e4c0129
commit 9f5122019f
7 changed files with 84 additions and 463 deletions

View File

@ -665,14 +665,13 @@ public final class MinecraftServer {
if (stopping) return;
stopping = true;
LOGGER.info("Stopping Minestom server.");
extensionManager.unloadAllExtensions();
LOGGER.info("Unloading all extensions.");
extensionManager.shutdown();
updateManager.stop();
schedulerManager.shutdown();
connectionManager.shutdown();
server.stop();
storageManager.getLoadedLocations().forEach(StorageLocation::close);
LOGGER.info("Unloading all extensions.");
extensionManager.shutdown();
LOGGER.info("Shutting down all thread pools.");
benchmarkManager.disable();
MinestomTerminal.stop();

View File

@ -71,13 +71,6 @@ public abstract class Extension {
}
/**
* Called after postTerminate when reloading an extension
*/
public void unload() {
}
@NotNull
public DiscoveredExtension getOrigin() {
return origin;

View File

@ -72,6 +72,37 @@ public class ExtensionManager {
this.loadOnStartup = loadOnStartup;
}
@NotNull
public File getExtensionFolder() {
return extensionFolder;
}
public @NotNull Path getExtensionDataRoot() {
return extensionDataRoot;
}
public void setExtensionDataRoot(@NotNull Path dataRoot) {
this.extensionDataRoot = dataRoot;
}
@NotNull
public Collection<Extension> getExtensions() {
return immutableExtensions.values();
}
@Nullable
public Extension getExtension(@NotNull String name) {
return extensions.get(name.toLowerCase());
}
public boolean hasExtension(@NotNull String name) {
return extensions.containsKey(name);
}
//
// Loading
//
/**
* Loads all extensions in the extension folder into this server.
* <br><br>
@ -187,6 +218,17 @@ public class ExtensionManager {
}
}
public boolean loadDynamicExtension(@NotNull File jarFile) throws FileNotFoundException {
if (!jarFile.exists()) {
throw new FileNotFoundException("File '" + jarFile.getAbsolutePath() + "' does not exists. Cannot load extension.");
}
LOGGER.info("Discover dynamic extension from jar {}", jarFile.getAbsolutePath());
DiscoveredExtension discoveredExtension = discoverFromJar(jarFile);
List<DiscoveredExtension> extensionsToLoad = Collections.singletonList(discoveredExtension);
return loadExtensionList(extensionsToLoad);
}
/**
* Loads an extension into Minestom.
*
@ -583,135 +625,6 @@ public class ExtensionManager {
}
}
@NotNull
public File getExtensionFolder() {
return extensionFolder;
}
public @NotNull Path getExtensionDataRoot() {
return extensionDataRoot;
}
public void setExtensionDataRoot(@NotNull Path dataRoot) {
this.extensionDataRoot = dataRoot;
}
@NotNull
public Collection<Extension> getExtensions() {
return immutableExtensions.values();
}
@Nullable
public Extension getExtension(@NotNull String name) {
return extensions.get(name.toLowerCase());
}
public boolean hasExtension(@NotNull String name) {
return extensions.containsKey(name);
}
private void unload(@NotNull Extension ext) {
ext.preTerminate();
ext.terminate();
// remove callbacks for this extension
String extensionName = ext.getOrigin().getName();
ext.triggerChange(observer -> observer.onExtensionUnload(extensionName));
// TODO: more callback types
// Remove event node
EventNode<Event> eventNode = ext.getEventNode();
MinecraftServer.getGlobalEventHandler().removeChild(eventNode);
ext.postTerminate();
ext.unload();
// remove as dependent of other extensions
// this avoids issues where a dependent extension fails to reload, and prevents the base extension to reload too
for (Extension e : extensions.values()) {
e.getDependents().remove(ext.getOrigin().getName());
}
String id = ext.getOrigin().getName().toLowerCase();
// remove from loaded extensions
extensions.remove(id);
// remove class loader, required to reload the classes
MinestomExtensionClassLoader classloader = ext.getOrigin().removeMinestomExtensionClassLoader();
try {
// close resources
classloader.close();
} catch (IOException e) {
MinecraftServer.getExceptionManager().handleException(e);
}
//TODO : Remove extension from dependents
// MinestomRootClassLoader.getInstance().removeChildInHierarchy(classloader);
}
public boolean reload(@NotNull String extensionName) {
Extension ext = extensions.get(extensionName.toLowerCase());
if (ext == null) {
throw new IllegalArgumentException("Extension " + extensionName + " is not currently loaded.");
}
File originalJar = ext.getOrigin().getOriginalJar();
if (originalJar == null) {
LOGGER.error("Cannot reload extension {} that is not from a .jar file!", extensionName);
return false;
}
LOGGER.info("Reload extension {} from jar file {}", extensionName, originalJar.getAbsolutePath());
List<String> dependents = new LinkedList<>(ext.getDependents()); // copy dependents list
List<File> originalJarsOfDependents = new LinkedList<>();
for (String dependentID : dependents) {
Extension dependentExt = extensions.get(dependentID.toLowerCase());
File dependentOriginalJar = dependentExt.getOrigin().getOriginalJar();
originalJarsOfDependents.add(dependentOriginalJar);
if (dependentOriginalJar == null) {
LOGGER.error("Cannot reload extension {} that is not from a .jar file!", dependentID);
return false;
}
LOGGER.info("Unloading dependent extension {} (because it depends on {})", dependentID, extensionName);
unload(dependentExt);
}
LOGGER.info("Unloading extension {}", extensionName);
unload(ext);
System.gc();
// ext and its dependents should no longer be referenced from now on
// rediscover extension to reload. We allow dependency changes, so we need to fully reload it
List<DiscoveredExtension> extensionsToReload = new LinkedList<>();
LOGGER.info("Rediscover extension {} from jar {}", extensionName, originalJar.getAbsolutePath());
DiscoveredExtension rediscoveredExtension = discoverFromJar(originalJar);
extensionsToReload.add(rediscoveredExtension);
for (File dependentJar : originalJarsOfDependents) {
// rediscover dependent extension to reload
LOGGER.info("Rediscover dependent extension (depends on {}) from jar {}", extensionName, dependentJar.getAbsolutePath());
extensionsToReload.add(discoverFromJar(dependentJar));
}
// ensure correct order of dependencies
loadExtensionList(extensionsToReload);
return true;
}
public boolean loadDynamicExtension(@NotNull File jarFile) throws FileNotFoundException {
if (!jarFile.exists()) {
throw new FileNotFoundException("File '" + jarFile.getAbsolutePath() + "' does not exists. Cannot load extension.");
}
LOGGER.info("Discover dynamic extension from jar {}", jarFile.getAbsolutePath());
DiscoveredExtension discoveredExtension = discoverFromJar(jarFile);
List<DiscoveredExtension> extensionsToLoad = Collections.singletonList(discoveredExtension);
return loadExtensionList(extensionsToLoad);
}
private boolean loadExtensionList(@NotNull List<DiscoveredExtension> extensionsToLoad) {
// ensure correct order of dependencies
LOGGER.debug("Reorder extensions to ensure proper load order");
@ -747,7 +660,23 @@ public class ExtensionManager {
return true;
}
public void unloadExtension(@NotNull String extensionName) {
//
// Shutdown / Unload
//
/**
* Shutdowns all the extensions by unloading them.
*/
public void shutdown() {// copy names, as the extensions map will be modified via the calls to unload
Set<String> extensionNames = new HashSet<>(extensions.keySet());
for (String ext : extensionNames) {
if (extensions.containsKey(ext)) { // is still loaded? Because extensions can depend on one another, it might have already been unloaded
unloadExtension(ext);
}
}
}
private void unloadExtension(@NotNull String extensionName) {
Extension ext = extensions.get(extensionName.toLowerCase());
if (ext == null) {
@ -764,31 +693,32 @@ public class ExtensionManager {
LOGGER.info("Unloading extension {}", extensionName);
unload(ext);
// call GC to try to get rid of classes and classloader
System.gc();
}
/**
* Shutdowns all the extensions by unloading them.
*/
public void shutdown() {
//todo(mattw) what is different here from the method below?
for (Extension extension : getExtensions()) {
extension.unload();
}
}
private void unload(@NotNull Extension ext) {
ext.preTerminate();
ext.terminate();
// remove callbacks for this extension
String extensionName = ext.getOrigin().getName();
ext.triggerChange(observer -> observer.onExtensionUnload(extensionName));
/**
* Unloads all extensions
*/
public void unloadAllExtensions() {
// copy names, as the extensions map will be modified via the calls to unload
Set<String> extensionNames = new HashSet<>(extensions.keySet());
for (String ext : extensionNames) {
if (extensions.containsKey(ext)) { // is still loaded? Because extensions can depend on one another, it might have already been unloaded
unloadExtension(ext);
}
// Remove event node
EventNode<Event> eventNode = ext.getEventNode();
MinecraftServer.getGlobalEventHandler().removeChild(eventNode);
ext.postTerminate();
// remove from loaded extensions
String id = ext.getOrigin().getName().toLowerCase();
extensions.remove(id);
// cleanup classloader
try {
MinestomExtensionClassLoader classloader = ext.getOrigin().removeMinestomExtensionClassLoader();
classloader.close();
//todo child classloaders, also since there is no reload, is this actually necessary?
} catch (IOException e) {
MinecraftServer.getExceptionManager().handleException(e);
}
}
}

View File

@ -1,79 +0,0 @@
package demo.commands;
import net.kyori.adventure.text.Component;
import net.minestom.server.MinecraftServer;
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.command.builder.CommandContext;
import net.minestom.server.command.builder.arguments.ArgumentString;
import net.minestom.server.command.builder.arguments.ArgumentType;
import net.minestom.server.command.builder.exception.ArgumentSyntaxException;
import net.minestom.server.extensions.Extension;
import net.minestom.server.extensions.ExtensionManager;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
public class ReloadExtensionCommand extends Command {
// the extensions name as an array
private static String[] extensionsName;
static {
ReloadExtensionCommand.extensionsName = MinecraftServer.getExtensionManager().getExtensions()
.stream()
.map(extension -> extension.getOrigin().getName())
.toArray(String[]::new);
}
private final ArgumentString extensionName;
public ReloadExtensionCommand() {
super("reload");
setDefaultExecutor(this::usage);
extensionName = ArgumentType.String("extensionName");
setArgumentCallback(this::gameModeCallback, extensionName);
addSyntax(this::execute, extensionName);
}
private void usage(CommandSender sender, CommandContext context) {
sender.sendMessage(Component.text("Usage: /reload <extension name>"));
}
private void execute(CommandSender sender, CommandContext context) {
final String name = context.get(extensionName);
sender.sendMessage(Component.text("extensionName = " + name + "...."));
ExtensionManager extensionManager = MinecraftServer.getExtensionManager();
Extension ext = extensionManager.getExtension(name);
if (ext != null) {
try {
extensionManager.reload(name);
} catch (Throwable t) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
t.printStackTrace();
t.printStackTrace(new PrintStream(baos));
baos.flush();
baos.close();
String contents = new String(baos.toByteArray(), StandardCharsets.UTF_8);
contents.lines().map(Component::text).forEach(sender::sendMessage);
} catch (IOException e) {
e.printStackTrace();
}
}
} else {
sender.sendMessage(Component.text("Extension '" + name + "' does not exist."));
}
}
private void gameModeCallback(CommandSender sender, ArgumentSyntaxException argumentSyntaxException) {
sender.sendMessage(Component.text("'" + argumentSyntaxException.getInput() + "' is not a valid extension name!"));
}
}

View File

@ -1,71 +0,0 @@
package demo.commands;
import net.kyori.adventure.text.Component;
import net.minestom.server.MinecraftServer;
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.command.builder.CommandContext;
import net.minestom.server.command.builder.arguments.Argument;
import net.minestom.server.command.builder.arguments.ArgumentType;
import net.minestom.server.command.builder.exception.ArgumentSyntaxException;
import net.minestom.server.extensions.Extension;
import net.minestom.server.extensions.ExtensionManager;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
public class UnloadExtensionCommand extends Command {
private final Argument<Extension> extensionName;
public UnloadExtensionCommand() {
super("unload");
setDefaultExecutor(this::usage);
extensionName = ArgumentType.String("extensionName").map((input) -> {
Extension extension = MinecraftServer.getExtensionManager().getExtension(input);
if (extension == null) throw new ArgumentSyntaxException("The specified extension was not found", input, 1);
return extension;
});
setArgumentCallback(this::extensionCallback, extensionName);
addSyntax(this::execute, extensionName);
}
private void usage(CommandSender sender, CommandContext context) {
sender.sendMessage(Component.text("Usage: /unload <extension name>"));
}
private void execute(CommandSender sender, CommandContext context) {
final Extension ext = context.get(extensionName);
sender.sendMessage(Component.text("extensionName = " + ext.getOrigin().getName() + "...."));
ExtensionManager extensionManager = MinecraftServer.getExtensionManager();
try {
extensionManager.unloadExtension(ext.getOrigin().getName());
} catch (Throwable t) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
t.printStackTrace();
t.printStackTrace(new PrintStream(baos));
baos.flush();
baos.close();
String contents = baos.toString(StandardCharsets.UTF_8);
contents.lines().map(Component::text).forEach(sender::sendMessage);
} catch (IOException e) {
e.printStackTrace();
}
}
}
private void extensionCallback(CommandSender sender, ArgumentSyntaxException exception) {
sender.sendMessage(Component.text("'" + exception.getInput() + "' is not a valid extension name!"));
}
}

View File

@ -1,125 +0,0 @@
package improveextensions.unloadcallbacks;
import net.minestom.server.MinecraftServer;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.EntityCreature;
import net.minestom.server.entity.EntityType;
import net.minestom.server.event.GlobalEventHandler;
import net.minestom.server.event.entity.EntityTickEvent;
import net.minestom.server.event.instance.InstanceTickEvent;
import net.minestom.server.extensions.Extension;
import net.minestom.server.extensions.isolation.MinestomExtensionClassLoader;
import net.minestom.server.instance.Instance;
import net.minestom.server.utils.time.TimeUnit;
import org.junit.jupiter.api.Assertions;
import org.opentest4j.AssertionFailedError;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
public class UnloadCallbacksExtension extends Extension {
private boolean ticked1 = false;
private boolean ticked2 = false;
private boolean tickedScheduledNonTransient = false;
private boolean tickedScheduledTransient = false;
private boolean zombieTicked = false;
private boolean instanceTicked = false;
private final Consumer<InstanceTickEvent> callback = this::onTick;
private void onTick(InstanceTickEvent e) {
ticked1 = true;
}
@Override
public void initialize() {
GlobalEventHandler globalEvents = MinecraftServer.getGlobalEventHandler();
// this callback will be automatically removed when unloading the extension
globalEvents.addListener(InstanceTickEvent.class, callback);
// this one too
globalEvents.addListener(InstanceTickEvent.class, e -> ticked2 = true);
Instance instance = MinecraftServer.getInstanceManager().getInstances().stream().findFirst().orElseThrow();
// add an event callback on an instance
globalEvents.addListener(InstanceTickEvent.class, e -> instanceTicked = true);
instance.loadChunk(0, 0);
// add an event callback on an entity
EntityCreature zombie = new EntityCreature(EntityType.ZOMBIE);
globalEvents.addListener(EntityTickEvent.class, entityTickEvent -> {
if (entityTickEvent.getEntity() == zombie) {
zombieTicked = true;
}
});
zombie.setInstance(instance, new Vec(8, 64, 8) /* middle of chunk */);
// this callback will be cancelled
MinecraftServer.getSchedulerManager().buildTask(() -> {
tickedScheduledNonTransient = true;
}).repeat(100L, TimeUnit.MILLISECOND).schedule();
// this callback will NOT be cancelled
MinecraftServer.getSchedulerManager().buildTask(() -> {
tickedScheduledTransient = true;
}).repeat(100L, TimeUnit.MILLISECOND).schedule();
try {
Assertions.assertNotNull(MinestomExtensionClassLoader.findExtensionObjectOwner(callback));
Assertions.assertEquals("UnloadCallbacksExtension", MinestomExtensionClassLoader.findExtensionObjectOwner(callback));
} catch (AssertionFailedError e) {
e.printStackTrace();
System.exit(-1);
}
MinecraftServer.getSchedulerManager().buildTask(() -> {
// unload self
MinecraftServer.getExtensionManager().unloadExtension(getOrigin().getName());
}).delay(1L, TimeUnit.SECOND).schedule();
}
@Override
public void terminate() {
new Thread(() -> {
try {
// wait for complete termination of this extension
Thread.sleep(10);
ticked1 = false;
ticked2 = false;
tickedScheduledNonTransient = false;
tickedScheduledTransient = false;
instanceTicked = false;
zombieTicked = false;
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
AtomicBoolean executedDelayTaskAfterTerminate = new AtomicBoolean(false);
// because terminate is called just before unscheduling and removing event callbacks,
// the following task will never be executed, because it is not transient
MinecraftServer.getSchedulerManager().buildTask(() -> {
executedDelayTaskAfterTerminate.set(true);
}).delay(100L, TimeUnit.MILLISECOND).schedule();
// this shutdown tasks will not be executed because it is not transient
MinecraftServer.getSchedulerManager().buildShutdownTask(() -> Assertions.fail("This shutdown task should be unloaded when the extension is"));
MinecraftServer.getSchedulerManager().buildTask(() -> {
// Make sure callbacks are disabled
try {
Assertions.assertFalse(ticked1, "ticked1 should be false because the callback has been unloaded");
Assertions.assertFalse(ticked2, "ticked2 should be false because the callback has been unloaded");
Assertions.assertFalse(tickedScheduledNonTransient, "tickedScheduledNonTransient should be false because the callback has been unloaded");
Assertions.assertFalse(zombieTicked, "zombieTicked should be false because the callback has been unloaded");
Assertions.assertFalse(instanceTicked, "instanceTicked should be false because the callback has been unloaded");
Assertions.assertTrue(tickedScheduledTransient, "tickedScheduledNonTransient should be true because the callback has NOT been unloaded");
Assertions.assertFalse(executedDelayTaskAfterTerminate.get(), "executedDelayTaskAfterTerminate should be false because the callback has been unloaded before executing");
System.out.println("All tests passed.");
} catch (AssertionFailedError e) {
e.printStackTrace();
}
MinecraftServer.stopCleanly(); // TODO: fix deadlock which happens because stopCleanly waits on completion of scheduler tasks
}).delay(1L, TimeUnit.SECOND).schedule();
}
}

View File

@ -1,26 +0,0 @@
package improveextensions.unloadextensiononstop;
import net.minestom.server.MinecraftServer;
import net.minestom.server.extensions.Extension;
import net.minestom.server.utils.time.TimeUnit;
import org.junit.jupiter.api.Assertions;
public class UnloadExtensionOnStop extends Extension {
private boolean terminated = false;
@Override
public void initialize() {
MinecraftServer.getSchedulerManager().buildShutdownTask(() -> {
Assertions.assertTrue(terminated, "Extension should have been terminated on shutdown.");
System.out.println("All tests passed.");
});
MinecraftServer.getSchedulerManager().buildTask(MinecraftServer::stopCleanly).delay(1L, TimeUnit.SECOND).schedule();
}
@Override
public void terminate() {
terminated = true;
}
}