Links
Tags
Creators
Details
0.20.2-neoforge
Compatibility
Changes
v0.20.2 — Fixes two race windows in async chunk saving
In two very narrow timing windows, async chunk saving exposed a false "this chunk has no write in flight" signal to the main thread. When hit, the same chunk ends up with two concurrent writes, or a save request is wrongly treated as "already being handled" and skipped. The worst outcomes are the chunk rolling back to an older state after a restart, or the last edits before an unload never reaching disk
Each window is only a few instructions wide and requires the main thread to process a save of that same chunk at exactly that moment, so the real-world probability is low. They are data-safety issues nonetheless, and all 0.20.x users are advised to upgrade. This release only touches the chunk saving path
1. Problem
In ChunkMap.save the main thread picks one of two branches purely from the chunk's save state: if it is "in flight" (a write is already under way), the latest content is registered as a relay snapshot to be written once the in-flight write finishes; if it is "dirty", a new save cycle is started. Switching between these states has to happen in a single step, with no other value visible to the main thread in between
Two paths split that switch into two steps, briefly exposing "dirty" in the middle:
- In-place retry after a failed write. When a write fails and
maxRetriesis not yet exhausted, the callback resubmits the already-serialized old data in place. It first set the state to "dirty" and only in the next step back to "in flight". A main thread caught in between concludes nothing is in flight: if it wins the race it opens a second save cycle that writes the same chunk concurrently with the retry; if it loses, it treats this save as "already being handled" and lets it through without registering a relay. If the latter happens during a chunk unload, the last edits before the unload have no copy left anywhere - Relay write. When the in-flight write finishes and a relay snapshot is waiting, the callback first set the state to "dirty" and took the snapshot, and only in the next step entered the relay cycle. Registering a relay does not mark the chunk as saved, so a main thread processing the same chunk during this gap likewise opens a second cycle. If the chunk was edited again after the relay snapshot was taken, both cycles consider their data stale when they land and neither marks the chunk clean; if the older data happens to finish writing last, the region file keeps the old content while the chunk is already marked as saved and will not be written again
The second window does not require a disk error — it only needs the same chunk to be edited and saved again while its previous write is in flight (typically on unload, or during autosave under load)
Under some interleavings both windows also leave the internal pending-drain counter permanently one too high: /betterautosave drain-unload never sees it reach zero, and the Prometheus mustDrain metric keeps reporting a pending chunk
2. Fix
The rule: only a callback that is truly finishing, with no further write to follow, may set the state to "dirty". A callback that is going to write again stays "in flight" throughout
- When a failed write still has retries left, the state stays "in flight" and the data is resubmitted in place. Only when the serialization thread itself fails and no resubmission is possible does the fallback set the state to "dirty" on its own, so the next save cycle recaptures the chunk. The entity saving path has worked this way since 0.11.0; this release brings the chunk path in line
- Taking the relay snapshot and entering the relay cycle are now one atomic operation, with no "dirty" in between
Neither change touches the save format, config keys or the public API, and the state machine behaves as before on every other path
3. Verification
- All 538 unit tests pass, 9 of them new: they drive the state machine step by step in the interleaved order on a single thread to reproduce both windows deterministically, plus a randomized case covering retry limits from 0 to 10 including the exact-exhaustion boundary
- Mutation testing: reverting each key part of the fix individually (6 variants) makes tests fail every time
- The fix was independently reviewed adversarially by two separate reviewers
4. Upgrading
Drop in the new jar. The save format and config keys are unchanged
Projects on Modrinth are automatically available through a Maven repository for use with JVM build tools such as Gradle. To learn more about the Modrinth Maven API, click here.
Note: When available, you should use the creator's maven repo instead as it will have transitive dependency information that the Modrinth Maven API does not. You may also end up with duplicate dependencies if you use a mix of Modrinth and non-Modrinth Maven repositories for your dependencies, because the group identifier will be different when served through the Modrinth Maven API.
Maven coordinates:
Version ID:
build.gradle:
repositories {
exclusiveContent {
forRepository {
maven {
name = "Modrinth"
url = "https://api.modrinth.com/maven"
}
}
// forRepositories(fg.repository) // Uncomment when using ForgeGradle
filter {
includeGroup "maven.modrinth"
}
}
}
// Standard Gradle dependency
dependencies {
implementation "maven.modrinth:WAXR3mM5:rFbPhzl4"
}
// Legacy Loom dependency
dependencies {
modImplementation "maven.modrinth:WAXR3mM5:rFbPhzl4"
}

