Skip to content

World.unloadChunk(x, z, false) cumulatively persists entities to disk #14188

Description

@kccricket

Expected behavior

Calling World.unloadChunk(x, z, false) on a chunk should leave that chunk's data as if the plugin had never touched it. No terrain changes and no entity changes should be persisted for that unload.

Observed/Actual behavior

save=false only suppresses the terrain write. Entity data is written unconditionally, regardless of the flag, and — for a chunk whose entities aren't yet backed by a completed disk read (i.e. entities from a chunk that was just generated) — that write merges with whatever's already on disk instead of replacing it.

Net effect: a plugin that generates a chunk for its own purposes (map rendering, LOD building, pregeneration, analysis) and calls unloadChunk(x, z, false) to clean up gets only half of what it asked for. Terrain regeneration is idempotent, so this is invisible under normal testing. Entity regeneration is not: worldgen mob spawning (NoiseBasedChunkGenerator.spawnOriginalMobs) seeds its RNG from setDecorationSeed(worldSeed, chunkX, chunkZ), so regenerating a chunk N times reproduces the same mobs at the same coordinates with the same attributes N times, differing only in UUID. Each of those N generations gets merged onto the last, so N regenerate/discard cycles silently persist N identical copies of every worldgen mob in that chunk.

I hit this in production via a plugin (Distant Horizons Support) that pregenerated LOD data across a large area, loading and discarding chunks it needed along the way. One pregeneration run left 50,716 duplicate mobs (a third of the world's entity count) in a 256-chunk-radius disc, with up to 105 copies of a single spawn.

Root cause, in the actual unload path:

CraftWorld.unloadChunk0 (paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java:460):

private boolean unloadChunk0(int x, int z, boolean save) {
    ...
    if (!save) {
        chunk.tryMarkSaved(); // ChunkAccess.tryMarkSaved(): this.unsaved = false
    }
    this.unloadChunkRequest(x, z);
    ...
}

tryMarkSaved() (ChunkAccess.java:334) only clears the dirty flag on the terrain chunk (and its PDC). In the actual unload, NewChunkHolder.unloadStage2 (ca/spottedleaf/moonrise/patches/chunk_system/scheduling/NewChunkHolder.java:869):

// unload chunk data
if (!shouldLevelChunkNotSave) {
    this.saveChunk(chunk, true, chunkWrite);   // gated on chunk.isUnsaved() - correctly skipped
}
...
// unload entity data
if (entityChunk != null) {
    this.saveEntities(entityChunk, true, entityWrite);   // unconditional - no flag consulted
    ...
}

Terrain is correctly gated and skipped. Entities are saved regardless. The periodic-save path is equally blunt (NewChunkHolder.save, ~line 1812): canSaveChunk checks chunk.isUnsaved(); canSaveEntities is just entities != null — no dirty check, no flag check at all.

Why it accumulates rather than just leaking once: NewChunkHolder.saveEntities (line 1886), for a transient entity chunk (not yet backed by a completed disk read — exactly the state of freshly-generated worldgen entities), merges rather than replaces:

final Completable<CompoundTag> mergeFrom = MoonriseRegionFileIO.loadDataAsync(
    this.world, this.chunkX, this.chunkZ, MoonriseRegionFileIO.RegionFileType.ENTITY_DATA, false, Priority.NORMAL
);
final Completable<CompoundTag> toWrite = mergeFrom.handle((onDisk, thr) -> {
    ...
    ChunkEntitySlices.copyEntities(onDisk, save);
    return save;
});

ChunkEntitySlices.copyEntities (.../level/entity/ChunkEntitySlices.java, ~line 104):

final ListTag entitiesInto = into.getListOrEmpty("Entities");
into.put("Entities", entitiesInto);
entitiesInto.addAll(0, entitiesFrom);

A union, not a replace. Each discard-then-regenerate cycle writes on-disk ∪ newly-generated. The existing comment above this branch ("if we're a transient chunk, we cannot save until unloading because otherwise a double save will result in double adding the entities") shows the double-save hazard for one generation is known and guarded against — but nothing guards against repeated generation of the same chunk between saves, which is exactly what a plugin discarding and later re-requesting a chunk produces.

Steps/models to reproduce

Minimal shape, no plugin logic beyond the two calls:

  1. Request a chunk that hasn't been generated yet via the async getChunkAtAsync(x, z, true) (or equivalent), so it goes through full worldgen including mob spawning.
  2. Once loaded, call world.unloadChunk(x, z, false).
  3. Repeat steps 1–2 for the same chunk coordinate a few times (each repeat forces a fresh generation, since nothing was ever written to region/).
  4. Inspect entities/r.<region>.mca for that chunk. Expect one copy of every worldgen-spawned mob per repeat, all sharing Paper.Origin and attribute values, each with a distinct UUID and Spigot.ticksLived = 0.

I validated this at production scale with both a real plugin driving the cycle and a minimal POC.

Plugin and Datapack List

Reproducible without any plugin logic beyond direct API calls (see above). Originally surfaced via Distant Horizons Support (a third-party LOD-generation plugin); not specific to it.

Paper version

This server is running Paper version 26.2-112-main@c9e894d (2026-08-11T05:18:39Z) (Implementing API version 26.2.build.112-stable)

Production incident occurred on 26.2-63-main@3a55f62.

Other

Suggested remedy: either honor save=false for entity data the same way it's honored for terrain, or document clearly that it covers terrain only and provide a real way to express "discard this chunk's entity changes" — the current behavior is worse than either alternative, since it looks like it works.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions