Modded adapters

This commit is contained in:
Brian Neumann-Fopiano committed 2026-08-15 15:58:26 -04:00
1 parent 8297e49ed2
commit ef2e1b8f58
29 files changed
+2647 -710

No files matched your search

@@ -40,6 +40,7 @@ import java.util.stream.Stream;
public final class IrisDatapackCompiler {
private static final int INPUT_FINGERPRINT_SCHEMA = 2;
private static final int INPUT_BUFFER_BYTES = 64 * 1024;
private static final int WORLD_PACK_SCAN_DEPTH = 8;
private static final List<String> INPUT_DIRECTORIES = List.of("dimensions", "biomes", "snippet");
private static final String FLAT_VOID_LEVEL_STEM = """
@@ -138,18 +139,26 @@ public final class IrisDatapackCompiler {
List<CompilerInputEntry> entries = collectCompilerInputEntries(normalizedRoot);
updateDigestInt(digest, entries.size());
byte[] buffer = new byte[8192];
byte[] buffer = new byte[INPUT_BUFFER_BYTES];
for (CompilerInputEntry entry : entries) {
updateDigestString(digest, entry.relativePath());
updateDigestLong(digest, Files.size(entry.source()));
try (InputStream input = Files.newInputStream(entry.source())) {
updateDigestLong(digest, entry.size());
long readBytes = 0L;
try (InputStream input = Files.newInputStream(
entry.source(),
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
readBytes += read;
}
}
}
if (readBytes != entry.size()) {
throw new IOException("Iris datapack compiler input changed while hashing: " + entry.source());
}
}
}
return HexFormat.of().formatHex(digest.digest());
@@ -326,26 +335,53 @@ public final class IrisDatapackCompiler {
|| !Files.isDirectory(dimensionsRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
List<Path> namespaces = visibleDirectories(dimensionsRoot);
List<Path> candidates = new ArrayList<>();
Files.walkFileTree(dimensionsRoot, Set.of(), WORLD_PACK_SCAN_DEPTH, new SimpleFileVisitor<>() {
for (Path namespace : namespaces) {
collectNamespaceWorldPackRoots(namespace, candidates);
}
candidates.sort(Comparator.comparing(Path::toString));
for (Path candidate : candidates) {
addPackRoot(candidate, roots, validateWholePack);
}
}
private static void collectNamespaceWorldPackRoots(
Path namespace,
List<Path> candidates
) throws IOException {
Files.walkFileTree(namespace, Set.of(), WORLD_PACK_SCAN_DEPTH, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
if (!directory.equals(dimensionsRoot)
&& PackDirectoryResolver.containsHiddenPathSegment(dimensionsRoot, directory)) {
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) {
if (!directory.equals(namespace)
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
return FileVisitResult.SKIP_SUBTREE;
}
if ("pack".equals(directory.getFileName().toString())
&& directory.getParent() != null
&& "iris".equals(directory.getParent().getFileName().toString())
&& hasDimensions(directory)) {
candidates.add(directory);
Path irisRoot = directory.resolve("iris");
Path candidate = irisRoot.resolve("pack");
if (!Files.isSymbolicLink(irisRoot)
&& !Files.isSymbolicLink(candidate)
&& Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)
&& hasDimensions(candidate)) {
candidates.add(candidate);
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
});
candidates.sort(Comparator.comparing(Path::toString));
for (Path candidate : candidates) {
addPackRoot(candidate, roots, validateWholePack);
}
private static List<Path> visibleDirectories(Path root) throws IOException {
try (Stream<Path> entries = Files.list(root)) {
return entries
.filter(entry -> !PackDirectoryResolver.isHiddenName(entry.getFileName().toString()))
.filter(entry -> !Files.isSymbolicLink(entry))
.filter(entry -> Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS))
.sorted(Comparator.comparing(Path::toString))
.toList();
}
}
@@ -416,7 +452,7 @@ public final class IrisDatapackCompiler {
}
if (file.getFileName().toString().endsWith(".json")) {
String relativePath = packRoot.relativize(file).toString().replace(File.separatorChar, '/');
entries.add(new CompilerInputEntry(file, relativePath));
entries.add(new CompilerInputEntry(file, relativePath, attributes.size()));
}
return FileVisitResult.CONTINUE;
}
@@ -503,7 +539,7 @@ public final class IrisDatapackCompiler {
public record CompilationResult(int packCount, int dimensionCount, int biomeCount) {
}
private record CompilerInputEntry(Path source, String relativePath) {
private record CompilerInputEntry(Path source, String relativePath, long size) {
}
private record DimensionCandidate(
@@ -72,7 +72,9 @@ import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -86,6 +88,7 @@ public class ServerConfigurator {
private static final Object DATAPACK_INSTALL_LOCK = new Object();
private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace";
private static final String COMPILER_INPUT_FINGERPRINT_CACHE = "datapack-compiler-input-fingerprint";
private static final int FINGERPRINT_BUFFER_BYTES = 64 * 1024;
private static volatile boolean loadedDatapackRuntimeReady;
private static volatile String loadedDatapackCompilerInputFingerprint = "";
private static volatile long loadedDatapackRuntimeGeneration;
@@ -401,7 +404,10 @@ public class ServerConfigurator {
String current;
long fingerprintStart = System.nanoTime();
try {
current = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
current = restoredCompilerInputFingerprint();
if (current.isBlank()) {
current = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
}
} catch (IOException | RuntimeException exception) {
reportTiming(timingConsumer, "datapack_compiler_input_fingerprint", fingerprintStart);
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
@@ -473,6 +479,13 @@ public class ServerConfigurator {
IrisSettings.get().getGeneral().adjustVanillaHeight);
}
static String restoredCompilerInputFingerprint() {
if (!loadedDatapackRuntimeReady || loadedDatapackRestartRequired) {
return "";
}
return Objects.requireNonNullElse(loadedDatapackCompilerInputFingerprint, "");
}
private static boolean pinLoadedDatapackCompilerInputs() {
return pinLoadedDatapackCompilerInputs(null);
}
@@ -570,13 +583,11 @@ public class ServerConfigurator {
List<FingerprintEntry> entries = collectFingerprintEntries(root.toRealPath());
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
for (FingerprintEntry entry : entries) {
BasicFileAttributes attributes = Files.readAttributes(
entry.source(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, relativePath.length);
digest.update(relativePath);
updateDigestLong(digest, attributes.size());
updateDigestLong(digest, attributes.lastModifiedTime().toMillis());
updateDigestLong(digest, entry.size());
updateDigestLong(digest, entry.lastModifiedMillis());
}
return HexFormat.of().formatHex(digest.digest());
} catch (IOException exception) {
@@ -587,31 +598,56 @@ public class ServerConfigurator {
}
public static String computePackFingerprint(File packsDir) {
return computePackContentSnapshot(packsDir).content();
}
public static PackContentSnapshot computePackContentSnapshot(File packsDir) {
Path root = resolveFingerprintRoot(packsDir);
if (root == null) {
return "";
return new PackContentSnapshot("", Map.of());
}
try {
Path resolvedRoot = root.toRealPath();
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Map<String, MessageDigest> packDigests = new LinkedHashMap<>();
List<FingerprintEntry> entries = collectFingerprintEntries(resolvedRoot);
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
byte[] buffer = new byte[8192];
byte[] buffer = new byte[FINGERPRINT_BUFFER_BYTES];
for (FingerprintEntry entry : entries) {
byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, relativePath.length);
digest.update(relativePath);
updateDigestLong(digest, Files.size(entry.source()));
try (InputStream input = Files.newInputStream(entry.source())) {
MessageDigest packDigest = entry.packName() == null
? null
: packDigests.computeIfAbsent(entry.packName(), ignored -> newSha256Digest());
updateFingerprintEntry(digest, entry.relativePath(), entry.size());
if (packDigest != null) {
updateFingerprintEntry(packDigest, entry.packRelativePath(), entry.size());
}
long readBytes = 0L;
try (InputStream input = Files.newInputStream(
entry.source(),
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
if (packDigest != null) {
packDigest.update(buffer, 0, read);
}
readBytes += read;
}
}
}
if (readBytes != entry.size()) {
throw new IOException("Iris pack changed while fingerprinting: " + entry.source());
}
}
return HexFormat.of().formatHex(digest.digest());
Map<String, String> packContents = new LinkedHashMap<>();
for (Map.Entry<String, MessageDigest> entry : packDigests.entrySet()) {
packContents.put(entry.getKey(), HexFormat.of().formatHex(entry.getValue().digest()));
}
return new PackContentSnapshot(
HexFormat.of().formatHex(digest.digest()),
Map.copyOf(packContents));
} catch (IOException exception) {
throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception);
} catch (NoSuchAlgorithmException e) {
@@ -619,6 +655,59 @@ public class ServerConfigurator {
}
}
public static String computePackTreeFingerprint(File packDir) {
Path root = resolveFingerprintRoot(packDir);
if (root == null) {
return "";
}
try {
List<FingerprintEntry> entries = new ArrayList<>();
collectFingerprintTree(root.toRealPath(), "", null, entries);
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[FINGERPRINT_BUFFER_BYTES];
for (FingerprintEntry entry : entries) {
updateFingerprintEntry(digest, entry.relativePath(), entry.size());
long readBytes = 0L;
try (InputStream input = Files.newInputStream(
entry.source(),
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
readBytes += read;
}
}
}
if (readBytes != entry.size()) {
throw new IOException("Iris pack changed while fingerprinting: " + entry.source());
}
}
return HexFormat.of().formatHex(digest.digest());
} catch (IOException exception) {
throw new UncheckedIOException("Unable to fingerprint Iris pack at " + root, exception);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 not available", exception);
}
}
private static MessageDigest newSha256Digest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 not available", exception);
}
}
private static void updateFingerprintEntry(MessageDigest digest, String relativePath, long size) {
byte[] relativeBytes = relativePath.getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, relativeBytes.length);
digest.update(relativeBytes);
updateDigestLong(digest, size);
}
private static Path resolveFingerprintRoot(File packsDir) {
if (packsDir == null) {
return null;
@@ -714,11 +803,19 @@ public class ServerConfigurator {
throw new IOException("Iris pack fingerprint rejected symbolic link: " + child);
}
PackDirectoryResolver.requireSafePackTree(child.toFile());
collectFingerprintTree(child.toRealPath(), childName, entries);
collectFingerprintTree(child.toRealPath(), childName, childName, entries);
} else if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
collectFingerprintTree(child, childName, entries);
collectFingerprintTree(child, childName, childName, entries);
} else if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)) {
entries.add(new FingerprintEntry(child, childName));
BasicFileAttributes attributes = Files.readAttributes(
child, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
entries.add(new FingerprintEntry(
child,
childName,
null,
null,
attributes.size(),
attributes.lastModifiedTime().toMillis()));
} else {
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + child);
}
@@ -730,12 +827,17 @@ public class ServerConfigurator {
private static void collectFingerprintTree(
Path treeRoot,
String logicalRoot,
String packName,
List<FingerprintEntry> entries
) throws IOException {
Files.walkFileTree(treeRoot, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) {
if (!directory.equals(treeRoot)
&& treeRoot.relativize(directory).getNameCount() == 1
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
return FileVisitResult.SKIP_SUBTREE;
}
@@ -745,7 +847,9 @@ public class ServerConfigurator {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
String fileName = file.getFileName().toString();
if (PackDirectoryResolver.isHiddenName(fileName) || isGeneratedPackFile(fileName)) {
if ((treeRoot.relativize(file).getNameCount() == 1
&& PackDirectoryResolver.isHiddenName(fileName))
|| isGeneratedPackFile(fileName)) {
return FileVisitResult.CONTINUE;
}
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
@@ -755,7 +859,14 @@ public class ServerConfigurator {
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + file);
}
String relative = treeRoot.relativize(file).toString().replace(File.separatorChar, '/');
entries.add(new FingerprintEntry(file, logicalRoot + "/" + relative));
String logicalRelative = logicalRoot.isEmpty() ? relative : logicalRoot + "/" + relative;
entries.add(new FingerprintEntry(
file,
logicalRelative,
packName,
packName == null ? null : relative,
attributes.size(),
attributes.lastModifiedTime().toMillis()));
return FileVisitResult.CONTINUE;
}
@@ -770,12 +881,26 @@ public class ServerConfigurator {
return name != null && name.endsWith(CODE_WORKSPACE_SUFFIX);
}
private record FingerprintEntry(Path source, String relativePath) {
private record FingerprintEntry(
Path source,
String relativePath,
String packName,
String packRelativePath,
long size,
long lastModifiedMillis
) {
}
record PackFingerprint(String metadata, String content) {
}
public record PackContentSnapshot(String content, Map<String, String> packContents) {
public PackContentSnapshot {
content = Objects.requireNonNullElse(content, "");
packContents = Map.copyOf(Objects.requireNonNullElse(packContents, Map.of()));
}
}
private record FingerprintCache(String content, String metadata) {
}
@@ -62,11 +62,13 @@ import java.net.URL;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileVisitResult;
import java.nio.file.FileStore;
import java.nio.file.FileSystems;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
@@ -119,6 +121,7 @@ public final class DatapackIngestService {
private static final int MAX_TRANSACTION_COUNT = 1_024;
private static final int MAX_SCRATCH_DELETE_ATTEMPTS = 3;
private static final int WINDOWS_LEGACY_PATH_LIMIT = 247;
private static final int HASH_BUFFER_BYTES = 64 * 1024;
private static final Set<String> RESERVED_IDS = Set.of("iris");
private static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock();
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
@@ -152,20 +155,23 @@ public final class DatapackIngestService {
Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath();
try {
String localFingerprint = startupValidationFingerprint(root, worldFolders);
StartupValidationCache cached = readStartupValidationCache(cacheFile);
if (startupValidationCacheMatches(
cached,
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
localFingerprint)) {
activeStartupValidation = cached;
IrisLogging.info("External datapacks match the persisted startup validation; remote resolution and full revalidation were skipped.");
IrisStartupValidation.markDatapacksReady();
return StartupValidationOutcome.READY;
if (startupValidationContextMatches(
cached, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) {
String localFingerprint = startupValidationFingerprint(root, worldFolders);
if (startupValidationCacheMatches(
cached,
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
localFingerprint)) {
activeStartupValidation = cached;
IrisLogging.info("External datapacks match the persisted startup validation; remote resolution and full revalidation were skipped.");
IrisStartupValidation.markDatapacksReady();
return StartupValidationOutcome.READY;
}
}
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Persisted external datapack validation could not be reused: "
@@ -216,8 +222,8 @@ public final class DatapackIngestService {
public static void runPostStartupTasks() {
refreshWorkspaces();
autoImportDatapackStructures();
refreshStartupValidationAfterMaintenance();
boolean maintenanceChanged = autoImportDatapackStructures();
refreshStartupValidationAfterMaintenance(maintenanceChanged);
}
private static StartupValidationCache cacheStartupValidation(
@@ -247,7 +253,10 @@ public final class DatapackIngestService {
}
}
private static void refreshStartupValidationAfterMaintenance() {
private static void refreshStartupValidationAfterMaintenance(boolean maintenanceChanged) {
if (!maintenanceChanged) {
return;
}
StartupValidationCache validated = activeStartupValidation;
if (validated == null || !IrisStartupValidation.isReady()) {
return;
@@ -623,6 +632,9 @@ public final class DatapackIngestService {
ManifestWrite manifestWrite = null;
boolean manifestDurabilityConfirmed = false;
try {
for (InstallExecution install : installs) {
verifyInstallExecution(install);
}
manifestWrite = prepareManifestWrite(root, manifest);
manifestWrite.publish();
manifestDurabilityConfirmed = true;
@@ -631,8 +643,9 @@ public final class DatapackIngestService {
rollbackInstallExecutions(installs, manifestFailure);
report.failed.add("manifest - " + manifestFailure.getMessage());
report.updated.clear();
report.upToDate.clear();
report.requiresRestart = false;
message(sender, C.RED + "Datapack ingest rolled back because the manifest could not be committed: "
message(sender, C.RED + "Datapack ingest rolled back before the manifest commit: "
+ manifestFailure.getMessage());
IrisLogging.reportError(manifestFailure);
return report;
@@ -912,7 +925,7 @@ public final class DatapackIngestService {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path rootPath = root.toPath().toAbsolutePath().normalize();
List<Path> entries = new ArrayList<>();
List<MetadataEntry> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootPath)) {
Iterator<Path> iterator = paths.iterator();
int pathCount = 0;
@@ -928,17 +941,18 @@ public final class DatapackIngestService {
if (Files.isSymbolicLink(path)) {
throw new IOException("Datapack contains a symbolic link: " + path);
}
entries.add(path);
String relativePath = rootPath.relativize(path).toString();
entries.add(new MetadataEntry(path, relativePath));
}
}
entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString()));
for (Path entry : entries) {
String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/');
entries.sort(Comparator.comparing(MetadataEntry::relativePath));
for (MetadataEntry entry : entries) {
String relative = entry.relativePath().replace(File.separatorChar, '/');
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
BasicFileAttributes attributes = Files.readAttributes(
entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
entry.path(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack contains an unsupported filesystem entry: " + entry);
throw new IOException("Datapack contains an unsupported filesystem entry: " + entry.path());
}
digest.update((byte) (attributes.isDirectory() ? 1 : 2));
updateDigestInt(digest, relativeBytes.length);
@@ -1909,6 +1923,12 @@ public final class DatapackIngestService {
stripOverrides,
root
);
try {
verifyInstallExecution(execution);
} catch (IOException failure) {
rollbackInstallExecutions(List.of(execution), failure);
throw failure;
}
finishInstallExecution(execution);
return execution.result();
}
@@ -1973,12 +1993,14 @@ public final class DatapackIngestService {
boolean changed = false;
List<InstallPlan> publishPlans = new ArrayList<>();
List<InstallPlan> unchangedPlans = new ArrayList<>();
try {
for (InstallPlan plan : plans) {
changed |= plan.contentChanged();
if (plan.publishRequired()) {
publishPlans.add(plan);
} else {
unchangedPlans.add(plan);
cleanupInstallPlan(plan, true);
}
}
@@ -1993,7 +2015,15 @@ public final class DatapackIngestService {
throw cleanupFailure;
}
if (publishPlans.isEmpty()) {
return new InstallExecution(new InstallResult(changed), null);
return new InstallExecution(
new InstallResult(changed),
null,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
Manifest committedManifest = readCommittedManifest(root);
@@ -2033,10 +2063,44 @@ public final class DatapackIngestService {
}
throw publishFailure;
}
return new InstallExecution(new InstallResult(changed), coordinator);
return new InstallExecution(
new InstallResult(changed),
coordinator,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
static void verifyInstallExecution(InstallExecution execution) throws IOException {
if (execution.verified()) {
return;
}
if (execution.verifyStagedSource()) {
validateManagedDirectory(execution.stagedDir(), execution.entry().id);
Ownership stagedOwnership = readOwnership(execution.stagedDir());
String stagedHash = directoryHash(execution.stagedDir());
if (!Objects.equals(stagedHash, execution.stagedHash())
|| !ownershipMetadataMatches(stagedOwnership, execution.entry(), stagedHash)) {
throw new IOException("Iris datapack staging changed before installation commit for "
+ execution.entry().id);
}
}
for (InstallPlan plan : execution.publishedPlans()) {
verifyDesiredInstallSnapshot(plan.target(), plan, "published datapack target");
}
for (InstallPlan plan : execution.unchangedPlans()) {
verifyOriginalInstallSnapshot(plan.target(), plan, "unchanged datapack target");
}
execution.markVerified();
}
static void finishInstallExecution(InstallExecution execution) throws IOException {
if (!execution.verified()) {
throw new IOException("Datapack install cannot commit before final verification");
}
if (execution.coordinator() == null) {
return;
}
@@ -2118,6 +2182,17 @@ public final class DatapackIngestService {
) throws IOException {
ensureInstallTargetRoot(worldFolder);
File target = new File(worldFolder, entry.id);
InstallPlan unchanged = tryPrepareUnchangedManagedInstall(
stagedDir,
worldFolder,
target,
entry,
stagedHash,
stripOverrides,
verifiedStagingInstall);
if (unchanged != null) {
return unchanged;
}
boolean canonicalStagingInstall = verifiedStagingInstall != null
&& verifiedStagingInstall.isCanonicalInstall(worldFolder, target);
boolean legacyReplacementAuthorized = canonicalStagingInstall
@@ -2134,16 +2209,22 @@ public final class DatapackIngestService {
String scratchRootFileIdentity = directoryIdentity(pendingRoot);
IO.copyDirectory(stagedDir.toPath(), pending.toPath());
Files.deleteIfExists(new File(pending, OWNERSHIP_MARKER).toPath());
if (!Objects.equals(stagedHash, directoryHash(pending))) {
String copiedHash = directoryHash(pending);
if (!Objects.equals(stagedHash, copiedHash)) {
throw new IOException("Datapack staging changed or copied incompletely while preparing " + entry.id);
}
Files.deleteIfExists(new File(pending, OVERRIDES_STRIPPED_MARKER).toPath());
boolean removedOverrideMarker = Files.deleteIfExists(
new File(pending, OVERRIDES_STRIPPED_MARKER).toPath());
if (stripOverrides) {
stripVanillaStructureOverrides(pending);
writeMarker(new File(pending, OVERRIDES_STRIPPED_MARKER));
}
validatePackMetadata(pending);
writeOwnership(pending, entry);
if (!stripOverrides && !removedOverrideMarker) {
writeOwnership(pending, entry, copiedHash);
} else {
writeOwnership(pending, entry);
}
validateInstallTree(pending, worldFolder, "Prepared datapack install");
Ownership desiredOwnership = readOwnership(pending);
String desiredHash = desiredOwnership.contentHash;
@@ -2243,6 +2324,69 @@ public final class DatapackIngestService {
}
}
private static InstallPlan tryPrepareUnchangedManagedInstall(
File stagedDir,
File worldFolder,
File target,
Entry entry,
String stagedHash,
boolean stripOverrides,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
if (verifiedStagingInstall != null
|| stripOverrides
|| pathExists(new File(stagedDir, OVERRIDES_STRIPPED_MARKER).toPath(),
"staged datapack override marker")
|| !pathExists(target.toPath(), "datapack install target")) {
return null;
}
if (Files.isSymbolicLink(target.toPath())
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
}
Ownership ownership = readOwnershipOrNull(target);
if (ownership == null) {
return null;
}
if (!entry.id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + target.getPath());
}
if (!ownershipMetadataMatches(ownership, entry, stagedHash)) {
return null;
}
validateInstallTree(target, worldFolder, "Existing datapack install");
removeFinderMetadata(target);
String currentHash = directoryHash(target);
if (!Objects.equals(currentHash, stagedHash)) {
return null;
}
String markerHash = ownershipMarkerFingerprint(target);
String identity = directoryIdentity(target);
File pendingRoot = installScratchRoot(worldFolder);
return new InstallPlan(
target,
new File(pendingRoot, entry.id + "-" + UUID.randomUUID()),
new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID()),
pendingRoot,
true,
false,
false,
currentHash,
currentHash,
markerHash,
markerHash,
identity,
identity,
realDirectoryPath(worldFolder, "datapack target root"),
"",
directoryIdentity(worldFolder),
"",
entry.id,
entry.url,
null
);
}
private static File installScratchRoot(File targetFolder) {
File parent = targetFolder.getParentFile();
return new File(parent == null ? targetFolder : parent, ".iris-datapack-install");
@@ -2550,10 +2694,16 @@ public final class DatapackIngestService {
}
static void writeOwnership(File directory, Entry entry) throws IOException {
writeOwnership(directory, entry, directoryHash(directory));
}
private static void writeOwnership(File directory, Entry entry, String contentHash) throws IOException {
if (!isValidManagedId(entry.id) || entry.url == null || entry.url.isBlank()) {
throw new IOException("Invalid Iris datapack ownership identity for " + directory.getPath());
}
String contentHash = directoryHash(directory);
if (contentHash == null || contentHash.isBlank()) {
throw new IOException("Missing Iris datapack ownership content hash for " + directory.getPath());
}
Ownership ownership = new Ownership(
OWNERSHIP_SCHEMA,
entry.id,
@@ -2640,6 +2790,10 @@ public final class DatapackIngestService {
}
static boolean isUsableStaging(File stagedDir, Entry entry) {
return inspectUsableStaging(stagedDir, entry).usable();
}
private static StagingInspection inspectUsableStaging(File stagedDir, Entry entry) {
try {
validateManagedDirectory(stagedDir, entry.id);
Ownership ownership = readOwnership(stagedDir);
@@ -2649,22 +2803,28 @@ public final class DatapackIngestService {
|| !Objects.equals(ownership.versionNumber, entry.versionNumber)
|| !Objects.equals(ownership.sha1, entry.sha1)
|| !Objects.equals(ownership.contentHash, contentHash)) {
return false;
return new StagingInspection(false, false, false);
}
PackResources resources = scanPackResources(stagedDir);
List<String> previousStructureKeys = copyList(entry.structureKeys);
List<String> previousTemplateKeys = copyList(entry.templateKeys);
boolean ownershipCorrected = false;
if (!copyList(resources.structureKeys).equals(copyList(ownership.structureKeys))
|| !copyList(resources.templateKeys).equals(copyList(ownership.templateKeys))) {
Entry corrected = copyEntry(entry);
corrected.structureKeys = resources.structureKeys;
corrected.templateKeys = resources.templateKeys;
writeOwnership(stagedDir, corrected);
ownershipCorrected = true;
}
entry.structureKeys = resources.structureKeys;
entry.templateKeys = resources.templateKeys;
return true;
boolean manifestChanged = !previousStructureKeys.equals(copyList(entry.structureKeys))
|| !previousTemplateKeys.equals(copyList(entry.templateKeys));
return new StagingInspection(true, ownershipCorrected, manifestChanged);
} catch (IOException e) {
IrisLogging.warn("Ignoring unusable Iris datapack staging at " + stagedDir.getPath() + ": " + e.getMessage());
return false;
return new StagingInspection(false, false, false);
}
}
@@ -2697,6 +2857,14 @@ public final class DatapackIngestService {
}
}
private static boolean sameDatapackVolume(
Path root,
FileStore rootStore,
Path entry
) throws IOException {
return sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry));
}
private static String directoryHash(File root) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
@@ -2709,10 +2877,7 @@ public final class DatapackIngestService {
int pathCount = 0;
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(rootPath)) {
continue;
}
if (path.equals(rootMarker)) {
if (path.equals(rootPath) || path.equals(rootMarker)) {
continue;
}
if (isFinderMetadata(path)) {
@@ -2737,7 +2902,7 @@ public final class DatapackIngestService {
}
}
entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString()));
byte[] buffer = new byte[8192];
byte[] buffer = new byte[HASH_BUFFER_BYTES];
long totalBytes = 0;
for (Path entry : entries) {
String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/');
@@ -2885,36 +3050,56 @@ public final class DatapackIngestService {
return true;
}
private static void autoImportDatapackStructures() {
private static boolean autoImportDatapackStructures() {
TRANSACTION_LOCK.lock();
try {
autoImportDatapackStructuresLocked();
return autoImportDatapackStructuresLocked();
} finally {
TRANSACTION_LOCK.unlock();
}
}
private static void autoImportDatapackStructuresLocked() {
private static boolean autoImportDatapackStructuresLocked() {
boolean autoImportEnabled = IrisSettings.get().getGeneral().autoImportDatapackStructures;
File root = IrisPlatforms.get().dataFolder("datapacks");
boolean recovered;
try {
recoverTransactions(root, ServerConfigurator.getDatapacksFolder());
recovered = recoverTransactions(root, ServerConfigurator.getDatapacksFolder());
} catch (IOException e) {
IrisLogging.reportError("Automatic datapack structure import blocked by incomplete transaction recovery.", e);
return;
return false;
}
Manifest manifest = readManifest(root);
if (manifest.entries.isEmpty()) {
return;
return recovered;
}
Map<String, Entry> manifestEntriesByUrl = new HashMap<>();
Map<String, Entry> entriesByUrl = new HashMap<>();
File stagingRoot = new File(root, "staging");
for (Entry entry : manifest.entries) {
if (entry.url != null) {
manifestEntriesByUrl.put(entry.url, entry);
}
if (entry.url != null && isUsableStaging(new File(stagingRoot, entry.id), entry)) {
}
List<IrisData> packs;
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
packs = stream.filter(Objects::nonNull).toList();
}
if (!autoImportEnabled && !hasRemovedImportState(packs, manifest.entries)) {
return recovered;
}
Map<String, Entry> entriesByUrl = new HashMap<>();
File stagingRoot = new File(root, "staging");
boolean stagingStateChanged = false;
boolean manifestStagingMetadataChanged = false;
for (Entry entry : manifest.entries) {
if (entry.url == null) {
continue;
}
StagingInspection inspection = inspectUsableStaging(new File(stagingRoot, entry.id), entry);
stagingStateChanged |= inspection.ownershipCorrected();
manifestStagingMetadataChanged |= inspection.manifestChanged();
if (inspection.usable()) {
entriesByUrl.put(entry.url, entry);
}
}
@@ -2924,10 +3109,6 @@ public final class DatapackIngestService {
int cleanupTargets = 0;
Set<String> completedUrls = new HashSet<>();
Set<String> failedUrls = new HashSet<>();
List<IrisData> packs;
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
packs = stream.filter(Objects::nonNull).toList();
}
for (IrisData data : packs) {
Set<String> configured = configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
@@ -3055,16 +3236,33 @@ public final class DatapackIngestService {
}
}
if (attemptedPacks == 0 && cleanupTargets == 0) {
return;
if (manifestStagingMetadataChanged) {
writeManifest(root, manifest);
}
return recovered || stagingStateChanged || manifestStagingMetadataChanged;
}
writeManifest(root, manifest);
if (attemptedPacks == 0) {
IrisLogging.info("Datapack editable-import cleanup reconciled " + cleanupTargets + " removed source target(s).");
return;
return true;
}
IrisLogging.info("Datapack structure import refreshed " + completedUrls.size() + " source(s) across "
+ completedPacks + "/" + attemptedPacks
+ " pack(s). Reference the imported keys from a 'structures' placement to position them manually.");
return true;
}
private static boolean hasRemovedImportState(List<IrisData> packs, List<Entry> entries) {
for (IrisData data : packs) {
Set<String> configured = configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
return true;
}
}
}
return false;
}
static String importRevision(Entry entry) {
@@ -4237,24 +4435,42 @@ public final class DatapackIngestService {
private static void validateScratchTree(Path root) throws IOException {
FileStore rootStore = Files.getFileStore(root);
try (Stream<Path> paths = Files.walk(root)) {
List<Path> entries = paths.limit(MAX_MANAGED_PATHS + 1L).toList();
if (entries.size() > MAX_MANAGED_PATHS) {
throw new IOException("Datapack scratch contains too many paths: " + root);
}
for (Path entry : entries) {
BasicFileAttributes attributes = Files.readAttributes(
entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
int[] pathCount = new int[]{0};
Files.walkFileTree(root, new SimpleFileVisitor<>() {
private FileVisitResult inspect(Path entry, BasicFileAttributes attributes) throws IOException {
pathCount[0]++;
if (pathCount[0] > MAX_MANAGED_PATHS) {
throw new IOException("Datapack scratch contains too many paths: " + root);
}
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| (!attributes.isDirectory() && !attributes.isRegularFile())) {
throw new IOException("Datapack scratch contains an unsupported file: " + entry);
}
if (!sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry))) {
if (!sameDatapackVolume(root, rootStore, entry)) {
throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry);
}
return FileVisitResult.CONTINUE;
}
}
@Override
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) throws IOException {
return inspect(directory, attributes);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
return inspect(file, attributes);
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to inspect datapack scratch entry: " + file, failure);
}
});
}
private static boolean sameScratchVolume(Path first, Path second) throws IOException {
@@ -5450,7 +5666,76 @@ public final class DatapackIngestService {
FAILED
}
record InstallExecution(InstallResult result, DatapackCoordinator coordinator) {
static final class InstallExecution {
private final InstallResult result;
private final DatapackCoordinator coordinator;
private final File stagedDir;
private final Entry entry;
private final String stagedHash;
private final boolean verifyStagedSource;
private final List<InstallPlan> publishedPlans;
private final List<InstallPlan> unchangedPlans;
private boolean verified;
private InstallExecution(
InstallResult result,
DatapackCoordinator coordinator,
File stagedDir,
Entry entry,
String stagedHash,
boolean verifyStagedSource,
List<InstallPlan> publishedPlans,
List<InstallPlan> unchangedPlans
) {
this.result = result;
this.coordinator = coordinator;
this.stagedDir = stagedDir;
this.entry = copyEntry(entry);
this.stagedHash = stagedHash;
this.verifyStagedSource = verifyStagedSource;
this.publishedPlans = List.copyOf(publishedPlans);
this.unchangedPlans = List.copyOf(unchangedPlans);
}
InstallResult result() {
return result;
}
private DatapackCoordinator coordinator() {
return coordinator;
}
private File stagedDir() {
return stagedDir;
}
private Entry entry() {
return entry;
}
private String stagedHash() {
return stagedHash;
}
private boolean verifyStagedSource() {
return verifyStagedSource;
}
private List<InstallPlan> publishedPlans() {
return publishedPlans;
}
private List<InstallPlan> unchangedPlans() {
return unchangedPlans;
}
private boolean verified() {
return verified;
}
private void markVerified() {
verified = true;
}
}
private record PackResources(
@@ -5460,6 +5745,13 @@ public final class DatapackIngestService {
) {
}
private record StagingInspection(
boolean usable,
boolean ownershipCorrected,
boolean manifestChanged
) {
}
public record StructureScopeResources(
String source,
List<String> structureKeys,
@@ -5840,6 +6132,9 @@ public final class DatapackIngestService {
) {
}
private record MetadataEntry(Path path, String relativePath) {
}
private record DirectoryMove(
File target,
File backup,
@@ -59,6 +59,7 @@ import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public interface INMSBinding {
boolean hasTile(Material material);
@@ -279,6 +280,10 @@ public interface INMSBinding {
throw new UnsupportedOperationException("The active NMS binding does not support current Paper world data staging.");
}
default boolean awaitServerShutdownBoundary(long timeout, TimeUnit unit) {
return true;
}
KMap<Material, List<BlockProperty>> getBlockProperties();
private void validateDimensionTypes(WorldCreator c) {
@@ -0,0 +1,59 @@
package art.arcane.iris.core.nms;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
public final class ServerShutdownBoundary {
private static final long MAX_JOIN_SLICE_MILLIS = 1000L;
private ServerShutdownBoundary() {
}
public static boolean await(
BooleanSupplier boundaryReached,
Thread serverThread,
long timeout,
TimeUnit unit
) {
BooleanSupplier reached = Objects.requireNonNull(boundaryReached, "Server shutdown boundary");
Thread activeServerThread = Objects.requireNonNull(serverThread, "Server thread");
TimeUnit activeUnit = Objects.requireNonNull(unit, "Server shutdown timeout unit");
if (reached.getAsBoolean()) {
return true;
}
if (activeServerThread == Thread.currentThread()) {
return false;
}
long timeoutNanos = Math.max(0L, activeUnit.toNanos(timeout));
long started = System.nanoTime();
boolean interrupted = false;
while (!reached.getAsBoolean()) {
long remaining = timeoutNanos - (System.nanoTime() - started);
if (remaining <= 0L || !activeServerThread.isAlive()) {
restoreInterrupt(interrupted);
return reached.getAsBoolean();
}
long joinMillis = Math.max(
1L,
Math.min(MAX_JOIN_SLICE_MILLIS, TimeUnit.NANOSECONDS.toMillis(remaining))
);
try {
activeServerThread.join(joinMillis);
} catch (InterruptedException e) {
interrupted = true;
}
}
restoreInterrupt(interrupted);
return true;
}
private static void restoreInterrupt(boolean interrupted) {
if (interrupted) {
Thread.currentThread().interrupt();
}
}
}
@@ -19,16 +19,21 @@
package art.arcane.iris.core.pack;
import java.io.IOException;
import java.nio.file.NoSuchFileException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public final class PackValidationRegistry {
private static final Map<String, PackValidationResult> RESULTS = new ConcurrentHashMap<>();
private static final Map<Path, PackValidationResult> ROOT_RESULTS = new ConcurrentHashMap<>();
private static final Map<Path, RootState> ROOT_STATES = new ConcurrentHashMap<>();
private PackValidationRegistry() {
}
@@ -44,7 +49,78 @@ public final class PackValidationRegistry {
if (packRoot == null || result == null) {
return;
}
ROOT_RESULTS.put(normalize(packRoot), result);
publish(normalize(packRoot), new RootValidation(result, ""));
}
public static void publish(Path packRoot, PackValidationResult result, String contentFingerprint) {
if (packRoot == null || result == null || contentFingerprint == null || contentFingerprint.isBlank()) {
return;
}
publish(normalize(packRoot), new RootValidation(result, contentFingerprint));
}
public static PackValidationResult publishMatchingCopy(
Path sourceRoot,
Path targetRoot,
String copiedContentFingerprint
) {
if (sourceRoot == null || targetRoot == null
|| copiedContentFingerprint == null || copiedContentFingerprint.isBlank()) {
return null;
}
RootValidation sourceValidation = matchingValidation(sourceRoot, copiedContentFingerprint);
if (sourceValidation == null) {
return null;
}
publish(normalize(targetRoot), sourceValidation);
return sourceValidation.result();
}
public static RootMutation beginRootMutation(Path packRoot) {
Path normalizedRoot = normalize(Objects.requireNonNull(packRoot, "Pack root"));
AtomicReference<RootMutation> mutation = new AtomicReference<>();
ROOT_STATES.compute(normalizedRoot, (path, current) -> {
if (current != null && current.mutating()) {
throw new IllegalStateException("Iris pack validation is already mutating " + normalizedRoot);
}
long generation = nextGeneration(current);
mutation.set(new RootMutation(normalizedRoot, generation));
return new RootState(generation, true, null);
});
return mutation.get();
}
public static ValidationTicket tryBeginValidation(Path packRoot) {
Path normalizedRoot = normalize(Objects.requireNonNull(packRoot, "Pack root"));
AtomicReference<ValidationTicket> ticket = new AtomicReference<>();
ROOT_STATES.compute(normalizedRoot, (path, current) -> {
RootState state = current == null ? new RootState(0L, false, null) : current;
if (!state.mutating()) {
ticket.set(new ValidationTicket(normalizedRoot, state.generation()));
}
return state;
});
return ticket.get();
}
public static boolean publishIfCurrent(ValidationTicket ticket, PackValidationResult result) {
if (ticket == null || result == null) {
return false;
}
AtomicBoolean published = new AtomicBoolean();
ROOT_STATES.compute(ticket.packRoot, (path, current) -> {
if (current == null
|| current.mutating()
|| current.generation() != ticket.generation) {
return current;
}
published.set(true);
return new RootState(
current.generation(),
false,
new RootValidation(result, ""));
});
return published.get();
}
public static PackValidationResult get(String packName) {
@@ -58,7 +134,10 @@ public final class PackValidationRegistry {
if (packRoot == null) {
return null;
}
return ROOT_RESULTS.get(normalize(packRoot));
RootState state = ROOT_STATES.get(normalize(packRoot));
return state == null || state.mutating() || state.validation() == null
? null
: state.validation().result();
}
public static PackValidationResult requireLoadable(String packName) {
@@ -117,22 +196,158 @@ public final class PackValidationRegistry {
if (packRoot == null) {
return;
}
ROOT_RESULTS.remove(normalize(packRoot));
Path normalizedRoot = normalize(packRoot);
ROOT_STATES.compute(normalizedRoot, (path, current) -> {
if (current != null && current.mutating()) {
return current;
}
return new RootState(nextGeneration(current), false, null);
});
}
public static void clear() {
RESULTS.clear();
ROOT_RESULTS.clear();
ROOT_STATES.clear();
}
private static Path normalize(Path packRoot) {
Path normalizedRoot = packRoot.toAbsolutePath().normalize();
try {
return normalizedRoot.toRealPath();
} catch (NoSuchFileException exception) {
return normalizedRoot;
Path existing = normalizedRoot;
List<Path> missingNames = new ArrayList<>();
while (existing != null && !Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) {
Path name = existing.getFileName();
if (name != null) {
missingNames.add(name);
}
existing = existing.getParent();
}
if (existing == null) {
return normalizedRoot;
}
Path resolved = existing.toRealPath();
for (int index = missingNames.size() - 1; index >= 0; index--) {
resolved = resolved.resolve(missingNames.get(index));
}
return resolved.normalize();
} catch (IOException exception) {
throw new IllegalArgumentException("Unable to resolve Iris pack root: " + normalizedRoot, exception);
}
}
private static void publish(Path normalizedRoot, RootValidation validation) {
ROOT_STATES.compute(normalizedRoot, (path, current) -> {
if (current != null && current.mutating()) {
throw new IllegalStateException("Iris pack validation is mutating " + normalizedRoot);
}
return new RootState(nextGeneration(current), false, validation);
});
}
private static RootValidation matchingValidation(Path sourceRoot, String copiedContentFingerprint) {
Path normalizedSource = normalize(sourceRoot);
RootState sourceState = ROOT_STATES.get(normalizedSource);
if (sourceState == null
|| sourceState.mutating()
|| sourceState.validation() == null
|| !copiedContentFingerprint.equals(sourceState.validation().contentFingerprint())) {
return null;
}
return sourceState.validation();
}
private static long nextGeneration(RootState current) {
return current == null ? 1L : Math.incrementExact(current.generation());
}
private static void closeMutation(Path packRoot, long generation) {
ROOT_STATES.computeIfPresent(packRoot, (path, current) -> {
if (!current.mutating() || current.generation() != generation) {
return current;
}
return new RootState(generation, false, null);
});
}
public static final class RootMutation implements AutoCloseable {
private final Path packRoot;
private final long generation;
private RootValidation pendingValidation;
private boolean closed;
private RootMutation(Path packRoot, long generation) {
this.packRoot = packRoot;
this.generation = generation;
}
public synchronized PackValidationResult stageMatchingCopy(
Path sourceRoot,
String copiedContentFingerprint
) {
requireOpen();
RootValidation matching = matchingValidation(sourceRoot, copiedContentFingerprint);
if (matching == null) {
return null;
}
pendingValidation = matching;
return matching.result();
}
public synchronized void stage(PackValidationResult result) {
requireOpen();
pendingValidation = new RootValidation(
Objects.requireNonNull(result, "Pack validation result"),
"");
}
public synchronized void commit() {
requireOpen();
if (pendingValidation == null) {
throw new IllegalStateException("No pack validation is staged for " + packRoot);
}
AtomicBoolean published = new AtomicBoolean();
ROOT_STATES.computeIfPresent(packRoot, (path, current) -> {
if (!current.mutating() || current.generation() != generation) {
return current;
}
published.set(true);
return new RootState(generation, false, pendingValidation);
});
if (!published.get()) {
throw new IllegalStateException("Iris pack validation mutation lost ownership of " + packRoot);
}
closed = true;
}
@Override
public synchronized void close() {
if (closed) {
return;
}
closed = true;
closeMutation(packRoot, generation);
}
private void requireOpen() {
if (closed) {
throw new IllegalStateException("Iris pack validation mutation is already closed for " + packRoot);
}
}
}
public static final class ValidationTicket {
private final Path packRoot;
private final long generation;
private ValidationTicket(Path packRoot, long generation) {
this.packRoot = packRoot;
this.generation = generation;
}
}
private record RootValidation(PackValidationResult result, String contentFingerprint) {
}
private record RootState(long generation, boolean mutating, RootValidation validation) {
}
}
@@ -210,6 +210,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private final JigsawStudioTripleSneakTracker tripleSneakTracker = new JigsawStudioTripleSneakTracker();
private final JigsawStudioToolCodec toolCodec = new JigsawStudioToolCodec();
private final JigsawStudioPreviewRenderer previewRenderer = new JigsawStudioPreviewRenderer();
private final AtomicBoolean disableStarted = new AtomicBoolean();
private final Object saveLifecycleLock = new Object();
private final Set<UUID> savesInProgress = new HashSet<>();
private final Set<UUID> graphMutationsInProgress = new HashSet<>();
@@ -234,6 +235,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
@Override
public void onEnable() {
disableStarted.set(false);
enabled = true;
menuController = new JigsawStudioMenuController(BukkitPlatform.volmitPlugin(), this);
INSTANCE = this;
@@ -241,13 +243,32 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
@Override
public void onDisable() {
finalizeAllJigsawTileWatches();
drainAutosavesBeforeDisable();
quiesceForServerShutdown();
}
public void quiesceForServerShutdown() {
if (!disableStarted.compareAndSet(false, true)) {
return;
}
try {
finalizeAllJigsawTileWatches();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to finalize Jigsaw Studio tile watches during shutdown.", exception);
}
try {
drainAutosavesBeforeDisable();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to drain Jigsaw Studio autosaves during shutdown.", exception);
}
enabled = false;
JigsawStudioMenuController activeMenuController = menuController;
menuController = null;
if (activeMenuController != null) {
activeMenuController.closeAll();
try {
activeMenuController.closeAll();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to close Jigsaw Studio menus during shutdown.", exception);
}
}
particlesDisabled.clear();
visualizationLoops.clear();
@@ -259,7 +280,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
toolConfirmations.clear();
tripleSneakTracker.clearAll();
evaluations.clear();
previewRenderer.removeAll();
try {
previewRenderer.removeAll();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to remove Jigsaw Studio previews during shutdown.", exception);
}
reopenRequiredRequests.clear();
unregisterRetries.clear();
unregisterDrainWarnings.clear();
@@ -276,6 +301,9 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
}
public void register(Engine engine, JigsawStudioGenerator generator) {
if (!enabled || disableStarted.get()) {
return;
}
Engine activeEngine = Objects.requireNonNull(engine, "Jigsaw Studio engine");
JigsawStudioGenerator activeGenerator = Objects.requireNonNull(generator, "Jigsaw Studio generator");
World world = BukkitWorldBinding.world(activeEngine.getTarget().getWorld());
@@ -306,6 +334,9 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
new AtomicLong());
UUID displacedRequestId = null;
synchronized (saveLifecycleLock) {
if (!enabled || disableStarted.get()) {
return;
}
ActiveStudio previous = studios.get(world.getUID());
if (previous != null && previous.generator() == activeGenerator) {
return;
@@ -338,7 +369,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
}
public void activationCommitted(World world, UUID requestId) {
if (world == null || requestId == null) {
if (!enabled || disableStarted.get() || world == null || requestId == null) {
return;
}
ActiveStudio studio = studios.get(world.getUID());
@@ -355,7 +386,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
int chunkX,
int chunkZ
) {
if (engine == null || generator == null) {
if (!enabled || disableStarted.get() || engine == null || generator == null) {
return;
}
World world = BukkitWorldBinding.world(engine.getTarget().getWorld());
@@ -363,7 +394,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
return;
}
ActiveStudio studio = studios.get(world.getUID());
if (studio == null || studio.engine() != engine || studio.generator() != generator) {
if (!enabled || disableStarted.get()
|| studio == null || studio.engine() != engine || studio.generator() != generator) {
return;
}
markChunkAvailable(studio, chunkX, chunkZ);
@@ -5259,7 +5291,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private void finalizeAllJigsawTileWatches() {
for (JigsawTileWatch watch : List.copyOf(jigsawTileWatches.values())) {
finalizeJigsawTileWatch(watch);
try {
finalizeJigsawTileWatch(watch);
} catch (Throwable exception) {
IrisLogging.reportError("Failed to finalize a Jigsaw Studio tile watch during shutdown.", exception);
}
}
}
@@ -5655,7 +5691,14 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private void drainAutosavesBeforeDisable() {
for (ActiveStudio studio : List.copyOf(studios.values())) {
drainAutosavesBeforeRemoval(studio);
try {
drainAutosavesBeforeRemoval(studio);
} catch (Throwable exception) {
IrisLogging.reportError(
"Failed to drain Jigsaw Studio autosaves in world "
+ studio.worldId() + " during shutdown.",
exception);
}
}
if (!autosaves.isEmpty()) {
IrisLogging.warn("Jigsaw Studio disabled with %d autosave operation(s) still pending after the final drain attempt",
@@ -29,6 +29,7 @@ import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackDownloadExecution;
import art.arcane.iris.core.pack.PackDownloader;
@@ -64,12 +65,14 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
@@ -82,7 +85,6 @@ import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import art.arcane.iris.core.localization.BukkitRuntimeMessages;
import art.arcane.iris.core.localization.IrisLanguage;
@@ -198,6 +200,7 @@ public class StudioSVC implements IrisService {
Path parent = target.getParent();
Path stage = null;
AtomicDirectoryPublisher.Publication publication = null;
PackValidationRegistry.RootMutation validationMutation = null;
IrisData previousData = IrisData.getLoaded(target.toFile()).orElse(null);
IrisData createdData = null;
boolean refreshedPreviousData = false;
@@ -207,10 +210,7 @@ public class StudioSVC implements IrisService {
throw new IOException("World pack target has no parent: " + target);
}
Files.createDirectories(parent);
if (!replaceExisting
&& (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target))) {
throw new FileAlreadyExistsException(target.toString());
}
requireSafePublicationTarget(target, replaceExisting);
stage = Files.createTempDirectory(parent, ".pack.installing-");
copyPackTree(source, stage);
@@ -223,9 +223,18 @@ public class StudioSVC implements IrisService {
} finally {
stagedData.close();
}
validationMutation = PackValidationRegistry.beginRootMutation(target);
requireSafePublicationTarget(target, replaceExisting);
publication = AtomicDirectoryPublisher.publish(stage, target);
stage = null;
validatePublishedPack(target);
String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile());
PackValidationResult publishedValidation =
validatePublishedPack(target, source, copiedFingerprint, validationMutation);
if (!publishedValidation.isLoadable()) {
throw new BrokenPackException(
target.toString(),
publishedValidation.getBlockingErrors());
}
IrisData installedData;
// Live engines only ever attach to detached openRuntime loaders, never to the
@@ -249,6 +258,7 @@ public class StudioSVC implements IrisService {
throw new IOException("Published pack does not contain a loadable dimension '" + dimensionKey + "'.");
}
publication.commit();
validationMutation.commit();
try {
publication.cleanupBackup();
} catch (IOException cleanupFailure) {
@@ -275,6 +285,9 @@ public class StudioSVC implements IrisService {
sender.sendMessage("Failed to install studio pack '" + dimensionKey + "': " + errorDetail(e));
return null;
} finally {
if (validationMutation != null) {
validationMutation.close();
}
if (stage != null) {
try {
AtomicDirectoryPublisher.deleteTree(stage);
@@ -289,11 +302,56 @@ public class StudioSVC implements IrisService {
PackValidationRegistry.remove(packRoot);
}
static void requireSafePublicationTarget(Path target, boolean replaceExisting) throws IOException {
if (Files.isSymbolicLink(target)) {
throw new IOException("World pack target is a symbolic link: " + target);
}
if (!replaceExisting && Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new FileAlreadyExistsException(target.toString());
}
}
static PackValidationResult validatePublishedPack(Path packRoot) {
invalidatePackValidation(packRoot);
PackValidationResult result = PackValidator.validate(packRoot.toFile());
PackValidationRegistry.publish(packRoot, result);
return PackValidationRegistry.requireLoadable(packRoot);
try (PackValidationRegistry.RootMutation mutation =
PackValidationRegistry.beginRootMutation(packRoot)) {
PackValidationResult result = PackValidator.validate(packRoot.toFile());
mutation.stage(result);
mutation.commit();
return PackValidationRegistry.requireLoadable(packRoot);
}
}
static PackValidationResult validatePublishedPack(
Path packRoot,
Path validatedSource,
String copiedContentFingerprint
) {
try (PackValidationRegistry.RootMutation mutation =
PackValidationRegistry.beginRootMutation(packRoot)) {
PackValidationResult result = validatePublishedPack(
packRoot,
validatedSource,
copiedContentFingerprint,
mutation);
mutation.commit();
return PackValidationRegistry.requireLoadable(packRoot);
}
}
private static PackValidationResult validatePublishedPack(
Path packRoot,
Path validatedSource,
String copiedContentFingerprint,
PackValidationRegistry.RootMutation mutation
) {
PackValidationResult result = mutation.stageMatchingCopy(
validatedSource,
copiedContentFingerprint);
if (result == null) {
result = PackValidator.validate(packRoot.toFile());
mutation.stage(result);
}
return result;
}
static void rollbackFailedPublication(
@@ -318,7 +376,6 @@ public class StudioSVC implements IrisService {
}
static Path resolveSafePackSource(File sourceFolder) throws IOException {
PackDirectoryResolver.requireSafePackTree(sourceFolder);
Path source = sourceFolder.toPath().toAbsolutePath().normalize().toRealPath();
if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(source)) {
throw new IOException("Source pack is missing or unsafe: " + sourceFolder);
@@ -1202,25 +1259,85 @@ public class StudioSVC implements IrisService {
}
static void copyPackTree(Path source, Path target) throws IOException {
try (Stream<Path> entries = Files.walk(source)) {
for (Path entry : entries.sorted(Comparator.naturalOrder()).toList()) {
if (Files.isSymbolicLink(entry)) {
throw new IOException("Pack contains a symbolic link: " + entry);
}
Path destination = target.resolve(source.relativize(entry)).normalize();
if (!destination.startsWith(target)) {
throw new IOException("Pack entry escapes its installation stage: " + entry);
}
if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination);
} else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination.getParent());
Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES);
} else {
throw new IOException("Pack contains an unsupported entry: " + entry);
}
}
Path normalizedSource = source.toRealPath();
if (!Files.isDirectory(normalizedSource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Pack source is not a directory: " + normalizedSource);
}
Path requestedTarget = target.toAbsolutePath().normalize();
if (Files.isSymbolicLink(requestedTarget)) {
throw new IOException("Pack installation stage is a symbolic link: " + requestedTarget);
}
Path normalizedTarget;
if (Files.exists(requestedTarget, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isDirectory(requestedTarget, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Pack installation stage is not a directory: " + requestedTarget);
}
normalizedTarget = requestedTarget.toRealPath();
} else {
Path parent = Objects.requireNonNull(
requestedTarget.getParent(),
"Pack installation stage parent");
normalizedTarget = parent.toRealPath().resolve(requestedTarget.getFileName()).normalize();
}
if (normalizedTarget.startsWith(normalizedSource)
|| normalizedSource.startsWith(normalizedTarget)) {
throw new IOException("Pack source and installation stage overlap: "
+ normalizedSource + " and " + normalizedTarget);
}
Files.walkFileTree(normalizedSource, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) throws IOException {
if (attributes.isSymbolicLink()) {
throw new IOException("Pack contains a symbolic link: " + directory);
}
if (!directory.equals(normalizedSource)
&& normalizedSource.relativize(directory).getNameCount() == 1
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
return FileVisitResult.SKIP_SUBTREE;
}
Files.createDirectories(copyDestination(
normalizedSource,
normalizedTarget,
directory));
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
if (attributes.isSymbolicLink()) {
throw new IOException("Pack contains a symbolic link: " + file);
}
if (!attributes.isRegularFile()) {
throw new IOException("Pack contains an unsupported entry: " + file);
}
String fileName = file.getFileName().toString();
if ((normalizedSource.relativize(file).getNameCount() == 1
&& PackDirectoryResolver.isHiddenName(fileName))
|| fileName.endsWith(".code-workspace")) {
return FileVisitResult.CONTINUE;
}
Path destination = copyDestination(normalizedSource, normalizedTarget, file);
Files.createDirectories(Objects.requireNonNull(destination.getParent(), "Pack entry parent"));
Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to copy pack entry: " + file, failure);
}
});
}
private static Path copyDestination(Path source, Path target, Path entry) throws IOException {
Path destination = target.resolve(source.relativize(entry)).normalize();
if (!destination.startsWith(target)) {
throw new IOException("Pack entry escapes its installation stage: " + entry);
}
return destination;
}
static void publishNewDirectory(Path stage, Path target) throws IOException {
@@ -97,6 +97,7 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
@Accessors(fluent = true, chain = true)
public class IrisCreator {
private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L;
private static final long WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS = 60L;
private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L;
/**
@@ -398,17 +399,42 @@ public class IrisCreator {
return;
}
Throwable failure = awaitTeleportFailure(
teleportFuture,
player.getName(),
WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS,
TimeUnit.SECONDS
);
if (failure != null) {
reportSenderTeleportFailure(player, world, failure);
}
}
static Throwable awaitTeleportFailure(
CompletableFuture<Boolean> teleportFuture,
String playerName,
long timeout,
TimeUnit unit
) {
CompletableFuture<Boolean> requiredFuture = Objects.requireNonNull(teleportFuture, "teleportFuture");
String requiredPlayerName = Objects.requireNonNull(playerName, "playerName");
TimeUnit requiredUnit = Objects.requireNonNull(unit, "unit");
try {
Boolean teleported = teleportFuture.get(60L, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(teleported)) {
reportSenderTeleportFailure(player, world, new IllegalStateException(
"The runtime teleport operation returned false for player \"" + player.getName() + "\"."));
}
Boolean teleported = requiredFuture.get(timeout, requiredUnit);
return Boolean.TRUE.equals(teleported)
? null
: new IllegalStateException(
"The runtime teleport operation returned false for player \"" + requiredPlayerName + "\".");
} catch (TimeoutException e) {
ServerConfigurator.restart("World entry teleport timed out for \"" + world.getName() + "\".");
reportSenderTeleportFailure(player, world, e);
requiredFuture.cancel(false);
return e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return e;
} catch (ExecutionException e) {
return e.getCause() == null ? e : e.getCause();
} catch (Throwable e) {
reportSenderTeleportFailure(player, world, e);
return e;
}
}
@@ -494,6 +494,15 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
return future;
}
@Override
public void quiesceForServerShutdown() {
Looper activeHotloader = hotloader;
hotloader = null;
if (activeHotloader != null) {
activeHotloader.interrupt();
}
}
@Override
public boolean isStudio() {
return studio;
@@ -66,6 +66,9 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider {
}
}
default void quiesceForServerShutdown() {
}
boolean isStudio();
default boolean isClosing() {