Skip to content

Research Exception Hierarchy

TheMeinerLP edited this page Aug 1, 2026 · 2 revisions

Research: exception hierarchy for the Anvil package

Question. Replace the generic JDK exceptions in net.onelitefeather.falco.anvil with a dedicated hierarchy that has both checked and unchecked types, so that "the server never loads a world in a strange state". This page is the design that came out of that question; it is not a description of shipped behaviour, because none of it is implemented.

Method. Three agents worked on this — hierarchy semantics, a complete catalogue of every throw site, and the world-consistency guarantees. The language rules below were established by compiling probe code, which the investigation records as javac 25.0.3; the probes are not checked in. The counts over the package were established by reading falco-anvil/src. They agreed on almost everything and disagreed on one point that changes the whole migration.

Established. What Java permits and forbids for a mixed checked/unchecked hierarchy — four hard rules, each with the compiler error that produces it. A proposed set of six types. Seven rules the implementation would have to follow.

Open. Whether the checked root extends IOException. That decision is unmade and the two agents reached opposite conclusions; both arguments are recorded below rather than resolved. Also open, and newly so: whether moving AnvilChunkException into a new package is acceptable — see the correction in What Java allows here.

True at. Written 2026-07-31, entered the repository at fc0aef5. The counts and the two corrections below were re-checked against falco-anvil/src at ca79507. Nothing here has been implemented, so no statement on this page describes running code.

Two premises of this document were imported from another codebase and do not hold for Falco. They are corrected in place below, with the source citation that replaces them. One of them reverses the conclusion it was supporting. Everything else on the page survived the re-check.

What Java allows here

Verified by compiling probe code, recorded by the investigation as javac 25.0.3.

A common root over checked and unchecked is impossible as a class. Java has exactly two roots, Exception and RuntimeException, and a class cannot be both. The only bracket is an interface — but it cannot be caught:

catch (AnvilFault f)
  -> error: incompatible types: AnvilFault cannot be converted to Throwable

An interface therefore only helps after a broad catch, for instanceof or a pattern switch. It is not a way to catch both families in one clause. This is worth stating plainly because the opposite is a natural assumption.

Sealed exceptions compile, but give no exhaustiveness in catch. A try block that catches both permitted subtypes still does not satisfy the compiler; a catch of the sealed supertype remains necessary. Java has no exhaustiveness analysis for catch clauses. One reason to seal anyway survives: it prevents a downstream project from breaking a pattern switch by adding a subtype.

Correction. The document originally offered a second reason — that the repository "already uses sealed in eight places (MapEntry, ClickHolder, IItem, InventoryLayout, …)". Falco declares no sealed type at all, verified at ca79507: grep -rn sealed over falco-anvil/src, falco-light/src and falco-instance/src returns nothing, and none of the four named types exists in this project. That premise was carried in from another codebase. Sealing the hierarchy would therefore be the first use of sealed here, not a continuation of house style — which does not make it wrong, but removes the argument that it is already settled practice.

All sealed members must live in one package. Falco has no module-info.java, so it runs in the unnamed module, where a sealed class may only permit subtypes from its own package. Verified:

error: class Root in unnamed module cannot extend a sealed class in a different package

Consequence: either every exception type moves into …anvil.exception, or all stay in …anvil. A mix breaks compilation.

Correction, and it reverses the conclusion. The document originally argued that moving AnvilChunkException is safe "because it is @since 1.16.0 and the released version is 1.15.2, so nothing depends on it yet". Both figures belong to another codebase. At ca79507, AnvilChunkException carries @since 0.1.0 and Falco's released version is 0.3.0, so the type has shipped in every release the project has made and moving it is a source- and binary-breaking change for anyone who catches it by name. What does license a move is a different fact on the same file: the class is annotated @ApiStatus.Experimental, and its own Javadoc says the API "may still change while it is being validated against real worlds". That is an argument the maintainer can accept or reject; it is not the free move the original sentence described.

java.nio.file.Path must not be stored in an exception field. It is not serializable (NotSerializableException: sun.nio.fs.UnixPath, verified), which would make the exception unserializable. Store the region path as a String.

No serialVersionUID. The repository has zero occurrences across all five modules, re-checked at ca79507; the build sets no -Xlint, and these exceptions never leave the process.

The open decision: does the checked root extend IOException?

The two agents that looked at this reached opposite conclusions, and both arguments are sound.

For extending IOException — it is a migration question. The 40 throws clauses in falco-anvil/src/main, both catch (IOException | RuntimeException) multi-catches in FalcoAnvilLoader, and the 14 assertThrows(IOException.class) assertions in the tests keep working untouched. The change becomes additive. All three counts were re-checked at ca79507 and are exact, not approximate; they were also exact when the investigation ran at fc0aef5.

Against extending IOException — it is a correctness question. Every existing catch (IOException) would silently keep catching the new types, including the swallowing block in saveChunk. The migration would then be compile-time only and change nothing at runtime. The cost (8 signatures in main) is exactly what the compiler is for.

This is a genuine trade-off between migration cost and enforcement, not a case where one side is wrong. It needs a decision before implementation.

Proposed types

The variant below is the one both agents converged on apart from the inheritance question. Six types for the thirteen classes of the package that are not themselves an exception — the package holds 14 classes plus its package-info.java at ca79507, of which only AnvilChunkException is an exception today. Both agents explicitly warned against more types, since one nobody catches is Javadoc maintenance without a decision point.

Type Kind Purpose
AnvilFault sealed interface Common contract, carries the ChunkLocation. Not catchable — for pattern switches after a broad catch.
AnvilFormatException checked, abstract sealed Root for everything the file itself got wrong.
RegionFormatException checked, final Broken .mca structure: header too short, implausible length field, overlapping sectors, unsupported compression scheme.
ChunkDataException checked, final Broken chunk NBT: missing key, wrong type, empty palette, index outside the palette.
AnvilChunkException unchecked, non-sealed The boundary type. Exists already; gains implements AnvilFault.
ChunkLocation record (chunkX, chunkZ, region, dimension) — the single definition of the log context.

AnvilChunkException must be declared non-sealed once it implements the sealed interface — a compiler requirement, and correct in substance since downstream may subclass it.

Rules the implementation has to follow

  1. Exactly one translation point from checked to unchecked: the catch in FalcoAnvilLoader.loadChunk, and its counterpart in saveChunk. No other class may wrap checked into unchecked, or the origin is lost and the double report to the ExceptionManager returns.
  2. Programmer errors keep the JDK types. BitPacker, SectorAllocator and PaletteData.singleValue keep throwing IllegalArgumentException / IllegalStateException. Nobody catches them, they lead into no recovery path, and most are provably unreachable from the disk path.
  3. One exception to rule 2: SectorAllocator.reserve rejecting overlapping sectors is reachable from the disk path via RegionFile.readHeader. It is corruption detection, not a programmer error, and is translated at the RegionFile boundary — without changing the allocator, which would cost it its file independence.
  4. Real IO stays java.io.IOException. There is no explicit throw site for it in the package; FileChannel, Files and channel.force produce it implicitly. Wrapping adds no knowledge.
  5. No throw without a location. Every message from the format branch names at least the region path and the chunk coordinate. RegionFile already does this; NbtReads and PaletteData name only the key or the number and must have the location passed in — otherwise the tick log reads palette must hold at least one entry with no hint which file it came from.
  6. The context format is defined once, in ChunkLocation.toString(). Exception messages must not repeat the coordinates as text, or today's double formatting persists.
  7. Never pass coordinates into NbtReads, PaletteData, SectionCodec, BitPacker. These are deliberately format-only and testable without a running server. The location is attached at the loader boundary.

Style

New package …anvil.exception with a package-info.java in the exact repository form (@NotNullByDefault + package + import, four lines). Class Javadoc starting with The {@link X} is … explaining the why, plus @author / @version / @since. Constructors (String) and (String, Throwable); the (Throwable)-only constructor is deliberately omitted for the format exceptions, because a format violation without a description is useless.

References

  • falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ — the 14 classes and the package-info.java this design would cover, and the source of the 40 throws clauses, the two multi-catches in FalcoAnvilLoader and the @since / annotation facts above. Re-checked at ca79507.
  • Why a failed read throws rather than reporting the chunk as absent — the design constraint this hierarchy has to preserve — is argued in Rationale: Chunk Loading.
  • What the rest of these investigations are worth, and why their numbers are not benchmark numbers, is on Research.

Falco


Start here

The measured record

Why it is built this way

  • Rationale — the index for the five rationale pages
  • Research — the index for the five investigations
  • Research: Fluent API — the investigation behind the builders; a record, not a reference

Working on the build

Clone this wiki locally