mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
Modded adapters
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -169,6 +169,38 @@ public class IrisDatapackCompilerInputFingerprintTest {
|
||||
"compiler-a").isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldInputRootDiscoveryUsesOnlyCanonicalWorldSnapshots() throws Exception {
|
||||
Path dataDirectory = tmp.newFolder("canonical-root-data").toPath();
|
||||
Path serverRoot = tmp.newFolder("canonical-root-server").toPath();
|
||||
Path canonicalPack = serverRoot.resolve("dimensions/iris/alpha/iris/pack");
|
||||
Path nestedKeyPack = serverRoot.resolve("dimensions/iris/runtime/studio/iris/pack");
|
||||
Path incompleteAncestorPack = serverRoot.resolve("dimensions/iris/runtime/iris/pack");
|
||||
Path nestedKeyDecoy = nestedKeyPack.resolve("region/archive/iris/pack");
|
||||
Path nestedRegionPack = serverRoot.resolve(
|
||||
"dimensions/iris/alpha/region/archive/iris/pack");
|
||||
Path nestedSavedPack = canonicalPack.resolve("objects/saved/iris/pack");
|
||||
Path customNamespacePack = serverRoot.resolve("dimensions/custom/beta/iris/pack");
|
||||
Path hiddenNamespacePack = serverRoot.resolve("dimensions/.hidden/beta/iris/pack");
|
||||
Path hiddenWorldPack = serverRoot.resolve("dimensions/custom/.hidden/iris/pack");
|
||||
write(canonicalPack.resolve("dimensions/alpha.json"), "{}");
|
||||
write(nestedKeyPack.resolve("dimensions/studio.json"), "{}");
|
||||
Files.createDirectories(incompleteAncestorPack);
|
||||
write(nestedKeyDecoy.resolve("dimensions/decoy.json"), "{}");
|
||||
write(nestedRegionPack.resolve("dimensions/decoy.json"), "{}");
|
||||
write(nestedSavedPack.resolve("dimensions/decoy.json"), "{}");
|
||||
write(customNamespacePack.resolve("dimensions/beta.json"), "{}");
|
||||
write(hiddenNamespacePack.resolve("dimensions/decoy.json"), "{}");
|
||||
write(hiddenWorldPack.resolve("dimensions/decoy.json"), "{}");
|
||||
|
||||
List<File> compilerRoots = IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot);
|
||||
|
||||
assertEquals(List.of(
|
||||
customNamespacePack.toAbsolutePath().normalize().toFile(),
|
||||
canonicalPack.toAbsolutePath().normalize().toFile(),
|
||||
nestedKeyPack.toAbsolutePath().normalize().toFile()), compilerRoots);
|
||||
}
|
||||
|
||||
private Path activePack(String name) throws IOException {
|
||||
Path pack = tmp.newFolder(name).toPath();
|
||||
write(pack.resolve("dimensions/overworld.json"), "dimension-a");
|
||||
|
||||
+105
-3
@@ -77,6 +77,36 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
assertNotEquals("Equal-size content changes must alter the fingerprint", before, after);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentSnapshotPublishesExactAggregateAndPerPackFingerprints() throws Exception {
|
||||
File packsDir = tmp.newFolder("content-snapshot-packs");
|
||||
Path alphaPack = packsDir.toPath().resolve("alpha");
|
||||
Path betaPack = packsDir.toPath().resolve("beta");
|
||||
Path alphaDimension = alphaPack.resolve("dimensions/alpha.json");
|
||||
Path betaDimension = betaPack.resolve("dimensions/beta.json");
|
||||
Files.createDirectories(alphaDimension.getParent());
|
||||
Files.createDirectories(betaDimension.getParent());
|
||||
Files.writeString(alphaDimension, "alpha-a", StandardCharsets.UTF_8);
|
||||
Files.writeString(betaDimension, "beta-a", StandardCharsets.UTF_8);
|
||||
|
||||
ServerConfigurator.PackContentSnapshot before =
|
||||
ServerConfigurator.computePackContentSnapshot(packsDir);
|
||||
|
||||
assertEquals(ServerConfigurator.computePackFingerprint(packsDir), before.content());
|
||||
assertEquals(ServerConfigurator.computePackTreeFingerprint(alphaPack.toFile()),
|
||||
before.packContents().get("alpha"));
|
||||
assertEquals(ServerConfigurator.computePackTreeFingerprint(betaPack.toFile()),
|
||||
before.packContents().get("beta"));
|
||||
|
||||
Files.writeString(alphaDimension, "alpha-b", StandardCharsets.UTF_8);
|
||||
ServerConfigurator.PackContentSnapshot after =
|
||||
ServerConfigurator.computePackContentSnapshot(packsDir);
|
||||
|
||||
assertNotEquals(before.content(), after.content());
|
||||
assertNotEquals(before.packContents().get("alpha"), after.packContents().get("alpha"));
|
||||
assertEquals(before.packContents().get("beta"), after.packContents().get("beta"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void computePackFingerprintChangesWhenFileIsAdded() throws Exception {
|
||||
Method method = fingerprintMethod();
|
||||
@@ -110,6 +140,31 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
assertEquals(before, ServerConfigurator.computePackFingerprint(packsDir));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void perPackFingerprintIncludesHiddenFilesCopiedIntoWorldSnapshots() throws Exception {
|
||||
File packsDir = tmp.newFolder("hidden-pack-content");
|
||||
Path pack = packsDir.toPath().resolve("testpack");
|
||||
Path visible = pack.resolve("dimensions/overworld.json");
|
||||
Path hidden = pack.resolve("dimensions/.broken.json");
|
||||
Files.createDirectories(visible.getParent());
|
||||
Files.writeString(visible, "visible", StandardCharsets.UTF_8);
|
||||
Files.writeString(hidden, "hidden-one", StandardCharsets.UTF_8);
|
||||
ServerConfigurator.PackContentSnapshot before =
|
||||
ServerConfigurator.computePackContentSnapshot(packsDir);
|
||||
|
||||
Files.writeString(hidden, "hidden-two", StandardCharsets.UTF_8);
|
||||
ServerConfigurator.PackContentSnapshot after =
|
||||
ServerConfigurator.computePackContentSnapshot(packsDir);
|
||||
|
||||
assertNotEquals(before.content(), after.content());
|
||||
assertNotEquals(
|
||||
before.packContents().get("testpack"),
|
||||
after.packContents().get("testpack"));
|
||||
assertEquals(
|
||||
after.packContents().get("testpack"),
|
||||
ServerConfigurator.computePackTreeFingerprint(pack.toFile()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void computePackFingerprintIgnoresGeneratedCodeWorkspaceFiles() throws Exception {
|
||||
File packsDir = tmp.newFolder("workspace-packs");
|
||||
@@ -117,6 +172,8 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
Files.createDirectories(dimension.getParent());
|
||||
Files.writeString(dimension, "authored", StandardCharsets.UTF_8);
|
||||
String before = ServerConfigurator.computePackFingerprint(packsDir);
|
||||
String packBefore = ServerConfigurator.computePackTreeFingerprint(
|
||||
packsDir.toPath().resolve("overworld").toFile());
|
||||
|
||||
Path workspace = packsDir.toPath().resolve("overworld/overworld.code-workspace");
|
||||
Files.writeString(workspace, "{\"folders\":[]}", StandardCharsets.UTF_8);
|
||||
@@ -128,6 +185,17 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
|
||||
assertEquals("Reordered workspace bytes must not alter the fingerprint",
|
||||
before, ServerConfigurator.computePackFingerprint(packsDir));
|
||||
|
||||
Path schema = packsDir.toPath().resolve("overworld/.iris/schema/dimension.json");
|
||||
Path repositoryObject = packsDir.toPath().resolve("overworld/.git/objects/blob");
|
||||
Files.createDirectories(schema.getParent());
|
||||
Files.createDirectories(repositoryObject.getParent());
|
||||
Files.writeString(schema, "generated schema", StandardCharsets.UTF_8);
|
||||
Files.writeString(repositoryObject, "repository metadata", StandardCharsets.UTF_8);
|
||||
|
||||
assertEquals(before, ServerConfigurator.computePackFingerprint(packsDir));
|
||||
assertEquals(packBefore, ServerConfigurator.computePackTreeFingerprint(
|
||||
packsDir.toPath().resolve("overworld").toFile()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -316,18 +384,20 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recoveryRunsBeforeFingerprintEarlyReturnAndCompilation() throws Exception {
|
||||
public void recoveryRunsBeforeRestoredFingerprintReuseHashFallbackAndCompilation() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/ServerConfigurator.java"));
|
||||
int installIfChanged = source.indexOf("installDataPacksIfChanged(boolean fullInstall)");
|
||||
int recovery = source.indexOf("DatapackIngestService.reapplyFromStaging", installIfChanged);
|
||||
int fingerprint = source.indexOf("computeCurrentDatapackCompilerInputFingerprint", recovery);
|
||||
int restored = source.indexOf("restoredCompilerInputFingerprint()", recovery);
|
||||
int fingerprint = source.indexOf("computeCurrentDatapackCompilerInputFingerprint", restored);
|
||||
int earlyReturn = source.indexOf("resultForUnchangedFingerprint", fingerprint);
|
||||
int compile = source.indexOf("compileDataPacksLocked(", earlyReturn);
|
||||
int cache = source.indexOf("writeCompilerInputFingerprintCache(cacheFile.toPath(), current)", compile);
|
||||
|
||||
assertTrue(recovery >= 0);
|
||||
assertTrue(fingerprint > recovery);
|
||||
assertTrue(restored > recovery);
|
||||
assertTrue(fingerprint > restored);
|
||||
assertTrue(earlyReturn > fingerprint);
|
||||
assertTrue(compile > earlyReturn);
|
||||
assertTrue(cache > compile);
|
||||
@@ -358,6 +428,38 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
assertFalse(ServerConfigurator.reusableRuntimeFingerprint(null, "abc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restoredCompilerInputFingerprintRequiresReadyNonRestartingRuntime() throws Exception {
|
||||
Field ready = ServerConfigurator.class.getDeclaredField("loadedDatapackRuntimeReady");
|
||||
Field fingerprint = ServerConfigurator.class.getDeclaredField(
|
||||
"loadedDatapackCompilerInputFingerprint");
|
||||
Field restartRequired = ServerConfigurator.class.getDeclaredField("loadedDatapackRestartRequired");
|
||||
ready.setAccessible(true);
|
||||
fingerprint.setAccessible(true);
|
||||
restartRequired.setAccessible(true);
|
||||
boolean previousReady = ready.getBoolean(null);
|
||||
String previousFingerprint = (String) fingerprint.get(null);
|
||||
boolean previousRestartRequired = restartRequired.getBoolean(null);
|
||||
|
||||
try {
|
||||
ready.setBoolean(null, true);
|
||||
fingerprint.set(null, "restored-fingerprint");
|
||||
restartRequired.setBoolean(null, false);
|
||||
assertEquals("restored-fingerprint", ServerConfigurator.restoredCompilerInputFingerprint());
|
||||
|
||||
restartRequired.setBoolean(null, true);
|
||||
assertEquals("", ServerConfigurator.restoredCompilerInputFingerprint());
|
||||
|
||||
restartRequired.setBoolean(null, false);
|
||||
ready.setBoolean(null, false);
|
||||
assertEquals("", ServerConfigurator.restoredCompilerInputFingerprint());
|
||||
} finally {
|
||||
ready.setBoolean(null, previousReady);
|
||||
fingerprint.set(null, previousFingerprint);
|
||||
restartRequired.setBoolean(null, previousRestartRequired);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalDatapackMutationInvalidatesReadinessAndRetainsComparisonPin() throws Exception {
|
||||
Field ready = ServerConfigurator.class.getDeclaredField("loadedDatapackRuntimeReady");
|
||||
|
||||
@@ -246,6 +246,39 @@ public class DatapackIngestServiceTest {
|
||||
validated, "26.2", 4000, true, true, validated.urls));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupChecksCheapCacheContextBeforeHashingManagedDatapacks() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java"));
|
||||
int validation = source.indexOf("public static StartupValidationOutcome validateOnStartup()");
|
||||
int cacheRead = source.indexOf("readStartupValidationCache", validation);
|
||||
int contextCheck = source.indexOf("startupValidationContextMatches(", cacheRead);
|
||||
int fingerprint = source.indexOf("startupValidationFingerprint(", contextCheck);
|
||||
int fullValidation = source.indexOf("if (autoIngest && !configured.isEmpty())", fingerprint);
|
||||
|
||||
assertTrue(validation >= 0);
|
||||
assertTrue(cacheRead > validation);
|
||||
assertTrue(contextCheck > cacheRead);
|
||||
assertTrue(fingerprint > contextCheck);
|
||||
assertTrue(fullValidation > fingerprint);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unchangedPostStartupMaintenanceReturnsBeforeFingerprinting() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java"));
|
||||
int refresh = source.indexOf(
|
||||
"refreshStartupValidationAfterMaintenance(boolean maintenanceChanged)");
|
||||
int unchangedGuard = source.indexOf("if (!maintenanceChanged)", refresh);
|
||||
int validatedState = source.indexOf("StartupValidationCache validated", refresh);
|
||||
int fingerprint = source.indexOf("startupValidationFingerprint(", refresh);
|
||||
|
||||
assertTrue(refresh >= 0);
|
||||
assertTrue(unchangedGuard > refresh);
|
||||
assertTrue(validatedState > unchangedGuard);
|
||||
assertTrue(fingerprint > unchangedGuard);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void packMetadataMustContainAValidPackContract() throws Exception {
|
||||
File valid = temporaryFolder.newFolder("valid");
|
||||
@@ -733,6 +766,64 @@ public class DatapackIngestServiceTest {
|
||||
assertTrue(managed.isDirectory());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownershipDirectoryHashRetainsGoldenFramingAndExclusions() throws Exception {
|
||||
File managed = temporaryFolder.newFolder("directory-hash-golden");
|
||||
Path data = managed.toPath().resolve("data");
|
||||
Path example = data.resolve("example");
|
||||
Files.createDirectories(example);
|
||||
Files.createDirectory(managed.toPath().resolve("empty"));
|
||||
Files.write(example.resolve("value.bin"), new byte[]{0, 1, 2, 3, (byte) 0xff});
|
||||
Files.writeString(
|
||||
managed.toPath().resolve("pack.mcmeta"),
|
||||
"{\"pack\":{\"description\":\"golden\",\"pack_format\":88}}",
|
||||
StandardCharsets.UTF_8);
|
||||
Files.writeString(managed.toPath().resolve("z.txt"), "Iris\n", StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
managed.toPath().resolve(".iris-managed.json"),
|
||||
"ignored ownership marker",
|
||||
StandardCharsets.UTF_8);
|
||||
Files.writeString(managed.toPath().resolve(".DS_Store"), "ignored root metadata");
|
||||
Files.writeString(data.resolve(".DS_Store"), "ignored nested metadata");
|
||||
DatapackIngestService.Entry entry = entry("golden", "v1", "1", "sha");
|
||||
|
||||
DatapackIngestService.writeOwnership(managed, entry);
|
||||
|
||||
String expected = "aa62ee4ed00f0393e637411686082f253ec65ff788839b12c30fc175e5b501fb";
|
||||
assertEquals(expected, ownershipHash(managed));
|
||||
|
||||
Files.writeString(
|
||||
managed.toPath().resolve(".iris-managed.json"),
|
||||
"different ignored ownership marker",
|
||||
StandardCharsets.UTF_8);
|
||||
Files.writeString(managed.toPath().resolve(".DS_Store"), "different root metadata");
|
||||
Files.writeString(data.resolve(".DS_Store"), "different nested metadata");
|
||||
DatapackIngestService.writeOwnership(managed, entry);
|
||||
|
||||
assertEquals(expected, ownershipHash(managed));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void directoryHashRestatsAttributesAndVolumeBeforeOpeningEachFile() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java"));
|
||||
int method = source.indexOf("private static String directoryHash(File root)");
|
||||
int entries = source.indexOf("List<Path> entries = new ArrayList<>()", method);
|
||||
int loop = source.indexOf("for (Path entry : entries)", entries);
|
||||
int attributes = source.indexOf("BasicFileAttributes attributes = Files.readAttributes(", loop);
|
||||
int fileStore = source.indexOf("Files.getFileStore(entry)", attributes);
|
||||
int open = source.indexOf("Files.newInputStream(", fileStore);
|
||||
int digest = source.indexOf("return hex(digest.digest())", open);
|
||||
|
||||
assertTrue(method >= 0);
|
||||
assertTrue(entries > method);
|
||||
assertTrue(loop > entries);
|
||||
assertTrue(attributes > loop);
|
||||
assertTrue(fileStore > attributes);
|
||||
assertTrue(open > fileStore);
|
||||
assertTrue(digest > open);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedUpdateStagingCannotBeAdoptedByTheCommittedManifest() throws Exception {
|
||||
File staging = datapackDirectory("candidate-staging");
|
||||
@@ -833,6 +924,205 @@ public class DatapackIngestServiceTest {
|
||||
assertEquals("old", Files.readString(new File(firstTarget, "value.txt").toPath(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactManagedTargetSkipsPreparedCopyAndScratch() throws Exception {
|
||||
DatapackIngestService.Entry entry = entry("exact-managed", "v1", "1", "sha");
|
||||
File staging = datapackDirectory("exact-managed-source");
|
||||
Files.writeString(new File(staging, "value.txt").toPath(), "same", StandardCharsets.UTF_8);
|
||||
DatapackIngestService.writeOwnership(staging, entry);
|
||||
|
||||
File targetRoot = temporaryFolder.newFolder("exact-managed-target-root");
|
||||
File worldFolder = new File(targetRoot, "datapacks");
|
||||
assertTrue(worldFolder.mkdir());
|
||||
File target = new File(worldFolder, entry.id);
|
||||
writeManagedDatapack(target, entry, "same");
|
||||
File scratch = new File(targetRoot, ".iris-datapack-install");
|
||||
|
||||
DatapackIngestService.InstallPlan plan = DatapackIngestService.prepareInstall(
|
||||
staging,
|
||||
worldFolder,
|
||||
entry,
|
||||
ownershipHash(staging),
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
assertFalse(plan.publishRequired());
|
||||
assertFalse(plan.contentChanged());
|
||||
assertFalse(scratch.exists());
|
||||
assertTrue(plan.pending() == null || !plan.pending().exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stagedMutationBeforePrecommitRejectsAndRollsBackPublishedWorlds() throws Exception {
|
||||
PreparedMixedInstall fixture = preparedMixedInstall("staged-precommit-mutation");
|
||||
Path stagedValue = new File(fixture.staging(), "value.txt").toPath();
|
||||
FileTime originalTime = Files.getLastModifiedTime(stagedValue);
|
||||
Files.writeString(stagedValue, "bad", StandardCharsets.UTF_8);
|
||||
Files.setLastModifiedTime(stagedValue, originalTime);
|
||||
|
||||
try {
|
||||
DatapackIngestService.verifyInstallExecution(fixture.execution());
|
||||
fail("Expected changed staging to block the prepared install");
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage(), expected.getMessage().contains("staging changed"));
|
||||
DatapackIngestService.rollbackInstallExecutions(List.of(fixture.execution()), expected);
|
||||
assertEquals(0, expected.getSuppressed().length);
|
||||
}
|
||||
|
||||
assertEquals("new", Files.readString(
|
||||
new File(fixture.unchangedTarget(), "value.txt").toPath(), StandardCharsets.UTF_8));
|
||||
assertEquals("old", Files.readString(
|
||||
new File(fixture.changedTarget(), "value.txt").toPath(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unchangedTargetMutationBeforePrecommitRejectsAndRollsBackPublishedWorlds() throws Exception {
|
||||
PreparedMixedInstall fixture = preparedMixedInstall("unchanged-precommit-mutation");
|
||||
Path unchangedValue = new File(fixture.unchangedTarget(), "value.txt").toPath();
|
||||
FileTime originalTime = Files.getLastModifiedTime(unchangedValue);
|
||||
Files.writeString(unchangedValue, "bad", StandardCharsets.UTF_8);
|
||||
Files.setLastModifiedTime(unchangedValue, originalTime);
|
||||
|
||||
try {
|
||||
DatapackIngestService.verifyInstallExecution(fixture.execution());
|
||||
fail("Expected changed unchanged-target snapshot to block the prepared install");
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage(), expected.getMessage().contains("unchanged datapack target"));
|
||||
DatapackIngestService.rollbackInstallExecutions(List.of(fixture.execution()), expected);
|
||||
assertEquals(0, expected.getSuppressed().length);
|
||||
}
|
||||
|
||||
assertEquals("bad", Files.readString(unchangedValue, StandardCharsets.UTF_8));
|
||||
assertEquals("old", Files.readString(
|
||||
new File(fixture.changedTarget(), "value.txt").toPath(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifiedFreshInstallCommitsAfterExtractedDirectoryIsDeleted() throws Exception {
|
||||
PreparedVerifiedFreshInstall fixture = preparedVerifiedFreshInstall(
|
||||
"verified-fresh-deleted-extraction");
|
||||
DatapackIngestService.deleteInstallScratch(
|
||||
fixture.extractedDir(), "verified fresh datapack extraction");
|
||||
assertFalse(fixture.extractedDir().exists());
|
||||
|
||||
DatapackIngestService.verifyInstallExecution(fixture.execution());
|
||||
writeManifest(fixture.root(), fixture.entry());
|
||||
DatapackIngestService.finishInstallExecution(fixture.execution());
|
||||
|
||||
for (File target : List.of(fixture.worldTarget(), fixture.canonicalTarget())) {
|
||||
assertEquals("new", Files.readString(
|
||||
new File(target, "value.txt").toPath(), StandardCharsets.UTF_8));
|
||||
assertTrue(DatapackIngestService.isUsableStaging(target, fixture.entry()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publishedTargetMutationRollsBackUnaffectedParticipantsAndRemainsFailClosed() throws Exception {
|
||||
PreparedVerifiedFreshInstall fixture = preparedVerifiedFreshInstall(
|
||||
"published-precommit-mutation");
|
||||
Path publishedValue = new File(fixture.worldTarget(), "value.txt").toPath();
|
||||
FileTime originalTime = Files.getLastModifiedTime(publishedValue);
|
||||
Files.writeString(publishedValue, "bad", StandardCharsets.UTF_8);
|
||||
Files.setLastModifiedTime(publishedValue, originalTime);
|
||||
|
||||
try {
|
||||
DatapackIngestService.verifyInstallExecution(fixture.execution());
|
||||
fail("Expected changed published target to block the prepared install");
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage(), expected.getMessage().contains("published datapack target"));
|
||||
DatapackIngestService.rollbackInstallExecutions(List.of(fixture.execution()), expected);
|
||||
assertEquals(1, expected.getSuppressed().length);
|
||||
}
|
||||
|
||||
assertEquals("bad", Files.readString(publishedValue, StandardCharsets.UTF_8));
|
||||
assertFalse(fixture.canonicalTarget().exists());
|
||||
File transactionRoot = new File(fixture.root(), ".iris-datapack-transactions");
|
||||
File[] transactions = transactionRoot.listFiles(File::isDirectory);
|
||||
assertTrue(transactions != null && transactions.length == 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changedManagedTargetStillPreparesPublication() throws Exception {
|
||||
DatapackIngestService.Entry entry = entry("changed-managed", "v1", "1", "sha");
|
||||
File staging = datapackDirectory("changed-managed-source");
|
||||
Files.writeString(new File(staging, "value.txt").toPath(), "new", StandardCharsets.UTF_8);
|
||||
DatapackIngestService.writeOwnership(staging, entry);
|
||||
|
||||
File targetRoot = temporaryFolder.newFolder("changed-managed-target-root");
|
||||
File worldFolder = new File(targetRoot, "datapacks");
|
||||
assertTrue(worldFolder.mkdir());
|
||||
writeManagedDatapack(new File(worldFolder, entry.id), entry, "old");
|
||||
|
||||
DatapackIngestService.InstallPlan plan = DatapackIngestService.prepareInstall(
|
||||
staging,
|
||||
worldFolder,
|
||||
entry,
|
||||
ownershipHash(staging),
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(plan.publishRequired());
|
||||
assertTrue(plan.contentChanged());
|
||||
assertTrue(plan.pending().isDirectory());
|
||||
assertTrue(plan.pendingRoot().isDirectory());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changedOwnershipStillPreparesPublicationForExactContent() throws Exception {
|
||||
DatapackIngestService.Entry entry = entry("changed-ownership", "v2", "2", "new-sha");
|
||||
File staging = datapackDirectory("changed-ownership-source");
|
||||
Files.writeString(new File(staging, "value.txt").toPath(), "same", StandardCharsets.UTF_8);
|
||||
DatapackIngestService.writeOwnership(staging, entry);
|
||||
|
||||
File targetRoot = temporaryFolder.newFolder("changed-ownership-target-root");
|
||||
File worldFolder = new File(targetRoot, "datapacks");
|
||||
assertTrue(worldFolder.mkdir());
|
||||
DatapackIngestService.Entry prior = entry("changed-ownership", "v1", "1", "old-sha");
|
||||
File target = new File(worldFolder, entry.id);
|
||||
writeManagedDatapack(target, prior, "same");
|
||||
|
||||
DatapackIngestService.InstallPlan plan = DatapackIngestService.prepareInstall(
|
||||
staging,
|
||||
worldFolder,
|
||||
entry,
|
||||
ownershipHash(staging),
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(plan.publishRequired());
|
||||
assertFalse(plan.contentChanged());
|
||||
assertTrue(plan.pending().isDirectory());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overrideStrippingStillPreparesPublicationForExactContent() throws Exception {
|
||||
DatapackIngestService.Entry entry = entry("strip-managed", "v1", "1", "sha");
|
||||
File staging = datapackDirectory("strip-managed-source");
|
||||
Files.writeString(new File(staging, "value.txt").toPath(), "same", StandardCharsets.UTF_8);
|
||||
DatapackIngestService.writeOwnership(staging, entry);
|
||||
|
||||
File targetRoot = temporaryFolder.newFolder("strip-managed-target-root");
|
||||
File worldFolder = new File(targetRoot, "datapacks");
|
||||
assertTrue(worldFolder.mkdir());
|
||||
writeManagedDatapack(new File(worldFolder, entry.id), entry, "same");
|
||||
|
||||
DatapackIngestService.InstallPlan plan = DatapackIngestService.prepareInstall(
|
||||
staging,
|
||||
worldFolder,
|
||||
entry,
|
||||
ownershipHash(staging),
|
||||
true,
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(plan.publishRequired());
|
||||
assertTrue(plan.contentChanged());
|
||||
assertTrue(new File(plan.pending(), ".iris-overrides-stripped").isFile());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactLegacyUnmarkedWorldInstallCanReceiveManagedOwnership() throws Exception {
|
||||
DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha");
|
||||
@@ -1101,6 +1391,7 @@ public class DatapackIngestServiceTest {
|
||||
false,
|
||||
fixture.root(),
|
||||
fixture.authorization());
|
||||
DatapackIngestService.verifyInstallExecution(execution);
|
||||
writeManifest(fixture.root(), fixture.desired());
|
||||
DatapackIngestService.finishInstallExecution(execution);
|
||||
|
||||
@@ -3060,6 +3351,67 @@ public class DatapackIngestServiceTest {
|
||||
return new ReapplyFixture(root, stagingRoot, staging, worlds, new File(world, entry.id));
|
||||
}
|
||||
|
||||
private PreparedMixedInstall preparedMixedInstall(String name) throws Exception {
|
||||
File root = temporaryFolder.newFolder(name + "-root").toPath().toRealPath().toFile();
|
||||
DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha");
|
||||
writeManifest(root, entry);
|
||||
File staging = new File(root, "staging/" + entry.id);
|
||||
writeManagedDatapack(staging, entry, "new");
|
||||
|
||||
File unchangedRoot = temporaryFolder.newFolder(name + "-unchanged-root");
|
||||
File unchangedWorld = new File(unchangedRoot, "datapacks");
|
||||
assertTrue(unchangedWorld.mkdir());
|
||||
File unchangedTarget = new File(unchangedWorld, entry.id);
|
||||
writeManagedDatapack(unchangedTarget, entry, "new");
|
||||
|
||||
File changedRoot = temporaryFolder.newFolder(name + "-changed-root");
|
||||
File changedWorld = new File(changedRoot, "datapacks");
|
||||
assertTrue(changedWorld.mkdir());
|
||||
File changedTarget = new File(changedWorld, entry.id);
|
||||
writeManagedDatapack(changedTarget, entry, "old");
|
||||
|
||||
KList<File> worlds = new KList<>();
|
||||
worlds.add(unchangedWorld);
|
||||
worlds.add(changedWorld);
|
||||
DatapackIngestService.InstallExecution execution =
|
||||
DatapackIngestService.prepareInstallExecution(staging, worlds, entry, false, root);
|
||||
|
||||
assertTrue(execution.result().changed());
|
||||
assertEquals("new", Files.readString(
|
||||
new File(changedTarget, "value.txt").toPath(), StandardCharsets.UTF_8));
|
||||
return new PreparedMixedInstall(staging, unchangedTarget, changedTarget, execution);
|
||||
}
|
||||
|
||||
private PreparedVerifiedFreshInstall preparedVerifiedFreshInstall(String name) throws Exception {
|
||||
LegacyStagingFixture fixture = legacyStagingFixture(name, false, true, false);
|
||||
File world = temporaryFolder.newFolder(name + "-world");
|
||||
KList<File> worlds = new KList<>();
|
||||
worlds.add(world);
|
||||
DatapackIngestService.InstallExecution execution =
|
||||
DatapackIngestService.prepareInstallExecution(
|
||||
fixture.source(),
|
||||
worlds,
|
||||
fixture.desired(),
|
||||
false,
|
||||
fixture.root(),
|
||||
fixture.authorization());
|
||||
File worldTarget = new File(world, fixture.desired().id);
|
||||
|
||||
assertTrue(fixture.source().isDirectory());
|
||||
assertEquals("new", Files.readString(
|
||||
new File(worldTarget, "value.txt").toPath(), StandardCharsets.UTF_8));
|
||||
assertEquals("new", Files.readString(
|
||||
new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8));
|
||||
return new PreparedVerifiedFreshInstall(
|
||||
fixture.root(),
|
||||
fixture.source(),
|
||||
fixture.desired(),
|
||||
fixture.target(),
|
||||
worldTarget,
|
||||
execution
|
||||
);
|
||||
}
|
||||
|
||||
private JsonObject manifestEntry(File root) throws Exception {
|
||||
JsonObject manifest = JsonParser.parseString(Files.readString(
|
||||
new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
@@ -3075,6 +3427,24 @@ public class DatapackIngestServiceTest {
|
||||
) {
|
||||
}
|
||||
|
||||
private record PreparedMixedInstall(
|
||||
File staging,
|
||||
File unchangedTarget,
|
||||
File changedTarget,
|
||||
DatapackIngestService.InstallExecution execution
|
||||
) {
|
||||
}
|
||||
|
||||
private record PreparedVerifiedFreshInstall(
|
||||
File root,
|
||||
File extractedDir,
|
||||
DatapackIngestService.Entry entry,
|
||||
File canonicalTarget,
|
||||
File worldTarget,
|
||||
DatapackIngestService.InstallExecution execution
|
||||
) {
|
||||
}
|
||||
|
||||
private LegacyStagingFixture legacyStagingFixture(
|
||||
String name,
|
||||
boolean committed,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package art.arcane.iris.core.nms;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ServerShutdownBoundaryTest {
|
||||
@Test
|
||||
public void await_returnsImmediatelyWhenBoundaryIsAlreadyReached() {
|
||||
assertTrue(ServerShutdownBoundary.await(
|
||||
() -> true,
|
||||
Thread.currentThread(),
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void await_doesNotJoinTheCallingServerThread() {
|
||||
assertFalse(ServerShutdownBoundary.await(
|
||||
() -> false,
|
||||
Thread.currentThread(),
|
||||
5L,
|
||||
TimeUnit.SECONDS
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void await_blocksUntilAuthoritativeBoundaryIsReached() throws Exception {
|
||||
CountDownLatch serverStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseServer = new CountDownLatch(1);
|
||||
CountDownLatch waiterStarted = new CountDownLatch(1);
|
||||
CountDownLatch waiterFinished = new CountDownLatch(1);
|
||||
AtomicBoolean boundaryReached = new AtomicBoolean(false);
|
||||
AtomicReference<Boolean> result = new AtomicReference<>(false);
|
||||
Thread serverThread = new Thread(() -> {
|
||||
serverStarted.countDown();
|
||||
await(releaseServer);
|
||||
boundaryReached.set(true);
|
||||
}, "server-boundary-test");
|
||||
Thread waiterThread = new Thread(() -> {
|
||||
waiterStarted.countDown();
|
||||
result.set(ServerShutdownBoundary.await(
|
||||
boundaryReached::get,
|
||||
serverThread,
|
||||
5L,
|
||||
TimeUnit.SECONDS
|
||||
));
|
||||
waiterFinished.countDown();
|
||||
}, "server-boundary-waiter-test");
|
||||
|
||||
serverThread.start();
|
||||
assertTrue(serverStarted.await(1L, TimeUnit.SECONDS));
|
||||
waiterThread.start();
|
||||
assertTrue(waiterStarted.await(1L, TimeUnit.SECONDS));
|
||||
assertFalse(waiterFinished.await(0L, TimeUnit.MILLISECONDS));
|
||||
|
||||
releaseServer.countDown();
|
||||
|
||||
assertTrue(waiterFinished.await(2L, TimeUnit.SECONDS));
|
||||
assertTrue(result.get());
|
||||
serverThread.join();
|
||||
waiterThread.join();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void await_returnsFalseWhenBoundaryDoesNotArriveBeforeTimeout() throws Exception {
|
||||
CountDownLatch releaseServer = new CountDownLatch(1);
|
||||
Thread serverThread = new Thread(() -> await(releaseServer), "server-boundary-timeout-test");
|
||||
serverThread.start();
|
||||
|
||||
try {
|
||||
assertFalse(ServerShutdownBoundary.await(
|
||||
() -> false,
|
||||
serverThread,
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS
|
||||
));
|
||||
} finally {
|
||||
releaseServer.countDown();
|
||||
serverThread.join();
|
||||
}
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,19 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackValidationRegistryTest {
|
||||
@@ -120,6 +130,98 @@ public class PackValidationRegistryTest {
|
||||
assertEquals(result, PackValidationRegistry.requireLoadable(realRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void copiedValidationPublishesOnlyForTheExactValidatedFingerprint() throws Exception {
|
||||
Path sourceRoot = temporaryFolder.newFolder("copy-source").toPath();
|
||||
Path matchingTarget = temporaryFolder.newFolder("copy-matching-target").toPath();
|
||||
Path mismatchedTarget = temporaryFolder.newFolder("copy-mismatched-target").toPath();
|
||||
PackValidationResult result = new PackValidationResult(
|
||||
"source", List.of(), List.of("source warning"), 7L);
|
||||
PackValidationRegistry.publish(sourceRoot, result, "fingerprint-a");
|
||||
|
||||
assertSame(result, PackValidationRegistry.publishMatchingCopy(
|
||||
sourceRoot,
|
||||
matchingTarget,
|
||||
"fingerprint-a"));
|
||||
assertSame(result, PackValidationRegistry.requireLoadable(matchingTarget));
|
||||
assertNull(PackValidationRegistry.publishMatchingCopy(
|
||||
sourceRoot,
|
||||
mismatchedTarget,
|
||||
"fingerprint-b"));
|
||||
assertNull(PackValidationRegistry.get(mismatchedTarget));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unfingerprintedRepublishRevokesCopiedValidationReuse() throws Exception {
|
||||
Path sourceRoot = temporaryFolder.newFolder("republished-source").toPath();
|
||||
Path targetRoot = temporaryFolder.newFolder("republished-target").toPath();
|
||||
PackValidationResult initial = new PackValidationResult("source", List.of(), List.of(), 3L);
|
||||
PackValidationResult replacement = new PackValidationResult("source", List.of(), List.of(), 5L);
|
||||
PackValidationRegistry.publish(sourceRoot, initial, "old-fingerprint");
|
||||
|
||||
PackValidationRegistry.publish(sourceRoot, replacement);
|
||||
|
||||
assertNull(PackValidationRegistry.publishMatchingCopy(
|
||||
sourceRoot,
|
||||
targetRoot,
|
||||
"old-fingerprint"));
|
||||
assertSame(replacement, PackValidationRegistry.requireLoadable(sourceRoot));
|
||||
assertNull(PackValidationRegistry.get(targetRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootMutationDefeatsAnInterleavedStaleValidationTicket() throws Exception {
|
||||
Path packRoot = temporaryFolder.newFolder("reserved-root").toPath();
|
||||
PackValidationResult original = new PackValidationResult(
|
||||
"pack", List.of(), List.of("original"), 1L);
|
||||
PackValidationResult stale = new PackValidationResult(
|
||||
"pack", List.of(), List.of("stale"), 2L);
|
||||
PackValidationResult replacement = new PackValidationResult(
|
||||
"pack", List.of(), List.of("replacement"), 3L);
|
||||
PackValidationRegistry.publish(packRoot, original);
|
||||
CountDownLatch ticketReady = new CountDownLatch(1);
|
||||
CountDownLatch mutationStarted = new CountDownLatch(1);
|
||||
AtomicReference<PackValidationRegistry.ValidationTicket> ticket = new AtomicReference<>();
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
Future<Boolean> stalePublish = executor.submit(() -> {
|
||||
PackValidationRegistry.ValidationTicket validationTicket =
|
||||
PackValidationRegistry.tryBeginValidation(packRoot);
|
||||
ticket.set(validationTicket);
|
||||
ticketReady.countDown();
|
||||
if (!mutationStarted.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Root mutation did not begin");
|
||||
}
|
||||
return PackValidationRegistry.publishIfCurrent(validationTicket, stale);
|
||||
});
|
||||
|
||||
try {
|
||||
assertTrue(ticketReady.await(5, TimeUnit.SECONDS));
|
||||
assertNotNull(ticket.get());
|
||||
try (PackValidationRegistry.RootMutation mutation =
|
||||
PackValidationRegistry.beginRootMutation(packRoot)) {
|
||||
assertNull(PackValidationRegistry.get(packRoot));
|
||||
assertThrows(BrokenPackException.class,
|
||||
() -> PackValidationRegistry.requireLoadable(packRoot));
|
||||
assertNull(PackValidationRegistry.tryBeginValidation(packRoot));
|
||||
mutationStarted.countDown();
|
||||
|
||||
assertFalse(stalePublish.get(5, TimeUnit.SECONDS));
|
||||
assertNull(PackValidationRegistry.get(packRoot));
|
||||
assertNull(PackValidationRegistry.tryBeginValidation(packRoot));
|
||||
|
||||
mutation.stage(replacement);
|
||||
assertNull(PackValidationRegistry.get(packRoot));
|
||||
mutation.commit();
|
||||
}
|
||||
|
||||
assertSame(replacement, PackValidationRegistry.requireLoadable(packRoot));
|
||||
} finally {
|
||||
mutationStarted.countDown();
|
||||
executor.shutdownNow();
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private void assertBroken(String pack, String expectedReason) {
|
||||
try {
|
||||
PackValidationRegistry.requireLoadable(pack);
|
||||
@@ -132,11 +234,12 @@ public class PackValidationRegistryTest {
|
||||
throw new AssertionError("Expected pack validation to fail closed");
|
||||
}
|
||||
|
||||
private void assertBroken(Path packRoot, String expectedReason) {
|
||||
private void assertBroken(Path packRoot, String expectedReason) throws IOException {
|
||||
try {
|
||||
PackValidationRegistry.requireLoadable(packRoot);
|
||||
} catch (BrokenPackException e) {
|
||||
assertEquals(packRoot.toAbsolutePath().normalize().toString(), e.getPackName());
|
||||
Path expectedRoot = packRoot.getParent().toRealPath().resolve(packRoot.getFileName()).normalize();
|
||||
assertEquals(expectedRoot.toString(), e.getPackName());
|
||||
assertTrue(e.getReasons().toString(), e.getReasons().stream().anyMatch(
|
||||
reason -> reason.contains(expectedReason)));
|
||||
return;
|
||||
|
||||
@@ -30,6 +30,7 @@ import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockState;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
@@ -37,6 +38,7 @@ import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.iris.util.common.math.IrisBlockVector;
|
||||
import art.arcane.iris.util.common.plugin.VolmitPlugin;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
@@ -114,6 +116,35 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
public class JigsawStudioServiceCaptureTest {
|
||||
|
||||
@Test
|
||||
public void shutdownQuiesceIsIdempotentAndResetsOnEnable() throws Exception {
|
||||
JigsawStudioService service = new JigsawStudioService();
|
||||
VolmitPlugin plugin = mock(VolmitPlugin.class);
|
||||
Field disableStartedField = JigsawStudioService.class.getDeclaredField("disableStarted");
|
||||
Field enabledField = JigsawStudioService.class.getDeclaredField("enabled");
|
||||
disableStartedField.setAccessible(true);
|
||||
enabledField.setAccessible(true);
|
||||
AtomicBoolean disableStarted = (AtomicBoolean) disableStartedField.get(service);
|
||||
|
||||
try (MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class)) {
|
||||
platform.when(BukkitPlatform::volmitPlugin).thenReturn(plugin);
|
||||
|
||||
service.onEnable();
|
||||
assertFalse(disableStarted.get());
|
||||
assertTrue(enabledField.getBoolean(service));
|
||||
|
||||
service.quiesceForServerShutdown();
|
||||
service.quiesceForServerShutdown();
|
||||
assertTrue(disableStarted.get());
|
||||
assertFalse(enabledField.getBoolean(service));
|
||||
|
||||
service.onEnable();
|
||||
assertFalse(disableStarted.get());
|
||||
assertTrue(enabledField.getBoolean(service));
|
||||
service.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void successfulStudioSavePlaysOneOwnerLocalBell() {
|
||||
Player player = mock(Player.class);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.pack.BrokenPackException;
|
||||
@@ -43,6 +44,12 @@ public class StudioSVCWorldPackPublishTest {
|
||||
Path target = root.resolve("iris/pack");
|
||||
Files.createDirectories(source.resolve("dimensions"));
|
||||
Files.writeString(source.resolve("dimensions/example.json"), "{}");
|
||||
Files.writeString(source.resolve("dimensions/.hidden.json"), "{}");
|
||||
Files.createDirectories(source.resolve(".iris/schema"));
|
||||
Files.createDirectories(source.resolve(".git/objects"));
|
||||
Files.writeString(source.resolve(".iris/schema/generated.json"), "{}");
|
||||
Files.writeString(source.resolve(".git/objects/blob"), "metadata");
|
||||
Files.writeString(source.resolve("source.code-workspace"), "{}");
|
||||
Files.createDirectories(stage);
|
||||
|
||||
StudioSVC.copyPackTree(source, stage);
|
||||
@@ -51,6 +58,10 @@ public class StudioSVCWorldPackPublishTest {
|
||||
|
||||
assertFalse(Files.exists(stage));
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/example.json")));
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/.hidden.json")));
|
||||
assertFalse(Files.exists(target.resolve(".iris")));
|
||||
assertFalse(Files.exists(target.resolve(".git")));
|
||||
assertFalse(Files.exists(target.resolve("source.code-workspace")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -111,6 +122,93 @@ public class StudioSVCWorldPackPublishTest {
|
||||
assertTrue(Files.isRegularFile(stage.resolve("dimensions/example.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void copyRejectsAnInstallationStageInsideTheSource() throws IOException {
|
||||
Path source = temporaryFolder.newFolder("overlapping-copy").toPath();
|
||||
Path stage = source.resolve("nested-stage");
|
||||
Files.writeString(source.resolve("pack.txt"), "source");
|
||||
|
||||
IOException failure = assertThrows(
|
||||
IOException.class,
|
||||
() -> StudioSVC.copyPackTree(source, stage));
|
||||
|
||||
assertTrue(failure.getMessage().contains("overlap"));
|
||||
assertFalse(Files.exists(stage));
|
||||
assertEquals("source", Files.readString(source.resolve("pack.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void copyRejectsASymbolicInstallationStage() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("linked-copy-stage").toPath();
|
||||
Path source = root.resolve("source");
|
||||
Path outside = root.resolve("outside");
|
||||
Path stage = root.resolve("stage");
|
||||
Files.createDirectories(source);
|
||||
Files.createDirectories(outside);
|
||||
Files.writeString(source.resolve("pack.txt"), "source");
|
||||
try {
|
||||
Files.createSymbolicLink(stage, outside);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
|
||||
IOException failure = assertThrows(
|
||||
IOException.class,
|
||||
() -> StudioSVC.copyPackTree(source, stage));
|
||||
|
||||
assertTrue(failure.getMessage().contains("symbolic link"));
|
||||
assertFalse(Files.exists(outside.resolve("pack.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replaceExistingRejectsSymbolicTargetBeforePublication() throws Exception {
|
||||
String sourceCode = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/service/StudioSVC.java"));
|
||||
int install = sourceCode.indexOf("private IrisDimension installIntoDirectory(");
|
||||
int initialTargetSafety = sourceCode.indexOf(
|
||||
"requireSafePublicationTarget(target, replaceExisting)",
|
||||
install);
|
||||
int beginMutation = sourceCode.indexOf(
|
||||
"PackValidationRegistry.beginRootMutation(target)",
|
||||
initialTargetSafety);
|
||||
int finalTargetSafety = sourceCode.indexOf(
|
||||
"requireSafePublicationTarget(target, replaceExisting)",
|
||||
beginMutation);
|
||||
int publish = sourceCode.indexOf(
|
||||
"AtomicDirectoryPublisher.publish(stage, target)",
|
||||
finalTargetSafety);
|
||||
assertTrue(install >= 0);
|
||||
assertTrue(initialTargetSafety > install);
|
||||
assertTrue(beginMutation > initialTargetSafety);
|
||||
assertTrue(finalTargetSafety > beginMutation);
|
||||
assertTrue(publish > finalTargetSafety);
|
||||
|
||||
Path root = temporaryFolder.newFolder("symbolic-replacement-target").toPath();
|
||||
Path outside = root.resolve("outside-pack");
|
||||
Path target = root.resolve("world/iris/pack");
|
||||
Files.createDirectories(outside);
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.writeString(outside.resolve("sentinel.txt"), "unchanged");
|
||||
try {
|
||||
Files.createSymbolicLink(target, outside);
|
||||
} catch (IOException | UnsupportedOperationException | SecurityException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
PackValidationResult existingValidation = new PackValidationResult(
|
||||
"pack", List.of(), List.of("existing target"), 29L);
|
||||
PackValidationRegistry.publish(target, existingValidation);
|
||||
|
||||
IOException failure = assertThrows(
|
||||
IOException.class,
|
||||
() -> StudioSVC.requireSafePublicationTarget(target, true));
|
||||
|
||||
assertTrue(failure.getMessage().contains("symbolic link"));
|
||||
assertTrue(Files.isSymbolicLink(target));
|
||||
assertEquals(outside.toRealPath(), target.toRealPath());
|
||||
assertEquals("unchanged", Files.readString(outside.resolve("sentinel.txt")));
|
||||
assertSame(existingValidation, PackValidationRegistry.requireLoadable(target));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectedPublicationEvictsCreatedLoaderBeforeDiskRollback() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("cache-rollback").toPath();
|
||||
@@ -149,6 +247,120 @@ public class StudioSVCWorldPackPublishTest {
|
||||
assertTrue(PackValidationRegistry.isBroken(packRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactCopiedFingerprintReusesSourceSemanticValidation() throws Exception {
|
||||
Path root = temporaryFolder.newFolder("matching-validation-copy").toPath();
|
||||
Path source = root.resolve("source");
|
||||
Path target = root.resolve("target");
|
||||
writeValidPack(source);
|
||||
StudioSVC.copyPackTree(source, target);
|
||||
String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(source.toFile());
|
||||
String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile());
|
||||
PackValidationResult sourceValidation = new PackValidationResult(
|
||||
"source", List.of(), List.of("preserved source warning"), 17L);
|
||||
PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint);
|
||||
|
||||
PackValidationResult reused = StudioSVC.validatePublishedPack(
|
||||
target,
|
||||
source,
|
||||
copiedFingerprint);
|
||||
|
||||
assertEquals(sourceFingerprint, copiedFingerprint);
|
||||
assertSame(sourceValidation, reused);
|
||||
assertSame(sourceValidation, PackValidationRegistry.requireLoadable(target));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void copiedFingerprintMismatchFallsBackToTargetValidation() throws Exception {
|
||||
Path root = temporaryFolder.newFolder("mismatched-validation-copy").toPath();
|
||||
Path source = root.resolve("source");
|
||||
Path target = root.resolve("target");
|
||||
writeValidPack(source);
|
||||
StudioSVC.copyPackTree(source, target);
|
||||
String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(source.toFile());
|
||||
PackValidationResult sourceValidation = new PackValidationResult(
|
||||
"source", List.of(), List.of(), 19L);
|
||||
PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint);
|
||||
Files.writeString(target.resolve("dimensions/main.json"), "{");
|
||||
String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile());
|
||||
|
||||
assertFalse(sourceFingerprint.equals(copiedFingerprint));
|
||||
assertThrows(BrokenPackException.class, () -> StudioSVC.validatePublishedPack(
|
||||
target,
|
||||
source,
|
||||
copiedFingerprint));
|
||||
assertTrue(PackValidationRegistry.isBroken(target));
|
||||
assertSame(sourceValidation, PackValidationRegistry.requireLoadable(source));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replacementKeepsTargetUnauthorizedThroughPublishedFingerprintWindow() throws Exception {
|
||||
String sourceCode = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/service/StudioSVC.java"));
|
||||
int install = sourceCode.indexOf("private IrisDimension installIntoDirectory(");
|
||||
int beginMutation = sourceCode.indexOf("PackValidationRegistry.beginRootMutation(target)", install);
|
||||
int publish = sourceCode.indexOf("AtomicDirectoryPublisher.publish(stage, target)", beginMutation);
|
||||
int fingerprint = sourceCode.indexOf(
|
||||
"ServerConfigurator.computePackTreeFingerprint(target.toFile())",
|
||||
publish);
|
||||
int stageValidation = sourceCode.indexOf(
|
||||
"validatePublishedPack(target, source, copiedFingerprint, validationMutation)",
|
||||
fingerprint);
|
||||
int publishCommit = sourceCode.indexOf("publication.commit()", stageValidation);
|
||||
int validationCommit = sourceCode.indexOf("validationMutation.commit()", publishCommit);
|
||||
assertTrue(install >= 0);
|
||||
assertTrue(beginMutation > install);
|
||||
assertTrue(publish > beginMutation);
|
||||
assertTrue(fingerprint > publish);
|
||||
assertTrue(stageValidation > fingerprint);
|
||||
assertTrue(publishCommit > stageValidation);
|
||||
assertTrue(validationCommit > publishCommit);
|
||||
|
||||
Path root = temporaryFolder.newFolder("validation-publication-window").toPath();
|
||||
Path sourcePack = root.resolve("source");
|
||||
Path target = root.resolve("world/iris/pack");
|
||||
Path stage = root.resolve("world/iris/.pack.installing-test");
|
||||
writeValidPack(sourcePack);
|
||||
writeValidPack(target);
|
||||
StudioSVC.copyPackTree(sourcePack, stage);
|
||||
String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(sourcePack.toFile());
|
||||
PackValidationResult sourceValidation = new PackValidationResult(
|
||||
"source", List.of(), List.of(), 23L);
|
||||
PackValidationResult staleTargetValidation = new PackValidationResult(
|
||||
"pack", List.of(), List.of("stale target"), 11L);
|
||||
PackValidationRegistry.publish(sourcePack, sourceValidation, sourceFingerprint);
|
||||
PackValidationRegistry.publish(target, staleTargetValidation);
|
||||
assertSame(staleTargetValidation, PackValidationRegistry.requireLoadable(target));
|
||||
|
||||
AtomicDirectoryPublisher.Publication publication = null;
|
||||
try (PackValidationRegistry.RootMutation validationMutation =
|
||||
PackValidationRegistry.beginRootMutation(target)) {
|
||||
assertNull(PackValidationRegistry.get(target));
|
||||
assertThrows(BrokenPackException.class, () -> PackValidationRegistry.requireLoadable(target));
|
||||
publication = AtomicDirectoryPublisher.publish(stage, target);
|
||||
assertNull(PackValidationRegistry.get(target));
|
||||
String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile());
|
||||
assertNull(PackValidationRegistry.get(target));
|
||||
assertThrows(BrokenPackException.class, () -> PackValidationRegistry.requireLoadable(target));
|
||||
|
||||
PackValidationResult staged = validationMutation.stageMatchingCopy(
|
||||
sourcePack,
|
||||
copiedFingerprint);
|
||||
|
||||
assertSame(sourceValidation, staged);
|
||||
assertNull(PackValidationRegistry.get(target));
|
||||
publication.commit();
|
||||
assertNull(PackValidationRegistry.get(target));
|
||||
validationMutation.commit();
|
||||
assertSame(sourceValidation, PackValidationRegistry.requireLoadable(target));
|
||||
publication.cleanupBackup();
|
||||
} finally {
|
||||
if (publication != null) {
|
||||
publication.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createdProjectRollbackEvictsOnlyItsCachedLoaderBeforeDeletion() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("project-cache-rollback").toPath();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core.tools;
|
||||
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import org.bukkit.Chunk;
|
||||
@@ -8,10 +9,14 @@ import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
@@ -20,6 +25,7 @@ import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class IrisCreatorTeleportTest {
|
||||
@Test
|
||||
@@ -99,4 +105,72 @@ public class IrisCreatorTeleportTest {
|
||||
|
||||
assertThrows(CompletionException.class, result::join);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void awaitTeleportFailure_returnsNullForSuccessfulTeleport() {
|
||||
CompletableFuture<Boolean> teleport = CompletableFuture.completedFuture(true);
|
||||
|
||||
Throwable failure = IrisCreator.awaitTeleportFailure(
|
||||
teleport,
|
||||
"ParthOP69",
|
||||
1L,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
|
||||
assertNull(failure);
|
||||
assertFalse(teleport.isCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void awaitTeleportFailure_reportsFalseTeleportResult() {
|
||||
CompletableFuture<Boolean> teleport = CompletableFuture.completedFuture(false);
|
||||
|
||||
Throwable failure = IrisCreator.awaitTeleportFailure(
|
||||
teleport,
|
||||
"ParthOP69",
|
||||
1L,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
|
||||
assertTrue(failure instanceof IllegalStateException);
|
||||
assertEquals(
|
||||
"The runtime teleport operation returned false for player \"ParthOP69\".",
|
||||
failure.getMessage()
|
||||
);
|
||||
assertFalse(teleport.isCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void awaitTeleportFailure_unwrapsExceptionalTeleportResult() {
|
||||
IllegalStateException expected = new IllegalStateException("teleport failed");
|
||||
CompletableFuture<Boolean> teleport = CompletableFuture.failedFuture(expected);
|
||||
|
||||
Throwable failure = IrisCreator.awaitTeleportFailure(
|
||||
teleport,
|
||||
"ParthOP69",
|
||||
1L,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
|
||||
assertSame(expected, failure);
|
||||
assertFalse(teleport.isCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void awaitTeleportFailure_cancelsTimedOutTeleportWithoutRestartingServer() {
|
||||
CompletableFuture<Boolean> teleport = new CompletableFuture<>();
|
||||
|
||||
try (MockedStatic<ServerConfigurator> serverConfigurator = mockStatic(ServerConfigurator.class)) {
|
||||
Throwable failure = IrisCreator.awaitTeleportFailure(
|
||||
teleport,
|
||||
"ParthOP69",
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
|
||||
assertTrue(failure instanceof TimeoutException);
|
||||
assertTrue(teleport.isCancelled());
|
||||
serverConfigurator.verifyNoInteractions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -380,6 +380,40 @@ public class BukkitChunkGeneratorGenerationStageGateTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queuedStageRemainsAdmittedWhileShutdownIsOnlyQuiesced() throws Exception {
|
||||
AtomicBoolean closing = new AtomicBoolean(false);
|
||||
BukkitChunkGenerator.GenerationStageGate gate =
|
||||
new BukkitChunkGenerator.GenerationStageGate(1, closing::get);
|
||||
gate.acquireExclusive();
|
||||
boolean exclusiveHeld = true;
|
||||
BukkitChunkGenerator.GenerationStagePermit admitted = null;
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
try {
|
||||
Future<BukkitChunkGenerator.GenerationStagePermit> stage =
|
||||
executor.submit(() -> gate.acquireStage("paper-queued-before-shutdown-boundary"));
|
||||
awaitQueueLength(gate, 1);
|
||||
|
||||
assertFalse(closing.get());
|
||||
gate.releaseExclusive();
|
||||
exclusiveHeld = false;
|
||||
|
||||
admitted = stage.get(2, TimeUnit.SECONDS);
|
||||
assertEquals(0, gate.availablePermits());
|
||||
admitted.close();
|
||||
assertEquals(1, gate.availablePermits());
|
||||
} finally {
|
||||
if (admitted != null) {
|
||||
admitted.close();
|
||||
}
|
||||
if (exclusiveHeld) {
|
||||
gate.releaseExclusive();
|
||||
}
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queuedStageIsRejectedAfterCloseBegins() throws Exception {
|
||||
AtomicBoolean closing = new AtomicBoolean(false);
|
||||
|
||||
Reference in New Issue
Block a user