Skip to content

Fix partial stream state in MemoryOptimizedHistory lazy getters; harden lazy init - #3595

Open
MattBDev wants to merge 4 commits into
mainfrom
phase1/memoryoptimizedhistory-dcl
Open

Fix partial stream state in MemoryOptimizedHistory lazy getters; harden lazy init#3595
MattBDev wants to merge 4 commits into
mainfrom
phase1/memoryoptimizedhistory-dcl

Conversation

@MattBDev

@MattBDev MattBDev commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Part of Phase 1 of the com.fastasyncworldedit.core.history improvement plan. Touches MemoryOptimizedHistory only.

Rewritten after review. This PR was originally titled and described as fixing a data-loss race. That framing was wrong and has been corrected — see Correction at the bottom. The lead item below is a real, deterministic, single-threaded bug.

The bug

Every lazy stream getter assigned its *Stream buffer field before constructing the wrapping compressed/NBT stream, which can throw:

idsStream    = new FastByteArrayOutputStream(...);   // published
idsStreamZip = getCompressedOS(idsStream);           // ← throws

flush() and close() gated on the buffer field being non-null and then unconditionally dereferenced the wrapper field. So an IOException during history setup left a half-built pair, and FaweStreamChangeSet.add() swallows that IOException with a printStackTrace() — meaning the edit carries on and the failure resurfaces much later as an uncaught NullPointerException escaping close(), after super.close() has already run.

Secondary, in getBlockOS() only: if writeHeader() 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

  • Publish together. Each getter builds buffer + wrapper into locals and assigns both fields only once construction has fully succeeded, so a failure leaves neither set.
  • Gate on the wrapper. flush()/close() now test the *StreamZip field they actually dereference, rather than the buffer field. Correct regardless of how the pair was published.
  • Close on failure. getBlockOS() closes the compressed stream if writeHeader() throws.
  • Dropped a redundant setOrigin(x, z) in getBlockOS()writeHeader() already calls it, and removing it means a failed construction no longer mutates the origin either.

Also included: lazy-init hardening

The six *Zip fields are now volatile and all six getters use check → synchronize → re-check. Four of them (the NBT getters) previously had no locking at all, and getBlockOS/getBiomeOS had an outer check with no re-check inside the lock — so in isolation, 16 concurrent callers each got their own stream (the new getBlockOSReturnsSingleInstanceUnderConcurrency test 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:test183/183 pass, whole module.

The 12 tests in MemoryOptimizedHistoryConcurrencyTest all fail cleanly against pre-fix code and all pass with the fix:

Test Pre-fix failure
getterFailureLeavesNoPartialStateForFlushOrClose ×6 getters NullPointerException: Cannot invoke ...flush() because "*StreamZip" is null
getBlockOSClosesCompressedStreamWhenHeaderWriteFails WantedButNotInvoked: faweOutputStream.close()
getBlockOSReturnsSingleInstanceUnderConcurrency ×5 expected: <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 is compileOnly for main, so the test JVM otherwise hits NoClassDefFoundError: LZ4BlockOutputStream via getCompressedOS().

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:

  • Every production caller reaches these getters via FaweStreamChangeSet.add/addTile*/addEntity*/addBiomeChange, all of which are only invoked from AbstractChangeSet.processSet(...) — which is public final synchronized on the same instance the getters lock. The unguarded fast-path read is therefore a reentrant read already under the monitor.
  • Draining is additionally serialised by workerSemaphore (1 permit).
  • The alternative HistoryExtent path is unsynchronised but EditSessionBuilder makes the two paths mutually exclusive (combineStages → processor; else → HistoryExtent), and that path is driven by a single EditSession thread.

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

  • Rebased onto current main (was based on 995c825da, before the Gradle deprecation fixes in Address gradle deprecations #3592).
  • Not rebased on any sibling PR; mergeable independently.
  • Building inside a git worktree additionally needs the Grgit fix from Add baseline benchmark for history write path (pre Phase 1 concurrency fixes) #3593; deliberately not included.
  • Pre-existing, not addressed here — worth a follow-up: the getters never consult AbstractChangeSet.closed, so a late write task after close() constructs a fresh buffer that ids will never include, silently losing that history.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 15, 2026 06:05
@MattBDev
MattBDev requested a review from a team as a code owner July 15, 2026 06:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 volatile and 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Copilot AI review requested due to automatic review settings July 15, 2026 12:06
@MattBDev

Copy link
Copy Markdown
Contributor Author

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:

  1. That I haven't missed a seventh call site with the same buffer-then-wrapper-field pattern (I audited all six getOS methods declared in this class; getBiomeIS/getIS read-side methods don't have this issue since they don't construct+publish a pair of fields).
  2. The new regression test (getBlockOSFailureLeavesNoPartialStateForFlushOrClose) uses a Mockito spy to force getCompressedOS() to throw, rather than a real compression-library failure - worth confirming that's a faithful enough substitute for the failure modes that actually occur in production (disk-full-style IOExceptions from the underlying FastByteArrayOutputStream, not just an injected throw).
  3. Whether any caller depends on the previous (buggy) behavior in a way I haven't considered - I don't believe so, since the old behavior was an uncaught NPE rather than any deliberate contract, but flagging in case.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread worldedit-core/build.gradle.kts
@dordsor21

Copy link
Copy Markdown
Member

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)

MattBDev and others added 4 commits July 25, 2026 21:46
…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
Copilot AI review requested due to automatic review settings July 26, 2026 01:54
@MattBDev
MattBDev force-pushed the phase1/memoryoptimizedhistory-dcl branch from 7df0cdb to 708447d Compare July 26, 2026 01:54
@MattBDev MattBDev changed the title Fix broken double-checked locking in MemoryOptimizedHistory stream getters Fix partial stream state in MemoryOptimizedHistory lazy getters; harden lazy init Jul 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

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);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants