This commit is contained in:
Brian Neumann-Fopiano
2026-08-11 20:42:37 -04:00
parent d9860782ef
commit 8a189195ad
2 changed files with 91 additions and 78 deletions
@@ -197,6 +197,7 @@ public final class PendingWorldReplacementManager implements Listener {
public synchronized void processPendingStartupReplacements() { public synchronized void processPendingStartupReplacements() {
ArrayList<String> failures = new ArrayList<>(); ArrayList<String> failures = new ArrayList<>();
ArrayList<String> restartBoundaries = new ArrayList<>();
List<Transaction> transactions; List<Transaction> transactions;
try { try {
transactions = loadTransactions(); transactions = loadTransactions();
@@ -208,7 +209,12 @@ public final class PendingWorldReplacementManager implements Listener {
} }
for (Transaction transaction : transactions) { for (Transaction transaction : transactions) {
try { try {
processStartupTransaction(transaction); inspectStartupTransaction(transaction);
} catch (RestartBoundaryRequired boundary) {
String message = "Pending replacement for " + transaction.worldKey()
+ " still requires a complete server restart: " + detail(boundary);
restartBoundaries.add(message);
Iris.warn(message);
} catch (Throwable failure) { } catch (Throwable failure) {
String message = "Pending replacement for " + transaction.worldKey() String message = "Pending replacement for " + transaction.worldKey()
+ " failed safely: " + detail(failure); + " failed safely: " + detail(failure);
@@ -219,15 +225,20 @@ public final class PendingWorldReplacementManager implements Listener {
if (!failures.isEmpty()) { if (!failures.isEmpty()) {
IrisStartupValidation.markPacksInvalid(failures); IrisStartupValidation.markPacksInvalid(failures);
} }
if (!restartBoundaries.isEmpty()) {
IrisStartupValidation.requireRestart(restartBoundaries.getFirst());
}
} }
public synchronized void verifyLoadedPublishedWorlds() { public synchronized void verifyLoadedPublishedWorlds() {
try { try {
for (Transaction transaction : loadTransactions()) { for (Transaction transaction : loadTransactions()) {
if (transaction.phase() != Phase.PUBLISHED) { if (transaction.phase() == Phase.CLEANUP_PENDING) {
continue; scheduleCommittedCleanup(transaction);
} else if (transaction.phase() == Phase.PUBLISHED) {
WorldIdentity.resolve(transaction.worldKey())
.ifPresent(world -> verifyPublishedWorld(world, transaction));
} }
WorldIdentity.resolve(transaction.worldKey()).ifPresent(world -> verifyPublishedWorld(world, transaction));
} }
} catch (Throwable failure) { } catch (Throwable failure) {
Iris.reportError("Failed to inspect published Iris world replacements.", failure); Iris.reportError("Failed to inspect published Iris world replacements.", failure);
@@ -245,6 +256,8 @@ public final class PendingWorldReplacementManager implements Listener {
Transaction transaction = findTransaction(WorldIdentity.key(world)); Transaction transaction = findTransaction(WorldIdentity.key(world));
if (transaction != null && transaction.phase() == Phase.PUBLISHED) { if (transaction != null && transaction.phase() == Phase.PUBLISHED) {
verifyPublishedWorld(world, transaction); verifyPublishedWorld(world, transaction);
} else if (transaction != null && transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
} }
} catch (Throwable failure) { } catch (Throwable failure) {
Iris.reportError("Failed to verify a published Iris world replacement.", failure); Iris.reportError("Failed to verify a published Iris world replacement.", failure);
@@ -271,21 +284,30 @@ public final class PendingWorldReplacementManager implements Listener {
generator.getTarget().getDimension().getLoadKey())) { generator.getTarget().getDimension().getLoadKey())) {
throw new IOException("The replaced world loaded an unexpected Iris dimension."); throw new IOException("The replaced world loaded an unexpected Iris dimension.");
} }
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve( ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
IrisWorldStorage.levelRoot().toPath(), requireCompatibleEnvironment(
transaction.worldKey() target.slotKind(),
generator.getTarget().getDimension().getEnvironment()
); );
ReplacementPaths paths = replacementPaths(target, transaction.id()); ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
String fingerprint = WorldReplacementFilesystem.fingerprintPack( String fingerprint = WorldReplacementFilesystem.fingerprintPack(
paths.target().resolve("iris/pack")); paths.target().resolve("iris/pack"));
if (!transaction.packFingerprint().equals(fingerprint)) { if (!transaction.packFingerprint().equals(fingerprint)) {
throw new IOException("The replacement pack changed before runtime verification."); throw new IOException("The replacement pack changed before runtime verification.");
} }
WorldReplacementFilesystem.cleanupBackup(paths);
deleteJournal(transaction.id());
Iris.success("Committed Iris world replacement for " + transaction.worldKey() + ".");
} catch (Throwable failure) { } catch (Throwable failure) {
initiateRollback(transaction, failure); initiateRollback(transaction, failure);
return;
}
Transaction committed = transaction.withPhase(Phase.CLEANUP_PENDING);
try {
writeTransaction(committed);
Iris.success("Committed Iris world replacement for " + transaction.worldKey() + ".");
scheduleCommittedCleanup(committed);
} catch (Throwable failure) {
Iris.reportError("The replacement for " + transaction.worldKey()
+ " was verified, but its cleanup journal could not be advanced."
+ " The retained backup was not removed.", failure);
} }
} }
@@ -295,23 +317,6 @@ public final class PendingWorldReplacementManager implements Listener {
try { try {
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING); Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
writeTransaction(rollback); writeTransaction(rollback);
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
transaction.worldName()
);
if (current.matchesGeneratorAndSeed(replacement)) {
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
transaction.worldName(),
replacement,
transaction.originalConfiguration()
)) {
throw new IOException("bukkit.yml changed during replacement rollback.");
}
} else if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
throw new IOException("bukkit.yml no longer matches either side of the replacement.");
}
ServerConfigurator.restart("An Iris world replacement failed verification and will be rolled back."); ServerConfigurator.restart("An Iris world replacement failed verification and will be rolled back.");
} catch (Throwable rollbackFailure) { } catch (Throwable rollbackFailure) {
failure.addSuppressed(rollbackFailure); failure.addSuppressed(rollbackFailure);
@@ -322,25 +327,30 @@ public final class PendingWorldReplacementManager implements Listener {
} }
} }
private void processStartupTransaction(Transaction transaction) throws IOException { private void inspectStartupTransaction(Transaction transaction) throws IOException {
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve( ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
IrisWorldStorage.levelRoot().toPath(), ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
transaction.worldKey()
);
ReplacementPaths paths = replacementPaths(target, transaction.id());
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot( WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML, ServerProperties.BUKKIT_YML,
transaction.worldName() transaction.worldName()
); );
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction); WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction);
if (transaction.phase() == Phase.ROLLBACK_PENDING) { if (transaction.phase() == Phase.CLEANUP_PENDING) {
processRollback(transaction, paths, current, replacement); if (!current.matchesGeneratorAndSeed(replacement)) {
throw new IOException("A verified replacement no longer matches bukkit.yml.");
}
WorldReplacementFilesystem.validateCommittedTarget(paths, transaction.packFingerprint());
return; return;
} }
if (transaction.phase() == Phase.ROLLBACK_PENDING
|| transaction.phase() == Phase.ROLLBACK_CLEANUP
|| transaction.phase() == Phase.ARMED) {
throw new RestartBoundaryRequired("The world-storage transaction has not reached its cold bootstrap.");
}
if (transaction.phase() == Phase.PREPARED) { if (transaction.phase() == Phase.PREPARED) {
if (current.matchesGeneratorAndSeed(replacement)) { if (current.matchesGeneratorAndSeed(replacement)) {
transaction = transaction.withPhase(Phase.ARMED); writeTransaction(transaction.withPhase(Phase.ARMED));
writeTransaction(transaction); throw new RestartBoundaryRequired("The replacement was armed before its journal phase was durable.");
} else if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) { } else if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
WorldReplacementFilesystem.discardStage(paths); WorldReplacementFilesystem.discardStage(paths);
deleteJournal(transaction.id()); deleteJournal(transaction.id());
@@ -350,53 +360,40 @@ public final class PendingWorldReplacementManager implements Listener {
throw new IOException("bukkit.yml does not match the prepared replacement or its original state."); throw new IOException("bukkit.yml does not match the prepared replacement or its original state.");
} }
} }
if (transaction.phase() == Phase.ARMED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
throw new IOException("bukkit.yml no longer authorizes the armed replacement.");
}
WorldReplacementFilesystem.publish(
paths,
transaction.originalTargetPresent(),
transaction.packFingerprint()
);
transaction = transaction.withPhase(Phase.PUBLISHED);
writeTransaction(transaction);
Iris.success("Published Iris world replacement for " + transaction.worldKey()
+ "; waiting for runtime verification.");
}
if (transaction.phase() == Phase.PUBLISHED) { if (transaction.phase() == Phase.PUBLISHED) {
if (!current.matchesGeneratorAndSeed(replacement)) { if (!current.matchesGeneratorAndSeed(replacement)) {
throw new IOException("bukkit.yml changed after the replacement was published."); throw new IOException("bukkit.yml changed after the replacement was published.");
} }
String fingerprint = WorldReplacementFilesystem.fingerprintPack( WorldReplacementFilesystem.validatePublishedTarget(
paths.target().resolve("iris/pack")); paths,
if (!transaction.packFingerprint().equals(fingerprint)) { transaction.originalTargetPresent(),
throw new IOException("Published replacement pack fingerprint does not match its journal."); transaction.packFingerprint()
} );
} }
} }
private void processRollback( private synchronized void scheduleCommittedCleanup(Transaction transaction) {
Transaction transaction, if (!cleanupInFlight.add(transaction.id())) {
ReplacementPaths paths, return;
WorldGeneratorSnapshot current, }
WorldGeneratorSnapshot replacement J.a(() -> cleanupCommittedReplacement(transaction));
) throws IOException { }
if (current.matchesGeneratorAndSeed(replacement)) {
if (!BukkitWorldConfiguration.restoreIfMatching( private void cleanupCommittedReplacement(Transaction transaction) {
ServerProperties.BUKKIT_YML, try {
transaction.worldName(), ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
replacement, ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
transaction.originalConfiguration() WorldReplacementFilesystem.cleanupBackup(paths);
)) { deleteJournal(transaction.id());
throw new IOException("bukkit.yml changed during startup rollback."); Iris.success("Removed the retained backup for " + transaction.worldKey() + ".");
} } catch (Throwable failure) {
} else if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) { Iris.reportError("Could not clean the retained backup for " + transaction.worldKey()
throw new IOException("bukkit.yml conflicts with the pending world rollback."); + "; cleanup will retry without rolling back the verified world.", failure);
} finally {
synchronized (this) {
cleanupInFlight.remove(transaction.id());
}
} }
WorldReplacementFilesystem.rollback(paths, transaction.originalTargetPresent());
deleteJournal(transaction.id());
Iris.success("Restored the retained world for " + transaction.worldKey() + ".");
} }
private ExactWorldSlotPathPolicy.Target prepareTarget(NamespacedKey worldKey) throws IOException { private ExactWorldSlotPathPolicy.Target prepareTarget(NamespacedKey worldKey) throws IOException {
@@ -177,6 +177,22 @@ public final class WorldReplacementFilesystem {
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint); requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
} }
public static void validatePublishedTarget(
ReplacementPaths paths,
boolean originalTargetPresent,
String expectedPackFingerprint
) throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
String expectedFingerprint = requireFingerprint(expectedPackFingerprint);
State state = inspect(requiredPaths);
if (!state.targetPresent() || state.stagePresent()
|| originalTargetPresent != state.backupPresent()) {
throw new IOException("Published replacement storage does not match its retained-world contract.");
}
requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
}
public static String fingerprintPack(Path packRoot) throws IOException { public static String fingerprintPack(Path packRoot) throws IOException {
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
requireDirectory(root, "pack root"); requireDirectory(root, "pack root");