diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index a9a7d5bfb..8960222b8 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -39,6 +39,7 @@ import art.arcane.iris.core.SettingsHotloadWatch; import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.datapack.DatapackIngestService.StartupValidationOutcome; +import art.arcane.iris.core.lifecycle.IrisRuntimeStatics; import art.arcane.iris.core.lifecycle.ManagedWorldLoader; import art.arcane.iris.core.lifecycle.MissingWorldStorageLog; import art.arcane.iris.core.lifecycle.PaperLibBootstrap; @@ -216,16 +217,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private volatile ServerShutdownBoundary serverShutdownBoundary; public static VolmitSender getSender() { - if (sender == null) { - sender = new VolmitSender(Bukkit.getConsoleSender()); - sender.setTag(instance.getTag()); + VolmitSender current = sender; + if (current == null) { + Iris plugin = instance; + current = new VolmitSender(Bukkit.getConsoleSender()); + current.setTag(plugin == null ? IrisSafeguard.mode().tag("") : plugin.getTag()); + sender = current; } - return sender; + return current; } @SuppressWarnings("unchecked") public static T service(Class c) { - return (T) instance.services.get(c); + Iris plugin = instance; + if (plugin == null || plugin.services == null) { + throw new IllegalStateException("Iris is disabled; " + c.getSimpleName() + " is unavailable"); + } + return (T) plugin.services.get(c); } public static void callEvent(Event e) { @@ -264,7 +272,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public static File getTemp() { - return instance.getDataFolder("cache", "temp"); + Iris plugin = instance; + if (plugin == null) { + throw new IllegalStateException("Iris is disabled; the temp folder is unavailable"); + } + return plugin.getDataFolder("cache", "temp"); } public static void msg(String string) { @@ -543,11 +555,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { Iris::msg, Iris::reportError, (event) -> Iris.callEvent((org.bukkit.event.Event) event), - () -> Iris.instance.getDataFolder(), - (path) -> Iris.instance.getDataFile(path), - () -> Iris.instance.getJarFile(), - () -> Iris.instance.getIrisVersion(), - () -> Iris.instance.getMCVersion())); + this::getDataFolder, + this::getDataFile, + this::getJarFile, + this::getIrisVersion, + this::getMCVersion)); SlimJar.load(); } @@ -1055,10 +1067,24 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { Iris.reportError("Failed to close Iris I/O workers.", failure); } clearQueues(); - IrisServices.clear(); + IrisRuntimeStatics.reset(); + releaseStatics(); } } + /** + * A disabled plugin must not stay reachable through a static. Everything that reads these either runs + * while Iris is enabled or already handles their absence; the platform binding outlives them because + * core still logs through it until finishTerminalCleanup unbinds it. + */ + private static void releaseStatics() { + linkMultiverseCore = null; + compat = null; + tickets = null; + sender = null; + instance = null; + } + private void quiesceRuntimeForServerShutdown(String reason) { serverStopTeardownDeferred.set(true); INMS.get().deferPluginClassLoaderClose(); @@ -1201,6 +1227,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { failure.printStackTrace(SHUTDOWN_ERRORS); } } + BukkitPlatform.releaseHost(); } } @@ -1395,7 +1422,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public int getIrisVersion() { - String input = Iris.instance.getDescription().getVersion(); + String input = getDescription().getVersion(); int hyphenIndex = input.indexOf('-'); if (hyphenIndex != -1) { String result = input.substring(0, hyphenIndex); diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index ed709bf7d..d9edd7444 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -158,6 +158,20 @@ public class ServerConfigurator { } } + /** + * Forgets what the previous boot loaded, so an enable in the same JVM recompiles and revalidates rather + * than trusting a fingerprint taken against a server that is gone. + */ + public static void resetLoadedDatapackRuntime() { + synchronized (DATAPACK_INSTALL_LOCK) { + loadedDatapackRuntimeReady = false; + loadedDatapackCompilerInputFingerprint = ""; + loadedDatapackRegistryRequirements = Map.of(); + loadedDatapackRuntimeGeneration++; + loadedDatapackRestartRequired = false; + } + } + public static LoadedDatapackRuntimeInvalidation invalidateLoadedDatapackRuntime() { synchronized (DATAPACK_INSTALL_LOCK) { boolean wasReady = loadedDatapackRuntimeReady; diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisRuntimeStatics.java b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisRuntimeStatics.java new file mode 100644 index 000000000..7ae8da04b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisRuntimeStatics.java @@ -0,0 +1,28 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.runtime.WorldRuntimeControlService; +import art.arcane.iris.spi.IrisServices; + +/** + * Drops every process-wide cache core keeps about the running server, so a disable leaves nothing an + * enable in the same JVM could answer from. + *

+ * The capability singletons are the reason this exists: each holds reflective handles onto the live + * CraftServer and MinecraftServer, and a boot that reused them would be talking to the previous server's + * objects. The platform binding itself is not cleared here - core still logs through it while the adapter + * finishes its own teardown - and is unbound last by the adapter. + */ +public final class IrisRuntimeStatics { + private IrisRuntimeStatics() { + } + + public static void reset() { + IrisServices.clear(); + WorldLifecycleService.reset(); + WorldRuntimeControlService.reset(); + ServerConfigurator.resetLoadedDatapackRuntime(); + IrisLanguage.shutdown(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java index 5ed11e45d..68818c289 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java @@ -51,6 +51,15 @@ public final class WorldLifecycleService { this.worldBackendByKey = new ConcurrentHashMap<>(); } + /** + * Drops the cached snapshot so the next boot in this JVM builds one against the server it is running on. + */ + public static void reset() { + synchronized (WorldLifecycleService.class) { + instance = null; + } + } + public static WorldLifecycleService get() { WorldLifecycleService current = instance; if (current != null) { diff --git a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java index 4dc586eee..13094df67 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java +++ b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java @@ -429,6 +429,8 @@ public final class IrisLanguage { } public static synchronized void shutdown() { + dataFolder = null; + PLAIN_MEMO.set(null); PluginLanguageService current = selections; selections = null; if (current != null) { diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java index d8ddb486b..fc3e3470a 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java @@ -76,6 +76,15 @@ public final class WorldRuntimeControlService { + ", " + backend.describeCapabilities(); } + /** + * Drops the cached snapshot so the next boot in this JVM builds one against the server it is running on. + */ + public static void reset() { + synchronized (WorldRuntimeControlService.class) { + instance = null; + } + } + public static WorldRuntimeControlService get() { WorldRuntimeControlService current = instance; if (current != null) { diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java index 0a4111d46..c79f19978 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java @@ -50,6 +50,7 @@ final class DecoratorCore { static final ThreadLocal SCRATCH_OPTS = ThreadLocal.withInitial(PlaceOpts::new); + static final class PlaceOpts { boolean caveSkipFluid; boolean underwater; diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java index b681fe28e..e52f400ef 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java @@ -103,6 +103,19 @@ public final class BukkitPlatform implements IrisPlatform { PLUGIN = plugin; } + /** + * Drops every reference this class holds to the disabled plugin. Called last in the adapter's teardown, + * after core has stopped logging through the host bridge, so a disabled plugin instance is not reachable + * from a static for the life of the JVM. + */ + public static void releaseHost() { + PLUGIN = null; + HUD_BAR = null; + HUD_LANES = null; + CONSOLE = null; + BRIDGE = null; + } + public static Plugin plugin() { Plugin plugin = PLUGIN; if (plugin == null) { diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/IrisRuntimeStaticsTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/IrisRuntimeStaticsTest.java new file mode 100644 index 000000000..57a515c00 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/IrisRuntimeStaticsTest.java @@ -0,0 +1,51 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.core.runtime.WorldRuntimeControlService; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.testsupport.BukkitTestServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; + +/** + * Iris keeps process-wide caches that describe the running server: the service registry, and the lifecycle + * and runtime-control singletons that each hold a capability snapshot of reflective handles onto the live + * server. Nothing cleared them on disable, so a re-enable in the same JVM kept answering from the previous + * boot's snapshot. + */ +public class IrisRuntimeStaticsTest { + @Before + public void installServer() { + BukkitTestServer.install(); + } + + @After + public void clearStatics() { + IrisRuntimeStatics.reset(); + } + + @Test + public void resetUnbindsEveryRegisteredService() { + Runnable service = () -> { + }; + IrisServices.register(Runnable.class, service); + + IrisRuntimeStatics.reset(); + + assertNull(IrisServices.getOrNull(Runnable.class)); + } + + @Test + public void resetDropsTheCachedCapabilitySingletons() { + WorldLifecycleService lifecycle = WorldLifecycleService.get(); + WorldRuntimeControlService control = WorldRuntimeControlService.get(); + + IrisRuntimeStatics.reset(); + + assertNotSame(lifecycle, WorldLifecycleService.get()); + assertNotSame(control, WorldRuntimeControlService.get()); + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformHostReleaseTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformHostReleaseTest.java new file mode 100644 index 000000000..361fc42b2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformHostReleaseTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.platform.bukkit; + +import org.bukkit.plugin.Plugin; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.mockito.Mockito.mock; + +/** + * The host statics are how core reaches the plugin. Nothing cleared them on disable, so the disabled + * plugin instance, its console sender and its log bridge stayed reachable from a static for the life of + * the JVM. + */ +public class BukkitPlatformHostReleaseTest { + @After + public void clearHost() { + BukkitPlatform.releaseHost(); + } + + @Test + public void releasingTheHostDropsThePluginAndTheHud() { + BukkitPlatform.hostPlugin(mock(Plugin.class)); + + BukkitPlatform.releaseHost(); + + assertFalse(BukkitPlatform.hasPlugin()); + assertFalse(BukkitPlatform.hasHud()); + } +}