From 486c6eeae9b10ddfa8bd113e1cd904305c141692 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 8 Sep 2026 04:37:35 -0400 Subject: [PATCH] Defer runtime injection - Add RuntimeInjection so the Java agent attach and the server-code injection run once, on demand, instead of during every enable - Install the injection when a world Iris created is about to be built and when Bukkit asks for the generator of a world configured outside Iris - Lock the runtime with the same reason a boot-time injection failure used to raise when the deferred install fails - Add general.eagerRuntimeInjection to keep boot-time injection and its startup verification - Re-read the startup lock after a deferred install so a failed install refuses the world instead of generating it - Move the stale cache/temp sweep off the boot thread and make every pack import wait it out - Move the default-pack presence warning off the boot thread; it walks the whole pack tree to pick a warning - Build the wand and dust templates on the first tick instead of during service enable - Report enable duration, per-phase timings and the three slowest services on every boot - Drop the wand and dust templates on service disable --- .../src/main/java/art/arcane/iris/Iris.java | 67 ++++++++++- .../iris/core/IrisWorldGeneratorResolver.java | 28 ++++- .../art/arcane/iris/core/service/WandSVC.java | 17 ++- .../art/arcane/iris/core/IrisSettings.java | 7 ++ .../core/lifecycle/IrisRuntimeStatics.java | 2 + .../core/lifecycle/WorldLifecycleService.java | 2 + .../iris/core/runtime/RuntimeInjection.java | 105 ++++++++++++++++++ .../iris/core/safeguard/task/Tasks.java | 33 +++--- .../arcane/iris/core/service/StudioSVC.java | 48 +++++++- .../core/runtime/RuntimeInjectionTest.java | 73 ++++++++++++ .../safeguard/TasksInjectionDeferralTest.java | 79 +++++++++++++ 11 files changed, 426 insertions(+), 35 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/RuntimeInjection.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/RuntimeInjectionTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/safeguard/TasksInjectionDeferralTest.java 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 8960222b8..569d0d0a2 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 @@ -149,6 +149,7 @@ import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.concurrent.CopyOnWriteArrayList; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -161,6 +162,7 @@ import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; @SuppressWarnings("CanBeFinal") public class Iris extends VolmitPlugin implements Listener, ReloadAware { @@ -626,6 +628,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1); return false; } + EnableTimings timings = new EnableTimings(); alreadyDrained.set(false); postStopFinisherStarted.set(false); serverStopTeardownDeferred.set(false); @@ -647,6 +650,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions())); PaperLibBootstrap.install(); SimdSupport.install(); + timings.mark("bootstrap"); services = new KMap<>(); BukkitPlatform.hostHud(new HudActionBar(this), new HudBossBarLane()); // Explicit, ordered service list: the previous reflective jar scan gave hash-ordered @@ -685,9 +689,13 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisServices.register(PreservationRegistry.class, services.get(PreservationSVC.class)); compat = IrisCompat.configured(getDataFile("compat.json")); IrisServices.register(IrisCompat.class, compat); + timings.mark("services"); ServerConfigurator.configure(); + timings.mark("serverConfig"); StartupValidationOutcome datapackValidation = DatapackIngestService.validateOnStartup(); + timings.mark("datapacks"); IrisSafeguard.execute(); + timings.mark("safeguard"); getSender().setTag(getTag()); // A cosmetic banner must never abort the bootstrap. J.attempt(this::splash); @@ -696,6 +704,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { // Paper's bootstrap runs before any plugin logger exists, so orphan-storage warnings raised there // never reach logs/latest.log. Replay them once now that the platform log is up. MissingWorldStorageLog.replayToPlatformLog(); + timings.mark("splash"); tickets = new ChunkTickets(); linkMultiverseCore = new MultiverseCoreLink(); IrisServices.register(MultiverseCoreLink.class, linkMultiverseCore); @@ -708,18 +717,24 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisServices.register(ManagedWorldLoader.class, (ManagedWorldLoader) this::loadManagedWorld); SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("iris.json")); settingsHotloadWatch = watch; - // Stale-temp cleanup must complete before services enable: StudioSVC.onEnable downloads - // packs through cache/temp on an async thread, and a concurrent delete of that folder - // truncated pack imports mid-copy (partial packs/ without dimensions/). - IO.delete(getTemp()); + // Off the boot thread, because a stale cache/temp can hold a whole abandoned pack import and + // deleting it recursively is unbounded. Every pack import waits this sweep out instead, which is + // the invariant that mattered: a delete running underneath an import truncated it mid-copy. + StudioSVC.gateDownloadsOnStaleTempCleanup(MultiBurst.ioBurst.completeValueAsync(() -> { + IO.delete(getTemp()); + return null; + })); + timings.mark("tempSweep"); // One throwing service must not abort the bootstrap: the steps after this loop // (listeners, shutdown hook, replacement journals) are the safety-critical ones. // Only services that actually enabled get listeners and a later onDisable. enabledServices.clear(); for (IrisService service : orderedServices) { + long serviceStartedAt = System.nanoTime(); try { service.onEnable(); enabledServices.add(service); + timings.markService(service.getClass().getSimpleName(), serviceStartedAt); } catch (Throwable e) { // A service failure is NOT a datapack validation failure: the admission gate // must never lock every login over a broken cosmetic service. Log loudly, @@ -740,10 +755,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { Iris.reportError("Failed to register listener for " + service.getClass().getSimpleName() + ".", e); } } + timings.mark("serviceEnable"); if (datapackValidation == StartupValidationOutcome.READY) { IrisServices.get(ExternalDataSVC.class).setContentChangeListener(generatorResolver::requestExternalContentRefresh); generatorResolver.validateAllPacks(); } + timings.mark("packValidation"); addShutdownHook(); pendingWorldReplacements.processPendingStartupReplacements(); pendingWorldDeletes.processPendingStartupWorldDeletes(); @@ -771,9 +788,51 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { // match a slice type; the block-state slice is deliberately never retainable (regenerable, huge). IrisToolbelt.retainMantleDataForSlice(TreeBlockMaterial.class.getCanonicalName()); }); + timings.mark("startupTasks"); + timings.report(); return true; } + /** + * Where the enable thread went, so a boot that got slower can be attributed to a phase rather than to + * Iris in general. The total is a lifecycle milestone and lands in logs/latest.log; the breakdown is + * for whoever is looking. + */ + private static final class EnableTimings { + private static final int REPORTED_SERVICES = 3; + + private final long startedAt = System.nanoTime(); + private final StringBuilder phases = new StringBuilder(); + private final Map serviceMillis = new LinkedHashMap<>(); + private long lastMark = startedAt; + + private void mark(String phase) { + long now = System.nanoTime(); + if (!phases.isEmpty()) { + phases.append(' '); + } + phases.append(phase).append('=').append(TimeUnit.NANOSECONDS.toMillis(now - lastMark)).append("ms"); + lastMark = now; + } + + private void markService(String service, long serviceStartedAt) { + serviceMillis.put(service, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - serviceStartedAt)); + } + + private void report() { + IrisLogging.notice("Enabled in %dms (%s)", + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt), phases); + String slowest = serviceMillis.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(REPORTED_SERVICES) + .map(entry -> entry.getKey() + "=" + entry.getValue() + "ms") + .collect(Collectors.joining(" ")); + if (!slowest.isBlank()) { + IrisLogging.notice("Slowest services: %s", slowest); + } + } + } + private void reconcileStartupWorlds() { generatorResolver.startupWorldsReady().whenComplete((ignored, failure) -> { if (failure != null) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index 2171388af..09ee5a08e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -30,6 +30,7 @@ import art.arcane.iris.core.pack.PackValidationCache; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; +import art.arcane.iris.core.runtime.RuntimeInjection; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; @@ -486,17 +487,23 @@ public final class IrisWorldGeneratorResolver { Iris.debug("Generator discovery probe for loaded world " + worldName); return IrisFailClosedChunkGenerator.discoveryProbe(worldName); } - Optional startupDenial = IrisStartupValidation.denialReason(); - if (startupDenial.isPresent()) { - Iris.warn("Keeping configured Iris world '" + worldName - + "' generation-locked: " + startupDenial.get()); - return IrisFailClosedChunkGenerator.startupLock(worldName, startupDenial.get()); + ChunkGenerator startupLock = startupLockedGenerator(worldName); + if (startupLock != null) { + return startupLock; } ChunkGenerator stagedGenerator = WorldLifecycleStaging.consumeGenerator(worldName); if (stagedGenerator != null) { Iris.debug("Using staged runtime generator for " + worldName); return stagedGenerator; } + // A world Iris created staged its generator above and installed the injection on the way in. This + // is a world configured outside Iris, and Bukkit asks for its generator before it builds the level, + // so this is the last point at which the injection it needs can still be installed in time. + RuntimeInjection.installIfDeferred(); + startupLock = startupLockedGenerator(worldName); + if (startupLock != null) { + return startupLock; + } Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id); if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType(); Iris.debug("Generator ID: " + id + " requested by bukkit/plugin"); @@ -516,6 +523,17 @@ public final class IrisWorldGeneratorResolver { } } + @Nullable + private static ChunkGenerator startupLockedGenerator(String worldName) { + Optional startupDenial = IrisStartupValidation.denialReason(); + if (startupDenial.isEmpty()) { + return null; + } + Iris.warn("Keeping configured Iris world '" + worldName + + "' generation-locked: " + startupDenial.get()); + return IrisFailClosedChunkGenerator.startupLock(worldName, startupDenial.get()); + } + private static boolean isPlotSquaredGeneratorDiscoveryProbe(String worldName, String id) { return PLOT_SQUARED_DISCOVERY_WORLD.equals(worldName) && id != null && id.isEmpty(); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/WandSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/WandSVC.java index 927dbaf06..af0d02536 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/WandSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/WandSVC.java @@ -75,8 +75,8 @@ public class WandSVC implements IrisService { private static final int MS_PER_TICK = Integer.parseInt(System.getProperty("iris.ms_per_tick", "30")); private static final int PLAYER_RESCAN_INTERVAL_TICKS = 100; - private static ItemStack dust; - private static ItemStack wand; + private static volatile ItemStack dust; + private static volatile ItemStack wand; private final Map activePlayers = new ConcurrentHashMap<>(); private final AtomicBoolean playerRescanScheduled = new AtomicBoolean(false); @@ -374,12 +374,16 @@ public class WandSVC implements IrisService { @Override public void onEnable() { - wand = createWand(); - dust = createDust(); enabled = true; activePlayers.clear(); ticksUntilPlayerRescan = 0; taskId = J.ar(this::tickAll, 1); + // Building the templates reaches the item and enchantment registries, which costs more than the + // rest of this service's startup put together and is only needed to recognise a pre-marker wand. + J.s(() -> { + wand = createWand(); + dust = createDust(); + }); } @Override @@ -389,6 +393,8 @@ public class WandSVC implements IrisService { J.car(taskId); taskId = -1; } + wand = null; + dust = null; activePlayers.clear(); playerRescanScheduled.set(false); } @@ -607,7 +613,8 @@ public class WandSVC implements IrisService { return false; } Byte marker = is.getItemMeta().getPersistentDataContainer().get(dustKey(), PersistentDataType.BYTE); - return (marker != null && marker == (byte) 1) || is.isSimilar(dust); + ItemStack template = dust; + return (marker != null && marker == (byte) 1) || (template != null && is.isSimilar(template)); } private static NamespacedKey wandKey() { diff --git a/core/src/main/java/art/arcane/iris/core/IrisSettings.java b/core/src/main/java/art/arcane/iris/core/IrisSettings.java index 936a12d22..85f428455 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -308,6 +308,13 @@ public class IrisSettings { public boolean debug = false; public boolean dumpMantleOnError = false; public boolean disableNMS = false; + /** + * Attach the Java agent and install the server-code injection while Iris enables, instead of the + * first time a world Iris generates is about to load. Boot-time injection costs roughly a second + * on a server that may never load an Iris world; turn this on to pay it up front and have the + * startup safeguard verify it. + */ + public boolean eagerRuntimeInjection = false; public boolean splashLogoStartup = true; public boolean useConsoleCustomColors = true; public boolean useCustomColorsIngame = true; 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 index 7ae8da04b..9381a3433 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisRuntimeStatics.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisRuntimeStatics.java @@ -2,6 +2,7 @@ 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.RuntimeInjection; import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.spi.IrisServices; @@ -22,6 +23,7 @@ public final class IrisRuntimeStatics { IrisServices.clear(); WorldLifecycleService.reset(); WorldRuntimeControlService.reset(); + RuntimeInjection.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 68818c289..bc04a5120 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 @@ -1,6 +1,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.runtime.RuntimeInjection; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.util.common.scheduling.J; @@ -85,6 +86,7 @@ public final class WorldLifecycleService { public CompletableFuture create(WorldLifecycleRequest request) { WorldLifecycleBackend backend; try { + RuntimeInjection.installIfDeferred(); if (request.callerKind() == WorldLifecycleCaller.FORCED_STUDIO) { ServerConfigurator.requireWorldCreationReady(true); } else { diff --git a/core/src/main/java/art/arcane/iris/core/runtime/RuntimeInjection.java b/core/src/main/java/art/arcane/iris/core/runtime/RuntimeInjection.java new file mode 100644 index 000000000..6b65a9270 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/RuntimeInjection.java @@ -0,0 +1,105 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.core.IrisStartupValidation; +import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.v1X.NMSBinding1X; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.project.agent.Agent; + +/** + * Attaches the Java agent and installs the server-code injection Iris generation needs, once per boot. + *

+ * Both steps are expensive - a self-attach to the running JVM, then three class retransformations - and + * neither is needed until a world that Iris generates is about to exist. A server that never loads an Iris + * world never pays for them. Whoever is about to produce such a world calls {@link #installIfDeferred()} + * first; it is idempotent, and a failure marks the runtime invalid so the same lock that guards a failed + * boot-time injection guards a failed deferred one. + */ +public final class RuntimeInjection { + private static final Object LOCK = new Object(); + + private static volatile Outcome outcome; + + private RuntimeInjection() { + } + + public enum Failure { + NONE, + NO_BINDING, + AGENT, + INJECTION + } + + public record Outcome(boolean installed, Failure failure, String lockReason) { + } + + /** + * Runs the install if it has not run yet and reports what happened. Never throws. + */ + public static Outcome install() { + Outcome current = outcome; + if (current != null) { + return current; + } + + synchronized (LOCK) { + if (outcome != null) { + return outcome; + } + Outcome resolved = attemptInstall(); + outcome = resolved; + return resolved; + } + } + + /** + * Installs on the deferred path and locks the runtime when that fails, so the caller's own readiness + * check refuses the world instead of loading it onto uninstrumented server code. + */ + public static void installIfDeferred() { + Outcome resolved = install(); + if (resolved.installed()) { + return; + } + + IrisLogging.error("Iris runtime injection is unavailable: " + resolved.lockReason()); + IrisStartupValidation.markRuntimeInvalid(resolved.lockReason()); + } + + public static boolean isInstalled() { + Outcome current = outcome; + return current != null && current.installed(); + } + + /** + * Forgets the result so the next boot in this JVM installs against the binding it is running with. + */ + public static void reset() { + synchronized (LOCK) { + outcome = null; + } + } + + static String agentLockReason() { + return "Iris Java agent is unavailable. Add -javaagent:" + Agent.AGENT_JAR.getPath() + + " to the JVM arguments before -jar and restart the server."; + } + + private static Outcome attemptInstall() { + if (!INMS.isBound() || INMS.get() instanceof NMSBinding1X) { + return new Outcome(false, Failure.NO_BINDING, + "Iris cannot use this server's NMS runtime. Resolve the Server Version or NMS Disabled error above and restart the server."); + } + + if (!Agent.install()) { + return new Outcome(false, Failure.AGENT, agentLockReason()); + } + + if (!INMS.get().injectBukkit()) { + return new Outcome(false, Failure.INJECTION, + "Iris runtime injection failed. Resolve the startup errors and restart the server."); + } + + return new Outcome(true, Failure.NONE, null); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java b/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java index 79d0dc8cb..46dad3f52 100644 --- a/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java +++ b/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java @@ -2,7 +2,9 @@ package art.arcane.iris.core.safeguard.task; import art.arcane.iris.BuildConstants; import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisWorlds; +import art.arcane.iris.core.runtime.RuntimeInjection; import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.v1X.NMSBinding1X; @@ -128,28 +130,29 @@ public final class Tasks { }); private static final Task INJECTION = Task.critical("injection", INJECTION_LOCK_REASON, () -> { - if (!INMS.isBound() || INMS.get() instanceof NMSBinding1X) { - return CheckResult.danger(NMS_LOCK_REASON, - Diagnostic.Logger.ERROR.create("Code Injection"), - Diagnostic.Logger.ERROR.create("- Runtime injection was skipped because Iris has no usable NMS binding.")); + if (!IrisSettings.get().getGeneral().isEagerRuntimeInjection()) { + return CheckResult.stable( + Diagnostic.Logger.INFO.create("Runtime Injection"), + Diagnostic.Logger.INFO.create("- Deferred to the first world load. Set general.eagerRuntimeInjection to verify it during startup.")); } - if (!Agent.install()) { - String agentLockReason = "Iris Java agent is unavailable. Add -javaagent:" - + Agent.AGENT_JAR.getPath() + " to the JVM arguments before -jar and restart the server."; - return CheckResult.danger(agentLockReason, + RuntimeInjection.Outcome outcome = RuntimeInjection.install(); + if (outcome.installed()) { + return CheckResult.stable(); + } + + return switch (outcome.failure()) { + case NO_BINDING -> CheckResult.danger(outcome.lockReason(), + Diagnostic.Logger.ERROR.create("Code Injection"), + Diagnostic.Logger.ERROR.create("- Runtime injection was skipped because Iris has no usable NMS binding.")); + case AGENT -> CheckResult.danger(outcome.lockReason(), Diagnostic.Logger.ERROR.create("Java Agent"), Diagnostic.Logger.ERROR.create("- Add -javaagent:" + Agent.AGENT_JAR.getPath() + " before -jar in your startup command and restart the server."), Diagnostic.Logger.ERROR.create("- Dynamic attachment requires -XX:+EnableDynamicAgentLoading and a host that permits JVM attachment.")); - } - - if (!INMS.get().injectBukkit()) { - return CheckResult.danger(INJECTION_LOCK_REASON, + case INJECTION, NONE -> CheckResult.danger(INJECTION_LOCK_REASON, Diagnostic.Logger.ERROR.create("Code Injection"), Diagnostic.Logger.ERROR.create("- Failed to inject code. Please contact support")); - } - - return CheckResult.stable(); + }; }); private static final Task DIMENSION_TYPES = Task.critical("dimensionTypes", DIMENSION_TYPE_LOCK_REASON, () -> { diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index 876fed1bb..d11262a7b 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -90,8 +90,10 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Pattern; @@ -106,23 +108,54 @@ public class StudioSVC implements IrisService { private static final Pattern PROJECT_NAME = Pattern.compile("[a-z0-9_-]+"); private static final AtomicCache counter = new AtomicCache<>(); private final StudioTransitionQueue studioTransitions = new StudioTransitionQueue(); + private static final long STALE_TEMP_CLEANUP_TIMEOUT_SECONDS = 120L; + private static volatile CompletableFuture staleTempCleanup = CompletableFuture.completedFuture(null); + private final Object downloadAdmissionMonitor = new Object(); private volatile IrisProject activeProject; private volatile CompletableFuture activeOpen; private PackDownloadExecution activeDownload; private boolean downloadAdmissionOpen; + /** + * Binds the stale cache/temp sweep a boot starts in the background. Pack imports stage through that + * folder, so a delete running underneath one truncates it; the import waits the sweep out instead of + * the boot waiting for the sweep. + */ + public static void gateDownloadsOnStaleTempCleanup(CompletableFuture cleanup) { + staleTempCleanup = cleanup == null ? CompletableFuture.completedFuture(null) : cleanup; + } + + private static void awaitStaleTempCleanup() { + CompletableFuture cleanup = staleTempCleanup; + if (cleanup.isDone()) { + return; + } + + try { + cleanup.get(STALE_TEMP_CLEANUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + IrisLogging.reportError("Interrupted while waiting for the stale Iris temp sweep to finish.", interrupted); + } catch (ExecutionException | TimeoutException failure) { + IrisLogging.reportError("The stale Iris temp sweep did not finish; this pack import may race it.", failure); + } + } + @Override public void onEnable() { synchronized (downloadAdmissionMonitor) { activeDownload = null; downloadAdmissionOpen = true; } - String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType(); - if (!PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) { - IrisLogging.warn("Default pack '" + configuredPack - + "' is not installed. Install a built-in pack with /iris download pack=overworld or provide /iris download link=."); - } + MultiBurst.ioBurst.completeValueAsync(() -> { + String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType(); + if (!PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) { + IrisLogging.warn("Default pack '" + configuredPack + + "' is not installed. Install a built-in pack with /iris download pack=overworld or provide /iris download link=."); + } + return null; + }); } @Override @@ -1274,7 +1307,10 @@ public class StudioSVC implements IrisService { execution = new PackDownloadExecution( lease, - cancellation -> executePackMutation(mutation, reporter, failureMessage, cancellation) + cancellation -> { + awaitStaleTempCleanup(); + executePackMutation(mutation, reporter, failureMessage, cancellation); + } ); PackDownloadExecution trackedExecution = execution; execution.onCompletion(() -> { diff --git a/core/src/test/java/art/arcane/iris/core/runtime/RuntimeInjectionTest.java b/core/src/test/java/art/arcane/iris/core/runtime/RuntimeInjectionTest.java new file mode 100644 index 000000000..be058c945 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/RuntimeInjectionTest.java @@ -0,0 +1,73 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.core.IrisStartupValidation; +import art.arcane.iris.testsupport.BukkitTestServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * Attaching the agent and retransforming server classes costs about a second of every boot, and a server + * that never loads an Iris world never needs either. Deferring it only stays safe while a deferred failure + * locks the runtime exactly like a boot-time one did, so the world it was about to generate is refused + * rather than handed to uninstrumented server code. + */ +public class RuntimeInjectionTest { + @Before + public void resetInjection() { + BukkitTestServer.install(); + RuntimeInjection.reset(); + } + + @After + public void clearValidation() { + RuntimeInjection.reset(); + IrisStartupValidation.disable(); + } + + @Test + public void anInstallWithoutAUsableNmsBindingIsRefusedAndNamesTheReason() { + RuntimeInjection.Outcome outcome = RuntimeInjection.install(); + + assertFalse(outcome.installed()); + assertEquals(RuntimeInjection.Failure.NO_BINDING, outcome.failure()); + assertTrue(outcome.lockReason(), outcome.lockReason().contains("NMS runtime")); + } + + @Test + public void theOutcomeIsResolvedOnceUntilReset() { + RuntimeInjection.Outcome first = RuntimeInjection.install(); + + assertSame(first, RuntimeInjection.install()); + + RuntimeInjection.reset(); + assertFalse(RuntimeInjection.install() == first); + } + + @Test + public void aDeferredInstallFailureLocksTheRuntimeWithItsReason() { + IrisStartupValidation.begin(); + IrisStartupValidation.markDatapacksReady(); + IrisStartupValidation.markPacksReady(); + IrisStartupValidation.markRuntimeReady(); + assertTrue(IrisStartupValidation.isReady()); + + RuntimeInjection.installIfDeferred(); + + assertFalse(IrisStartupValidation.isReady()); + assertEquals(RuntimeInjection.install().lockReason(), + IrisStartupValidation.denialReason().orElseThrow()); + } + + @Test + public void anUninstalledRuntimeReportsItself() { + RuntimeInjection.install(); + + assertFalse(RuntimeInjection.isInstalled()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/safeguard/TasksInjectionDeferralTest.java b/core/src/test/java/art/arcane/iris/core/safeguard/TasksInjectionDeferralTest.java new file mode 100644 index 000000000..264876eaf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/safeguard/TasksInjectionDeferralTest.java @@ -0,0 +1,79 @@ +package art.arcane.iris.core.safeguard; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.safeguard.task.CheckResult; +import art.arcane.iris.core.safeguard.task.Diagnostic; +import art.arcane.iris.core.safeguard.task.Task; +import art.arcane.iris.core.safeguard.task.Tasks; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.testsupport.BukkitTestServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * With injection deferred the injection check has nothing to verify yet, so it must say so and stay Stable + * rather than reporting a Danger the server has not actually hit. Turning the setting on brings the boot + * verification back. + */ +public class TasksInjectionDeferralTest { + private IrisPlatform previousPlatform; + private IrisSettings previousSettings; + + @Before + public void installServerAndSettings() { + BukkitTestServer.install(); + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + IrisPlatforms.bind(mock(IrisPlatform.class)); + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + } + + @After + public void restore() { + IrisSettings.settings = previousSettings; + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void injectionIsDeferredByDefaultAndSaysSo() { + assertTrue("deferred injection is the default", !IrisSettings.settings.getGeneral().isEagerRuntimeInjection()); + + CheckResult result = injection().run(); + + assertEquals(Mode.STABLE, result.mode()); + List lines = result.diagnostics().stream().map(Diagnostic::getMessage).toList(); + assertTrue(lines.toString(), lines.contains("Runtime Injection")); + assertTrue(lines.toString(), lines.stream().anyMatch(line -> line.contains("general.eagerRuntimeInjection"))); + } + + @Test + public void theEagerSettingBringsBackBootTimeVerification() { + IrisSettings.settings.getGeneral().setEagerRuntimeInjection(true); + + CheckResult result = injection().run(); + + assertEquals(Mode.UNSTABLE, result.mode()); + assertTrue(result.lockReason(), result.lockReason().contains("NMS runtime")); + } + + private static Task injection() { + for (Task task : Tasks.getTasks()) { + if ("injection".equals(task.getId())) { + return task; + } + } + throw new IllegalStateException("The injection check is missing from the safeguard task list"); + } +}