From 3ba6d5b9c1b4f54cbb8fa64157bf1af3548333bf Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 14 Oct 2026 21:01:08 -0400 Subject: [PATCH] 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 --- .../catalog/ModdedWorldgenCatalogs.java | 84 ++++++++++++------- .../catalog/ModdedWorldgenCatalogsTest.java | 84 +++++++++++++++++++ 2 files changed, 140 insertions(+), 28 deletions(-) create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/catalog/ModdedWorldgenCatalogsTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/catalog/ModdedWorldgenCatalogs.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/catalog/ModdedWorldgenCatalogs.java index 5de99124e..c7d94d711 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/catalog/ModdedWorldgenCatalogs.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/catalog/ModdedWorldgenCatalogs.java @@ -55,56 +55,68 @@ public final class ModdedWorldgenCatalogs { public static CompletableFuture capture(NativeModdedServer server) { Objects.requireNonNull(server, "server"); + return capture(() -> scan(server)); + } + + static CompletableFuture capture(CaptureScan scan) { CompletableFuture pending = new CompletableFuture<>(); CompletableFuture 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 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 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 pending, CaptureResult result, RuntimeException failure) { + private static void finish(CompletableFuture 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) { } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/catalog/ModdedWorldgenCatalogsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/catalog/ModdedWorldgenCatalogsTest.java new file mode 100644 index 000000000..bdc405a1e --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/catalog/ModdedWorldgenCatalogsTest.java @@ -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 . + */ + +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 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 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 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); + } +}