All findings · Lucene finding · LFV-1221 · referencemanager-failed-swap-releases-installed-reference

A failed refresh makes ReferenceManager close its own current reference

When maybeRefresh() installs a new reference and releasing the old one throws, ReferenceManager also releases the new reference it has just made current. From then on every acquire() throws an IllegalStateException that blames the caller, until the manager is rebuilt; every release since 3.5.0 is affected.

Summary

Priority
Low, rank 947A failed ReferenceManager refresh can leave the manager holding a closed current reference, so every later acquire() throws an IllegalStateException that blames the caller (before 4.7.0 it spins forever) until the manager is rebuilt, in every release since 3.5.0. It cannot happen with the stock managers and ordinary listeners: it needs a custom decRef that fails on a non-final release, or a re-entrant refresh listener while a reader close fails, so it is contrived.
Impact
minor
Since
3.5.0 (2011, LUCENE-3476)introduced 2011-10-01 · over 15 years
Affected
3.5.0 to 10.6.0 and main
Status
documented
Found by
a proof-obligation audit of ReferenceManager's refresh contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

maybeRefresh() swaps in a new reference in two steps: make it current, then release the old one. A cleanup finally releases the new reference whenever the swap did not return normally. If the second step throws, the new reference is already current, so the cleanup closes the manager's own current reference.

Three search threads acquire and release the manager's current reference A; a refresh makes a new reference B, points current at B and then takes the manager's count off A, and a red box marks that when this last step throws, the cleanup also takes the manager's count off B, so the expected panel shows B current with count 1 and acquire() returning B, while the actual panel shows B current but closed with count 0 and acquire() throwing IllegalStateException.
A ReferenceManager makes the new reference current before it releases the old one, so when that release fails, its cleanup wrongly releases the new, already current reference too. Generated with AI, checked against the code.
Diagram of a ReferenceManager during maybeRefresh. Old reference A has count 2 after the refresh acquires it; new reference B has count 1. The swap makes B current, then releasing A throws an IOException. The cleanup finally then releases B, whose count drops to 0 although B is still current, and the next acquire() throws IllegalStateException 'The managed reference has already closed'. Expected: B keeps count 1 and acquire() returns B.
The two reference counts through the repro: B becomes current, releasing A throws, and the cleanup releases B to 0 while it is still current, so the next acquire() throws. With the fix B keeps its count. Select the still to open it at full size. Generated with AI, checked against the code.
  1. A ReferenceManager holds reference A with count 1, its own count. You call maybeRefresh(): it acquires A (count 2) and gets a new reference B (count 1).
  2. swapReference makes B current, then releases A, and that decRef throws an IOException, which the decRef contract allows.
  3. You expect maybeRefresh() to throw that IOException and B to stay current with count 1, so the next acquire() returns B.
  4. Instead a cleanup finally, written for a B that was never installed, releases B as well: B drops to count 0, which the manager treats as closed, while it is still current.
  5. Every later acquire(), and so every later uncontended maybeRefresh(), throws IllegalStateException: The managed reference has already closed - this is likely a bug when the reference count is modified outside of the ReferenceManager.

Who is affected

Only applications with their own ReferenceManager whose decRef can throw before the final release, or a SearcherManager (by reading, also a ReaderManager) whose beforeRefresh listener calls maybeRefresh() again while a reader close fails. Stock managers with ordinary listeners stay usable. The failure is loud, with no wrong results, but the manager must be rebuilt. Contrived, so priority Low.

Mechanism, terms and numbers518 words

ReferenceManager.swapReference makes the new reference current first and releases the old one second (ReferenceManager.java:56-57 on main). doMaybeRefresh marks the refresh as done only after swapReference returns (:172-173), and its finally releases the new reference whenever that mark is missing (:174-178). That finally came with LUCENE-3476 (2011) for a new reference the swap never installed, because the manager was closed during the refresh. When releasing the old reference throws instead (the abstract decRef is documented to throw IOException, :60-65), the new reference is already current, and the finally drops the manager's own count on it and closes it. maybeRefresh() throws the IOException; then every acquire() throws IllegalStateException "The managed reference has already closed - this is likely a bug when the reference count is modified outside of the ReferenceManager" (:112-113), and every later uncontended maybeRefresh() fails the same way in its own acquire() (:164). The application has to build a new manager. It needs a custom ReferenceManager whose decRef throws on a non-final release, or a SearcherManager (by reading, also a ReaderManager) whose beforeRefresh listener calls maybeRefresh() again while a reader close fails; the stock managers with ordinary listeners stay usable. Every release from 3.5.0 to 10.6.0 and main is affected (before 4.7.0 acquire() loops forever instead). The fix decides whether the new reference was installed before swapReference can throw.

Terms

  • ReferenceManager: the base class of SearcherManager and ReaderManager. It keeps one current reference and holds one count on it; acquire() adds a count for the caller, release() takes it back, and the reference closes when its count reaches 0. maybeRefresh() opens a newer reference and swaps it in.
  • The swap: swapReference (ReferenceManager.java:53-58) sets current = newReference (:56), then releases the old reference (:57), which calls the subclass's decRef.

The bug

// ReferenceManager.java:164-181 (doMaybeRefresh, condensed)
final G reference = acquire();          // :164  the refresh holds its own count on the old reference
...
try {
  swapReference(newReference);          // :172  current = new (:56), then release(old) (:57) throws
  refreshed = true;                     // :173  not reached
} finally {
  if (!refreshed) {
    release(newReference);              // :176  releases the reference that is now current
  }
}
...
release(reference);                     // :181

The repro's counts (A is the old reference, B the new one):

stepABcurrent
start1A
acquire() at :1642A
refreshIfNeeded returns B21A
current = B, then release(A) throws21B
finally: release(B)20 (closed, for the manager)B
release(A) at :181; maybeRefresh() throws the IOException10B
acquire(): tryIncRef(B) fails, B has count 0 and is currentIllegalStateException (:112)

Expected: B keeps count 1 and acquire() returns it. (A stays at 1 in the repro because the test's decRef throws before it decrements; that is the test subclass's choice, not part of the bug. The independent reproducer's decRef decrements first, like IndexReader.decRef, and fails the same way.)

Before LUCENE-5436 (#6499, 4.7.0) acquire() had no such check and looped forever on the closed reference.

Exposure and likelihood220 words
  • Who is hit. (a) A custom ReferenceManager subclass (public, @lucene.experimental) whose decRef can throw on a non-final release: the repro. (b) A stock SearcherManager whose RefreshListener.beforeRefresh calls maybeRefresh() on the same manager, while closing the intermediate reader throws (executed with a throwing IndexReader.ClosedListener); there close() of the manager then throws AlreadyClosedException: this IndexReader is closed too. The stock ReaderManager inherits the same refresh code and listeners, so it can reach the state the same way (by reading; not run).
  • Why the stock path is safe otherwise. doMaybeRefresh holds its own count on the old reference (:164), so the release inside the swap is never the final one, and IndexReader.decRef throws IOException only when the count reaches 0, from doClose() or the reader-closed listeners (IndexReader.java:222-227). A reader close failure then surfaces at the outer release (:181) and the manager stays usable (two controls passed).
  • Effect. One IOException, then every acquire() and every later uncontended maybeRefresh() throws until the application builds a new manager. The message says the caller changed the count, which points debugging the wrong way. No search returns wrong hits and nothing is written to the index.
  • Scale. None: one manager, one refresh, single thread, assertions on or off.
  • Likelihood: contrived. No public report of it was found since 2011.

Minimal repro

One JUnit test, single thread, deterministic, public API only: an anonymous ReferenceManager over AtomicInteger counters whose first decRef throws IOException. It fails with assertions on and off on main and passes with either fix.

TestReferenceManagerFailedSwap.java (one test, lucene/core)62 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.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestReferenceManagerFailedSwap extends LuceneTestCase {

  // Expected: acquire() succeeds after the failed maybeRefresh; actual: IllegalStateException.
  public void testAcquireAfterReleaseOfOldReferenceFails() throws Exception {
    ReferenceManager<AtomicInteger> manager =
        new ReferenceManager<>() {
          boolean failNextDecRef = true;

          {
            current = new AtomicInteger(1);
          }

          @Override
          protected void decRef(AtomicInteger ref) throws IOException {
            if (failNextDecRef) {
              failNextDecRef = false;
              throw new IOException("decRef failed");
            }
            ref.decrementAndGet();
          }

          @Override
          protected AtomicInteger refreshIfNeeded(AtomicInteger ref) {
            return new AtomicInteger(1);
          }

          @Override
          protected boolean tryIncRef(AtomicInteger ref) {
            return ref.get() > 0 && ref.incrementAndGet() > 0;
          }

          @Override
          protected int getRefCount(AtomicInteger ref) {
            return ref.get();
          }
        };
    expectThrows(IOException.class, manager::maybeRefresh);
    manager.release(manager.acquire());
  }
}
Command
cd $LUCENE
cp $REPRO/TestReferenceManagerFailedSwap.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestReferenceManagerFailedSwap -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
After maybeRefresh() throws the IOException, acquire() returns the new current reference.
Observed
acquire() throws IllegalStateException: The managed reference has already closed ... (ReferenceManager.java:112).

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

More files, environment, full output91 words

The first decRef call is the old reference's release inside the swap, so that is the one that throws. The test draws no random values. It belongs in lucene/core/src/test/org/apache/lucene/search/ and passes :lucene:core:check -x test (format, lint, forbidden APIs).

Regression test: this same test.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestReferenceManagerFailedSwap > testAcquireAfterReleaseOfOldReferenceFails FAILED
    java.lang.IllegalStateException: The managed reference has already closed - this is likely a bug when the reference count is modified outside of the ReferenceManager
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:9B25C6C40B135ABD]:0)
        at org.apache.lucene.search.ReferenceManager.acquire(ReferenceManager.java:112)
        at org.apache.lucene.search.TestReferenceManagerFailedSwap.testAcquireAfterReleaseOfOldReferenceFails(TestReferenceManagerFailedSwap.java:60)

Why it looks unintended

  • acquire()'s own comment says a closed current reference "can only happen if somebody outside of the RM decrements the refcount"; here the manager does it.
  • decRef is documented to throw IOException with no limit to the final release, and nothing forbids calling maybeRefresh() from a listener.
  • The cleanup was added (LUCENE-3476) for a new reference the swap never installed.
The full argument190 words
  • The manager's own invariant. acquire()'s comment (ReferenceManager.java:103-111): "This can only happen if somebody outside of the RM decrements the refcount without a corresponding increment since the RM assigns the new reference before counting down the reference." The exception message (:112-113) blames the caller.
  • The contract allows the trigger. decRef: "@throws IOException if reference decrement on the given resource failed" (:60-65). RefreshListener (:296-306) sets no rule against re-entry; the refreshLock is a ReentrantLock (:43) and doMaybeRefresh's comment allows the same thread to lock again (:155-160); IndexReader.ClosedListener.onClose declares IOException (IndexReader.java:141-145).
  • History. The finally came with LUCENE-3476 (#4550, 2011), "SearcherManager misses to close IR if manager is closed during reopen": the swap threw AlreadyClosedException (then from an inline null check, today from ensureOpen(), :54) and the new searcher was never installed. None of #4550, #4834 (LUCENE-3761) or #6499 (LUCENE-5436) discusses a failing release of the old reference.
  • Tests. TestSearcherManager.testReferenceDecrementIllegally (TestSearcherManager.java:331) expects this IllegalStateException only after a decRef made outside the manager. No test makes the old reference's release fail during a refresh.
The devil's-advocate check167 words

The strongest case: every in-tree manager's decRef fails only on the final release (IndexReader.decRef: "@throws IOException in case an IOException occurs in doClose()", IndexReader.java:208-213; its reader-closed listeners run there too, :222-227), and during a normal refresh the swap's release is never final. A decRef that fails earlier, or calling maybeRefresh() from inside its own beforeRefresh, could be called misuse.

Why it fails: it explains why the bug is rare, not why releasing the reference the manager has just made current would be correct. The abstract decRef contract does not limit failures to the final release, nothing forbids re-entry, and no javadoc, test, CHANGES entry or discussion says a failed refresh may leave a closed reference current; the code comment says the opposite. The cross-model review (OpenAI GPT-5.6) rated the intended-behaviour case weak; a later cross-model fact check rated it moderate and noted that a maintainer could reasonably close the issue as too contrived. What survives is a severity argument, hence priority Low.

Suggested fix

Recommended Decide "installed" under the manager's monitor, before the swap can throw

Decide whether the new reference was installed while holding the manager's monitor, before swapReference can throw, and release it in the finally only if it was not. Validated: the repro passes with assertions on and off; TestSearcherManager, TestControlledRealTimeReopenThread, TestLiveFieldValues and TestSearcherTaxonomyManager pass (with the repro, 26 tests, 0 failures, 2 skipped), and so does :lucene:core:check -x test.

           boolean installed = false;
           try {
-            swapReference(newReference);
+            synchronized (this) {
+              ensureOpen();
+              // swapReference makes newReference current before it releases the old reference,
+              // so from here on a failure must not release newReference: the manager owns it
+              installed = true;
+              swapReference(newReference);
+            }
             refreshed = true;
           } finally {
-            if (!refreshed) {
+            if (!installed) {
All options and trade-offs2 options ยท 570 words

Validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-03). Both fixes keep refreshed false on this path, so listeners still get afterRefresh(false); whether they should is the separate LFV-312 question (a different fix site). Open PR #15985 rewrites doMaybeRefresh into a loop but keeps this code.

Workaround until fixed: let a custom decRef fail only on the final release, as IndexReader.decRef does, and do not call maybeRefresh() from inside beforeRefresh (controls of both shapes stay usable); otherwise rebuild the manager after a failed maybeRefresh().

Decide "installed" under the manager's monitor, before the swap can throw (validated by the checks) (recommended)

Check ensureOpen() and set an installed flag while holding the monitor that swapReference and close() use; release the new reference in the finally only when it was not installed. Once ensureOpen() has passed under the monitor, swapReference (re-entrant on it) cannot fail before current = newReference, so the flag is exact. The case LUCENE-3476 handles (close() ran first) still releases the new reference.

           boolean installed = false;
           try {
-            swapReference(newReference);
+            synchronized (this) {
+              ensureOpen();
+              // swapReference makes newReference current before it releases the old reference,
+              // so from here on a failure must not release newReference: the manager owns it
+              installed = true;
+              swapReference(newReference);
+            }
             refreshed = true;
           } finally {
-            if (!refreshed) {
+            if (!installed) {

Trade-offs

About ten lines. It also covers a cross-thread interleaving the one-line fix leaves open, by reading: if close() runs after the failed swap but before the finally, close() releases the new reference as current and the finally must not release it again. Monitor use and lock order are the same as today (swapReference already takes the monitor under refreshLock).

What the checks validated

  • Stock 5adff78bb168: the repro fails through gradle with assertions on and off; with fix B it passes, on and off.
  • With fix B, the repro, TestSearcherManager (12 tests), TestControlledRealTimeReopenThread (6, 1 skipped) and TestLiveFieldValues (1): 20 tests, 0 failures; TestSearcherTaxonomyManager (lucene/facet): 6 tests, 0 failures, 1 skipped.
  • :lucene:core:check -x test (format, lint, forbidden APIs) with fix B and the test: passes.
  • The cross-thread interleaving and the stock route (b) were checked by reading only; no test drives them with fix B.

One line: skip the release when the new reference is current (validated by the checks)

if (!refreshed && current != newReference) in the finally; the hunt's fix sketch, unchanged (comment included).

           } finally {
-            if (!refreshed) {
+            // release the new reference only if swapReference did not install it: once it is
+            // current, the manager's count on it must stay even if releasing the old one failed
+            if (!refreshed && current != newReference) {
               release(newReference);
             }

Trade-offs

Smallest change. It reads current outside the monitor, so in the interleaving above (a concurrent close() between the failed swap and the finally) it still releases the new reference twice; the stock code does the same there, so it is no worse than today.

What the checks validated

  • With fix A the repro passes, assertions on and off; the repro and the same three core suites: 20 tests, 0 failures, 1 skipped; :lucene:core:check -x test passes.
  • Earlier, the hunt's runs of the same one-line change passed the repro (on, off), TestSearcherManager (12 of 12) and the independent reproducer's two tests.

Downstream note

Downstream products are exposed only if they ship their own ReferenceManager subclass whose decRef can fail before the final release, or call maybeRefresh() from a refresh listener; no query, document or index input can trigger it.

How it was found

A proof-obligation audit of ReferenceManager's refresh path checked each release of the new reference against the invariant stated in acquire(), and found the cleanup also runs after the swap has installed it. A public-API JUnit test then witnessed it on main; independent checks reproduced it with their own tests.

The method in full118 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The obligation: doMaybeRefresh may release the new reference only if the swap did not install it, because acquire() relies on the manager never leaving a closed reference current (:103-111). The finally (:174-178) keys on refreshed, which is still false when swapReference throws after current = newReference (:56-57), so the obligation is not met.
  • A one-test JUnit witnessed it on main with assertions on and off. An independent reproducer, an executed review by another model family (OpenAI GPT-5.6) and a devil's-advocate verifier wrote their own tests from the claim alone, including two routes through the stock SearcherManager.

Prior reports, history and checks

Prior reports

No prior report: three scripted prior-art runs (one on the exact error message), manual GitHub searches in Lucene and downstream projects, the mailing lists, JIRA and the web, plus the changed files of all 409 open PRs (2026-11-03), rechecked the same day.

Searches3 searches

No report found in the documented searches says that ReferenceManager releases the reference swapReference has just installed when releasing the old one throws. Related, not this bug: #4550 (LUCENE-3476, added the release for the not-installed case), #4834 (LUCENE-3761, created ReferenceManager with this code), #5632 (LUCENE-4566, renamed the flag, added listeners), #6499 (LUCENE-5436, added the "already closed" error assuming only an outside decrement causes it), #15831 and open PR #15985 (a lost refresh through tryLock; the PR keeps this code), #15068 (a stack through swapReference(null) from close(), another defect). Downstream reports with the same message (orientdb #8774, #8829, #8832; a 2014 Elasticsearch forum thread) show no failed refresh: symptom match only, not evidence of impact.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; Elasticsearch, OpenSearch, Solr, Lucene.NET; dev@, java-user@, issues@, commits@; JIRA archive; web2026-11-03three scripted prior-report search runs (45, 152 and 38 distinct hits) and manual GitHub searchesonly the related items above
Open PRs2026-11-03the changed files of all 409 open apache/lucene PRs; the diff of the one touching the fileonly #15985 touches ReferenceManager.java, and it keeps this code
Recheck (GitHub)2026-11-03class, method and message queries; PRs updated since 2026-11-01; upstream file historynothing new; upstream main 1c2072f0284 still has the same swap and finally

History

Introduced by LUCENE-3476 (a234ffd21c9, 2011-10-01) in SearcherManager, first released in 3.5.0; LUCENE-3761 moved it unchanged into ReferenceManager (3.6.0). Executed: 3.5.0 to 10.6.0 and main fail. acquire() loops forever until 4.7.0, then throws. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-3476 (#4550; svn era, no pull request), Simon Willnauer, 2011-10-01: a234ffd21c9 on branch_3x (shipped in 3.5.0) and d514a4ac75b on trunk. It wrapped the swap in success / finally { if (!success) release(newSearcher); }, around a swap that already installed the new searcher before releasing the old one. LUCENE-3761 (#4834, Shai Erera, 2012-02-14; 4e5ead2640a on branch_3x, first in 3.6.0, and f4c6a4098ea on trunk) moved it unchanged into the new ReferenceManager; LUCENE-4566 (f6b3aa4054b) renamed the flag to refreshed. Nothing has changed those lines since; all 127 release tags from 3.6.0 to 10.6.0 in the history clone have the same code (8.8.2 to 8.11.x are not tagged there; 8.11.4 was executed). In 3.5.0 the stock SearcherManager held no count across the swap, so any failure closing the old reader hit it; from 3.6.0 only a custom decRef or (from 4.2.0, when RefreshListener.beforeRefresh came with LUCENE-4695) a re-entrant listener reaches it; the 4.1.0 listener has only afterRefresh(), which runs after the swap and the outer release. 3.6.0, 4.6.1, 4.7.0 and 8.11.4 ran plain-Java copies of the same steps and 3.5.0 the stock SearcherManager with a reader-finished listener that throws a RuntimeException (an IOException from doClose takes the same path, by reading), with acquire() bounded by a 10 s join. Upstream main 1c2072f0284 (2026-11-02) is unchanged here. All runs used JDK 25.0.3.

Versions and branches executed, with results
Version or commitVerdictResult
3.4.0 no APIno SearcherManager or ReferenceManager (the probe does not compile)
3.5.0 first: 3.5.0stock SearcherManager with a throwing reader-finished listener: maybeReopen throws, then acquire() loops forever
3.6.0, 4.6.1 affectedmaybeRefresh throws, then acquire() loops forever (3.6.0 with assertions on and off)
4.7.0, 8.11.4 affectedacquire() throws the IllegalStateException
9.12.3, 10.5.2, 10.6.0 affectedone-test JUnit variants fail; the stock SearcherManager route reaches the same state on 9.12.3 and 10.6.0
main 5adff78bb168 affectedthe one-test repro fails through gradle, assertions on and off; passes with either fix

Independent checks

An independent reproducer, an executed review by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication all rated it real; the adjudication confirmed it (medium confidence, priority Low). This doc's gradle runs reproduced it on main and validated both fixes.

Checks and verdicts229 words

The cross-model review ran on main ea57360912ed, whose relevant files equal 5adff78bb168's, and its tests were re-run on 5adff78bb168 with the same result; every other check ran on 5adff78bb168 or a release.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOne-test JUnit fails on main (on, off), 10.6.0 and 9.12.3; the one-line fix passes it and TestSearcherManager (12 of 12); a stock SearcherManager probe reaches the same state.
independent reproducer realOwn test (decRef decrements, then throws, like IndexReader.decRef) fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3; own stock-SearcherManager test fails on main; both pass with the fix.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own subclass test fails; own stock test shows the closed current searcher while a non-re-entrant control stays usable; intended-behaviour argument rated weak.
devil's-advocate verifier real, LowRare but not intended; a control whose decRef fails only at 0 stays usable.
adjudication confirmed (medium), LowRe-ran the finder's test (on, off) and both of the reproducer's tests: all fail.
this doc (gradle) reproduced; both fixes validatedGradle in a clean scratch worktree of 5adff78bb168: the repro fails with assertions on and off; it passes with fix A and with fix B (on, off), and the related suites and :lucene:core:check -x test pass with each fix.

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,709 words

Finding record

LFV-1221, snapshot of 2026-11-06

Registry title
ReferenceManager.maybeRefresh releases the new reference it has just made current when releasing the old one throws, so every later acquire() throws IllegalStateException (since 3.5.0)
Area
core / search: ReferenceManager (SearcherManager, ReaderManager and other subclasses)
Priority
Low, rank 947: A failed ReferenceManager refresh can leave the manager holding a closed current reference, so every later acquire() throws an IllegalStateException that blames the caller (before 4.7.0 it spins forever) until the manager is rebuilt, in every release since 3.5.0. It cannot happen with the stock managers and ordinary listeners: it needs a custom decRef that fails on a non-final release, or a re-entrant refresh listener while a reader close fails, so it is contrived.
Impact class
minor
Impact note
Only applications that run their own ReferenceManager subclass whose decRef can throw on a non-final release, or a stock SearcherManager whose RefreshListener.beforeRefresh calls maybeRefresh again on the same manager while closing the intermediate reader throws (for example a throwing IndexReader.ClosedListener), are hit: after that one failed maybeRefresh, every acquire() and every later maybeRefresh throws IllegalStateException 'The managed reference has already closed - this is likely a bug when the reference count is modified outside of the ReferenceManager', and close() of the SearcherManager throws AlreadyClosedException, so the manager is unusable until the application builds a new one; -ea and -da, no scale, every release since 3.5.0 (before 4.7.0 acquire() spins forever instead); with the stock managers and ordinary listeners it does not happen, nothing is written to the index and no wrong result is returned.
Summary
You call maybeRefresh on a ReferenceManager (for example a SearcherManager), and releasing the old reference inside the swap throws an IOException, which decRef is documented to do; you expect maybeRefresh to throw it and the manager to stay usable, with acquire() returning the new current reference. Instead swapReference has already made the new reference current (ReferenceManager.java:56) before it releases the old one (:57), and doMaybeRefresh's cleanup finally, which exists for a new reference the swap never installed, releases it anyway (:174-178), so the manager drops its own count on its current reference and closes it. Every later acquire() then throws IllegalStateException 'The managed reference has already closed - this is likely a bug when the reference count is modified outside of the ReferenceManager', blaming the caller for a state the manager created itself.
Introduced
a234ffd21c9131f391646c5f3ced8964d6796a82 (branch_3x, svn r1177943, the code that shipped in 3.5.0; trunk twin d514a4ac75b5374a3c19193137e3bbc972f7bdd2, svn r1177940, ten minutes earlier, first in 4.0.0-alpha): LUCENE-3476 'SearcherManager misses to close IR if manager is closed during reopen' (issue #4550; svn era, no PR) added 'success = false; try { swapSearcher(newSearcher); success = true; } finally { if (!success) release(newSearcher); }' to SearcherManager.maybeReopen around a swap that already installed the new searcher before releasing the old one, for the case where close() ran during the reopen and the new searcher was never installed. The same day f02ae7fc9a5 / 52e8d40c2f2 merged the warm and swap try blocks and kept the shape; LUCENE-3761 ('Generalize SearcherManager', #4834) moved it unchanged into the new ReferenceManager (4e5ead2640a on branch_3x, first in 3.6.0; f4c6a4098ea on trunk, where git blame stops because the file was created there); LUCENE-4566 (f6b3aa4054b) renamed the flag to refreshed [read: git log -S 'release(newReference)' and -S 'release(newSearcher)' on the full-history clone; exec: 3.5.0 SearcherManager form and 3.6.0 ReferenceManager form fail] (2011-10-01 (author date = commit date: 08:10:21Z trunk, 08:20:40Z branch_3x); moved into ReferenceManager 2012-02-14 (12:55:43Z branch_3x, 15:36:14Z trunk))
First affected version
3.5.0 [exec] (the first release with SearcherManager, stock class: maybeReopen throws, then acquire() spins forever; 3.4.0 has neither SearcherManager nor ReferenceManager [exec: compile error]); ReferenceManager, the repro's API, is affected from its first release 3.6.0 [exec]
Affected versions
Every release from 3.5.0 through 10.6.0 (4ae946e5a9b, released 2026-10-29) and main 5adff78bb168 (12.0.0-SNAPSHOT); never fixed, not a regression, not new in 10.6. Symptom after the failed refresh: 3.5.0 to 4.6.1 acquire() loops forever on the closed current reference; from 4.7.0 (LUCENE-5436, #6499) acquire() throws the IllegalStateException. [exec] 3.5.0 (stock SearcherManager form, -ea), 3.6.0 (-ea and -da), 4.6.1 (-ea), 4.7.0 (-ea) and 8.11.4 (-da) with plain main() copies of the same steps (core jar only; acquire() bounded by a 10 s join because it never returns before 4.7.0), the one-test JUnit repro on 9.12.3, 10.6.0 and main (-ea and -da), the independent reproduction's own test on 10.5.2, 10.6.0 and 9.12.3 (-ea), and the stock-class route (SearcherManager, a RefreshListener that re-enters maybeRefresh once, a throwing ClosedListener on the intermediate reader) on 9.12.3, 10.6.0 and main (-da). [read] all 127 release tags 3.6.0 to 10.6.0 in the history clone have both the swap order and the finally (8.8.2 to 8.11.x are not tagged there; 8.8.1 read and 8.11.4 executed). Reach by version: in 3.5.0 any failure closing the old reader hit it with the stock class; from 3.6.0 maybeRefresh holds its own count on the old reference across the swap, so the stock managers reach it only through a re-entrant RefreshListener (from 4.2.0, when LUCENE-4695 added RefreshListener.beforeRefresh) plus a failing reader close, and otherwise only a custom ReferenceManager reaches it. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still has the code, and the newest upstream commit on ReferenceManager.java is cad76ccfd2d (author date 2025-01-16, comment grammar) [read, 2026-11-03]. Caveats: one seed per run, JDK 25.0.3 on Linux aarch64; the defect is plain Java logic.
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
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's scripted prior-report search and manual reads; rechecked the same day): three scripted prior-report searches (the finder's 51 queries; run 2 with new terms, 152 distinct hits; run 3 on the exception message text, 38 distinct hits) over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, plus manual GitHub REST searches in apache/lucene and across GitHub (Elasticsearch, OpenSearch, Solr, Lucene.NET) and web reads. The nearest items define the code but not this path: #4550 (LUCENE-3476) added the release of the new reference for the not-installed case only (close() during a reopen; body and all comments read); #6499 (LUCENE-5436) added the 'already closed' IllegalStateException on the stated assumption that only a decrement outside the manager can close the current reference; #4834 (LUCENE-3761) and #5632 (LUCENE-4566) do not discuss release(old) throwing. Open PR #15985 (for #15831, last activity 2026-10-16) restructures doMaybeRefresh into a loop but keeps 'swapReference(newReference); refreshed = true; } finally { if (!refreshed) release(newReference); }', so it neither reports nor fixes this. Downstream reports with the same message (orientechnologies/orientdb #8774, #8829, #8832; a 2014 Elasticsearch forum thread) show no failed refresh and were never traced to this path: symptom match only. All 409 open apache/lucene PRs were checked by changed files (only #15985 touches ReferenceManager.java), and none of the open PRs updated since 2026-11-01 touches ReferenceManager.java, SearcherManager.java or ReaderManager.java; the newest upstream commit on ReferenceManager.java is cad76ccfd2d (author date 2025-01-16, comment grammar).
Lucene commit tested
main 5adff78bb168 (the one-test repro through gradle in a clean scratch worktree, assertions on and off, unfixed and with fixes A and B; with each fix the repro, TestSearcherManager, TestControlledRealTimeReopenThread, TestLiveFieldValues and :lucene:core:check -x test; with fix B also TestSearcherTaxonomyManager, 2026-11-03). Earlier checks: standalone runs of the repro on main (assertions on and off), an earlier one-test variant on the 10.6.0 and 9.12.3 jars, the stock SearcherManager with a throwing reader-finished listener on 3.5.0, plain-Java copies of the same steps on 3.6.0, 4.6.1, 4.7.0 and 8.11.4, and a stock SearcherManager route on 9.12.3, 10.6.0 and main. The cross-model review ran on main ea57360912ed, whose ReferenceManager, SearcherManager and IndexReader equal 5adff78bb168's, and was re-run on 5adff78bb168.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:27-36 (at 5adff78bb168) (class javadoc: 'This class ensures each reference is closed only once all threads have finished using it' :29-30; @lucene.experimental :35), :43 (refreshLock is a ReentrantLock), :53-58 (synchronized swapReference: ensureOpen :54, current = newReference :56 BEFORE release(oldReference) :57), :60-65 (decRef: '@throws IOException if reference decrement on the given resource failed'), :84-116 (acquire: 'Obtain the current reference' :85; IllegalStateException when tryIncRef fails, getRefCount == 0 and current == ref :101-113; comment :103-111 'This can only happen if somebody outside of the RM decrements the refcount without a corresponding increment since the RM assigns the new reference before counting down the reference'), :134-142 (close: swapReference(null) releases the current reference), :154-188 (doMaybeRefresh: same-thread relocking allowed :155-160; acquire() of the old reference :164; swapReference :172; refreshed = true :173; the finally that releases newReference whenever !refreshed :174-178, the fix site; outer release(reference) :181), :295-306 (RefreshListener: no rule against re-entry)
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:141-145 (at 5adff78bb168) (ClosedListener.onClose throws IOException), :205-232 (decRef: '@throws IOException in case an IOException occurs in doClose()'; closes the reader at refCount 0)
  • lucene/core/src/test/org/apache/lucene/search/TestSearcherManager.java:331 (at 5adff78bb168) (testReferenceDecrementIllegally: the IllegalStateException is asserted only after a decRef outside the manager)
  • Lucene main 5adff78bb168 SECURITY.md:48-50 ('In-scope reports')
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:41-58 (at 5adff78bb168) (current, refreshLock, swapReference)
  • lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:91-116 (at 5adff78bb168) (acquire; the guard at :101-113)
  • lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:154-188 (at 5adff78bb168) (doMaybeRefresh: :164, :172-178, :181)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:222-227 (at 5adff78bb168) (decRef throws IOException only at count 0, from doClose or the reader-closed listeners); StandardDirectoryReader.java:668-671 (the listeners' onClose); ReaderManager.java:33, :87-103 (same refresh code, by reading)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:43, (at 5adff78bb168) :60-65, :103-113, :155-160, :296-306
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:141-145, (at 5adff78bb168) :208-213
  • lucene/core/src/test/org/apache/lucene/search/TestSearcherManager.java:331-361 (at 5adff78bb168)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:53-58, (at 5adff78bb168) :134-142, :161, :172-178
Diagrams
  • lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:53-58, (at 5adff78bb168) :91-116, :154-188

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

Repro files

About the diagrams

A step-by-step picture of the repro's reference counts, made in parallel with this doc from the code at 5adff78bb168 and the executed repro; the PNG is the summary still. Its fix strip shows the one-line fix A, which the diagram's runs executed; the recommended fix B is under Fixes.

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.