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
Finding LFV-252Area: search / ControlledRealTimeReopenThread (NRT generation tracking: close(), the HandleRefresh refresh listener, waitForGeneration)Priority: lowImpact: hangNovelty: new
AI-assisted finding, confirmed by automated checks, not yet triaged by a human Lucene committer. This site files nothing upstream.
What it shows
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.
The test shows that one refresh of the still-open SearcherManager after ControlledRealTimeReopenThread.close() lowers the closed Long.MAX_VALUE generation again (the refresh listener is never removed), so an untimed waitForGeneration(gen) on the closed instance blocks.
Expected vs actual
Expected the wait on the closed instance to return at once ("so any waiting search threads will return"); actual: it is still blocked after 5 s (getSearchingGen()=1), with -ea and -da.
Files
Run it
- Tested on Lucene main e1faeba08bde (2026-11-02): still reproduces.
- Tested on Lucene main 645d4fe14a20 (2026-10-28): reproduced.
- Recorded outcome of run 1 on Lucene main 645d4fe14a20: 1 test(s) run, 1 failed.
- How to read the outcome: a failing test is the finding reproducing (the failure message states expected against actual).
- Seeds: a rerun with the same seed replays the same random choices. Assertions off (-da -dsa -Dtests.asserts=false) is the production-like setting.
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.TestWaitForGenerationAfterClose
Gradle alternative: copy the test into the matching module's src/test/ tree of the checkout and run ./gradlew -p lucene/<module> test --tests <class> -Ptests.seed=<seed> (add -Ptests.asserts=false for a run without assertions).
Formal models and scripts this finding's part of the hunt used: nrt-reference-managers
Feedback
Open an issue in lucene-ai-bug-hunt. Please do not open issues or pull requests in apache/lucene about this finding unless a Lucene committer agrees it should be filed.
Snapshot of 2026-11-04. AI-assisted; not yet triaged by a human Lucene committer. Feedback: open an issue in lucene-ai-bug-hunt.