All findings · Lucene finding · LFV-497 · indexreader-decref-javadoc-refcount-unchanged-on-exception

IndexReader#decRef javadoc says the refCount is unchanged on exception, but a failing final decRef leaves the reader closed at refCount 0

IndexReader.decRef() documents "If an exception is hit, the refCount is unchanged", but when the final decRef() throws (a closed listener or doClose() fails), the reader is left closed at refCount 0, not 1. The code is intended since 4.7.1 (LUCENE-5553); only the javadoc is stale.

Summary

Priority
Low, rank 1275The decRef javadoc misstates the refCount after an exception in an Expert API while the code behaves as intended, documentation only.
Impact
minor
Since
4.7.1 (LUCENE-5553, 2014-03-25)introduced 2014-03-25 · over 12 years
Affected
4.7.1 to 10.6.0 and main; documentation only
Status
documented
Found by
non-formal: code reading while writing a TLA+ reader-lifecycle model; the witness is a JUnit test

AI-assisted page: this finding was first signalled by code reading or testing 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

decRef() decrements the count first; at 0 it marks the reader closed and runs doClose(), with the closed listeners notified in a try-with-resources (IndexReader.java:222-227). Nothing puts the count back if either throws, while the javadoc a few lines above (:208-209) says the count is unchanged.

Background: reader reference counting

  1. Every IndexReader has a reference count. It is 1 when you open the reader; incRef() adds one and decRef() removes one.
  2. When decRef() takes the count to 0, the reader closes: doClose() releases what it holds, and its closed listeners (registered through getReaderCacheHelper().addClosedListener) are notified, typically to evict cache entries.
  3. close() simply calls decRef(). After the reader is closed, a retried decRef() and most other uses of it throw AlreadyClosedException.
  4. Both doClose() and ClosedListener.onClose are declared to throw IOException.
Diagram of the bug: a DirectoryReader with refCount 1 and a closed listener that throws IOException. decRef() takes the count to 0, marks the reader closed, runs doClose() and then the listener, which throws. The javadoc says the refCount is unchanged (1); the reader actually stays closed at refCount 0, and a second decRef() throws AlreadyClosedException.
The refCount around a failed final decRef(): the javadoc promises 1 and a reader you still hold; the code leaves 0 and a closed reader. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You open a DirectoryReader (refCount 1) and register a reader-closed listener whose onClose throws IOException, which the ClosedListener interface allows.
  2. You call decRef() to release your reference, and it throws the listener's IOException.
  3. The javadoc says "If an exception is hit, the refCount is unchanged", so you expect getRefCount() to still be 1 and the reference to still be yours.
  4. Instead getRefCount() is 0 and the reader is closed: a second decRef() throws AlreadyClosedException. A reader whose own doClose() throws ends the same way.
  5. That outcome is right: since 4.7.1 (LUCENE-5553, 2014) a failing final close really closes the reader and still notifies its listeners. The sentence predates that change and was never updated.

Who is affected

Code that manages reader references itself (the Expert incRef/decRef API, or close()), sees the final release throw, and then trusts the sentence: it retries decRef() or keeps the reader. A retried decRef() gets an immediate AlreadyClosedException; no wrong result, hang or leak was found. Every release from 4.7.1 to 10.6.0, and main.

Mechanism, terms and numbers479 words

The javadoc of IndexReader#decRef says "If the refCount drops to 0, then this reader is closed. If an exception is hit, the refCount is unchanged." The method decrements first; at 0 it marks the reader closed and runs doClose() with the closed-listener notification in a try-with-resources, and nothing puts the count back if either throws. So after a final decRef() that throws, getRefCount() is 0, the reader is closed, and a retried decRef() throws AlreadyClosedException.

The code is the intended side. The sentence was written in 2009 (LUCENE-2156) together with a "Put reference back on failure" block; LUCENE-5553 (#6615, first released in 4.7.1) removed that block on purpose so that a failing close really closes the reader and still notifies its closed listeners, and left the sentence. The fix is javadoc-only. It affects the documentation in every release from 4.7.1 to 10.6.0 and on main.

The mechanism (main 645d4fe14a20)

  1. The javadoc. IndexReader.java:207-213: "Expert: decreases the refCount of this IndexReader instance. If the refCount drops to 0, then this reader is closed. If an exception is hit, the refCount is unchanged." and "@throws IOException in case an IOException occurs in doClose()".
  2. The code (IndexReader.java:215-232): :218-220 throws AlreadyClosedException if the count is already <= 0 (here the count really is unchanged); :222 refCount.decrementAndGet(); :223-227 at 0: closed = true; then try (Closeable _ = this::notifyReaderClosedListeners) { doClose(); }; :228-231 below 0: IllegalStateException ("too many decRef calls"). No line restores the count, so an exception from doClose() or from a listener leaves 0.
  3. Every listener is still called. StandardDirectoryReader.java:668-672 and SegmentReader.java:318-322 notify the listeners through IOUtils.applyToAll, so one that throws does not skip the others; the try-with-resources runs the notification also when doClose() throws (if both throw, the listener's exception is attached to the doClose() one as suppressed). The base notifyReaderClosedListeners() is a no-op (:153-155); only these two readers override it.
  4. Afterwards the reader is closed for good. ensureOpen() throws AlreadyClosedException when the count is <= 0 (:238-241); a retried decRef() hits :218-220; close() is a no-op because closed is already true (:351-356). So most reader operations (for example terms()) fail. A few accessors skip ensureOpen() on purpose and still answer: getRefCount(), "so that callers can see refCount is 0 (reader is closed)" (:159-160), and numDocs() / maxDoc() (BaseCompositeReader.java:141-166, SegmentReader.java:234-243).
  5. Both triggers are legal. ClosedListener.onClose(CacheKey key) throws IOException (:141-146, @lucene.experimental); doClose() is a protected hook declared to throw IOException (:358-359); Lucene's own TestIndexReaderClose uses both a throwing doClose() and throwing listeners.

In the repro

An empty commit, DirectoryReader.open (count 1), one closed listener that throws IOException. decRef(): the count goes to 0, closed is set, doClose() succeeds, then the listener throws and decRef() rethrows its IOException. getRefCount() returns 0; the javadoc promises 1.

Exposure and likelihood239 words
  • Who. Code that drives reader reference counting itself through the Expert incRef/decRef API (or close(), which calls decRef()) and, after the final release throws, follows the javadoc: it retries decRef(), or keeps the reader (for example in its own pool) believing it still holds a reference.
  • When. Only when closing a reader throws: a closed listener that throws, a reader subclass whose doClose() throws, or an I/O error while index files are released. All uncommon.
  • What goes wrong. A retried decRef() throws AlreadyClosedException at once, and so do most later reader operations; a few metadata accessors (numDocs(), maxDoc()) skip that check on purpose. No wrong result, hang or extra resource use was found; whatever a failing doClose() did not release stays unreleased whichever way the javadoc reads. The @throws clause names only doClose(), so the javadoc also hides that a listener can be the source.
  • Scope of the repro. It covers one StandardDirectoryReader; composite readers close or decRef() their children in their own doClose() with their own exception handling, which this page does not examine.
  • Default vs opt-in. No configuration changes the behaviour; the text is on a public final method of the core reader class, read by anyone writing reader-lifecycle code.
  • Scale and age. Every release from 4.7.1 (2014) to 10.6.0 and main (a few untagged 8.x releases inferred); the recorded searches found no report of the mismatch.

Minimal repro

One JUnit test, one method, deterministic: an empty index, a DirectoryReader with one closed listener that throws IOException, then decRef(). It asserts what the javadoc promises, a refCount of 1. Public API only, in the org.apache.lucene.index test package.

TestDecRefExceptionRefCount.java41 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.index;

import java.io.IOException;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDecRefExceptionRefCount extends LuceneTestCase {

  public void testRefCountUnchangedAfterFailedDecRef() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      new IndexWriter(dir, new IndexWriterConfig()).close();
      DirectoryReader reader = DirectoryReader.open(dir);
      reader
          .getReaderCacheHelper()
          .addClosedListener(
              key -> {
                throw new IOException("closed listener failure");
              });
      expectThrows(IOException.class, reader::decRef);
      // decRef javadoc: "If an exception is hit, the refCount is unchanged" (1); main leaves 0
      assertEquals("refCount after the failed decRef", 1, reader.getRefCount());
    }
  }
}
Command
cp $REPRO/TestDecRefExceptionRefCount.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestDecRefExceptionRefCount -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
Per the javadoc, getRefCount() is still 1 after the failed decRef().
Observed
getRefCount() is 0 and the reader is closed: refCount after the failed decRef expected:<1> but was:<0>.

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

More files, environment, full output309 words
  • Deterministic. The test code supplies no random input (ByteBuffersDirectory, new IndexWriterConfig()), though LuceneTestCase still randomizes its environment (locale, time zone); seeds DEADBEEF and 5EED1234 give the same failure, with assertions on and off.
  • It transcribes the current javadoc. Under the recommended javadoc fix it does not pass; it becomes obsolete, because the documented contract becomes "stays closed at refCount 0".
  • As a regression test for the javadoc fix (optional for a javadoc-only change): this same test expecting 0 instead of 1, with the comment "decRef javadoc (fixed): the reader stays closed (refCount 0) even if closing it throws" and the method name testReaderStaysClosedAfterFailedDecRef; the body is otherwise the same. It passes with the javadoc fix (re-run with its final wording), with assertions on (together with TestIndexReaderClose and TestReaderClosed, 5 tests) and off (1 test). It pins the behaviour, so a change that restored the count would be caught.
  • Releases. The same file fails unchanged on the 10.6.0, 10.5.2 and 9.12.3 release jars. Older releases lack the org.apache.lucene.tests framework; there the study used public-API main() probes (Version history).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 10.6.0, 10.5.2 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestDecRefExceptionRefCount.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestDecRefExceptionRefCount -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestDecRefExceptionRefCount > testRefCountUnchangedAfterFailedDecRef FAILED
    java.lang.AssertionError: refCount after the failed decRef expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:EBBFFB376FC65C1D]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.index.TestDecRefExceptionRefCount.testRefCountUnchangedAfterFailedDecRef(TestDecRefExceptionRefCount.java:38)
:lucene:core:test (FAILURE): 1 test, 1  failure
# -Ptests.asserts=false: the same failure
# stock main, TestIndexReaderClose + TestReaderClosed: 4 tests, BUILD SUCCESSFUL
# javadoc fix (option A), a comment-only change: this test still fails the same way;
#   the same test expecting 0 passes (with the two suites: 5 tests, BUILD SUCCESSFUL)

Why it looks unintended

  • Written in 2009 (LUCENE-2156) with a "Put reference back on failure" block that restored the count when doClose() failed; true then.
  • LUCENE-5553 (4.7.1) removed that block on purpose, "close really closes and throws first exc", and left the sentence.
  • Lucene's TestIndexReaderClose expects a close that threw to have really closed the reader: the code is right, the sentence is stale.
The full argument214 words
  • Written for the removed code. LUCENE-2156 (dc3f92e6e58c, Michael McCandless, 2009-12-16, first in 3.1) added the sentence in the same hunk as boolean success = false; try { ... doClose(); success = true; } finally { if (!success) { // Put reference back on failure refCount.incrementAndGet(); } }.
  • The change that made it false was deliberate. LUCENE-5553 / #6615, "IndexReader#ReaderClosedListener is not always called on IndexReader#close()" (Simon Willnauer, 2014-03-25), replaced the put-back with closed = true and unconditional listener notification. In the issue Simon Willnauer wrote that they "dropped putting the reference back since this should really just close everything and hand the exception back", and Michael McCandless gave "+1 for the patch and to change to 'close really closes and throws first exc' behavior". Nobody in the issue mentions the javadoc.
  • The new behaviour is tested. TestIndexReaderClose.testCloseUnderException, added by LUCENE-5553, expects IllegalStateException from close() when doClose() or a listener throws (TestIndexReaderClose.java:89), may then call close() again, which must not throw (:99-101), and checks that every counting listener was notified despite the failure (:102): "close really closes".
  • Later edits kept the sentence. LUCENE-8215 (2018), the 2020 reformat (which only re-wrapped it), #14302 (2025, which renamed the try-with-resources variables) and #15002 (2025) all touched decRef.
The devil's-advocate check156 words

Strongest case for "not a defect" (the study's intended-behaviour devil's advocate and the credibility audit): (1) the sentence can be read as covering only the already-closed pre-check (IndexReader.java:218-220), where the count really is unchanged; (2) it is a cosmetic nit in an Expert API: the behaviour is intended, a caller who trusts the sentence and retries gets AlreadyClosedException at once, and no complaint since 2014 was found.

Why it fails. (1) History rules out the narrow reading: the sentence was written in the same hunk as the put-back block it described, and the @throws line under it names doClose(), the path that block covered. (2) It is a concrete, false statement about the reader's state after an exception, on the method whose callers must write the exception handling, and it is read exactly then; the fix is two javadoc lines with no behaviour change. Upstream may still call it a cosmetic javadoc cleanup.

Suggested fix

Recommended A. Correct the sentence and the @throws clause (recommended)

Javadoc only: say that the reader stays closed (refCount 0) even if closing it throws, and name closed listeners in @throws. No behaviour change. Validated with gradle: format check and javadoc render pass; the same test expecting 0 and the closest reader-close suites pass.

   * Expert: decreases the refCount of this IndexReader instance. If the refCount drops to 0, then
-  * this reader is closed. If an exception is hit, the refCount is unchanged.
+  * this reader is closed. It stays closed (refCount 0) even if closing it throws an exception.
   *
-  * @throws IOException in case an IOException occurs in doClose()
+  * @throws IOException in case an IOException occurs in doClose() or in a closed listener
All options and trade-offs3 options · 498 words

Javadoc only; the behaviour is right and must not change. Both javadoc options were applied in a scratch worktree of main 645d4fe14a20 and checked with gradle.

A. Correct the sentence and the @throws clause (recommended) (validated by the checks)

Say that the reader stays closed (refCount 0) even if closing it throws, and name closed listeners in @throws. It makes no promise about closed listeners, which not every reader keeps or notifies itself.

   * Expert: decreases the refCount of this IndexReader instance. If the refCount drops to 0, then
-  * this reader is closed. If an exception is hit, the refCount is unchanged.
+  * this reader is closed. It stays closed (refCount 0) even if closing it throws an exception.
   *
-  * @throws IOException in case an IOException occurs in doClose()
+  * @throws IOException in case an IOException occurs in doClose() or in a closed listener

Trade-offs

  • No behaviour change. It tells callers what they need after a failed final release (the reader is gone: do not retry, do not reuse).
  • No listener promise on purpose: the base notifyReaderClosedListeners() is a no-op, only StandardDirectoryReader and SegmentReader override it, and readers such as MultiReader return a null cache helper or delegate to a sub-reader. An earlier draft said "its closed listeners are still notified"; it was dropped after review.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, this exact wording: :lucene:core:checkGoogleJavaFormat and :lucene:core:renderJavadoc pass, and the rendered IndexReader.html shows the new text.
  • The minimal test in its corrected form (expecting 0) together with TestIndexReaderClose (3 tests) and TestReaderClosed (1 test): 5 tests pass with assertions on; the corrected test also passes with assertions off.
  • The minimal test as written was re-run with the fix and still fails (expected:<1> but was:<0>), as it must for a comment-only change.
  • Not run: gradlew check or the whole core test suite (the change is inside a javadoc comment).

B. Delete the sentence only (validated by the checks)

Drop "If an exception is hit, the refCount is unchanged." and leave the rest.

-  * this reader is closed. If an exception is hit, the refCount is unchanged.
+  * this reader is closed.

Trade-offs

  • The smallest change that removes the false promise; it leaves @throws incomplete and does not tell callers what state the reader is in after a failure.

What the checks validated

  • Same worktree: :lucene:core:checkGoogleJavaFormat passes. Nothing else was run (a comment-only change).

Not an option: restore the count (validated by the checks)

Putting the reference back on failure would make the sentence true, but it would bring back the pre-4.7.1 state that LUCENE-5553 removed on purpose: a partly closed reader that still looks open, so a retried decRef() would run doClose() again (read from the 4.7.0 code). Related: facet TaxonomyReader.decRef still puts the reference back on failure, and its javadoc makes no promise either way; a different class, not part of this finding.

Trade-offs

  • Reverts a deliberate design decision; not proposed.

What the checks validated

  • Not run.

Downstream note

Downstream code that pools or wraps readers should treat any failed final decRef() or close() as closed; the Lucene.NET port carries the same sentence.

How it was found

Non-formal. While writing a TLA+ model of the reader lifecycle, a hunt unit read decRef's exception path and noticed that nothing restores the count; the model is not the witness. A public-API JUnit test on the stock jars is, and independent checks re-ran it and a 3-test variant.

The method in full111 words
  • Lead. Code reading of IndexReader.decRef's exception edge in a hunt unit (reader and shared-arena reference counting), while modelling it in TLA+. The model produced no counterexample for this; the finding is the code reading plus the executed test.
  • Witness. A public-API JUnit test on the stock jars that asserts the javadoc sentence, failing on main with assertions on and off and on 10.5.2 and 9.12.3; later trimmed to the one-method test shown here.
  • Verification. An independent reproducer (3 tests: listener path, doClose() path, control), a cross-model reviewer (OpenAI GPT-5.6, reading only), an intended-behaviour devil's advocate, an adjudication, version-history runs with public-API probes from 4.6.1 to main, and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26: 51 scripted queries and 31 GitHub issue/PR searches over issues, PRs, commits, the JIRA archive, mailing lists, downstream projects and all 408 open PRs). LUCENE-5553 (#6615) made the change but never mentions the javadoc. Upstream main still has the sentence (2026-11-01).

Searches4 searches

Read, not the same bug: #6615 / LUCENE-5553 (made the change; the origin of the defect, not a report of it); #3232 / LUCENE-2156 (wrote the sentence when it was true); #9262 / LUCENE-8215 and #15002 / #14999 (later decRef edits that kept it); the 2018 dev@ thread "IndexReader.close() contract" (about close() with refCount > 1, a different sentence); open PR #16847 (touches IndexReader.java, not decRef). The Lucene.NET port carries the same sentence; no lucenenet issue about it was found. Gaps: GitHub issue search does not index review comments on old diffs; list search matches words, not phrases; on 2026-11-01 the recheck compared the upstream file only.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive)2026-10-26a scripted prior-report search (16 issue/PR and 3 commit queries) and 31 GraphQL issue/PR searches (type ISSUE, not code search); exact phrases "refCount is unchanged" and "Put reference back on failure"; the comments of 19 related LUCENE issuesno report (exact phrases: 0 hits)
Open PRs2026-10-26the touched files of all 408 open apache/lucene PRsonly #16847 touches IndexReader.java, not decRef
Mailing lists, JIRA and downstream projects2026-10-26dev@, java-user@, issues@, commits@ (20 queries); JIRA text search; Elasticsearch, OpenSearch, Solr and Lucene.NETno report; Lucene.NET carries the same sentence
Upstream main2026-11-01GitHub API: IndexReader.java blob on main and the latest commits touching itf815e679bf6c has the same blob as 645d4fe14a20; not fixed

History

Stale since 4.7.1: LUCENE-5553 (beaa564674f in 4.7.1, a8b93f272f7 on trunk, 2014-03-25) removed the put-back but kept the 2009 sentence. With a throwing doClose(), 4.6.1 and 4.7.0 keep the count at 1; 4.7.1 to 10.6.0 and main leave 0. For a throwing listener the sentence was never true.

Versions tested6 rows
  • The sentence: LUCENE-2156 (dc3f92e6e58c, 2009-12-16, first in 3.1), written with the put-back block; true for the doClose() path until 4.7.0.
  • Introduced: LUCENE-5553 / #6615 (Simon Willnauer, 2014-03-25; an SVN-era JIRA patch, no GitHub PR): beaa564674f on lucene_solr_4_7, first in 4.7.1; e197d60cfe0 on branch_4x, first in 4.8.0; a8b93f272f7 on trunk. Between the 4.7.0 and 4.7.1 tags it is the only commit touching the reader classes involved.
  • Listener path: from 3.6.0 (by reading) to 4.7.0 listeners were notified after the put-back block, so a throwing listener already left 0 (executed on 4.7.0).
  • Coverage: every release tag in the history clone from 4.7.1 to 10.6.0 has the sentence without the put-back; 8.8.2 and 8.9.0 to 8.11.3 are inferred (the clone's 8.x tags stop at 8.8.1; 8.11.4 executed).
  • Side note, not part of this defect: from 4.7.1 to 5.5.x the doClose() IOException came back wrapped in RuntimeException; 0607f007bfa fixed that in 6.0.0.
  • Upstream: main f815e679bf6c (2026-11-01) has the same IndexReader.java blob as 645d4fe14a20.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, gradle, assertions on and off
10.6.0, 10.5.2, 9.12.3 affectedminimal test fails (standalone)
4.7.2 to 8.11.4 (10 releases probed) affecteddoClose() probe: count 0, retry throws AlreadyClosedException
4.7.1 first releasedoClose() and listener probes: count 0
4.7.0 listener onlydoClose() probe: count stays 1; listener probe: count 0
4.6.1 not affecteddoClose() probe: count stays 1

Independent checks

The finder, an independent reproducer (3 tests, 2 fail, control passes), a devil's advocate and the adjudication (confirmed, doc, high confidence) agree it is a documentation defect. A cross-model reviewer (OpenAI GPT-5.6, reading only) voted not real on procedure but wrote "Static evidence nevertheless strongly indicates a genuine stale-Javadoc issue".

Checks and verdicts247 words

The study ran on main ea57360912ed; IndexReader.java and TestIndexReaderClose.java are identical at 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) reproduced, docPublic-API JUnit fails 1 of 1 on main (assertions on and off), 10.5.2 and 9.12.3.
independent reproducer real, docOwn 3-test JUnit: listener path (retry gives AlreadyClosedException) and doClose() path fail, control passes, on main, 10.5.2 and 9.12.3; corrected the first affected release to 4.7.1.
cross-model review not real (low), procedural onlyOpenAI GPT-5.6, reading only (it could not run code); agreed on root cause and legal trigger; a separate re-run and separate checks failed as expected.
devil's advocate real, doc onlyBuilt the intended-behaviour case; it backs the code, not the sentence; blame defeats the narrow reading.
adjudication confirmed, doc, high, LowRe-ran the independent reproduction's test with assertions on and off: 2 of 3 fail, control passes.
credibility audit confirmedRe-ran both tests: a documentation defect. An impact audit (2026-10-29) called it a cosmetic doc nit; it stays a Low documentation defect.
this brief (2026-11-01) reproduced; fix validatedGradle in a scratch worktree of 645d4fe14a20; standalone on 10.6.0.
fact checks (2026-11-01) real; minor errors fixedAn independent fact check (re-ran the repro and both fixes), a cross-model fact check (OpenAI GPT-5.6, reading only) and a diagram check. The bug held; the brief's minor errors were fixed and option A was narrowed and re-validated.

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

Finding record

LFV-497, snapshot of 2026-11-06

Registry title
IndexReader#decRef javadoc says 'If an exception is hit, the refCount is unchanged', but since LUCENE-5553 (4.7.1) a final decRef whose doClose() or closed listener throws leaves the reader closed at refCount 0 (documentation defect; the code behaviour is intended and must not change)
Area
index / IndexReader reference counting (decRef javadoc)
Priority
Low, rank 1275: The decRef javadoc misstates the refCount after an exception in an Expert API while the code behaves as intended, documentation only.
Impact class
minor
Impact note
Documentation only: Lucene behaves as intended in every configuration; only code that manages reader references itself through the Expert incRef/decRef API and, trusting the sentence, retries decRef() or keeps using the reader after the final decRef() (or close()) threw, because a closed listener, a custom doClose() or the release of index files failed, is misled, and it then gets an immediate AlreadyClosedException, with no wrong data, no hang and no extra resource use; in every release from 4.7.1 through 10.6.0 and on main.
Summary
The javadoc of IndexReader#decRef says 'If an exception is hit, the refCount is unchanged', so after a final decRef() that throws (for example because a reader-closed listener or the reader's doClose() throws IOException) getRefCount() should still be 1. Instead the count is 0, the reader is closed, and a retried decRef() throws AlreadyClosedException. The code is the intended side: LUCENE-5553 (2014) deliberately made a failing close really close the reader and still notify every listener, and removed the old put-back-on-failure code that the sentence described, so the fix is javadoc-only.
Introduced
beaa564674fd (lucene_solr_4_7 branch, first released in 4.7.1; the same change is e197d60cfe0 on branch_4x, first in 4.8.0, and a8b93f272f7 on trunk), LUCENE-5553 / apache/lucene#6615 'IndexReader#ReaderClosedListener is not always called on IndexReader#close()', committed by s1monw; an SVN-era JIRA patch, no GitHub PR [read: git history clone; exec: on a doClose() failure 4.7.0 keeps the count at 1 and 4.7.1 leaves 0, and beaa564674f is the only commit between the two tags that touches IndexReader, FilterAtomicReader, SegmentReader, StandardDirectoryReader, DirectoryReader or IOUtils]. It removed the '// Put reference back on failure' refCount.incrementAndGet() block and set closed = true before doClose(), but left the javadoc sentence, which LUCENE-2156 (dc3f92e6e58c, mikemccand, 2009-12-16, first in 3.1) had written together with that put-back block. For an exception from a closed listener the sentence was never true: listeners (3.6.0 on, read) were notified after the put-back block, and 4.7.0 already leaves the count at 0 on that path [exec] (2014-03-25 (LUCENE-5553 commits on all three branches; the sentence itself dates from 2009-12-16))
First affected version
4.7.1 [exec] (the doClose() path that the @throws clause names; 4.6.1 and 4.7.0 still restore the count [exec]); on the closed-listener path the sentence was already false in 4.7.0 [exec] and, by reading, since listeners were added in 3.6.0
Affected versions
every release from 4.7.1 through 10.6.0 and main ea57360912ed (10.6.0 [exec 2026-11-01]: the minimal test fails on the release jars, -ea); not fixed upstream (upstream main a9779ef3c193 still has the sentence, fetched 2026-10-26) [exec: the follow-up's public-API main() probes fail on 4.7.1, 4.7.2, 4.8.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main (-ea and -da), with passing controls; the finder's JUnit fails on 9.12.3, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3; read: every release tag in the history clone from 4.7.1 to 10.5.2 has the sentence without the put-back; 8.9.0 to 8.11.3 are inferred, as the clone has no tags for them]. Not affected on the doClose() path: 4.6.1 and 4.7.0 [exec], 3.1 to 4.7.0 [read]; before 3.1 the sentence does not exist. Historical side note, not part of this defect: from 4.7.1 to 5.5.x the doClose() IOException came back wrapped in RuntimeException, fixed in 6.0.0 by 0607f007bfa [exec]. All runs on aarch64 with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (searched 2026-10-26: a scripted prior-report search, 51 queries over apache/lucene GitHub issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 31 more GitHub searches, where the exact phrase 'refCount is unchanged' got 0 hits on apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and apache/lucenenet and 'Put reference back on failure' got 0; JIRA text search 0 and the comments of 19 related LUCENE issues grepped; the touched files of all 408 open apache/lucene PRs scanned). Nearest items, not the same bug: #6615 / LUCENE-5553 (made the change; nobody in it mentions the javadoc), #3232 / LUCENE-2156 (wrote the sentence when it was true), #9262 / LUCENE-8215 and #15002 / #14999 (later decRef edits that kept the sentence), the 2018 dev@ thread 'IndexReader.close() contract' (about close() with refCount > 1, a different method and sentence), open PR #16847 (touches IndexReader.java, not decRef). The Lucene.NET port carries the same sentence, with no report there. Not fixed on upstream main a9779ef3c193 (2026-10-26)
Lucene commit tested
main 645d4fe14a20 (2026-10-28), through gradle in a scratch worktree (2026-11-01): the minimal test fails on stock main with assertions on and off, while TestIndexReaderClose and TestReaderClosed pass; with the javadoc fix (re-run with its final wording after review) the format check and the javadoc render pass, the minimal test still fails (a comment-only change), and its corrected form (expecting 0) passes with assertions on (with those two suites, 5 tests) and off; the delete-only alternative passes the format check. Standalone: the same test on the 10.6.0 release jars (2026-11-01) and earlier on main ea57360912ed (same IndexReader.java), 10.5.2 and 9.12.3; public-API probes on 4.6.1 to 8.11.4. Upstream main f815e679bf6c (2026-11-01) has the same IndexReader.java.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:141-145 (ClosedListener.onClose throws IOException, @lucene.experimental), :207-213 (decRef javadoc), :214-232 (decRef body), :351-355 (close() calls decRef), :359 (doClose() declared to throw IOException) at ea57360912ed
  • lucene/core/src/test/org/apache/lucene/index/TestIndexReaderClose.java:35-105 (testCloseUnderException: AlreadyClosedException after a close that threw, :97)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:135-162, 207-232, 238-241, 351-359 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:548-568, 668-672; lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:318-322
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:141-166; SegmentReader.java:234-243; FilterLeafReader.java:399-408; MultiReader.java:86-94; ParallelLeafReader.java:314-324; ParallelCompositeReader.java:88-94, 165-167 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexReaderClose.java:35-105, 166-172
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:207-232, 351-356 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:141-166 (numDocs/maxDoc skip ensureOpen, at 645d4fe14a20)
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:150-170
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:151-155; MultiReader.java:86-94; ParallelLeafReader.java:314-324 (at 645d4fe14a20)
History
  • upstream main f815e679bf6c, IndexReader.java blob e01c1460ab2c (read-only GitHub API, 2026-11-01)

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

Repro files

About the diagrams

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.