All findings · Lucene finding · LFV-252 · crt-close-sentinel-overwritten-by-later-refresh

A refresh after ControlledRealTimeReopenThread.close() undoes the closed state, so waitForGeneration can block forever

ControlledRealTimeReopenThread.close() sets its searchable generation to Long.MAX_VALUE so that waiters return, but never removes its refresh listener. One more refresh of the still-open manager writes an ordinary generation back (1 in the repro), and an untimed waitForGeneration on the closed instance then blocks until a later refresh, an interrupt or a second close(), or forever.

Summary

Priority
Low, rank 815An 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
hang
Since
4.4.0 (LUCENE-4967, 2013)introduced 2013-05-02 · over 13 years
Affected
every release 4.4.0 to 10.6.0, and main
Status
documented
Found by
formal: TLA+ model checking (TLC), replayed as a JUnit on the stock jar

AI-assisted page: this finding was first signalled by a formal tool's own output and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

How ControlledRealTimeReopenThread tracks what search can see

  1. A SearcherManager hands out searchers. maybeRefresh() or maybeRefreshBlocking() opens a newer one and calls every registered refresh listener before and after the refresh.
  2. Every IndexWriter change returns a generation number. ControlledRealTimeReopenThread refreshes the manager in the background and keeps searchingGen, the newest generation that search is guaranteed to see. A refresh listener, added to the manager in its constructor, updates it after each refresh.
  3. waitForGeneration(gen) blocks while gen > searchingGen, so a request can wait until its own change is searchable. close() stops the background thread and sets searchingGen to Long.MAX_VALUE, so that every waiter returns.
Diagram of the bug: the reopen thread is closed and its searchable generation is set to Long.MAX_VALUE; the application refreshes the still-open SearcherManager once more, and the reopen thread's leftover refresh listener writes generation 1 back; a document with generation 2 is then added and a request thread calls waitForGeneration(2) on the closed instance, which keeps waiting because 2 is greater than 1 and the stopped reopen thread will never refresh again; only a later refresh that covers 2, a second close() or an interrupt releases it.
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.

The finding, step by step

  1. You use a ControlledRealTimeReopenThread with a SearcherManager: after adding a document, a request calls crt.waitForGeneration(gen) to wait until search can see it. At shutdown you call crt.close(), which sets the searchable generation to Long.MAX_VALUE so that every waiter returns at once.
  2. 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, over Long.MAX_VALUE.
  3. 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 between close() and the wait.
  4. 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 returns false at its bound.

Who is affected

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.

The code (main 645d4fe14a20, ControlledRealTimeReopenThread.java)

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 removeListener call. ReferenceManager calls every registered listener's beforeRefresh and afterRefresh on every refresh, from maybeRefresh() and maybeRefreshBlocking(), whoever calls them (ReferenceManager.java:154-188, listeners at :166 and :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 throws AlreadyClosedException, so once the manager is closed only an interrupt or another close() call can release the waiter (close() has no closed-state guard: join() on the finished thread returns at once, and it writes Long.MAX_VALUE and 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 commit 18af6325ed9, first in 8.6.0). After close() 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 that close() already woke is put back to sleep was shown only by TLA+ model checking, never executed.

The numbers (minimal repro, main)

stepcallgetSearchingGen()waiter
1crt.start(); crt.close()Long.MAX_VALUE-
2mgr.maybeRefreshBlocking()1-
3w.addDocument(...) returns 2; a thread calls crt.waitForGeneration(2)1still blocked after 5 s (WAITING)
4mgr.maybeRefreshBlocking() againcovers 2released

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 after crt.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 untimed waitForGeneration(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 or close() 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) returns false at its bound, which its @return allows (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 IndexWriterConfig settings; 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).

Minimal repro

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.

TestWaitForGenerationAfterClose.java (one test class)66 lines
/*
 * 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);
  }
}
Command
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 between close() and the wait.
Observed
waitForGeneration(2) on the closed ControlledRealTimeReopenThread still blocked after 5 s (getSearchingGen()=1 after one refresh following close()).

All files of this repro: repro/LFV-252/

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; release 10.6.0: fails; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; a wait on a monitor nobody notifies)
Command (environment)
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)

Observed output

# 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 message

Why it looks unintended

  • close() says "Max it out so any waiting search threads will return"; on main the @return of waitForGeneration covers "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 NRTManager guarded this value; LiveFieldValues removes its listener in close().
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 @return of waitForGeneration(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, then mgr.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 to Long.MAX_VALUE from LUCENE-3488 (2011) on; LUCENE-3528 (#4602, 2011) named that closed value MAX_SEARCHER_GEN and added a guard for it in the reopen path, and LUCENE-3776 (#4849, 2012) kept the guard as if (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: NRTManager owned its managers, so a refresh after close was also rejected there.
  • Same package: LiveFieldValues adds itself as a listener in its constructor and removes itself in close() (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.

Suggested fix

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.max changes 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; TestControlledRealTimeReopenThread 5 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.

Only remove the listener in close() (not enough alone)

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.

Write the closed value from the listener once finish is set (as the Lucene.NET port does)

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 note

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.

How it was found

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.tla models the reopen thread with its Semaphore signal (#15830), waitForGeneration's wait/notify, close(), the HandleRefresh listener and an application thread that refreshes the same ReferenceManager.
  • Counterexamples. With the application refresh allowed after close(), TLC reported a deadlock (19-state traces). With the refresh started before close() returns, it reported a violation of the invariant NoReblockAfterClose (16-state trace): a waiter woken by close() 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), JIRA, mailing lists, Stack Overflow, web2026-10-26a 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 historyno 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-31ControlledRealTimeReopenThread, waitForGeneration, searchingGen, HandleRefresh, RefreshListener removeListener in apache/lucene issues and PRs; items on SearcherManager, ReferenceManager or a reopen thread created since 2026-10-26the same items; nothing new; #16210 still open, with no review decision

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal 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 affectedblocked; 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 waiterblocked; 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 APIthe class does not exist; the analogous probe on its NRTManager passes (the guard keeps the closed value)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (TLA+ model checking, JUnit) candidateTLC 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 verdictThe review could not run because of a service outage; it neither confirms nor refutes.
Reproducer (own JUnit) real3 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, mediumRead-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 realThe shutdown probe (refresh, then close the manager) breaks even the one-argument javadoc while the control passes; traced the dropped NRTManager guard.
Adjudication confirmed, mediumRe-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 downgradedNew 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-sensitive12 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 validatedGradle 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 fixesRe-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 verdictThe run ended before it answered. Its open questions were checked by hand.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,490 words

Finding record

LFV-252, snapshot of 2026-11-06

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 does TestControlledRealTimeReopenThread (two seeds). Release 10.6.0 fails the same test (constructor adapted to its double seconds). The hunt ran on main ea57360912ed (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 main 2f424fd428d (2026-10-30), branch_11x 603af870375 and branch_10_6 7e5aec4370e carry the same code (read from the source).

Lucene and upstream sources

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 removeListener
  • lucene/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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro's output.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.