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.
- Every
IndexReaderhas a reference count. It is 1 when you open the reader;incRef()adds one anddecRef()removes one. - When
decRef()takes the count to 0, the reader closes:doClose()releases what it holds, and its closed listeners (registered throughgetReaderCacheHelper().addClosedListener) are notified, typically to evict cache entries. close()simply callsdecRef(). After the reader is closed, a retrieddecRef()and most other uses of it throwAlreadyClosedException.- Both
doClose()andClosedListener.onCloseare declared to throwIOException.
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.- You open a
DirectoryReader(refCount 1) and register a reader-closed listener whoseonClosethrowsIOException, which theClosedListenerinterface allows. - You call
decRef()to release your reference, and it throws the listener'sIOException. - 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. - Instead
getRefCount()is 0 and the reader is closed: a seconddecRef()throwsAlreadyClosedException. A reader whose owndoClose()throws ends the same way. - 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.
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 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()". - The code (
IndexReader.java:215-232)::218-220throwsAlreadyClosedExceptionif the count is already <= 0 (here the count really is unchanged);:222refCount.decrementAndGet();:223-227at 0:closed = true;thentry (Closeable _ = this::notifyReaderClosedListeners) { doClose(); };:228-231below 0:IllegalStateException("too many decRef calls"). No line restores the count, so an exception fromdoClose()or from a listener leaves 0. - Every listener is still called.
StandardDirectoryReader.java:668-672andSegmentReader.java:318-322notify the listeners throughIOUtils.applyToAll, so one that throws does not skip the others; the try-with-resources runs the notification also whendoClose()throws (if both throw, the listener's exception is attached to thedoClose()one as suppressed). The basenotifyReaderClosedListeners()is a no-op (:153-155); only these two readers override it. - Afterwards the reader is closed for good.
ensureOpen()throwsAlreadyClosedExceptionwhen the count is <= 0 (:238-241); a retrieddecRef()hits:218-220;close()is a no-op becauseclosedis already true (:351-356). So most reader operations (for exampleterms()) fail. A few accessors skipensureOpen()on purpose and still answer:getRefCount(), "so that callers can see refCount is 0 (reader is closed)" (:159-160), andnumDocs()/maxDoc()(BaseCompositeReader.java:141-166,SegmentReader.java:234-243). - Both triggers are legal.
ClosedListener.onClose(CacheKey key) throws IOException(:141-146,@lucene.experimental);doClose()is a protected hook declared to throwIOException(:358-359); Lucene's ownTestIndexReaderCloseuses both a throwingdoClose()and throwing listeners.
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/decRefAPI (orclose(), which callsdecRef()) and, after the final release throws, follows the javadoc: it retriesdecRef(), 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()throwsAlreadyClosedExceptionat 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 failingdoClose()did not release stays unreleased whichever way the javadoc reads. The@throwsclause names onlydoClose(), so the javadoc also hides that a listener can be the source. - Scope of the repro. It covers one
StandardDirectoryReader; composite readers close ordecRef()their children in their owndoClose()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.
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.
/*
* 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());
}
}
}
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 faileddecRef(). - Observed
getRefCount()is 0 and the reader is closed:refCount after the failed decRef expected:<1> but was:<0>.
More files, environment, full output309 words
- Deterministic. The test code supplies no random input (
ByteBuffersDirectory,new IndexWriterConfig()), thoughLuceneTestCasestill randomizes its environment (locale, time zone); seedsDEADBEEFand5EED1234give 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
0instead of1, with the comment "decRef javadoc (fixed): the reader stays closed (refCount 0) even if closing it throws" and the method nametestReaderStaysClosedAfterFailedDecRef; the body is otherwise the same. It passes with the javadoc fix (re-run with its final wording), with assertions on (together withTestIndexReaderCloseandTestReaderClosed, 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.testsframework; there the study used public-APImain()probes (Version history).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 10.6.0, 10.5.2 and 9.12.3 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
# 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)- 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
TestIndexReaderCloseexpects 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 asboolean 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 = trueand 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, expectsIllegalStateExceptionfromclose()whendoClose()or a listener throws (TestIndexReaderClose.java:89), may then callclose()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.
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 listenerAll 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.
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 listenerTrade-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, onlyStandardDirectoryReaderandSegmentReaderoverride it, and readers such asMultiReaderreturn 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, seedDEADBEEF, this exact wording::lucene:core:checkGoogleJavaFormatand:lucene:core:renderJavadocpass, and the renderedIndexReader.htmlshows the new text. - The minimal test in its corrected form (expecting 0) together with
TestIndexReaderClose(3 tests) andTestReaderClosed(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 checkor the whole core test suite (the change is inside a javadoc comment).
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
@throwsincomplete and does not tell callers what state the reader is in after a failure.
What the checks validated
- Same worktree:
:lucene:core:checkGoogleJavaFormatpasses. Nothing else was run (a comment-only change).
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 code that pools or wraps readers should treat any failed final decRef() or close() as closed; the Lucene.NET port carries the same sentence.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive) | 2026-10-26 | a 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 issues | no report (exact phrases: 0 hits) |
| Open PRs | 2026-10-26 | the touched files of all 408 open apache/lucene PRs | only #16847 touches IndexReader.java, not decRef |
| Mailing lists, JIRA and downstream projects | 2026-10-26 | dev@, java-user@, issues@, commits@ (20 queries); JIRA text search; Elasticsearch, OpenSearch, Solr and Lucene.NET | no report; Lucene.NET carries the same sentence |
| Upstream main | 2026-11-01 | GitHub API: IndexReader.java blob on main and the latest commits touching it | f815e679bf6c has the same blob as 645d4fe14a20; not fixed |
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 thedoClose()path until 4.7.0. - Introduced: LUCENE-5553 / #6615 (Simon Willnauer, 2014-03-25; an SVN-era JIRA patch, no GitHub PR):
beaa564674fon lucene_solr_4_7, first in 4.7.1;e197d60cfe0on branch_4x, first in 4.8.0;a8b93f272f7on 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()IOExceptioncame back wrapped inRuntimeException;0607f007bfafixed that in 6.0.0. - Upstream: main
f815e679bf6c(2026-11-01) has the sameIndexReader.javablob as645d4fe14a20.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails, gradle, assertions on and off |
| 10.6.0, 10.5.2, 9.12.3 | affected | minimal test fails (standalone) |
| 4.7.2 to 8.11.4 (10 releases probed) | affected | doClose() probe: count 0, retry throws AlreadyClosedException |
| 4.7.1 | first release | doClose() and listener probes: count 0 |
| 4.7.0 | listener only | doClose() probe: count stays 1; listener probe: count 0 |
| 4.6.1 | not affected | doClose() probe: count stays 1 |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (a hunt unit) | reproduced, doc | Public-API JUnit fails 1 of 1 on main (assertions on and off), 10.5.2 and 9.12.3. |
| independent reproducer | real, doc | Own 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 only | OpenAI 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 only | Built the intended-behaviour case; it backs the code, not the sentence; blame defeats the narrow reading. |
| adjudication | confirmed, doc, high, Low | Re-ran the independent reproduction's test with assertions on and off: 2 of 3 fail, control passes. |
| credibility audit | confirmed | Re-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 validated | Gradle in a scratch worktree of 645d4fe14a20; standalone on 10.6.0. |
| fact checks (2026-11-01) | real; minor errors fixed | An 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 462 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,147 words
- 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, whileTestIndexReaderCloseandTestReaderClosedpass; 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 mainea57360912ed(sameIndexReader.java), 10.5.2 and 9.12.3; public-API probes on 4.6.1 to 8.11.4. Upstream mainf815e679bf6c(2026-11-01) has the sameIndexReader.java.
- 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 ea57360912edlucene/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-322lucene/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-170lucene/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)
- TestDecRefExceptionRefCount.java:
repro/LFV-497/src/org/apache/lucene/index/TestDecRefExceptionRefCount.java(41 lines)
assets/LFV-497/bug.png(462 KB)
