mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-27 06:20:02 +00:00
Modded adapters
This commit is contained in:
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() {
|
||||
|
||||
Reference in new issue
Block a user