Fix partial stream state in MemoryOptimizedHistory lazy getters; harden lazy init - #3595
Fix partial stream state in MemoryOptimizedHistory lazy getters; harden lazy init#3595MattBDev wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens MemoryOptimizedHistory against a lost-update race caused by broken double-checked locking in its lazy, cached stream getters, aligning the implementation with the concurrency-fix pattern used for the disk-backed history variant.
Changes:
- Make the six cached “*Zip” stream fields
volatileand update all six getters to use check → synchronized → re-check → construct. - Ensure
getBlockOS()fully initializes (writes header) before publishing the stream reference. - Add a concurrency regression test and adjust test runtime dependencies to ensure LZ4 stream classes are present at test runtime.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| worldedit-core/src/main/java/com/fastasyncworldedit/core/history/MemoryOptimizedHistory.java | Fix lazy stream initialization races via volatile + correct double-checked locking; adjust getBlockOS() publication ordering. |
| worldedit-core/src/test/java/com/fastasyncworldedit/core/history/MemoryOptimizedHistoryConcurrencyTest.java | Add regression test validating stream getter returns a single instance under concurrent access. |
| worldedit-core/build.gradle.kts | Add LZ4 library on the test runtime classpath to avoid NoClassDefFoundError during concurrency tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Flagging commit 7df0cdb for a second reviewer — this fixes a genuinely critical bug found during a follow-up code review, not just the original DCL race this PR started with: The bug: every one of the six lazy getters (getBlockOS, getBiomeOS, getEntityCreateOS, getEntityRemoveOS, getTileCreateOS, getTileRemoveOS) assigned its buffer field (e.g. idsStream) before constructing the wrapping compressed/NBT stream (getCompressedOS(...), or writeHeader(...) for getBlockOS specifically) - both of which can throw. A failure there left the buffer field non-null while the wrapper field (e.g. idsStreamZip) stayed null. flush()/close() gate on the buffer field being non-null and then unconditionally dereference the wrapper field, so instead of just skipping the failed stream, they NPE - and since MemoryOptimizedHistory.close() calls super.close() first, this NPE would propagate up through AbstractChangeSet.close() and out to whatever called close() on the changeset (e.g. EditSession's session-completion path), rather than the changeset just cleanly closing whatever streams did succeed. The fix: each getter now builds the buffer and its wrapper into locals and only publishes both fields together once construction fully succeeds, so a failure leaves neither field set. What to double-check:
|
|
Same here, parent functions/classes ensure the writes are single-threaded. Adding extra complexity would slow down history (which is already slow/a limiting factor for large edits) |
…tters The six lazy output-stream getters checked their backing field outside the lock without re-checking inside it, and the four NBT getters were not synchronized at all. None of the six fields were volatile, so two threads could both pass the null check and each construct a separate compressed stream over a fresh buffer, silently discarding one thread's recorded changes. Make the six *Zip fields volatile and give all six getters the standard double-checked-locking shape: check, synchronize, re-check, construct. This mirrors the equivalent fix in DiskStorageHistory so the two classes, which are read side by side, keep the same shape. getBlockOS() additionally now writes the header into a local before publishing the stream to the volatile field, so a fast-path reader cannot observe a stream whose header has not been written yet and interleave block data ahead of it. Adds MemoryOptimizedHistoryConcurrencyTest covering concurrent getter access. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both barrier.await() and done.await() were unbounded. If the getter under test ever deadlocked - exactly the failure mode this test exists to catch - the test would hang indefinitely instead of failing, potentially hanging CI. Bring this in line with the equivalent test in DiskStorageHistoryConcurrencyTest (PR #3594), which already does this: bound the barrier wait, and check done.await()'s return value, failing loudly with a diagnostic on timeout instead of silently proceeding. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…etter coverage
Every getter assigned its *Stream buffer field before constructing the
wrapping compressed/NBT stream, which can throw. A failure there (e.g.
getCompressedOS()) left the buffer field non-null while the wrapper field
(*StreamZip) stayed null. flush()/close() gate on the buffer field being
non-null and then dereference the wrapper field unconditionally, so they
would NPE instead of just skipping the failed stream. Build both into locals
and publish them together, so a failed construction leaves neither field set
and flush()/close() correctly treat the stream as never having existed.
Adds getBlockOSFailureLeavesNoPartialStateForFlushOrClose, a deterministic
regression test using a Mockito spy to force getCompressedOS() to throw:
against the pre-fix code it reproduces the exact NPE
("Cannot invoke FaweOutputStream.flush() because idsStreamZip is null");
with the fix, flush()/close() no-op cleanly.
Also, from code review of the DCL fix test:
- worker threads are now daemon, so a getter that deadlocks (the exact
failure mode being guarded against) can't keep the build alive past the
test's own timeout.
- getter results are asserted non-null before being recorded, since null is
a valid IdentityHashMap key and would otherwise let a buggy getter that
returns null pass as "one distinct instance".
- extended coverage to all six getters (previously only getBlockOS and
getTileCreateOS were exercised), so a future edit reintroducing the DCL bug
on getBiomeOS/getEntityCreateOS/getEntityRemoveOS/getTileRemoveOS would be
caught.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to the code review of this PR. Source: - getBlockOS() now closes the compressed stream if writeHeader() throws. It was the one getter doing further work after constructing the stream, and on failure that stream is never published, so nothing else could ever close it - it leaked along with its buffer. - flush()/close() now gate on the volatile *StreamZip wrapper fields rather than the *Stream buffer fields. The publish-together change makes the pair consistent, but gating on the field actually being dereferenced is correct regardless of how it was published. - Dropped the redundant setOrigin(x, z) in getBlockOS(); writeHeader() already calls it. Removing it also means a failed construction no longer mutates the origin. - Documented the real threading contract on the class: every current caller reaches these getters through AbstractChangeSet#processSet, which is synchronized on the same instance, so the DCL here is hardening for standalone correctness, not a fix for a reachable race. Tests: - Replaced the six @RepeatedTest race detectors with one. The interleaving they look for is not reachable through any production call path, and the pre-fix failure mode was heap exhaustion rather than a clean assertion, so the other five cost ~1900 threads per CI run for no signal. - Parameterised the deterministic partial-state test across all six getters, which is where the real regression coverage belongs. Every parameter fails against the pre-fix code. - Added coverage for the writeHeader-failure stream leak. - Fixed import ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vyr56YXYCEF4xiTHYAWyx8
7df0cdb to
708447d
Compare
| fail("Timed out waiting for racing threads to finish (possible barrier deadlock or hang - " | ||
| + (THREADS - done.getCount()) + "/" + THREADS + " threads finished)"); | ||
| } | ||
| assertTrue(failures.isEmpty(), () -> "Threads failed unexpectedly: " + failures); |
Part of Phase 1 of the
com.fastasyncworldedit.core.historyimprovement plan. TouchesMemoryOptimizedHistoryonly.The bug
Every lazy stream getter assigned its
*Streambuffer field before constructing the wrapping compressed/NBT stream, which can throw:flush()andclose()gated on the buffer field being non-null and then unconditionally dereferenced the wrapper field. So anIOExceptionduring history setup left a half-built pair, andFaweStreamChangeSet.add()swallows thatIOExceptionwith aprintStackTrace()— meaning the edit carries on and the failure resurfaces much later as an uncaughtNullPointerExceptionescapingclose(), aftersuper.close()has already run.Secondary, in
getBlockOS()only: ifwriteHeader()throws, the compressed stream has been constructed but is never published, so nothing can ever close it. It leaked along with its ~520KB buffer.The fix
flush()/close()now test the*StreamZipfield they actually dereference, rather than the buffer field. Correct regardless of how the pair was published.getBlockOS()closes the compressed stream ifwriteHeader()throws.setOrigin(x, z)ingetBlockOS()—writeHeader()already calls it, and removing it means a failed construction no longer mutates the origin either.Also included: lazy-init hardening
The six
*Zipfields are nowvolatileand all six getters use check → synchronize → re-check. Four of them (the NBT getters) previously had no locking at all, andgetBlockOS/getBiomeOShad an outer check with no re-check inside the lock — so in isolation, 16 concurrent callers each got their own stream (the newgetBlockOSReturnsSingleInstanceUnderConcurrencytest shows exactly that against pre-fix code:expected: <1> but was: <16>).This is hardening, not a live bug fix. See the correction below.
Verification
:worldedit-core:test— 183/183 pass, whole module.The 12 tests in
MemoryOptimizedHistoryConcurrencyTestall fail cleanly against pre-fix code and all pass with the fix:getterFailureLeavesNoPartialStateForFlushOrClose×6 gettersNullPointerException: Cannot invoke ...flush() because "*StreamZip" is nullgetBlockOSClosesCompressedStreamWhenHeaderWriteFailsWantedButNotInvoked: faweOutputStream.close()getBlockOSReturnsSingleInstanceUnderConcurrency×5expected: <1> but was: <16>This replaces the previous test suite, which had two problems the review caught: it repeated a 16-thread race detector across all six getters (~1900 threads per CI run), and its pre-fix failure mode was heap exhaustion crashing the test JVM, not a clean assertion. Both are fixed — one race test remains, and every failure above is a clean assertion.
testRuntimeOnly(libs.lz4Java)is still needed: lz4-java iscompileOnlyfor main, so the test JVM otherwise hitsNoClassDefFoundError: LZ4BlockOutputStreamviagetCompressedOS().Correction
The original description claimed the DCL race could silently discard recorded changes in production. That is not reachable, and the claim should not have shipped:
FaweStreamChangeSet.add/addTile*/addEntity*/addBiomeChange, all of which are only invoked fromAbstractChangeSet.processSet(...)— which ispublic final synchronizedon the same instance the getters lock. The unguarded fast-path read is therefore a reentrant read already under the monitor.workerSemaphore(1 permit).HistoryExtentpath is unsynchronised butEditSessionBuildermakes the two paths mutually exclusive (combineStages→ processor; else →HistoryExtent), and that path is driven by a singleEditSessionthread.The
volatile/DCL work is kept because the getters should be correct in isolation rather than by accident of a caller's lock, and the class javadoc now states that contract explicitly. But it fixes a latent footgun, not an observed bug.Notes
main(was based on995c825da, before the Gradle deprecation fixes in Address gradle deprecations #3592).git worktreeadditionally needs the Grgit fix from Add baseline benchmark for history write path (pre Phase 1 concurrency fixes) #3593; deliberately not included.AbstractChangeSet.closed, so a late write task afterclose()constructs a fresh buffer thatidswill never include, silently losing that history.🤖 Generated with Claude Code