The reopen thread marks itself closed by setting its searchable generation to Long.MAX_VALUE. A refresh listener it never removes overwrites that value on the next refresh of the manager.
- A
SearcherManagerhands out searchers.maybeRefresh()ormaybeRefreshBlocking()opens a newer one and calls every registered refresh listener before and after the refresh. - Every
IndexWriterchange returns a generation number.ControlledRealTimeReopenThreadrefreshes the manager in the background and keepssearchingGen, the newest generation that search is guaranteed to see. A refresh listener, added to the manager in its constructor, updates it after each refresh. waitForGeneration(gen)blocks whilegen > searchingGen, so a request can wait until its own change is searchable.close()stops the background thread and setssearchingGentoLong.MAX_VALUE, so that every waiter returns.
close() sets the generation to Long.MAX_VALUE; one more refresh of the manager runs the leftover listener, which writes 1 back; the untimed wait for generation 2 then sleeps. Select the still to open it at full size. Generated with AI, checked against the code.- You use a
ControlledRealTimeReopenThreadwith aSearcherManager: after adding a document, a request callscrt.waitForGeneration(gen)to wait until search can see it. At shutdown you callcrt.close(), which sets the searchable generation toLong.MAX_VALUEso that every waiter returns at once. - The application refreshes the still-open manager once more,
mgr.maybeRefreshBlocking(). The refresh listener that the reopen thread registered is still there and writes the writer's generation, 1, overLong.MAX_VALUE. - A thread that still holds the closed instance adds a document (generation 2) and calls the untimed
crt.waitForGeneration(2). You expect it to return at once, as it does when no refresh comes betweenclose()and the wait. - Instead it is still waiting after 5 s. Since 8.6.0 a later refresh that covers generation 2 releases it; if the manager is closed next, no refresh can come, and only an interrupt or a second
crt.close()frees the thread (on 4.4.0-8.5.2 later refreshes do not help either). The timed overload returnsfalseat its bound.
Users of the opt-in, experimental ControlledRealTimeReopenThread whose application refreshes the manager after (or while) closing it, for example a shutdown with a final refresh or a replacement reopen thread, while some thread still waits on the closed instance without a timeout. Nothing is corrupted or wrong; one thread parks. Contrived: no report since 2013.
Mechanism, terms and numbers585 words
ControlledRealTimeReopenThread registers a refresh listener on the ReferenceManager in its constructor and never removes it. close() stops the reopen thread and sets searchingGen = Long.MAX_VALUE ("Max it out so any waiting search threads will return"), so a wait issued after close() returns at once. But the manager is a separate object and stays open: one more refresh of it runs the leftover listener, which sets searchingGen to the writer's current generation with no check. An untimed waitForGeneration(gen) on the closed instance for a newer change then waits for a reopen that the stopped thread will never do. Since 8.6.0 a later refresh that covers gen releases it; if refreshes stop (for example the manager is closed next) only an interrupt or a second close() does, and on 4.4.0-8.5.2 no refresh releases it at all. The timed overload returns false at its bound, nothing is corrupted, no result is wrong and no lock is held. Present in every release from 4.4.0 to 10.6.0 and on main; the class it replaced, NRTManager, guarded this value.
manager.addListener(new HandleRefresh()); // :85, constructor; never removed
public void beforeRefresh() {
refreshStartGen = writer.getMaxCompletedSequenceNumber(); // :96
}
public void afterRefresh(boolean didRefresh) { // :100, runs on EVERY refresh of the manager
synchronized (waitingRoom) {
searchingGen = refreshStartGen; // :102, no check, also after close()
waitingRoom.notifyAll();
}
}
public void close() { // :110
finish = true; reloadRequests.release(); join(); // :111-117, stops the reopen thread
// Max it out so any waiting search threads will return:
searchingGen = Long.MAX_VALUE; // :122-123, outside waitingRoom
synchronized (waitingRoom) { waitingRoom.notifyAll(); } // :126-128
}
// waitForGeneration(targetGen, maxMS), :159-187
while (targetGen > searchingGen) { if (maxMS < 0) waitingRoom.wait(); ... } // :171-173- Nothing removes the listener. The class has no
removeListenercall.ReferenceManagercalls every registered listener'sbeforeRefreshandafterRefreshon every refresh, frommaybeRefresh()andmaybeRefreshBlocking(), whoever calls them (ReferenceManager.java:154-188, listeners at:166and:182). - After
close()no reopen thread is left to end a wait. The reopen thread, whose loop is the class's only refresh call (:213), has exited.ReferenceManager.close()calls no listener (ReferenceManager.java:134-142), and after it every refresh throwsAlreadyClosedException, so once the manager is closed only an interrupt or anotherclose()call can release the waiter (close()has no closed-state guard:join()on the finished thread returns at once, and it writesLong.MAX_VALUEand notifies again; executed on main and 8.5.2). - 4.4.0-8.5.2 are worse. There the start generation was captured by the reopen thread's own loop, not in
beforeRefresh(moved by the LUCENE-9304 commit18af6325ed9, first in 8.6.0). Afterclose()every refresh writes back the same stale value (3 on 8.5.2), so no refresh releases the waiter. - A refresh that starts before
close()returns and finishes after it overwrites the value too (executed on 4.4.0, 8.5.2, 8.6.0, 9.12.3, 10.5.2 and main, with the waiter started afterwards). The form where a waiter thatclose()already woke is put back to sleep was shown only by TLA+ model checking, never executed.
| step | call | getSearchingGen() | waiter |
|---|---|---|---|
| 1 | crt.start(); crt.close() | Long.MAX_VALUE | - |
| 2 | mgr.maybeRefreshBlocking() | 1 | - |
| 3 | w.addDocument(...) returns 2; a thread calls crt.waitForGeneration(2) | 1 | still blocked after 5 s (WAITING) |
| 4 | mgr.maybeRefreshBlocking() again | covers 2 | released |
Without step 2 the wait in step 3 returns at once (every control run, 4.4.0 to main). With the recommended fix getSearchingGen() stays Long.MAX_VALUE in step 2 and step 3 returns at once.
Exposure and likelihood327 words
- Who. The class is opt-in and
@lucene.experimental(ControlledRealTimeReopenThread.java:36). It takes an application that refreshes the still-open manager aftercrt.close()(a final refresh at shutdown, or a new reopen thread on the same manager) and a thread that still holds the closed instance and calls the untimedwaitForGeneration(gen)for a newer change. - Effect since 8.6.0 (all 9.x, 10.x, main). The thread waits until the next refresh whose start generation covers
gen. With a replacement reopen thread that is extra latency (its refreshes released 16 of 16 old waiters in a probe). If refreshes stop, for example because the manager is closed next, it waits until it is interrupted orclose()is called again, and forever if neither happens. - Effect on 4.4.0-8.5.2. No later refresh releases it; only an interrupt or a second
close()does. - Bounded parts. The timed
waitForGeneration(gen, maxMS)returnsfalseat its bound, which its@returnallows (300 ms in the probe). The parked thread holds no Lucene lock and uses no CPU; search, indexing and commit continue (13-19 ms while 16 waiters were pinned). The bug only delays a wait; it never reports a generation as visible early, so no stale or deleted data is served because of it. - Leak. Every closed instance stays registered on the manager and runs on every later refresh: about 400 bytes and 65 ns per refresh each (5,000 closed instances: median no-change refresh 2.8 µs -> 330 µs; approximate, one host).
- Scale. Any index size; nothing depends on the codec or the
IndexWriterConfigsettings; the repro indexes one empty document. - Likelihood: contrived. Every executed trigger waits on an already-closed instance after an extra refresh between
close()and the wait. Upstream tests never refresh the manager after closing the reopen thread (TestControlledRealTimeReopenThread.java:327-330,:408-411,:573-574,:592). A read-only code search found no use of the class in the default branches of Elasticsearch, OpenSearch or Solr, and nobody has reported it since 4.4.0 (2013).
One JUnit class, one method, public API only, no randomized input: start and close the reopen thread, refresh the manager once, add a document, and wait for it on a second thread without a timeout. After 5 s the test checks whether that thread still waits, then frees it with one more refresh. It fails with assertions on and off.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.search;
import java.time.Duration;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.IOUtils;
import org.apache.lucene.util.ThreadInterruptedException;
public class TestWaitForGenerationAfterClose extends LuceneTestCase {
public void testWaitForGenerationReturnsAfterCloseAndRefresh() throws Exception {
ByteBuffersDirectory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
SearcherManager mgr = new SearcherManager(w, null);
ControlledRealTimeReopenThread<IndexSearcher> crt =
new ControlledRealTimeReopenThread<>(w, mgr, Duration.ofSeconds(5), Duration.ZERO);
crt.start();
crt.close();
mgr.maybeRefreshBlocking();
long searchingGen = crt.getSearchingGen();
long gen = w.addDocument(new Document());
Thread waiter =
new Thread(
() -> {
try {
crt.waitForGeneration(gen);
} catch (InterruptedException e) {
throw new ThreadInterruptedException(e);
}
});
waiter.start();
waiter.join(5000);
boolean blocked = waiter.isAlive();
mgr.maybeRefreshBlocking();
waiter.join();
IOUtils.close(mgr, w, dir);
// expected: a wait on the closed instance returns; actual: it blocks until a later refresh
assertFalse(
"waitForGeneration("
+ gen
+ ") on the closed ControlledRealTimeReopenThread still blocked after 5 s"
+ " (getSearchingGen()="
+ searchingGen
+ " after one refresh following close())",
blocked);
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestWaitForGenerationAfterClose -Ptests.seed=DEADBEEF
- Expected
waitForGeneration(2)on the closed instance returns at once, as it does when no refresh comes betweenclose()and the wait.- Observed
waitForGeneration(2) on the closed ControlledRealTimeReopenThread still blocked after 5 s (getSearchingGen()=1 after one refresh following close()).
More files, environment, full output323 words
The class sits in org.apache.lucene.search next to TestControlledRealTimeReopenThread; it uses no package-private access. Smallest input: ByteBuffersDirectory, a default IndexWriterConfig, a SearcherManager, one empty document. The test framework still picks its per-seed defaults (codec, locale, time zone); the trigger does not depend on them, and every seed run fails. The second thread exists only because the symptom is that the call blocks; the trigger itself is sequential and deterministic. The test releases its own waiter with one more refresh before asserting (on 8.6.0+ a covering refresh frees it), so a failure is reported as the one assertion, not as a leaked thread. The test is meant for 8.6.0 and later: on 4.4.0-8.5.2 no refresh releases the waiter, so its final waiter.join() would not return there (by reading; the old-release runs used the study's ports).
On 10.x releases before #15830 the constructor takes seconds as double: use 5.0, 0.0 instead of the Duration arguments (that was the only change for the 10.6.0 run).
Regression test: this same class (or its one method added to TestControlledRealTimeReopenThread); with the recommended fix it passes in under a second.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; release 10.6.0: fails; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; a wait on a monitor nobody notifies) |
cp $REPRO/TestWaitForGenerationAfterClose.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestWaitForGenerationAfterClose -Ptests.seed=DEADBEEF
# takes about 5 s to fail; assertions off fails the same way: -Ptests.asserts=false (the recorded runs added --offline --no-daemon)
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (stack trimmed)
TestWaitForGenerationAfterClose > testWaitForGenerationReturnsAfterCloseAndRefresh FAILED
java.lang.AssertionError: waitForGeneration(2) on the closed ControlledRealTimeReopenThread still blocked after 5 s (getSearchingGen()=1 after one refresh following close())
at __randomizedtesting.SeedInfo.seed([DEADBEEF:69CC3397A5C0D1E7]:0)
at org.apache.lucene.search.TestWaitForGenerationAfterClose.testWaitForGenerationReturnsAfterCloseAndRefresh(TestWaitForGenerationAfterClose.java:57)
:lucene:core:test (FAILURE): 1 test, 1 failure
# standalone runner: main 645d4fe14a20 fails with -ea and -da (and 3 of 3 with seed 5EED1234, -da);
# release 10.6.0 (double-seconds constructor) fails with the same messageclose()says "Max it out so any waiting search threads will return"; on main the@returnofwaitForGenerationcovers "this instance was closed".- The closed state sticks: with no refresh in between, a wait after
close()returns at once in every version run. - The predecessor
NRTManagerguarded this value;LiveFieldValuesremoves its listener inclose().
The full argument342 words
- The code says
close()releases waiters: "Max it out so any waiting search threads will return" (ControlledRealTimeReopenThread.java:122, in every release since 4.4.0). On main, the@returnofwaitForGeneration(long, int)(added by #15830, 2026): "true if the targetGen is now available, or false if maxMS wait time was exceeded or this instance was closed. True might also be returned if this call returned due to a close()" (:155-157). - Sticky by design: a wait issued after
close()with no refresh in between returns at once in every version run, 4.4.0 to main. Only the leftover listener undoes it. - The one-argument javadoc says the call blocks "until the searcher is reopened by another thread calling ReferenceManager#maybeRefresh, or until the ReferenceManager is closed" (
:132-135). The shutdown sequence (one refresh, thenmgr.close()) breaks even that literal text. On its own this is weak support, because closing the manager has no wake-up mechanism in any version. - History:
NRTManager.close()set its generation toLong.MAX_VALUEfrom LUCENE-3488 (2011) on; LUCENE-3528 (#4602, 2011) named that closed valueMAX_SEARCHER_GENand added a guard for it in the reopen path, and LUCENE-3776 (#4849, 2012) kept the guard asif (searchingGen != MAX_SEARCHER_GEN)before writing a new generation; on LUCENE-3776, Michael McCandless called it trappy that refreshing that class's manager by another path "can unexpectedly cause threads to block forever" (a different trigger in the older class, fixed then). LUCENE-4967 (#6031, 2013) moved the tracking into the new listener without the guard, and its comments do not discuss close semantics or listener removal. Caveat:NRTManagerowned its managers, so a refresh after close was also rejected there. - Same package:
LiveFieldValuesadds itself as a listener in its constructor and removes itself inclose()(LiveFieldValues.java:46,:50-52). The Lucene.NET port has guarded the same write since 2017 (finish ? long.MaxValue : refreshStartGen), without describing the Java behaviour. - Legal trigger: nothing says the manager must not be refreshed after the reopen thread is closed;
maybeRefresh: "You must call this ... periodically" (ReferenceManager.java:190-192).
The devil's-advocate check181 words
The strongest case for "intended" (the second cross-model review, the devil's-advocate verifier, the adjudication and the audit): the class is an experimental Thread whose close() is documented as "Stop this thread. Blocks until it terminates." (:108). Waiting on a closed instance while someone else keeps refreshing the manager is mixed ownership; the one-argument javadoc literally says the call blocks until another thread refreshes, which a further refresh does satisfy on 8.6.0+; and in the shutdown form the waiting thread's next acquire() would fail anyway. The audit's view: a real but minor robustness defect, not a production hang.
Why it fails: nothing documents that waits or refreshes after close() are invalid; the close() comment and, on main, the @return expect close() to release waiters; the closed state is deliberately sticky until a listener that close() never removes undoes it; the shutdown form does not honour even the one-argument javadoc's "or until the ReferenceManager is closed"; and on 4.4.0-8.5.2 no refresh releases the waiter at all. Hence the narrow claim: only the untimed overload, and "can block forever" when refreshes stop.
Recommended Never lower the generation, publish the closed value under the monitor, and remove the listener in close() (validated)
In close(), remove the listener and write Long.MAX_VALUE under the listener's monitor; in the listener, never lower the generation (Math.max). That is +11/-5 lines in one file. The repro then passes, and so does TestControlledRealTimeReopenThread.
public void afterRefresh(boolean didRefresh) {
synchronized (waitingRoom) {
searchingGen = Math.max(searchingGen, refreshStartGen);
waitingRoom.notifyAll();
}
}
...
manager.removeListener(refreshListener); // in close(), after join()
synchronized (waitingRoom) {
searchingGen = Long.MAX_VALUE;
waitingRoom.notifyAll();
}All options and trade-offs4 options · 488 words
One fix was validated through gradle in a scratch worktree of 645d4fe14a20; a smaller variant without the listener removal is the hunt's fix and passed every earlier check; removing the listener alone is not enough. A combined change with open PR #16210 (an interrupted close()) is natural.
Never lower the generation, publish the closed value under the monitor, and remove the listener in close() (validated) (recommended)
Keep the listener in a field, remove it in close() after join(), write Long.MAX_VALUE inside synchronized (waitingRoom), and make the listener's write monotone.
public void afterRefresh(boolean didRefresh) {
synchronized (waitingRoom) {
searchingGen = Math.max(searchingGen, refreshStartGen);
waitingRoom.notifyAll();
}
}
...
manager.removeListener(refreshListener); // in close(), after join()
synchronized (waitingRoom) {
searchingGen = Long.MAX_VALUE;
waitingRoom.notifyAll();
}Trade-offs
- After
close()no later refresh calls the listener, and a refresh whose after-refresh loop had already taken its snapshot of the listener list can no longer lower the value. Each closed instance also stops costing memory and refresh time. Math.maxchanges behaviour only when a refresh's start generation is lower than the current value; outside the post-close case that was not analysed beyond the upstream suite passing.- Removing the listener alone would not cover a refresh already in progress during
close(), which is why the monotone write stays.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, OpenJDK 25.0.3 (2026-10-31): stock, the minimal test fails. With the fix: the minimal test passes (under a second) with assertions on and off;TestControlledRealTimeReopenThread5 passed, 1 skipped (testCRTReopen,@AwaitsFix) with seed DEADBEEF, and 15 passed, 3 skipped with seed 5EEDCAFE and 3 iterations; 0 failures. It is the only upstream test of the class. Formatting was not checked.
Only the monotone write and the closed value under the monitor (the hunt's fix) (validated by the checks)
The same change without the listener removal.
Trade-offs
Each closed instance stays registered on the manager and runs on every later refresh (about 400 bytes and 65 ns per refresh each).
What the checks validated
Passes the finder's test and upstream TestControlledRealTimeReopenThread 5 of 5 on ea57360912ed, an independent verifier's 3-test class, and the minimal repro on 645d4fe14a20 with assertions on and off. TLA+ model checking of this fix is clean for the sequential and the concurrent forms.
Call manager.removeListener(...) in close().
Trade-offs
Fixes the sequential form by reading, but a refresh whose after-refresh loop took its snapshot of the listener list before the removal still calls the removed listener and can still lower the value. Not run.
searchingGen = finish ? Long.MAX_VALUE : refreshStartGen in afterRefresh.
Trade-offs
Still needs the close() write under waitingRoom, may publish the closed value before the thread has stopped (the @return tolerates that), and leaves the listener registered. Not run.
Downstream, this matters only to a service that closes or replaces a ControlledRealTimeReopenThread while request threads still call its untimed waitForGeneration; a read-only code search found no use of the class in Elasticsearch, OpenSearch or Solr.
TLA+ model checking (TLC) of the reopen thread, waitForGeneration, close(), the refresh listener and an application thread that refreshes the same manager. TLC found a waiter that never returns when that refresh comes after close(); the trace was replayed as a public-API JUnit on the stock jar and checked by independent verifiers.
The method in full151 words
Method: formal (TLA+ model checking, the tool's own counterexample, then a public-API JUnit).
- Model.
CRT.tlamodels the reopen thread with itsSemaphoresignal (#15830),waitForGeneration's wait/notify,close(), theHandleRefreshlistener and an application thread that refreshes the sameReferenceManager. - Counterexamples. With the application refresh allowed after
close(), TLC reported a deadlock (19-state traces). With the refresh started beforeclose()returns, it reported a violation of the invariantNoReblockAfterClose(16-state trace): a waiter woken byclose()is put back to sleep. The fix models are clean (12,451 and 83,460 distinct states). - Confirmation. The sequential trace became a JUnit that fails on the stock main jar with assertions on and off. An independent reproducer, a devil's-advocate verifier, an adjudication and a credibility audit re-ran it or their own tests; a follow-up ran the version history, a prior-report search and a security probe. The concurrent re-block form was never executed.
No prior report found (173 logged queries on 2026-10-26 over GitHub, JIRA, the mailing lists, Stack Overflow and the web; rechecked 2026-10-31). Closest: open PR #16210, which fixes a different path to the same symptom (an interrupted close()) and, applied, does not fix this one.
Searches2 searches
Verdict new: no issue, PR, JIRA entry, list thread, Q&A page or web page reports a refresh after ControlledRealTimeReopenThread.close() lowering the closed generation, or the listener never being removed. Related: open PR #16210 (a combined fix is natural) and merged #15830 (the @return contract). LUCENE-3528 and LUCENE-3776 are the predecessor's guard, LUCENE-4967 created the class; the Lucene.NET port guards the same write without describing the defect. The sibling LFV-312 (same listener, failed refreshes reported as success) is known (downstream partial overlap, elastic/elasticsearch#146240; apache/pinot#19467 also describes it); it is a different defect.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), JIRA, mailing lists, Stack Overflow, web | 2026-10-26 | a scripted prior-report search (14 GitHub queries answered, 20 list queries, 6 JQL), 61 GitHub GraphQL searches over apache/lucene and 36 over downstream projects and all of GitHub, direct list, JIRA and Stack Overflow searches, 7 web searches, the touched files of all 413 open apache/lucene PRs, the local history | no report; closest #16210 (different trigger); related #15830, #4602 (LUCENE-3528), #4849 (LUCENE-3776), #6031 (LUCENE-4967), #6524 (LUCENE-5461), the Lucene.NET port's guard |
| GitHub (recheck) | 2026-10-31 | ControlledRealTimeReopenThread, waitForGeneration, searchingGen, HandleRefresh, RefreshListener removeListener in apache/lucene issues and PRs; items on SearcherManager, ReferenceManager or a reopen thread created since 2026-10-26 | the same items; nothing new; #16210 still open, with no review decision |
Present since the class was created by LUCENE-4967 (7b90561178d, 2013) and first released in 4.4.0; every release through 10.6.0 and main fails. Since 8.6.0 (a LUCENE-9304 commit) a later covering refresh releases the waiter; before, no refresh did (only an interrupt or a second close()).
Versions tested4 rows
Introduced by LUCENE-4967 ("Absorb NRTManager entirely into a separate reopen thread class", now #6031; no pull request), Michael McCandless: trunk 7b90561178d and branch_4x 79ee60d41bc (2013-05-02), first released in 4.4.0. It created the class with the never-removed listener and without the NRTManager guard. The LUCENE-9304 commit 18af6325ed9 (2020, first in 8.6.0) moved the generation capture into beforeRefresh, which turned the hang from permanent into "until the next covering refresh". #15830 (2026, main only) added the @return wording about close() and did not touch the write. All 115 release tags from 4.4.0 to 10.5.2 have the same defect lines; so do 10.6.0, branch_10_6, branch_11x and main (read from the source).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0 | affected | minimal repro: still blocked after 5 s (main with assertions on and off; 10.6.0 with the constructor adapted) |
8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2; main ea57360912ed | affected | blocked; a second covering refresh releases the waiter; the no-refresh control returns at once |
| 4.4.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.5.2 | no refresh releases the waiter | blocked; further refreshes write back the same stale value (3 on 8.5.2); only an interrupt or a second close() releases it (the second close() executed on 8.5.2); control passes |
| 4.3.1 | no such API | the class does not exist; the analogous probe on its NRTManager passes (the guard keeps the closed value) |
An independent reproducer, a devil's-advocate verifier, an adjudication and a credibility audit reproduced it on stock builds; a read-only cross-model review (OpenAI GPT-5.6) agreed, and a second gave no verdict because of a tooling failure. This doc's gradle run reproduced it and validated the fix; an independent fact check and a diagram check found only wording errors, now fixed.
Checks and verdicts392 words
Every executed check ran on stock builds; the cross-model reviewers could only read code, and separate runs re-ran the repro.
| Check | Verdict | What it found |
|---|---|---|
| Finder (TLA+ model checking, JUnit) | candidate | TLC deadlock and NoReblockAfterClose violation; the JUnit fails on main with assertions on and off (5 of 5 over 5 iterations), 10.5.2, 9.12.3, 9.0.0 and 8.11.4; passes with the fix. |
| cross-model review (OpenAI GPT-5.6, refute lens) | no verdict | The review could not run because of a service outage; it neither confirms nor refutes. |
| Reproducer (own JUnit) | real | 3 tests from the claim: the one-refresh and the shutdown variants fail, the no-refresh control passes, on main (assertions on and off), 10.5.2 and 9.12.3; 20 of 20 over 10 iterations; fix 3 of 3; open PR #16210 applied still fails 2 of 3. |
| Cross-model review (OpenAI GPT-5.6, intended lens) | real, medium | Read-only; no documented precondition against the trigger; cited LiveFieldValues; a separate run re-ran the finder's JUnit on main: fails. |
| Intended-behaviour devil's advocate | real | The shutdown probe (refresh, then close the manager) breaks even the one-argument javadoc while the control passes; traced the dropped NRTManager guard. |
| Adjudication | confirmed, medium | Re-ran the independent reproduction's class with a new seed, assertions on and off: 2 of 3 fail, control passes; fix 3 of 3. |
| Credibility audit (4th check) | keep, likelihood downgraded | New seeds on main, assertions on and off: the finder's test fails, the independent reproduction's 2 of 3 fail with the control passing, fix 3 of 3; likelihood lowered to contrived. |
| Follow-up (history, prior art, security) | affected 4.4.0 to main; new; not security-sensitive | 12 versions executed (all fail, all controls pass), 115-tag code check, 173-query prior-art search, executed security probe. |
| This doc's runs | reproduced, fix validated | Gradle on 645d4fe14a20: the minimal repro fails; with the recommended fix it and TestControlledRealTimeReopenThread pass; release 10.6.0 fails. |
| Independent fact check | holds; 4 wording fixes | Re-ran the repro standalone and through gradle (assertions on and off), fix A through gradle and its own probes; re-read every file:line, commit and quote. Found two history/attribution errors and two imprecisions, all fixed in this doc. |
| Diagram check | 2 wording fixes | |
| Cross-model fact check (OpenAI GPT-5.6) | no verdict | The run ended before it answered. Its open questions were checked by hand. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 557 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,490 words
- Registry title
- ControlledRealTimeReopenThread.close() never unregisters its refresh listener, so one later refresh of the still-open ReferenceManager overwrites the Long.MAX_VALUE 'closed' generation and an untimed waitForGeneration on the closed instance can block forever
- Area
- search / ControlledRealTimeReopenThread (NRT generation tracking: close(), the HandleRefresh refresh listener, waitForGeneration)
- Priority
- Low, rank 815: An untimed waitForGeneration on a closed ControlledRealTimeReopenThread can block forever only after an extra refresh during shutdown, a contrived hang, so it ends the new hang rows.
- Impact class
- hang
- Impact note
- Applications on Lucene 4.4.0 through 10.6.0 or main that use the opt-in, @lucene.experimental ControlledRealTimeReopenThread and refresh the still-open ReferenceManager after (or while) the reopen thread is closed, for example in a shutdown or when they replace the reopen thread, are hit when a thread that still holds the closed instance then calls the untimed waitForGeneration(gen) for a newer change: it stays WAITING until a later refresh covers gen (only extra latency if, for example, a replacement reopen thread keeps refreshing) or forever if refreshes stop (for example when the manager is closed next), and only an interrupt or a second close() releases it; on 4.4.0-8.5.2 further refreshes do not release it either; the timed overload returns false at its bound, as documented; any index size, default IndexWriterConfig and codec; no wrong results, no lock is held, and search, indexing and commit keep working.
- Summary
- You use a ControlledRealTimeReopenThread with a SearcherManager, close the reopen thread (for example at shutdown), the application refreshes the still-open manager once more, and a request thread that still holds the closed instance calls waitForGeneration(gen) for its own new change. You expect the call to return, because close() sets the searching generation to Long.MAX_VALUE 'so any waiting search threads will return', the method's @return says it returns when 'this instance was closed', and a wait with no refresh in between does return at once. Instead the HandleRefresh listener that the constructor registered on the manager is never removed, so that one refresh writes the writer's generation back over Long.MAX_VALUE, and the untimed wait blocks until some later refresh covers gen, which never comes once the manager is closed; only an interrupt or a second close() releases the thread.
- Introduced
- 7b90561178d5ab8ca5a165e8f7a3a1e0da48b205 (trunk, svn r1478438; branch_4x 79ee60d41bcc, svn r1478446, the commit the 4.4.0 release contains), 'LUCENE-4967: move NRTManager entirely into a reopen thread so it can interact with any ReferenceManager' by mikemccand (JIRA LUCENE-4967, now #6031, no pull request; its comments do not discuss close semantics or listener removal) [read: the commit created the class; git blame on main attributes the manager.addListener(new HandleRefresh()) line and the close() 'Max it out' write to 7b90561178d5, and git log --follow shows no later commit that removes the listener or guards the write]. It replaced NRTManager, whose refresh hook kept the closed generation ('if (searchingGen != MAX_SEARCHER_GEN)', LUCENE-3776, 9da52f43527; the sentinel itself from LUCENE-3528, 57461996e2f), with a listener on an external manager that has no such guard; the history does not show whether dropping the guard was deliberate. LUCENE-9304 (18af6325ed9, lucene-solr PR #1427, 8.6.0) moved the generation capture into beforeRefresh, which changed the hang from permanent to 'until the next refresh that covers the target' (2013-05-02)
- First affected version
- 4.4.0 [exec] (tag 2013-07-22; the first release with ControlledRealTimeReopenThread: a public-API port fails with -ea and -da and its no-refresh control passes); 4.3.1 has no such API [exec: the port does not compile], and the analogous probe on its predecessor NRTManager passes [exec]
- Affected versions
- every release from 4.4.0 through 10.6.0 and main ea57360912ed and 645d4fe14a20. [exec] fail, every control passes: 4.4.0 (-ea and -da), 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.5.2, 8.6.0, 8.11.4, 9.0.0, 9.12.3 (-ea and -da), 10.5.2 (-ea and -da) and main (-ea and -da) with the follow-up's history ports; the finder's JUnit on main (-ea, -da, 5 of 5 with --iters 5), 10.5.2, 9.12.3, 9.0.0 and 8.11.4; the independent reproduction's JUnit on main (-ea, -da, 20 of 20 with --iters 10), 10.5.2 and 9.12.3. [read] all 115 release tags from 4.4.0 to 10.5.2 in the history clone have the one addListener(new HandleRefresh()), no removeListener, the unconditional 'searchingGen = refreshStartGen' and the MAX write in close(). Two eras: 4.4.0-8.5.2, later refreshes write back the same stale generation, so the wait never ends however often the manager is refreshed [exec 4.4.0-8.5.2]; 8.6.0-main (LUCENE-9304), the next refresh that covers the target releases it [exec]. Not fixed on main: the minimal test fails on main 645d4fe14a20 with -ea and -da and on the 10.6.0 release jars (released 2026-10-29) with -ea, its constructor adapted to 10.6.0's double seconds [exec]; the 10.6.0 tag, branch_10_6 7e5aec4370e, branch_11x 603af870375 and upstream main 2f424fd428d carry the same code [read 2026-10-31].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- formal: TLA+/TLC
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record: 173 recorded queries, namely the scripted prior-report search over apache/lucene issues and PRs (14 of 16 answered), the dev@, java-user@, issues@ and commits@ lists (20) and the LUCENE JIRA archive (6), 61 GitHub GraphQL searches over apache/lucene and 36 over downstream projects and all of GitHub, direct list, JIRA and Q&A searches, 7 web searches, the changed files of all 413 open apache/lucene PRs and the local history; 14 hits read, none the same bug). Related, not the same bug: open PR #16210 (iprithv, 2026-06, stale) fixes a different path to the same symptom (close() interrupted in join() skips the MAX write and notifyAll) and leaves the listener in place; merged #15830 (viliam-durina, 2026-04) added the @return close wording; LUCENE-3528 (#4602) and LUCENE-3776 (#4849) added and kept the predecessor NRTManager's guard; LUCENE-4967 (#6031) created the class; the Lucene.NET port guards the same write since 2017 without describing the Java defect
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle in a scratch worktree and through a standalone runner with assertions on and off; with the recommended fix it passes, as doesTestControlledRealTimeReopenThread(two seeds). Release 10.6.0 fails the same test (constructor adapted to itsdoubleseconds). The hunt ran on mainea57360912ed(the involved files are identical) and executed 4.4.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.5.2, 8.6.0, 8.11.4, 9.0.0, 9.12.3 and 10.5.2: all fail, every no-refresh control passes. Upstream main2f424fd428d(2026-10-30),branch_11x603af870375andbranch_10_67e5aec4370ecarry the same code (read from the source).
- Finding record
lucene/core/src/java/org/apache/lucene/search/ControlledRealTimeReopenThread.java:36 (at ea57360912ed) (@lucene.experimental), :85 (manager.addListener(new HandleRefresh()), never removed), :88-106 (HandleRefresh: :96 beforeRefresh captures writer.getMaxCompletedSequenceNumber(), :100-105 afterRefresh sets 'searchingGen = refreshStartGen' unconditionally under waitingRoom at :102), :108-129 (close(): :117 join, :122-123 'Max it out so any waiting search threads will return: searchingGen = Long.MAX_VALUE' outside waitingRoom, :126-128 notifyAll), :131-141 (one-argument javadoc: blocks 'until the searcher is reopened by another thread calling ReferenceManager#maybeRefresh, or until the ReferenceManager is closed'), :155-157 (@return: '... or false if maxMS wait time was exceeded or this instance was closed. True might also be returned if this call returned due to a close()'), :159-187 (waitForGeneration; :171-182 loop while targetGen > searchingGen, :173 untimed wait)lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:45 (at ea57360912ed) (listeners in a CopyOnWriteArrayList), :154-186 (doMaybeRefresh under refreshLock: :166 listeners before, :182 listeners after, in a finally), :280 addListener, :288 removeListenerlucene/core/src/java/org/apache/lucene/search/LiveFieldValues.java:46 (at ea57360912ed) (addListener in the constructor), :51 (removeListener in close())lucene/CHANGES.txt:305 (at ea57360912ed) (GITHUB#15830), :12890 (LUCENE-5461), :13526 (LUCENE-4967, 4.4.0)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ControlledRealTimeReopenThread.java:85 (addListener, never removed), :88-106 (HandleRefresh; :96 beforeRefresh, :100-105 afterRefresh, :102 the write), :108-129 (close; :122-123 MAX outside waitingRoom; :126-128 notifyAll), :159-187 (waitForGeneration; :171-173 untimed wait), :190-219 (run; :213 the only refresh call)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:45 (CopyOnWriteArrayList listeners), :134-142 (close calls no listener), :154-188 (doMaybeRefresh; :166, :182 listener calls), :206-220 and :234-244 (maybeRefresh, maybeRefreshBlocking; ensureOpen at :207, :235)
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ControlledRealTimeReopenThread.java:36 (@lucene.experimental), :155-157 (@return)a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestControlledRealTimeReopenThread.java:327-330, :408-411, :573-574, :592 (no refresh after the reopen thread is closed)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ControlledRealTimeReopenThread.java:108 (close javadoc), :122-123, :132-135 (one-argument javadoc), :155-157 (@return)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LiveFieldValues.java:46, :50-52; ReferenceManager.java:134-142, :190-192
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:45, :267-277 (the before and after loops each iterate their own snapshot)
- History
a Lucene clone releases/lucene/10.6.0, origin/branch_10_6 7e5aec4370e (:76, :91, :117-118), origin/branch_11x 603af870375 and origin/main 2f424fd428d (:85, :102, :122-123) [read]a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:320 (GITHUB#15830), :13559 (LUCENE-4967)
- TestWaitForGenerationAfterClose.java (one test class):
repro/LFV-252/src/org/apache/lucene/search/TestWaitForGenerationAfterClose.java(66 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro's output.
assets/LFV-252/bug.png(557 KB)
