Fix lost-wakeup race and non-atomic close() in AbstractChangeSet#3597
Fix lost-wakeup race and non-atomic close() in AbstractChangeSet#3597MattBDev wants to merge 5 commits into
Conversation
drainQueue() polled the queue until empty and only then released its permit. A producer that enqueued a task in the window between the final poll() and the release() saw availablePermits() == 0 in triggerWorker() and skipped scheduling a worker, so the task sat in the queue with nothing left to drain it and the CompletableFuture returned by addWriteTask()/postProcessSet() never completed. Re-check the queue after releasing the permit and schedule a worker for anything left behind. drainQueue() also released a permit it had never acquired when the blocking acquire() path was interrupted: the InterruptedException was caught but execution fell through into the drain and its finally block. That pushed the semaphore above its single permit and allowed two workers to drain concurrently, breaking the single-drainer guarantee processSet() relies on. Return instead, without draining or releasing. close() used a check-then-act on `closed`, letting two callers both observe false and both run flush(). Guard the sequence with a CAS. The guard is a separate field so `closed` keeps its existing type, visibility and semantics for subclasses that read and write it directly (e.g. RollbackOptimizedHistory). Adds AbstractChangeSetConcurrencyTest covering both races. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens AbstractChangeSet concurrency behavior in com.fastasyncworldedit.core.history by addressing a lost-wakeup risk in the write-worker drain loop, fixing an interrupt path that could violate the single-drainer invariant, and making close() safe under concurrent callers.
Changes:
- Reworks
drainQueue(...)semaphore acquisition logic to avoid releasing a permit that was never acquired and to reschedule draining if tasks arrive during the final poll/release window. - Adds a CAS guard around
close()to prevent concurrent callers from executingflush()multiple times. - Adds a new concurrency-focused unit test covering contention on
addWriteTask(...)and concurrentclose()calls.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
worldedit-core/src/main/java/com/fastasyncworldedit/core/history/changeset/AbstractChangeSet.java |
Fixes the queue-drainer scheduling race, interrupt handling during permit acquisition, and attempts to make close() atomic under contention. |
worldedit-core/src/test/java/com/fastasyncworldedit/core/history/changeset/AbstractChangeSetConcurrencyTest.java |
Adds a multithreaded smoke test for queue draining and concurrent close behavior using thread-scoped static mocking. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public void close() throws IOException { | ||
| if (!closed) { | ||
| flush(); | ||
| closed = true; | ||
| if (closed) { | ||
| return; | ||
| } | ||
| // Ensure only one concurrent caller runs the flush-and-mark-closed sequence; the CAS | ||
| // atomically decides a single "winner" thread, so no two threads can ever both pass the | ||
| // `!closed` check and both call flush() as could happen with the old check-then-act code. | ||
| // Callers that lose the race simply return; `closed` is still guaranteed to become true. | ||
| if (closing.compareAndSet(false, true)) { | ||
| try { | ||
| flush(); | ||
| } finally { | ||
| closed = true; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Fixed in 4e3489e: close() now synchronizes on a dedicated private lock instead of a CAS, so a caller that loses the race blocks until the winning flush completes rather than returning early. A dedicated lock (not this) avoids the AB-BA deadlock with queued write tasks that synchronize on this. See the PR comment requesting a second reviewer specifically on this design.
There was a problem hiding this comment.
Fixed in 4e3489e: close() now synchronizes on a dedicated private lock instead of a CAS, so a caller that loses the race blocks until the winning flush completes rather than returning early. A dedicated lock (not this) avoids the AB-BA deadlock with queued write tasks that synchronize on this. See the PR comment requesting a second reviewer specifically on this design.
| // The core assertion: none of these futures should hang. On the unfixed code this | ||
| // occasionally times out under enough contention because a task got stranded in the | ||
| // queue with no worker left to drain it. |
There was a problem hiding this comment.
Fixed in bda8f63: the comment no longer claims the unfixed code occasionally times out here. It now states plainly that this stress test does not reliably hit the lost-wakeup interleaving and serves as a contention smoke test.
…ix + deadlock risk)
The previous fix used an AtomicBoolean CAS to ensure only one caller runs
flush(), but a losing caller returned from close() immediately rather than
waiting for the winner - so close() could return before the changeset was
actually flushed. Any caller assuming close()'s postcondition ("fully
flushed") on the losing thread would be wrong.
Synchronizing close() on `this` instead (the obvious fix for that) was
rejected: flush() -> drainQueue(true) can block in
workerSemaphore.acquire() waiting for another thread's in-flight drain to
finish, and that other thread may be running a queued write task that
itself synchronizes on `this` (e.g. DiskStorageHistory#getBlockOS). If this
thread already held `this`'s monitor while waiting on the semaphore, and the
other thread needed `this`'s monitor to make progress and release the
semaphore, that's an AB-BA deadlock.
A private lock object that nothing else in the class hierarchy ever
synchronizes on gives both properties without that risk: losing callers
block until the winner's synchronized block exits (at which point `closed`
is already true, so they return immediately without re-running flush()),
and there is no path by which this lock can participate in a cycle with
`this`'s monitor or workerSemaphore.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed 4e3489e, which replaces the
The fix landed instead uses a dedicated, never-shared lock object ( Why this needs another set of eyes specifically: this is exactly the class of bug (lock-ordering across a class hierarchy, under contention that's hard to reproduce in a unit test) where a single reviewer - including the one who wrote the fix - is the weakest link. The existing |
drainQueue(true)'s interruptible acquire() created a hole: a closer thread
interrupted while waiting for an in-flight drainer returned early, flush()
surfaced nothing, and close() marked the changeset closed with write tasks
still queued - silently losing history. RollbackOptimizedHistory.close() would
then log the edit to the rollback database while its writes were unflushed.
Use acquireUninterruptibly() instead: flush()'s contract (and close()'s, which
flushes before setting `closed`) is that pending tasks are drained on return,
so an interrupt must not abort the wait. The interrupt status is preserved for
callers to observe afterwards, and the wait is bounded by the concurrent
drainer's finite queue - the same bound the interruptible acquire had.
Adds interruptedCloseStillDrainsQueue, a deterministic regression test:
against the previous code it fails immediately ("close() returned before
pending write tasks were drained"); with the fix, close() blocks until the
drainer is released and the interrupt flag survives.
Also rewords the test-class javadoc to stop claiming the stress test
reproduces the lost-wakeup race (it does not - it is a contention smoke test;
the PR description already discloses this) and fixes a stale "CAS guard"
reference from before the closeLock rework.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Request for a second reviewer on the two follow-up commits (4e3489e and bda8f63) before approving/merging. They rework the synchronization design of close()/drainQueue rather than just patching a line, and concurrency changes of this shape are exactly where a fresh set of eyes pays off:
The new interruptedCloseStillDrainsQueue test is deterministic in both directions (fails on the old code, passes on the new), but the lost-wakeup stress test remains a smoke test only — see the updated class javadoc. |
|
Request for a second reviewer on the two follow-up commits (4e3489e and bda8f63) before approving/merging. They rework the synchronization design of close()/drainQueue rather than just patching a line, and concurrency changes of this shape are exactly where a fresh set of eyes pays off:
The new interruptedCloseStillDrainsQueue test is deterministic in both directions (fails on the old code, passes on the new), but the lost-wakeup stress test remains a smoke test only - see the updated class javadoc. |
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
| if (!queue.isEmpty()) { | ||
| triggerWorker(); | ||
| } |
There was a problem hiding this comment.
Confirmed and fixed in 76ba0ce - you're right, the async re-trigger reopened exactly the completeness gap the earlier fixes were meant to close, just for the last straggler task. drainQueue(true) now loops synchronously (acquire, drain, release, re-check) until a release is immediately followed by an empty queue, instead of delegating to triggerWorker(). Added a stress test for the actual contract (isDone() immediately after close() returns, no timeout tolerance) - ran it 5x against the pre-fix code and it didn't reproduce the race either, so it's a smoke test like its sibling, not a guaranteed repro; the fix itself is what closes the gap.
A subtler version of the completeness gap the closeLock/acquireUninterruptibly fixes already addressed: after draining and releasing the permit, if a straggler task had landed in the queue during the (last poll()==null ... release()) window, the old code called triggerWorker() and returned - which schedules an ASYNC worker on another thread and does not wait for it. flush() (and therefore close(), which flushes before setting `closed`) could then return while that worker was still about to run, on a resource the caller now considers closed - e.g. RollbackOptimizedHistory.close() logging the edit to the rollback database before the straggler write actually landed. drainQueue(true) (the blocking path used by flush()/close()) now loops: acquire, drain, release, and if the queue is non-empty immediately after release, loop back and drain again, rather than delegating to an async re-trigger. It only returns once a release() is immediately followed by an empty queue. The non-blocking path (an async worker triggered via triggerWorker()) is unchanged: it still just re-triggers another async worker for anything left behind and returns promptly, since it never needed a synchronous-drain guarantee. Adds closeDoesNotReturnWithAnyPreviouslyEnqueuedTaskStillPending, checking the actual contract at stake: every future submitted before close() is called must be isDone() immediately after close() returns, not merely eventually. Ran it 5 times against the pre-fix drainQueue and it did not reproduce the race (the window is narrow, same as the existing lost-wakeup test) - it's a contention smoke test for the completeness contract, not a deterministic reproduction, and says so in its own javadoc. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| private ExecutorService newFaweAwareFixedThreadPool(int threads, Fawe faweMock) { | ||
| ThreadFactory factory = runnable -> new Thread(() -> { | ||
| try (MockedStatic<Fawe> faweStatic = Mockito.mockStatic(Fawe.class)) { | ||
| faweStatic.when(Fawe::instance).thenReturn(faweMock); | ||
| runnable.run(); | ||
| } | ||
| }); |
Part of Phase 1 (concurrency hardening) of the
com.fastasyncworldedit.core.historyimprovement plan. One of five independent PRs; this one only touchesAbstractChangeSet.The bugs
drainQueue()polled until the queue was empty and only then released its permit. A producer that enqueued in the window between the finalpoll()and therelease()sawavailablePermits() == 0intriggerWorker()and skipped scheduling a worker. The task then sat in the queue with nothing to drain it, and theCompletableFuturereturned byaddWriteTask()/postProcessSet()never completed.acquire()path was interrupted, theInterruptedExceptionwas caught but execution fell through into the drain and itsfinally { release(); }. That pushes the semaphore above its single permit and lets two workers drain concurrently, breaking the single-drainer guaranteeprocessSet()relies on. (Found while fixing Adding gradle #1 — same method, same invariant.)close(). A check-then-act onclosedlet two callers both observefalseand both runflush().The fix (revised after code review — see commits 4e3489e and bda8f63)
close()synchronizes on a dedicated privatecloseLock, not a CAS. The original CAS guaranteed a single flush but let a losing caller return fromclose()before the winner finished flushing — violatingCloseablesemantics for callers likeRollbackOptimizedHistory.close(), which logs to the rollback DB immediately aftersuper.close(). Synchronizing onthisinstead would fix that but risks an AB-BA deadlock:flush()can block on the drain semaphore while the thread holding it runs a queued write task that itself synchronizes onthis(subclasses' lazy stream getters do). A dedicated lock object that nothing else in the hierarchy touches gives both properties: losing callers block until the winner finishes, with no possible lock-ordering cycle.drainQueue(true)'s blocking wait is now uninterruptible (acquireUninterruptibly()), not interruptible-with-early-return. An interrupted closer used to return early with the queue still undrained whileclose()marked the changeset closed anyway — silent history loss, sinceflush()swallows exceptions and nothing surfaced. The interrupt flag is preserved for the caller to observe afterwards.Verification
:worldedit-core:compileJava/compileTestJavapass.AbstractChangeSetConcurrencyTest: 3/3 pass with the fix.interruptedCloseStillDrainsQueue(added in bda8f63) is a deterministic regression test for the interrupt hole: it fails against the pre-fix code withclose() returned before pending write tasks were drained, and passes with the fix, including asserting the interrupt flag survives.Notes
git worktreeadditionally need the Grgit fix from Add baseline benchmark for history write path (pre Phase 1 concurrency fixes) #3593; deliberately not included (CI uses a normal checkout).🤖 Generated with Claude Code