Fix Iris replacement

This commit is contained in:
Brian Neumann-Fopiano committed 2026-08-15 15:58:07 -04:00
1 parent 4568b3fff1
commit 8297e49ed2
52 files changed
+4748 -198

No files matched your search

@@ -647,9 +647,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.s(() -> {
pendingWorldReplacements.captureVanillaLevelContext();
// Off-main: the verify body takes the replacement-manager monitor and SHA-hashes
// whole pack trees; neither belongs on the tick thread.
J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds);
pendingWorldReplacements.verifyLoadedPublishedWorlds();
J.a(this::bstats);
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
J.sr(this::tickQueue, 0);
@@ -40,6 +40,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -50,6 +51,7 @@ import java.util.concurrent.TimeoutException;
public final class PendingWorldReplacementManager implements Listener {
private final Iris plugin;
private final Set<UUID> cleanupInFlight = new HashSet<>();
private final Set<UUID> verificationInFlight = new HashSet<>();
public PendingWorldReplacementManager(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
@@ -67,12 +69,16 @@ public final class PendingWorldReplacementManager implements Listener {
public synchronized StagedReplacement stageReplacement(
VolmitSender sender,
NamespacedKey worldKey,
IrisDimension dimension
IrisDimension dimension,
Long requestedSeed
) throws IOException {
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
OptionalLong seedSelection = requestedSeed == null
? OptionalLong.empty()
: OptionalLong.of(requestedSeed.longValue());
IrisStartupValidation.requireWorldReplacementStagingReady();
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
@@ -93,7 +99,6 @@ public final class PendingWorldReplacementManager implements Listener {
UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
WorldReplacementFilesystem.requireExistingTarget(paths);
long effectiveSeed = WorldReplacementSeed.readAuthoritativeSeed(paths.target());
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) {
@@ -119,6 +124,11 @@ public final class PendingWorldReplacementManager implements Listener {
throw new IOException("Iris could not stage the dimension pack.");
}
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed(
paths.target(),
paths.stage(),
seedSelection
);
File stagedPack = paths.stage().resolve("iris/pack").toFile();
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
@@ -227,68 +237,160 @@ public final class PendingWorldReplacementManager implements Listener {
}
}
public synchronized void verifyLoadedPublishedWorlds() {
public void verifyLoadedPublishedWorlds() {
J.a(this::discoverLoadedPublishedWorlds);
}
private void discoverLoadedPublishedWorlds() {
List<Transaction> transactions;
try {
for (Transaction transaction : loadTransactions()) {
if (transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
} else if (transaction.phase() == Phase.PUBLISHED) {
WorldIdentity.resolve(toNamespacedKey(transaction.worldKey()))
.ifPresent(world -> verifyPublishedWorld(world, transaction));
}
synchronized (this) {
transactions = loadTransactions();
}
} catch (Throwable failure) {
Iris.reportError("Failed to inspect published Iris world replacements.", failure);
return;
}
for (Transaction transaction : transactions) {
if (transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
} else if (transaction.phase() == Phase.PUBLISHED) {
scheduleRuntimeCapture(transaction, 0);
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
World world = event.getWorld();
// One tick to let the load settle, then verify off-main: the body takes the manager
// monitor (held across pack staging by async threads) and SHA-hashes the whole pack
// tree — blocking the main thread on either froze the server.
J.s(() -> J.a(() -> verifyPublishedWorldIfPending(world)), 1);
WorldSlotKey worldKey;
try {
worldKey = toWorldSlotKey(WorldIdentity.key(event.getWorld()));
} catch (Throwable failure) {
Iris.reportError("Failed to capture a loaded world identity for replacement verification.", failure);
return;
}
J.a(() -> discoverLoadedWorldTransaction(worldKey));
}
private synchronized void verifyPublishedWorldIfPending(World world) {
private void discoverLoadedWorldTransaction(WorldSlotKey worldKey) {
Transaction transaction;
try {
Transaction transaction = findTransaction(WorldIdentity.key(world));
if (transaction != null && transaction.phase() == Phase.PUBLISHED) {
verifyPublishedWorld(world, transaction);
} else if (transaction != null && transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
synchronized (this) {
transaction = findTransaction(worldKey);
}
} catch (Throwable failure) {
Iris.reportError("Failed to verify a published Iris world replacement.", failure);
Iris.reportError("Failed to inspect a loaded Iris world replacement.", failure);
return;
}
if (transaction == null) {
return;
}
if (transaction.phase() == Phase.PUBLISHED) {
scheduleRuntimeCapture(transaction, 1);
} else if (transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
}
}
private void verifyPublishedWorld(World world, Transaction transaction) {
private void scheduleRuntimeCapture(Transaction transaction, int delayTicks) {
synchronized (this) {
if (!verificationInFlight.add(transaction.id())) {
return;
}
}
try {
J.s(() -> captureLoadedPublishedWorldOnGlobal(transaction), delayTicks);
} catch (Throwable failure) {
finishRuntimeVerification(transaction.id());
Iris.reportError("Could not schedule runtime verification for " + transaction.worldKey() + ".", failure);
}
}
private void captureLoadedPublishedWorldOnGlobal(Transaction transaction) {
World world;
try {
world = WorldIdentity.resolve(toNamespacedKey(transaction.worldKey())).orElse(null);
} catch (Throwable failure) {
dispatchRuntimeCaptureFailure(transaction, failure);
return;
}
if (world == null) {
finishRuntimeVerification(transaction.id());
return;
}
PublishedWorldRuntimeState runtimeState;
try {
runtimeState = capturePublishedWorldRuntime(world);
} catch (Throwable failure) {
dispatchRuntimeCaptureFailure(transaction, failure);
return;
}
try {
J.a(() -> runPublishedWorldVerification(runtimeState, transaction));
} catch (Throwable failure) {
finishRuntimeVerification(transaction.id());
Iris.reportError("Could not dispatch runtime verification for " + transaction.worldKey() + ".", failure);
}
}
private void dispatchRuntimeCaptureFailure(Transaction transaction, Throwable failure) {
try {
J.a(() -> runPublishedWorldCaptureFailure(transaction, failure));
} catch (Throwable dispatchFailure) {
finishRuntimeVerification(transaction.id());
dispatchFailure.addSuppressed(failure);
Iris.reportError("Could not dispatch a failed runtime capture for "
+ transaction.worldKey() + ".", dispatchFailure);
}
}
private void runPublishedWorldCaptureFailure(Transaction transaction, Throwable failure) {
try {
initiateRollback(transaction, failure);
} finally {
finishRuntimeVerification(transaction.id());
}
}
private void runPublishedWorldVerification(
PublishedWorldRuntimeState runtimeState,
Transaction transaction
) {
try {
verifyPublishedWorld(runtimeState, transaction);
} finally {
finishRuntimeVerification(transaction.id());
}
}
static PublishedWorldRuntimeState capturePublishedWorldRuntime(World world) {
World requiredWorld = Objects.requireNonNull(world, "world");
WorldSlotKey worldKey = toWorldSlotKey(WorldIdentity.key(requiredWorld));
boolean irisWorld = IrisToolbelt.isIrisWorld(requiredWorld);
long seed = requiredWorld.getSeed();
World.Environment bukkitEnvironment = requiredWorld.getEnvironment();
PlatformChunkGenerator generator = irisWorld ? IrisToolbelt.access(requiredWorld) : null;
String dimension = null;
IrisEnvironment dimensionEnvironment = null;
if (generator != null) {
IrisDimension runtimeDimension = generator.getTarget().getDimension();
dimension = runtimeDimension.getLoadKey();
dimensionEnvironment = runtimeDimension.getEnvironment();
}
return new PublishedWorldRuntimeState(
worldKey,
irisWorld,
seed,
bukkitEnvironment,
dimension,
dimensionEnvironment
);
}
private void verifyPublishedWorld(PublishedWorldRuntimeState runtimeState, Transaction transaction) {
try {
if (!transaction.worldKey().equals(toWorldSlotKey(WorldIdentity.key(world)))) {
throw new IOException("Loaded world identity does not match the replacement journal.");
}
if (!IrisToolbelt.isIrisWorld(world)) {
throw new IOException("The replaced world did not load with an Iris generator.");
}
if (world.getSeed() != transaction.seed()) {
throw new IOException("The replaced world loaded with an unexpected seed.");
}
World.Environment expectedEnvironment = expectedEnvironment(transaction.worldKey());
if (expectedEnvironment != null && world.getEnvironment() != expectedEnvironment) {
throw new IOException("The replaced world loaded with an unexpected environment.");
}
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null || !transaction.dimension().equals(
generator.getTarget().getDimension().getLoadKey())) {
throw new IOException("The replaced world loaded an unexpected Iris dimension.");
}
ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
requireCompatibleEnvironment(
target.slotKind(),
generator.getTarget().getDimension().getEnvironment()
);
validatePublishedWorldRuntime(runtimeState, transaction, target.slotKind());
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
paths.target().resolve("iris/pack"));
@@ -318,6 +420,39 @@ public final class PendingWorldReplacementManager implements Listener {
}
}
static void validatePublishedWorldRuntime(
PublishedWorldRuntimeState runtimeState,
Transaction transaction,
SlotKind slotKind
) throws IOException {
PublishedWorldRuntimeState requiredRuntimeState = Objects.requireNonNull(runtimeState, "runtimeState");
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
SlotKind requiredSlotKind = Objects.requireNonNull(slotKind, "slotKind");
if (!requiredTransaction.worldKey().equals(requiredRuntimeState.worldKey())) {
throw new IOException("Loaded world identity does not match the replacement journal.");
}
if (!requiredRuntimeState.irisWorld()) {
throw new IOException("The replaced world did not load with an Iris generator.");
}
if (requiredRuntimeState.seed() != requiredTransaction.seed()) {
throw new IOException("The replaced world loaded with an unexpected seed.");
}
World.Environment expectedEnvironment = expectedEnvironment(requiredTransaction.worldKey());
if (expectedEnvironment != null && requiredRuntimeState.bukkitEnvironment() != expectedEnvironment) {
throw new IOException("The replaced world loaded with an unexpected environment.");
}
if (requiredRuntimeState.dimension() == null
|| requiredRuntimeState.dimensionEnvironment() == null
|| !requiredTransaction.dimension().equals(requiredRuntimeState.dimension())) {
throw new IOException("The replaced world loaded an unexpected Iris dimension.");
}
requireCompatibleEnvironment(requiredSlotKind, requiredRuntimeState.dimensionEnvironment());
}
private synchronized void finishRuntimeVerification(UUID transactionId) {
verificationInFlight.remove(transactionId);
}
private void initiateRollback(Transaction transaction, Throwable failure) {
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
+ "; the retained world will be restored on restart.", failure);
@@ -608,4 +743,18 @@ public final class PendingWorldReplacementManager implements Listener {
super(message);
}
}
record PublishedWorldRuntimeState(
WorldSlotKey worldKey,
boolean irisWorld,
long seed,
World.Environment bukkitEnvironment,
String dimension,
IrisEnvironment dimensionEnvironment
) {
PublishedWorldRuntimeState {
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(bukkitEnvironment, "bukkitEnvironment");
}
}
}
@@ -79,12 +79,14 @@ import static org.bukkit.Bukkit.getServer;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.IrisMessages;
import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
import art.arcane.iris.core.localization.RuntimeUiMessages;
@Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command")
public class CommandIris implements DirectorExecutor {
private static final String NO_DOWNLOAD_SOURCE = "__none__";
private static final String PRESERVE_REPLACEMENT_SEED = "preserve";
private static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L;
private CommandStudio studio;
@@ -202,7 +204,15 @@ public class CommandIris implements DirectorExecutor {
defaultValue = "default",
customHandler = PackDimensionTypeHandler.class
)
String type
String type,
@Param(
name = "seed",
aliases = "s",
description = "The replacement seed; omit to preserve the target world's seed",
defaultValue = PRESERVE_REPLACEMENT_SEED,
customHandler = ReplacementSeedHandler.class
)
Long seed
) {
NamespacedKey worldKey;
try {
@@ -227,9 +237,13 @@ public class CommandIris implements DirectorExecutor {
try {
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
.pendingWorldReplacements()
.stageReplacement(sender(), worldKey, dimension);
.stageReplacement(sender(), worldKey, dimension, seed);
String seedDetail = seed == null
? " preserving seed " + staged.seed()
: " using seed " + staged.seed();
sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey()
+ ". Restart once to publish it. The current dimension is retained until Iris verifies the replacement.");
+ seedDetail + ". Restart once to publish it. The current dimension is retained until Iris "
+ "verifies the replacement.");
} catch (Throwable failure) {
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
String detail = failure.getMessage() == null || failure.getMessage().isBlank()
@@ -539,19 +553,17 @@ public class CommandIris implements DirectorExecutor {
String builtInPack = NO_DOWNLOAD_SOURCE.equals(pack) ? null : pack;
String directLink = NO_DOWNLOAD_SOURCE.equals(link) ? null : link;
if ((builtInPack == null) == (directLink == null)) {
sender().sendMessage("Use exactly one source: /iris download pack=overworld, /iris download pack=underworld, or /iris download link=<zip-url>.");
sender().sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_SOURCE));
return;
}
if (builtInPack != null) {
sender().sendMessage("Downloading built-in Iris pack '" + builtInPack + "' from its beta release.");
Iris.service(StudioSVC.class).downloadBuiltIn(sender(), builtInPack);
return;
}
if (!PackDownloader.isDirectZipUrl(directLink)) {
sender().sendMessage("Iris requires link= to contain a valid HTTP or HTTPS .zip URL.");
sender().sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_URL));
return;
}
sender().sendMessage("Downloading Iris pack from " + directLink + ".");
Iris.service(StudioSVC.class).downloadUrl(sender(), directLink);
}
@@ -894,6 +906,41 @@ public class CommandIris implements DirectorExecutor {
}
}
public static class ReplacementSeedHandler implements DirectorParameterHandler<Long> {
@Override
public KList<Long> getPossibilities() {
return null;
}
@Override
public String toString(Long value) {
return value == null ? PRESERVE_REPLACEMENT_SEED : Long.toString(value);
}
@Override
public Long parse(String in, boolean force) throws DirectorParsingException {
String seed = in == null ? "" : in.trim();
if (PRESERVE_REPLACEMENT_SEED.equalsIgnoreCase(seed)) {
return null;
}
try {
return Long.parseLong(seed);
} catch (NumberFormatException failure) {
throw new DirectorParsingException("Seed must be a signed 64-bit integer");
}
}
@Override
public boolean supports(Class<?> type) {
return type == Long.class;
}
@Override
public String getRandomDefault() {
return "1337";
}
}
public static class DownloadPackHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {