Contained catalog capture errors

- A worldgen catalog capture that fails with any throwable, during the server-thread scan or the background encode, always completes its future and frees the next capture
- Errors are logged with their stacktrace and no longer escape server startup; only virtual machine errors are rethrown after cleanup
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-14 21:01:08 -04:00
1 parent 56ba2e51b7
commit 3ba6d5b9c1
2 files changed
+140 -28

No files matched your search

@@ -55,56 +55,68 @@ public final class ModdedWorldgenCatalogs {
public static CompletableFuture<CaptureResult> capture(NativeModdedServer server) {
Objects.requireNonNull(server, "server");
return capture(() -> scan(server));
}
static CompletableFuture<CaptureResult> capture(CaptureScan scan) {
CompletableFuture<CaptureResult> pending = new CompletableFuture<>();
CompletableFuture<CaptureResult> running = RUNNING.compareAndExchange(null, pending);
if (running != null) {
return running;
}
long startedAt = System.nanoTime();
NativeModdedLoader loader = ModdedEngineBootstrap.loader();
NativeWorldgenScan scan;
CatalogCaptureContext context;
CaptureWork work;
try {
scan = NativeWorldgenScan.scan(server, loader);
context = new CatalogCaptureContext(loader.minecraftVersion(), loader.platformName(),
System.currentTimeMillis(), loader.mods());
} catch (RuntimeException failure) {
work = scan.scan();
} catch (Throwable failure) {
ModdedIrisLog.error("Iris could not read the worldgen registries for the catalog", failure);
finish(pending, null, failure);
rethrowIfVirtualMachineError(failure);
return pending;
}
long scanMillis = elapsedMillis(startedAt);
Path file = catalogFile();
Thread worker = new Thread(() -> encodeAndWrite(new PendingCapture(scan, context, file, startedAt, scanMillis), pending),
"Iris Worldgen Catalog");
Thread worker = new Thread(() -> complete(work, pending), "Iris Worldgen Catalog");
worker.setDaemon(true);
worker.start();
return pending;
}
private static void encodeAndWrite(PendingCapture capture, CompletableFuture<CaptureResult> pending) {
private static CaptureWork scan(NativeModdedServer server) {
long startedAt = System.nanoTime();
NativeModdedLoader loader = ModdedEngineBootstrap.loader();
NativeWorldgenScan scan = NativeWorldgenScan.scan(server, loader);
CatalogCaptureContext context = new CatalogCaptureContext(loader.minecraftVersion(), loader.platformName(),
System.currentTimeMillis(), loader.mods());
PendingCapture capture = new PendingCapture(scan, context, catalogFile(), startedAt, elapsedMillis(startedAt));
return () -> encodeAndWrite(capture);
}
private static void complete(CaptureWork work, CompletableFuture<CaptureResult> pending) {
try {
NativeWorldgenSnapshot snapshot = capture.scan().encode();
WorldgenCatalog catalog = WorldgenCatalogAssembler.assemble(snapshot, capture.context());
WorldgenCatalogStore.write(capture.file(), catalog);
LATEST.set(catalog);
CaptureResult result = new CaptureResult(catalog, capture.file(), elapsedMillis(capture.startedAt()));
ModdedIrisLog.info("Iris worldgen catalog: {} namespaces, {} biomes, {} placed features, {} structures, "
+ "{} dimension types, {} level stems -> {} ({}ms server thread, {}ms total)",
catalog.namespaces().size(), catalog.biomes().size(), catalog.placedFeatures().size(),
catalog.structures().size(), catalog.dimensionTypes().size(), catalog.levelStems().size(),
capture.file(), capture.scanMillis(), result.millis());
reportEncodeFailures(snapshot.failures());
finish(pending, result, null);
finish(pending, work.run(), null);
} catch (IOException failure) {
ModdedIrisLog.error("Iris could not write the worldgen catalog to " + capture.file(), failure);
ModdedIrisLog.error("Iris could not write the worldgen catalog", failure);
finish(pending, null, new UncheckedIOException(failure));
} catch (RuntimeException failure) {
} catch (Throwable failure) {
ModdedIrisLog.error("Iris could not build the worldgen catalog", failure);
finish(pending, null, failure);
rethrowIfVirtualMachineError(failure);
}
}
private static CaptureResult encodeAndWrite(PendingCapture capture) throws IOException {
NativeWorldgenSnapshot snapshot = capture.scan().encode();
WorldgenCatalog catalog = WorldgenCatalogAssembler.assemble(snapshot, capture.context());
WorldgenCatalogStore.write(capture.file(), catalog);
LATEST.set(catalog);
CaptureResult result = new CaptureResult(catalog, capture.file(), elapsedMillis(capture.startedAt()));
ModdedIrisLog.info("Iris worldgen catalog: {} namespaces, {} biomes, {} placed features, {} structures, "
+ "{} dimension types, {} level stems -> {} ({}ms server thread, {}ms total)",
catalog.namespaces().size(), catalog.biomes().size(), catalog.placedFeatures().size(),
catalog.structures().size(), catalog.dimensionTypes().size(), catalog.levelStems().size(),
capture.file(), capture.scanMillis(), result.millis());
reportEncodeFailures(snapshot.failures());
return result;
}
private static void reportEncodeFailures(EncodeFailures failures) {
if (failures.count() == 0) {
return;
@@ -118,7 +130,7 @@ public final class ModdedWorldgenCatalogs {
ModdedIrisLog.warn("Iris worldgen catalog left {} entries without JSON; first: {}", failures.count(), first);
}
private static void finish(CompletableFuture<CaptureResult> pending, CaptureResult result, RuntimeException failure) {
private static void finish(CompletableFuture<CaptureResult> pending, CaptureResult result, Throwable failure) {
RUNNING.compareAndSet(pending, null);
if (failure != null) {
pending.completeExceptionally(failure);
@@ -127,6 +139,12 @@ public final class ModdedWorldgenCatalogs {
pending.complete(result);
}
private static void rethrowIfVirtualMachineError(Throwable failure) {
if (failure instanceof VirtualMachineError virtualMachineError) {
throw virtualMachineError;
}
}
private static long elapsedMillis(long startedAt) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
}
@@ -134,6 +152,16 @@ public final class ModdedWorldgenCatalogs {
public record CaptureResult(WorldgenCatalog catalog, Path file, long millis) {
}
@FunctionalInterface
interface CaptureScan {
CaptureWork scan();
}
@FunctionalInterface
interface CaptureWork {
CaptureResult run() throws IOException;
}
private record PendingCapture(NativeWorldgenScan scan, CatalogCaptureContext context, Path file, long startedAt, long scanMillis) {
}
}
@@ -0,0 +1,84 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded.catalog;
import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs.CaptureResult;
import org.junit.Test;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ModdedWorldgenCatalogsTest {
private static final CaptureResult RESULT = new CaptureResult(null, Path.of("worldgen-catalog.json"), 1L);
@Test
public void scanErrorCompletesTheCaptureAndFreesTheNextOne() throws Exception {
CompletableFuture<CaptureResult> failed = ModdedWorldgenCatalogs.capture(() -> {
throw new LinkageError("registry class missing");
});
ExecutionException failure = assertThrows(ExecutionException.class, () -> failed.get(5, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof LinkageError);
assertSame(RESULT, succeed());
}
@Test
public void workerErrorCompletesTheCaptureAndFreesTheNextOne() throws Exception {
CompletableFuture<CaptureResult> failed = ModdedWorldgenCatalogs.capture(() -> () -> {
throw new AssertionError("encode exploded");
});
ExecutionException failure = assertThrows(ExecutionException.class, () -> failed.get(5, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof AssertionError);
assertSame(RESULT, succeed());
}
@Test
public void workerWriteFailureCompletesWithTheIoCause() throws Exception {
CompletableFuture<CaptureResult> failed = ModdedWorldgenCatalogs.capture(() -> () -> {
throw new IOException("disk full");
});
ExecutionException failure = assertThrows(ExecutionException.class, () -> failed.get(5, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof UncheckedIOException);
assertSame(RESULT, succeed());
}
@Test
public void virtualMachineErrorDuringTheScanIsRethrownAfterFreeingTheCapture() throws Exception {
assertThrows(OutOfMemoryError.class, () -> ModdedWorldgenCatalogs.capture(() -> {
throw new OutOfMemoryError("scan heap");
}));
assertSame(RESULT, succeed());
}
private static CaptureResult succeed() throws InterruptedException, ExecutionException, TimeoutException {
return ModdedWorldgenCatalogs.capture(() -> () -> RESULT).get(5, TimeUnit.SECONDS);
}
}