All findings · Lucene finding · LFV-235 · filtercodecreader-doclose-bypasses-refcount

Closing a FilterCodecReader closes the wrapped segment's files behind its reference count

Closing a FilterCodecReader, such as a wrapper passed to IndexWriter.addIndexes, closes the wrapped segment's files instead of releasing one reference. With the default codec, a DirectoryReader still holding that segment then silently finds 0 hits there, and its own close() throws; releases 6.4.2 to 10.6.0 are affected (10.6.0 by reading).

Summary

Priority
Medium, rank 86Since 6.4.2 closing a FilterCodecReader runs the wrapped segment's close hook directly, so a DirectoryReader or NRT reader that still uses that segment silently finds 0 hits there (MUST_NOT deny rules fail open) until a later merge of it closes the writer, a core lifecycle break through public API, but it needs application code that closes such a wrapper over a delegate still in use, which Lucene and the known downstream wrappers never do.
Impact
silent wrong results
Since
6.4.2 (LUCENE-7676, 2017)introduced 2017-02-08 · about 9 years
Affected
6.4.2 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
code reading during a review of filter-reader delegation; JUnit-confirmed

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

Every Lucene reader counts its users and closes its files only when the last one lets go. Closing a FilterCodecReader skips that count for the reader it wraps.

Summary card titled "Closing a FilterCodecReader closes the wrapped segment behind its reference count": FilterCodecReader.doClose() calls in.doClose() (the wrapped reader's close hook), not in.close() (release one reference). Panel 1, the executed example: DirectoryReader.open(dir) gives 1 hit and refCount 1; leaf.incRef() gives refCount 2 (the reader and the view); dest.addIndexes(view) copies 10 docs; view.close() at the end of the try closes the files while the count stays 2; count(id:3) on the reader returns 0 hits with no exception; r.close() after leaf.decRef() throws. Panel 2, inside view.close(): the view's own count reaches 0 so its doClose() runs, in.doClose() runs the leaf's close hook while 2 users hold it, core.decRef() and segDocValues.decRef() close the files, the refCount stays 2 so ensureOpen() passes on an empty term dictionary, and the sibling wrappers call in.close(). Panel 3: expected versus actual after view.close(). Footer: affected in every release from 6.4.2 to 10.5.2 and main (executed on 9 of those releases and on main; -da not run on 10.0.0); fix in.close().
One example step by step: a DirectoryReader and a wrapper share one segment, closing the wrapper closes the segment's files while two users still hold it, and the reader then finds 0 hits. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You open a DirectoryReader on 10 documents in one segment (default codec, MMapDirectory); id:3 has 1 hit. To copy the segment into another index you wrap it in a FilterCodecReader for addIndexes, first taking a reference for the wrapper with incRef(): the segment's count is 2.
  2. After addIndexes (10 documents copied) you close the wrapper. You expect it to give back its reference, count 1, and the DirectoryReader to keep working: a reader "will not really be closed until decRef has been called for all outstanding references".
  3. Instead FilterCodecReader.doClose() calls the segment's own close hook, in.doClose(), not in.close(). The segment's files close while its count stays 2, so it still looks open.
  4. The DirectoryReader now finds 0 hits for id:3, with no exception, and stored fields throw AlreadyClosedException. Releasing the leftover reference and closing the reader releases the segment a second time: close() throws an AssertionError (a NullPointerException with assertions off).
  5. Without incRef() it is the same, for example after re-sorting an index with SortingCodecReader.wrap and closing the wrappers. FilterLeafReader calls in.close(): clean with the extra reference, a loud AlreadyClosedException without it.

Who is affected

Applications that close a FilterCodecReader or SortingCodecReader while the wrapped segment is still used by an open or near-real-time reader, for example after copying or re-sorting an index with addIndexes. Lucene's production code never closes these wrappers, so normal indexing and search are unaffected. No scale is needed; the default codec is affected.

Mechanism, terms and numbers645 words

FilterCodecReader.doClose() calls in.doClose(), the wrapped reader's internal close hook, instead of in.close(), which releases one reference. So closing the wrapper closes the wrapped segment's files even while another reader, typically the DirectoryReader the segment came from, still holds it. With the default codec and MMapDirectory, that reader then silently finds 0 hits for a term that matched 1 document, its stored fields throw AlreadyClosedException, and its own close() later releases the segment a second time and, if the segment has doc values, throws. FilterLeafReader calls in.close() and is correct in the same sequence; FilterDirectoryReader also forwards to in.close() (the LUCENE-6696 fix). The call came in 6.4.2 (LUCENE-7676, 2017); every release from 6.4.2 to 10.6.0 (10.6.0 by reading) and main is affected. Lucene's production code never closes these wrappers, so only applications that close one while its segment is still in use are hit.

Terms

  • Reference count. Every IndexReader counts its users: incRef() adds one, close() and decRef() remove one, and the reader closes its files only when the count reaches 0. doClose() is the hook decRef() runs at that moment (IndexReader.java:215-232, "Implements close." at :358-359).
  • Segment reader (leaf). A DirectoryReader holds one SegmentReader per segment; each is a CodecReader.
  • FilterCodecReader. A public abstract wrapper around a CodecReader, mainly used to feed IndexWriter.addIndexes(CodecReader...) and merges. SortingCodecReader is a subclass.

The bug

  1. FilterCodecReader.doClose() is in.doClose(); (FilterCodecReader.java:123-126). It compiles only because the class is in IndexReader's package.
  2. Closing the wrapper takes the wrapper's own count to 0 and runs this hook, which runs SegmentReader.doClose() on the wrapped segment: core.decRef() and segDocValues.decRef(...) (SegmentReader.java:213-225). The segment's own count is not touched, so it stays above 0, its closed flag stays false and ensureOpen() keeps passing (IndexReader.java:238-248: it checks the count and the never-set closedByChild flag, not closed).
  3. The shared core closes postings, stored fields, points, vectors and norms (SegmentCoreReaders.java:167-180). The terms reader clears its field map on close (Lucene103BlockTreeTermsReader.java:272-280) and the points reader its readers map (Lucene90PointsReader.java:153-157), so with the default codec term and point queries see an empty segment, with no exception (randomized test codecs and directories can throw instead). Stored fields, doc values and vectors throw AlreadyClosedException. In the example the segment reader is the core's only user; readers of one segment may share the core and the doc-values producers (SegmentReader.java:42-43), and then the files close at a later release, possibly while another reader still uses them (read, not run).
  4. When the owner's release takes the segment's count to 0 (StandardDirectoryReader.java:566), SegmentReader.doClose() runs a second time. SegmentDocValues.decRef finds the producer already gone: assert dvp != null : "gen=" + gen, or a NullPointerException without assertions (SegmentDocValues.java:83-84). A segment without doc values closes silently. While another holder keeps a reference the owner's close can be clean: in the near-real-time probe it was, because the IndexWriter's reader pool still held the segment reader.
  5. FilterLeafReader.doClose() and FilterDirectoryReader.doClose() call in.close() (FilterLeafReader.java:416-419, FilterDirectoryReader.java:162-164). A bytecode sweep over the core, test-framework and module jars (all but benchmark, demo, luke and distribution) finds no other call of another reader's doClose().

The numbers

Default codec, MMapDirectory, 10 documents in one segment, id:3 has 1 hit. With an extra reference taken for the wrapper, the segment's count is 2 before and after the wrapper's close; after the leftover reference is released, count(id:3) is 0, stored fields throw AlreadyClosedException: this FieldsReader is closed, and DirectoryReader.close() throws AssertionError: gen=1 (or NullPointerException ... "dvp" is null), on main and 10.5.2, with assertions on and off. Re-sorting with SortingCodecReader.wrap and addIndexes, then closing the wrappers without an extra reference: the copy has all 10 documents, the still-open source reader finds 0 hits for id:3, and its close() throws gen=-1.

Exposure and likelihood379 words
  • Who is hit. Code that closes a FilterCodecReader (its own subclass, or the wrapper SortingCodecReader.wrap returns: a SortingCodecReader, or an anonymous FilterCodecReader when the reader is already sorted) whose wrapped reader is a leaf of an open DirectoryReader or a near-real-time reader. The natural trigger is copying, filtering, re-sorting or reordering an index through addIndexes(CodecReader...) and then closing the wrappers, with or without an extra reference: addIndexes does not close the readers it is given ("The provided IndexReaders are not closed"), and FilterCodecReader does not document whether closing it releases the wrapped reader.
  • Who is not. Lucene's production code (no harmful call site found): merges release the pooled segment reader, not the merge wrapper; SoftDeletesDirectoryReaderWrapper closes only its wrapped DirectoryReader; the misc splitters and the BP reorderer's documented usage never close their wrappers. Two downstream wrappers that were read (Solr's index splitter, Elasticsearch's lazy soft-deletes reader) are not closed directly. No affected downstream is known. The test framework closes such a wrapper in two norms-format tests, harmlessly (nothing uses the reader afterwards).
  • What the damage looks like. On the damaged segment, with the default codec and MMapDirectory, term and point queries silently match nothing (randomized test codecs and directories can throw instead); stored fields, doc values and kNN throw; other segments answer correctly. The owner's close() throws when it takes the segment's count to 0 and the segment has doc values. With a near-real-time reader (no deletes; with deletes not run) the damage carries into readers reopened from the same IndexWriter, and the next merge of that segment is a tragic event that closes the writer and loses uncommitted documents (5 of 15 in a probe); the committed index stays intact and CheckIndex is clean.
  • Settings. Opt-in usage pattern of an expert API; otherwise defaults: default IndexWriterConfig, default codec, assertions on or off. 10 documents are enough. The root cause does not depend on the directory; the runs on record used MMapDirectory and randomized test directories on JDK 25 / Linux aarch64.
  • How likely. Rare: it needs code that closes such a wrapper and then keeps using the wrapped reader's owner. A one-off migration often closes the source reader right after addIndexes, which then only shows as a throwing close. No report since 2017.

Minimal repro

The test shows that closing a FilterCodecReader over an incRef'd segment of an open DirectoryReader runs the segment's doClose() directly, so the still-open DirectoryReader silently loses the segment's terms.

TestFilterCodecReaderCloseKeepsReferencedReader.java (JUnit test)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.index;

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestFilterCodecReaderCloseKeepsReferencedReader extends LuceneTestCase {

  public void testClosingWrapperKeepsReferencedSegmentSearchable() throws IOException {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig config = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
      try (IndexWriter w = new IndexWriter(dir, config)) {
        Document doc = new Document();
        doc.add(new StringField("id", "1", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        CodecReader segment = (CodecReader) reader.leaves().get(0).reader();
        segment.incRef();
        new FilterCodecReader(segment) {
          @Override
          public CacheHelper getCoreCacheHelper() {
            return null;
          }

          @Override
          public CacheHelper getReaderCacheHelper() {
            return null;
          }
        }.close();
        // expected 1: the open DirectoryReader still references the segment; actual 0
        assertEquals(
            "id:1 hits in the open DirectoryReader after closing a FilterCodecReader",
            1,
            new IndexSearcher(reader).count(new TermQuery(new Term("id", "1"))));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-235/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestFilterCodecReaderCloseKeepsReferencedReader
Expected vs actual
Expected count(id:1) = 1 (the DirectoryReader still holds a reference). Actual: 0, with no exception.

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

More files, environment, full output43 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: id:1 hits in the open DirectoryReader after closing a FilterCodecReader expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.index.TestFilterCodecReaderCloseKeepsReferencedReader.testClosingWrapperKeepsReferencedSegmentSearchable(TestFilterCodecReaderCloseKeepsReferencedReader.java:55)

Why it looks unintended

  • The IndexReader javadoc: a reader "will not really be closed until decRef has been called for all outstanding references".
  • FilterLeafReader and FilterDirectoryReader call in.close(); LUCENE-6696 (2015) fixed this same in.doClose() mistake in FilterDirectoryReader.
  • It came in 6.4.2 with a test that only checks that every method is overridden; before, closing the wrapper never touched the wrapped reader.
The full argument348 words
  • Contracts. IndexReader#incRef (IndexReader.java:164-170, repeated for tryIncRef at :188-192): "RefCounts are used to determine when a reader can be closed safely ... the IndexReader will not really be closed until decRef has been called for all outstanding references." decRef (:207-209): "If the refCount drops to 0, then this reader is closed." doClose (:358-359): "Implements close." FilterLeafReader (FilterLeafReader.java:328): "Note that base reader is closed if this FilterLeafReader is closed", implemented as in.close(). addIndexes(CodecReader...) (IndexWriter.java:3257): "The provided IndexReaders are not closed": IndexWriter leaves their lifecycle to the caller. FilterCodecReader itself documents no ownership: its constructor only stores in and takes no reference (FilterCodecReader.java:54-61).
  • Sibling code. FilterLeafReader and FilterDirectoryReader call in.close(); MultiReader calls close() or decRef(); DirectoryReader releases its leaves with decRef(). A bytecode sweep over the core, test-framework and module jars (all but benchmark, demo, luke and distribution) finds no other call of another reader's doClose().
  • Tests. No upstream test asserts the current behaviour (TestFilterCodecReader checks only that every method is overridden, and getDelegate). Two norms-format tests close a test-framework MergingCodecReader leaf instead of its DirectoryReader and rely on that releasing the segment: they fail with an empty doClose() and pass with in.close(). That is compatibility evidence against option B, not a documented ownership contract.
  • History. in.doClose() came with LUCENE-7676 (#8727, ae68e6cebc3, 2017-02-08, @cpoerschke), "Fixed FilterCodecReader to override more super-class methods", together with the reflective test that requires an override of every CodecReader method. The issue body is empty; @rmuir wrote "looks good" and @jpountz "+1"; nobody discussed close behaviour. Before it, closing a FilterCodecReader did not touch the wrapped reader (5.0.0 to 6.4.1, where the inherited CodecReader.doClose() closed only the wrapper's own thread-local state; executed on 5.5.5 and 6.4.1).
  • Precedent. LUCENE-6696 (#7754, 2015, @jpountz, fixed in 5.3.0): the same in.doClose() in FilterDirectoryReader, fixed with in.close(): "This is wrong because if you call close() on both the underlying reader and a wrapper around it, then doClose() will have been called several times, which will break ref counting."
The devil's-advocate check288 words

The strongest "works as designed" case (built by the cross-model refute reviewer, the intended-behaviour reviewer and the devil's advocate; the cross-model reviews rated it weak, and the devil's advocate found that none of its points holds up): FilterCodecReader is a merge and addIndexes view that Lucene's production code never closes; SortingCodecReader "should only be used for merging"; its constructor takes no reference and no javadoc says whether the wrapper owns its delegate (the strongest point), so closing a wrapper around someone else's segment without taking a reference could be called a caller error; and the line is old and two committers approved it.

Why it fails: "never closed by Lucene" limits reach, not validity, since close() is public and addIndexes does not close the readers it is given; "only for merging" is about cost. No ownership model makes in.doClose() right: owning one reference means in.close(), owning none means an empty doClose() (as before 6.4.2, the wrapped reader is left alone), and even exclusive ownership would leave the wrapped reader looking open. Even the "caller error" case (no extra reference) gives a silent 0 hits where FilterLeafReader throws AlreadyClosedException. The 2017 review discussed only the test, so the line's age is no evidence of acceptance. What the case gets right: ownership is undocumented, so closing a wrapper without a reference can reasonably be called misuse; under that reading option A turns the silent 0 hits into a loud AlreadyClosedException rather than making the call work, and A releases only one reference even when several wrappers each took one. That bears on the fix and the framing; with a reference taken and released, the result still breaks the documented incRef/decRef contract. Verdict: not intended, a defect.

Suggested fix

Recommended Release one reference, like FilterLeafReader: in.close()

Replace in.doClose() with in.close() in FilterCodecReader.doClose(), and add a javadoc line modelled on FilterLeafReader's: the wrapped reader is closed when the wrapper is. Both repro tests (assertions on and off), the whole core module (8,871 tests), the codecs and misc modules and the static checks (assertions on) pass. An empty doClose() also fixes the repro but fails two norms-format tests.

  @Override
  protected void doClose() throws IOException {
-    in.doClose();
+    in.close();
  }
All options and trade-offs3 options ยท 638 words

Three code options were built and run in a scratch worktree at ea57360912ed. How each behaves when a caller closes a wrapper: with an extra reference taken (the test), A and C are correct and B leaks that reference; without one (the re-sort flow), B is correct and A and C fail loudly with AlreadyClosedException instead of today's silent 0 hits; with two wrappers over one segment, each with its own reference, only C releases both (A releases one, as FilterLeafReader does today).

Release one reference, like FilterLeafReader: in.close() (validated by the checks) (recommended)

doClose() calls in.close(); the constructor javadoc gains "Note that the underlying reader is closed if this FilterCodecReader is closed: closing this reader releases one reference to it". +4/-1 lines; ./gradlew tidy leaves it unchanged.

  @Override
  protected void doClose() throws IOException {
-    in.doClose();
+    in.close();
  }

Trade-offs

  • Matches FilterLeafReader, FilterDirectoryReader and the LUCENE-6696 fix.
  • Callers who close a wrapper without taking a reference and keep using the owner now get AlreadyClosedException instead of a silent 0 hits and a throwing close: loud, but a behaviour change (the re-sort probe).
  • Two wrappers over one segment, each with its own reference, release only one between them, because IndexReader.close() is idempotent per instance; FilterLeafReader has the same property today.
  • The same idempotency means that after the wrapper's in.close() the wrapped reader's own close() does nothing, so an owner that releases it with close() rather than decRef() leaks one reference. DirectoryReader and the IndexWriter's reader pool release leaves with decRef(), so the repro and the validated suites are unaffected; FilterLeafReader and FilterDirectoryReader behave the same today (executed with FilterDirectoryReader by the fact check).

What the checks validated

  • Both repro tests: 6 of 6 pass with -ea and -da; 15 of 15 with -Ptests.iters=5.
  • The two repro classes plus 18 related core suites (TestFilterCodecReader, TestIndexReaderClose, TestSoftDeletesDirectoryReaderWrapper, TestSortingCodecReader, TestIndexSorting, TestAddIndexes, TestIndexWriter, TestIndexWriterReader, ...): 372 tests, 0 failures.
  • The whole core module: 749 classes, 8,871 tests, 0 failures, 322 skipped. The whole codecs module: 693 tests, 0 failures. The whole misc module: 315 tests, 0 failures. These module runs, the 18 suites and the static checks ran with assertions on (seed DEADBEEF); with assertions off, the repro tests and the standalone program.
  • ./gradlew :lucene:core:check -x test (formatting, ecj lint, forbidden APIs): BUILD SUCCESSFUL.
  • The standalone program on the fixed jar: 1 hit, clean close, with -ea and -da; the security probe: 0 violations.

An empty doClose(): leave the wrapped reader alone, as before 6.4.2 (validated by the checks)

Closing a wrapper never affects the wrapped reader, as in 5.0.0 to 6.4.1 (where the inherited CodecReader.doClose() closed only the wrapper's own thread-local state), and like the view SlowCodecReaderWrapper.wrap builds for a reader that is not a CodecReader (a CodecReader is returned unchanged).

Trade-offs

  • The try-with-resources idiom around addIndexes just works.
  • A caller who takes a reference for the wrapper, as FilterLeafReader needs, silently leaks it, and the segment is never closed.
  • Breaks TestLucene90NormsFormatMergeInstance.testIndependentIterators and testIndependentSparseIterators ("MockDirectoryWrapper: cannot close: there are still 7 open files"), which close a MergingCodecReader leaf instead of its DirectoryReader; they would have to change (compatibility pressure, not an ownership contract).

What the checks validated

Both repro tests and the 18 related suites pass with -ea and -da (372 tests, 0 failures); the whole core module: 8,871 tests, 2 failures (the two norms tests).

Variant: in.decRef() (validated by the checks)

Releases exactly one reference per wrapper close.

Trade-offs

Two wrappers over one segment are correct too; it departs from the in.close() convention of the sibling classes, and behaves like in.close() when no reference was taken.

What the checks validated

Both repro tests and the 18 related suites pass with -ea and -da (372 tests, 0 failures); the whole module was not run.

Downstream note

Once the wrapper is closed, the segment's term and point readers are empty but still answer, so an application that enforces document-level restrictions as MUST_NOT clauses on terms or points stops excluding that segment's documents (executed: 10 hits instead of 8), and FILTER clauses on them match nothing.

How it was found

Code reading during a review of how Lucene's filter readers delegate. A bytecode scanner had listed FilterCodecReader.doClose() as a plain forward to the wrapped reader; reading it next to FilterLeafReader showed that forwarding this hook skips the reference count. Not a formal-methods find; a scanner check added afterwards finds no other such call.

The method in full164 words

Method: code reading (the hunt's "non-formal" tier), a hunt unit (delegation in Lucene's filter readers, 2026-10-26).

  • The finder (an AI tool) wrote a bytecode scanner (JDK class-file API, over the core, test-framework and module jars, all but benchmark, demo, luke and distribution) that classifies every method of every wrapper class as a forward, a super call or custom code, and runs checks for missed overrides, partial views and cache-helper misuse. It listed FilterCodecReader#doClose as a plain forward.
  • Reading the code next to FilterLeafReader's showed that forwarding this particular hook skips the delegate's reference count. A JUnit on the stock jars confirmed it.
  • Check F8 (any IndexReader#doClose() called on an object other than this/super) was then added to the scanner; it finds only this call. No formal or bounded-exhaustive tool produced the first signal.
  • Then an independent reviewer from another model family (OpenAI GPT-5.6) tried to refute it by running its own tests, and the hunt's checks followed (see the record).

Prior reports, history and checks

Prior reports

No prior report: 223 read-only queries on 2026-10-26 over GitHub issues and PRs, commits, four mailing lists, JIRA and the web, plus the changed files of all 413 open Lucene PRs. The closest item is LUCENE-6696 (#7754), the same mistake fixed in FilterDirectoryReader in 2015.

Searches6 searches

Novelty new: nobody has reported FilterCodecReader#doClose calling in.doClose() or its symptoms. Main, branch_10x and branch_10_6 still have the call; the four open PRs that touch FilterCodecReader.java (#16571, #16847, #12295, #13342) leave doClose unchanged. Search gaps: keyword search cannot rule out a report that describes only the symptoms without naming the class or the error messages; one tool run hit GitHub's search rate limit and the remaining phrasings ran through GraphQL search (the same index); private security reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-26REST and 40 GraphQL queries: FilterCodecReader with close, doClose, refCount, incRef, decRef, AlreadyClosedException; in.doClose(); SortingCodecReader close; SlowCodecReaderWrapper; LUCENE-7676; LUCENE-6696; the error stringsno report; related threads read (58 in total)
Downstream and all repositories2026-10-2617 GraphQL queries on Elasticsearch, OpenSearch, Solr, lucene-solr, crate and k-NN; 6 over all repositories; 10 code searches for FilterCodecReader subclasses that override doCloseno report; no downstream FilterCodecReader overrides doClose
Open apache/lucene PRs2026-10-26changed-file lists of all 413 open PRs#16571, #16847, #12295, #13342 touch the file; none changes doClose
Mailing lists2026-10-26dev@, java-user@, issues@ and commits@: 16 direct queries plus the tool's list queriesno report; FilterCodecReader threads are about migration, splitting and cache helpers
JIRA archive (LUCENE, SOLR)2026-10-267 queries plus the comments of 16 issuesLUCENE-6696 (precedent, FilterDirectoryReader) and LUCENE-7676 (introduced the call); neither reports this bug
Web2026-10-26general web searchesnothing relevant

History

Introduced in 6.4.2 by LUCENE-7676 (ae68e6cebc3, 2017), which added the in.doClose() override to satisfy an "override every method" test. All 75 release tags from 6.4.2 to 10.5.2 contain the call; the finder's JUnit fails on all 9 releases run in that range and on main; 6.4.1 and 5.5.5 pass.

Versions tested8 rows

Introduced by ae68e6cebc3 (master, 2017-02-08, LUCENE-7676 / #8727, "Fixed FilterCodecReader to override more super-class methods. Also added TestFilterCodecReader class.", @cpoerschke), backported as 05e0250ee02 (branch_6x, first released in 6.5.0) and e0653d96c61 (branch_6_4, committed 2017-02-22, first released in 6.4.2). Before it, FilterCodecReader inherited CodecReader#doClose, which never touches the wrapped reader (in 5.x to 6.4.1 it closed only the wrapper's own thread-local state). The line is unchanged since then; branch_10x and branch_10_6 still have it, so 10.6.0 (released 2026-10-29) is very likely affected (read, not executed).

Swapping only FilterCodecReader.java between 6.4.1 and 6.4.2 moves the failure in both directions; the 2017 commit itself was not rebuilt. On 6.4.2 and 6.5.0 without assertions only the 0-hit test fails, because SegmentDocValues.decRef swallowed the second-release exception until LUCENE-7775 (6.6.0).

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis doc's test: 2 of 3 fail with -ea and -da (0 hits; gen=-1 or NPE); the standalone program prints 0 hits and a throwing close
10.5.2 affectedthe finder's JUnit fails 2 of 3 with -ea and -da; the standalone program and the re-sort probe print 0 hits and a throwing close
6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.12.3 affectedthe finder's JUnit (version-adapted, same logic) fails 2 of 3 with -ea and -da
10.0.0 affectedfails 2 of 3 with -ea (-da not run)
6.4.2, 6.5.0 affectedfails 2 of 3 with -ea; 1 of 3 (0 hits) with -da
6.4.1 and 6.4.2 with swapped FilterCodecReader.java file swap6.4.1 with the 6.4.2 file fails 2 of 3; 6.4.2 with the 6.4.1 file passes 3 of 3
5.5.5, 6.4.1 not affectedpass 3 of 3 with -ea and -da (no doClose override)
fix in.close() on ea57360912ed not affectedboth tests pass; whole core module 8,871 tests, 0 failures

Independent checks

An independent reviewer from another model family, an independent reproducer, an intended-behaviour reviewer, a devil's advocate and an adjudication all rated it real, the adjudication with high confidence. This doc's own runs re-checked the test and the fix, and found that the empty-doClose() alternative fails two upstream tests.

Checks and verdicts708 words

Each check ran with its own lens; all executed checks used the stock main jars at ea57360912ed, with assertions on and off.

Each independent check and its verdict
CheckVerdictWhat it found
cross-model refute review real (high), intended argument weakAn independent reviewer from another model family (OpenAI GPT-5.6) wrote and ran its own tests: an open reader gets 0 hits instead of 1, and the wrapped reader's close hook runs at reference count 2; direct-close controls pass; with in.close() its tests and three nearby upstream suites pass.
Independent reproducer realIts own reference-neutral JUnit fails 3 of 4 on 5 seeds and on 10.5.2 and 9.12.3; its standalone program shows the silent 0 hits and the throwing close; in.close() and an empty doClose() each fix its tests.
Intended-behaviour review real (high), intended argument weakReading only (OpenAI GPT-5.6); a separate run re-ran the finder's JUnit on main and 10.5.2 (2 of 3 fail) and the fix (3 of 3 pass).
Devil's advocate real (high)Built the strongest "works as designed" case and showed why it fails; its no-extra-reference probe gets a silent 0 where FilterLeafReader throws.
adjudication confirmed (high), silent wrong resultsRe-ran the independent reproduction's JUnit with a new seed and the standalone program with -ea and -da, and the in.close() fix (4 of 4 pass).
security screen none (earlier "out of scope of SECURITY.md", retired 2026-10-27)An executed probe showed MUST_NOT deny rules on terms and points failing open in the damaged segment, also in reopened NRT readers; trigger application-only.
this doc's runs fix A validated; option B fails 2 upstream testsA deterministic rewrite of the test, the re-sort and two-wrapper probes, and the three fix options through gradle in a scratch worktree.
Independent fact check 1 text error and 9 minor findings, all fixedRe-ran this doc's test and the finder's on a clean copy of ea57360912ed (assertions on and off, a new random seed), the three fix options, the standalone program on main and the 10.5.2 jar (both assertion modes) and its own controls, and checked every cited line, commit, count and quote. Its one error: the summary tagged FilterDirectoryReader as executed in the same sequence; only FilterLeafReader was, and its own FilterDirectoryReader run showed a further trade-off of in.close() (the owner's later close() does nothing). Both are in this doc now, with the 9 minor wordings.
Cross-model fact check (OpenAI GPT-5.6, reading only) real; 9 of its 10 errors valid, all fixedRead-only review against the code and the run records: the bug is real as described. It found evidence labels that overstated what ran (assertion modes per run, a 5-seed claim with no record, the 10.5.2 runs, "every release" tagged executed), wording overclaims ("silently" without the codec, "any directory", "Lucene never closes", "ids leak") and missing caveats (shared cores, NRT with deletes, the one-segment assumption, undocumented ownership); all taken. One error, about the 8.11.4 javap check, was itself wrong (the jar exists and shows the call).
Diagram check 2 minor label errors, fixed and re-renderedEvery value, line reference and order of events matched the code and the probe. The summary card's "Affected" line now says 10.0.0 ran only with assertions on, and the control row says the FilterLeafReader view went through the same steps without addIndexes (it is not a CodecReader); the security strip then gave both rules' numbers. On 2026-10-27 the security strip was removed (boolean security rule) and they were re-rendered again.
Correction to the registry row 5 wordings out of dateThe registry row (fact strip and registry card) still has wordings the checks corrected here: impact_note and notes say the owner's close throws when the segment has "doc-values updates" (plain doc values are enough: gen=-1); impact_note says "Lucene's own code" never closes these wrappers (its production code; the test framework closes one in two norms tests); verification says the finder's test fails "on 5 of 5 seeds" (no run record); found_by says the sweep covered "all core and module jars" (all but benchmark, demo, luke and distribution); notes says the intended case was "all rated weak" (the cross-model reviews did; the intended-behaviour check found none of its points holds up).

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

Finding record

LFV-235, snapshot of 2026-11-06

Registry title
Closing a FilterCodecReader runs the wrapped reader's close hook directly (in.doClose() instead of in.close()), so a DirectoryReader that still uses that segment silently finds 0 hits, and its own close later releases the segment a second time and throws
Area
index / FilterCodecReader reader lifecycle (IndexReader reference counting)
Priority
Medium, rank 86: Since 6.4.2 closing a FilterCodecReader runs the wrapped segment's close hook directly, so a DirectoryReader or NRT reader that still uses that segment silently finds 0 hits there (MUST_NOT deny rules fail open) until a later merge of it closes the writer, a core lifecycle break through public API, but it needs application code that closes such a wrapper over a delegate still in use, which Lucene and the known downstream wrappers never do.
Impact class
silent wrong results
Impact note
Applications that close a FilterCodecReader (a custom filter, or the wrapper SortingCodecReader.wrap returns) whose delegate is still referenced elsewhere, typically a leaf of an open or near-real-time DirectoryReader wrapped for IndexWriter.addIndexes(CodecReader...) and closed with try-with-resources, are left with a still-open reader whose term and point queries silently match nothing in that segment (default codec, MMapDirectory, -ea and -da), whose stored fields and doc values throw AlreadyClosedException, and whose own close() later throws when the segment has doc-values updates (AssertionError gen=1 with -ea, NullPointerException with -da); with a near-real-time reader the damage carries into later readers of the same IndexWriter until the next merge of that segment fails as a tragic event that closes the writer and loses uncommitted documents (the committed index stays intact); Lucene's own code and the downstream wrappers that were read never close these wrappers, so ordinary indexing and search are not affected; no scale is needed; every release from 6.4.2 to 10.6.0 is affected (10.6.0 by reading).
Summary
You wrap a segment of an open DirectoryReader in a FilterCodecReader (for example to pass it to IndexWriter.addIndexes), take the usual extra reference with incRef(), and close the wrapper, expecting the DirectoryReader to keep working, because the IndexReader javadoc says a reader 'will not really be closed until decRef has been called for all outstanding references' and FilterLeafReader behaves exactly that way. Instead FilterCodecReader.doClose() calls the wrapped reader's internal close hook (in.doClose()) rather than releasing a reference (in.close()), so the segment's files are closed while the DirectoryReader still uses it: a TermQuery that matched 1 document now finds 0 with no error, reading stored fields throws AlreadyClosedException, and closing the DirectoryReader later releases the same resources a second time and throws.
Introduced
ae68e6cebc363e51cd68aee83830b5cb427b4799 (master, 2017-02-08, 'LUCENE-7676: Fixed FilterCodecReader to override more super-class methods', by cpoerschke; JIRA patch era, migrated as apache/lucene#8727, no pull request; on the issue rmuir wrote 'looks good' and jpountz '+1', both about the new test only), backported as 05e0250ee02816c1d0b8387dbaa777747bfeb6f0 (branch_6x, 2017-02-08, first released in 6.5.0) and e0653d96c61421c4b11b753c1afe6aac7e9bb087 (branch_6_4, 2017-02-22, first released in 6.4.2). It added 'doClose() { in.doClose(); }' together with TestFilterCodecReader#testDeclaredMethodsOverridden, a reflective test that requires FilterCodecReader to override every CodecReader method; before it FilterCodecReader inherited CodecReader#doClose, which never touches the delegate [read: git blame of FilterCodecReader.java:123-126 and git log -G doClose / -S 'in.doClose()' on the file return only ae68e6cebc3; exec: swapping only FilterCodecReader.java between 6.4.1 and 6.4.2 moves the failure in both directions, and a no-op doClose on main makes the repro pass with -ea and -da; the 2017 commit itself was not rebuilt] (2017-02-08 (master and branch_6x); 2017-02-22 (branch_6_4 backport))
First affected version
6.4.2 [exec] (6.4.1 and 5.5.5 pass [exec]; FilterCodecReader exists since 5.0.0 (LUCENE-6165) and had no doClose override before 6.4.2 [read]; the first affected release on the main line is 7.0.0 [exec])
Affected versions
every release from 6.4.2 to 10.5.2 (the last before 10.6.0) and main ea57360912ed [exec: the finder's JUnit, in version-adapted sources with identical logic, fails on 6.4.2, 6.5.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.0.0, 10.5.2 and main with -ea, and with -da on all of them except 10.0.0 (not run); on 6.4.2 and 6.5.0 with -da only the 0-hit test fails, because SegmentDocValues.decRef swallowed the second-release exception until LUCENE-7775 (6.6.0); read: all 75 release tags from 6.4.2 to 10.5.2 in the history clone contain in.doClose(), and javap of the SHA-1-checked 8.8.2 to 8.11.4 jars shows the same call]; not affected: 5.0.0 to 6.4.1 (no override) [exec: 5.5.5, 6.4.1] and every release before 5.0.0 (no FilterCodecReader); branch_10x 1309534296e3 and branch_10_6 2763db11b0a7 still contain the call [read through the upstream contents API, 2026-10-26], so 10.6.0 (released 2026-10-29) is very likely affected (not executed); not fixed in any release or on main
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
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, plus the finder's scripted prior-report search; the prior-report record verdict new: 223 logged read-only queries, through a scripted prior-report search over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the LUCENE and SOLR JIRA archive and the web, and manual ones: 63 GitHub GraphQL searches over apache/lucene, Elasticsearch, OpenSearch, Solr, lucene-solr, k-NN, crate and all repositories, 16 direct list queries, 7 JIRA queries, 10 code searches and the changed-file lists of all 413 open apache/lucene PRs; 58 issue and PR threads read, 21 related hits recorded, all judged not the same bug; no item by anyone); not fixed upstream: main, branch_10x and branch_10_6 still call in.doClose(), and the four open PRs that touch FilterCodecReader.java (#16571, #16847, #12295, #13342) leave doClose unchanged; closest items, not reports of this bug: LUCENE-6696 / #7754 (2015, jpountz, fixed in 5.3.0), the same in.doClose()-instead-of-in.close() mistake in FilterDirectoryReader, two years before FilterCodecReader gained its override, and LUCENE-7676 / #8727 (2017), which introduced the call without discussing close semantics
Lucene commit tested
main ea57360912ed (gradle in a scratch worktree, and the repro harness), with assertions on and off; the finder's JUnit on the release jars 6.4.2, 6.5.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.0.0 and 10.5.2 (all fail) and 5.5.5 and 6.4.1 (pass); the standalone program on main and on the 10.5.2 jar; the recommended fix on ea57360912ed (repro with assertions on and off; the core, codecs and misc modules and :lucene:core:check -x test with assertions on).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/FilterCodecReader.java:123-126 (doClose calls in.doClose())
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:164-170 (incRef javadoc: 'the IndexReader will not really be closed until decRef has been called for all outstanding references'), :207-226 (decRef runs doClose only when the refCount reaches 0)
  • lucene/core/src/java/org/apache/lucene/index/FilterLeafReader.java:328 and :416-419 ('base reader is closed if this FilterLeafReader is closed'; doClose calls in.close()); FilterDirectoryReader.java:162-164 (in.close()); SegmentReader.java:213-224 (doClose: core.decRef, SegmentDocValues.decRef); SegmentDocValues.java:78-86 (the gen assertion and the dvp NPE)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/FilterCodecReader.java:123-126; IndexReader.java:164-170, 215-232, 238-248, 351-359; SegmentReader.java:213-225; SegmentCoreReaders.java:167-180; SegmentDocValues.java:78-86; StandardDirectoryReader.java:566; FilterLeafReader.java:328, 416-419; FilterDirectoryReader.java:162-164; SegmentReader.java:42-43
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsReader.java:272-280; codecs/lucene90/Lucene90PointsReader.java:153-157
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3257 (addIndexes: 'The provided IndexReaders are not closed'), 5394-5419 (closeMergeReaders releases mr.reader); ReadersAndUpdates.java:238-245; SortingCodecReader.java:458-481; FilterCodecReader.java:54-61; FilterDirectoryReader.java:162-164
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexReader.java:164-170, 188-192, 207-209, 358-359; FilterLeafReader.java:328, 416-419; FilterDirectoryReader.java:162-164; MultiReader.java:97-112; StandardDirectoryReader.java:566; IndexWriter.java:3257; FilterCodecReader.java:54-61
  • Lucene main ea57360912ed: lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseNormsFormatTestCase.java:825, 842, 860, 877
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SlowCodecReaderWrapper.java:48-53; IndexReader.java:351-356

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

Repro files

About the diagrams

Made in parallel with this doc from one executed example on main ea57360912ed (public API, default codec, FSDirectory/MMapDirectory): every count, hit number and exception on screen comes from that run or its fixed counterpart. The PNG is the final summary card.

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.