All findings · Lucene finding · LFV-495 · filterdirectoryreader-reopen-wrap-failure-leaks-new-reader

A failed reopen of a FilterDirectoryReader leaks the new inner reader and the segments it shares

When DirectoryReader.openIfChanged on a FilterDirectoryReader, such as SoftDeletesDirectoryReaderWrapper, fails while wrapping the reopened reader, the inner reader it just opened is never closed: a shared segment keeps refCount 2, so closing your reader no longer frees its files. Every failed refresh leaks again, since 4.3.0.

Summary

Priority
Low, rank 1091A FilterDirectoryReader reopen that fails during wrapping leaks the new inner reader and its files, a bounded core exception-path leak.
Impact
minor
Since
4.3.0 (LUCENE-4902, 1923a0dd6a02, 2013-04-05); the stock soft-deletes trigger from 7.4.0introduced 2013-04-05 · over 13 years
Affected
4.3.0-10.6.0 and main; opt-in (SoftDeletesDirectoryReaderWrapper or an application wrapper)
Status
documented
Found by
formal: TLA+ model checking of reader reference counting and reopen, then a JUnit test

AI-assisted page: this finding was first signalled by a formal tool's own output 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

A FilterDirectoryReader reopen is wrapDirectoryReader(in.doOpenIfChanged()), and wrapDirectoryReader is return in == null ? null : doWrapDirectoryReader(in); with no try/catch. When wrapping throws, the freshly reopened inner reader is neither returned nor closed, so its references on the shared segments are never released.

Background: how a reopen shares segments

  1. A DirectoryReader is a list of segment readers. Each segment reader counts its references and closes its files when the count reaches 0; closing a reader removes its references.
  2. DirectoryReader.openIfChanged from a commit builds a new reader that reuses the old reader's unchanged segment readers, adding one reference to each, and opens readers only for new segments. A near-real-time reopen gets its segment readers from the IndexWriter instead.
  3. A FilterDirectoryReader wraps another reader, for example SoftDeletesDirectoryReaderWrapper hides soft-deleted documents. Its reopen reopens the inner reader first, then wraps the result in a new wrapper.
Diagram of the bug: your wrapped reader holds segment _0 at refCount 1. openIfChanged reopens the inner reader, which opens _1 and shares _0 (refCount 2), then wraps it; the wrap step throws an UncheckedIOException, and the new inner reader is neither returned nor closed. Result: _0 refCount 2 instead of 1, and 1 instead of 0 after your reader is closed; _1 stays open. With the fix, wrapDirectoryReader closes the new inner reader and _0 goes back to 1, then 0.
A reopen opens _1 and shares segment _0 (refCount 2); the wrap step throws and the new inner reader is dropped without being closed: _0 stays at 2, and at 1 after you close your reader. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You wrap a reader in a FilterDirectoryReader, for example SoftDeletesDirectoryReaderWrapper, and refresh it with DirectoryReader.openIfChanged or a SearcherManager. Your reader holds segment _0, whose reader has refCount 1.
  2. A new commit adds segment _1. The refresh first reopens the inner reader, which opens _1 and shares _0 (refCount 2); then it wraps that new reader, and the wrap step throws, for example on an I/O error while the soft-deletes wrapper reads _1's soft-deletes doc values.
  3. You expect the failed call to release what it opened: _0 back at refCount 1, so closing your reader frees it.
  4. Instead _0 stays at refCount 2, and at 1 after you close your reader: the new inner reader was dropped without being closed, and _1 stays open too. In near-real-time mode the writer also cannot delete merged-away files (11 in one test).
  5. Each further failed refresh leaks again: 100 failed SearcherManager refreshes left 104 index inputs open after the manager and the writer were closed. Search results stay correct.

Who is affected

Applications that refresh a SoftDeletesDirectoryReaderWrapper (opt-in soft deletes) or their own FilterDirectoryReader through openIfChanged, SearcherManager or ReaderManager, when the wrap step throws: a storage read error, an OutOfMemoryError or a throwing wrapper. Rare, but a downstream production stack trace shows exactly this failure. Results stay correct; open files, mappings and disk space leak per failed refresh.

Mechanism, terms and numbers630 words

Every reopen of a FilterDirectoryReader is wrapDirectoryReader(in.doOpenIfChanged(...)): Lucene first reopens the wrapped reader, which (on a reopen from a commit) shares each unchanged segment reader with your old reader by adding a reference to it, then wraps the result. wrapDirectoryReader is return in == null ? null : doWrapDirectoryReader(in); with no try/catch. When the wrap step throws, as SoftDeletesDirectoryReaderWrapper does on an I/O error while it reads a new segment's soft-deletes doc values, the freshly reopened inner reader is neither returned nor closed. Its references on the segment readers (from a commit, an extra one on every segment it shares with your reader), the new segments' readers and, for a near-real-time reader, its hold on the writer's files are never released: closing your reader leaves the shared segment at refCount 1 and its files open. Each failed refresh leaks again; results stay correct. The line is unchanged since the class was added in 4.3.0; a ten-line try/catch that closes the inner reader and rethrows fixes it.

The code (645d4fe14a20)

// FilterDirectoryReader.java:91-94: the constructor runs the wrap step before 'in' is stored
public FilterDirectoryReader(DirectoryReader in, SubReaderWrapper wrapper) throws IOException {
  super(in.directory(), wrapper.wrap(in.getSequentialSubReaders()), null);   // :92, can throw
  this.in = in;
}
// FilterDirectoryReader.java:107-109: no cleanup of 'in' when doWrapDirectoryReader throws
private final DirectoryReader wrapDirectoryReader(DirectoryReader in) throws IOException {
  return in == null ? null : doWrapDirectoryReader(in);
}
// FilterDirectoryReader.java:111-114, and five more final overloads to :144
protected final DirectoryReader doOpenIfChanged() throws IOException {
  return wrapDirectoryReader(in.doOpenIfChanged());
}

Step by step

  1. DirectoryReader.openIfChanged(oldReader) calls oldReader.doOpenIfChanged() (DirectoryReader.java:245-249); for a FilterDirectoryReader that is wrapDirectoryReader(in.doOpenIfChanged()).
  2. in.doOpenIfChanged() builds a new StandardDirectoryReader. From a commit (StandardDirectoryReader.java:477-511, then open at :174-184), the segments are walked from the last one (:233): a new segment gets a new reader (:296), and an unchanged one is shared: "No change; this reader will be shared between the old and the new one, so we must incRef it", then oldReader.incRef() (:317-323). The new reader is then constructed on them (:182-183). A near-real-time reopen goes through the writer instead (:456-466): its segment readers come from the writer's reader pool (IndexWriter.java:554), and it takes writer.incRefDeleter(segmentInfos) (StandardDirectoryReader.java:148), released only in its doClose (:548-568).
  3. doWrapDirectoryReader(newInner) constructs the subclass, whose constructor wraps the new leaves (FilterDirectoryReader.java:92). SoftDeletesDirectoryReaderWrapper (SoftDeletesDirectoryReaderWrapper.java:67-82) reuses a leaf it already wrapped when the reader-cache key matches (:116-120); for any other leaf that has soft-deletes doc values it reads them (:129-152; no such field: early return at :131; a FixedBitSet(maxDoc) at :139) and rethrows an IOException as UncheckedIOException (:124).
  4. If that throws, wrapDirectoryReader lets the exception through (FilterDirectoryReader.java:108). The new inner reader is never returned or closed, and keeps its references: +1 on every shared segment reader (from a commit) or the pooled readers' references (near-real-time), the new segment readers and their files, and (near-real-time) the deleter reference.
  5. The application owns only its old reader (DirectoryReader.java:236-238). From a commit, closing it takes each shared segment reader from 2 to 1, so it is never closed (IndexReader.java:164-170).

The numbers (the minimal test's scenario, from the diagram probe)

Two one-document commits: _0 shared, _1 new. After the failed openIfChanged: _0 refCount 2 (expected 1), the dropped inner reader 1, _1 1. After reader.close(): _0 1 (expected 0), the inner reader 1, _1 1. With the fix: 1, then 0.

Contrast

The inner reopen's tested failure path, an I/O error while the inner StandardDirectoryReader opens the new segment, is cleaned up by Lucene (the independent reproduction's control passes). This report concerns the wrap step, which runs after the inner reopen succeeded and has no cleanup.

Exposure and likelihood373 words
  • Who is hit: refreshes of a FilterDirectoryReader via DirectoryReader.openIfChanged, SearcherManager or ReaderManager. In stock Lucene that is SoftDeletesDirectoryReaderWrapper (opt-in soft deletes); ExitableDirectoryReader does no I/O in its wrap step, so only an OutOfMemoryError reaches it there. Any application wrapper whose wrap step can throw is hit too.
  • When: only when the wrap step throws after the inner reopen succeeded: an I/O error while the soft-deletes wrapper reads the doc values of a new or changed segment through an input that is already open (a storage read error), an OutOfMemoryError there, or a throwing application wrapper. With the default codec a missing doc-values file fails earlier, while the inner reopen builds the segment reader (SegmentReader.java:111, Lucene90DocValuesProducer.java:132), and that path cleans up; a missing file can reach the wrap step with storage that fetches data lazily. elastic/elasticsearch#160888 (Lucene 10.5.1) shows a production stack trace through exactly this point, a remote-storage read error thrown from SoftDeletesSubReaderWrapper.wrap inside wrapDirectoryReader during openIfChanged; it discusses only the missing file, not the leak.
  • How much (stock SearcherManager, real NIOFSDirectory and MMapDirectory, main ea57360912ed (the sources involved are identical at 645d4fe14a20), 10.5.2 and 9.12.3; nothing leaks with the fix):
    • commit-point refresh, 100 failed refreshes: 104 index inputs never closed after the manager and the writer were closed; with MMapDirectory 110 mapping regions survived close and garbage collection; with NIOFSDirectory the JDK reclaimed most descriptors after garbage collection (4 left after five System.gc() calls);
    • near-real-time refresh: the leaked reader keeps the writer's file references, so 11 files of merged-away segments stayed after forceMerge(1) + commit() and writer.close(), until the next IndexWriter deleted them; with indexing during 47 failed refreshes, 368 files (160,777 bytes in the tiny test) and 180 inputs, and 180 files stayed mapped after the next IndexWriter deleted them; by reasoning (not observed to the end), their disk space stays allocated until the mappings are released or the process exits.
  • Growth: linear in failed refresh attempts; a refresh loop that retries against a persistent storage fault leaks on every attempt.
  • What does not happen: the manager keeps serving the previous reader (checked over 100 failures), no soft-deleted document reappears, nothing is written to the index, each failed refresh returns promptly.

Minimal repro

One JUnit test on the public API: two one-document commits into a ByteBuffersDirectory, a minimal FilterDirectoryReader whose wrapper throws UncheckedIOException (as the soft-deletes wrapper does on a read error) when it wraps a reopened reader, one openIfChanged and one refCount assertion. Deterministic, single thread, about a second.

TestFilterDirectoryReaderReopenWrapFailure.java (one test, public API)71 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 java.io.UncheckedIOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFilterDirectoryReaderReopenWrapFailure extends LuceneTestCase {

  public void testFailedWrapOnReopenReleasesSharedSegment() throws IOException {
    Directory dir = new ByteBuffersDirectory();
    IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
    w.addDocument(new Document());
    w.commit();
    DirectoryReader reader = new FailOnReopenReader(DirectoryReader.open(dir), false);
    LeafReader segment = reader.leaves().get(0).reader();
    w.addDocument(new Document());
    w.commit();
    w.close();

    expectThrows(UncheckedIOException.class, () -> DirectoryReader.openIfChanged(reader));
    // expected: refCount 1 (only 'reader' holds the segment); actual: 2, reopened reader leaked
    assertEquals(1, segment.getRefCount());
    reader.close();
    dir.close();
  }

  private static class FailOnReopenReader extends FilterDirectoryReader {
    FailOnReopenReader(DirectoryReader in, boolean fail) throws IOException {
      super(
          in,
          new SubReaderWrapper() {
            @Override
            public LeafReader wrap(LeafReader reader) {
              if (fail) {
                throw new UncheckedIOException(new IOException("simulated read error"));
              }
              return reader;
            }
          });
    }

    @Override
    protected DirectoryReader doWrapDirectoryReader(DirectoryReader in) throws IOException {
      return new FailOnReopenReader(in, true);
    }

    @Override
    public CacheHelper getReaderCacheHelper() {
      return null;
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestFilterDirectoryReaderReopenWrapFailure.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestFilterDirectoryReaderReopenWrapFailure -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure); seeds DEADBEEF, 5EED1234 and 10 random seeds all failed
Expected
After the failed openIfChanged, the shared segment reader has refCount 1: only your reader holds it.
Observed
AssertionError: expected:<1> but was:<2>: the dropped inner reader still holds a reference; assertions on and off, on main, 10.6.0 and 9.12.3.

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

More files, environment, full output249 words

The test sits in org.apache.lucene.index by convention and uses no package-private access. The wrapper passes leaves through on the first open and throws when it wraps the reopened reader; getReaderCacheHelper() returns null, which IndexReader allows ("not suited for caching") and which plays no part in the bug. Only LuceneTestCase's randomized codec varies, and reader reference counts do not go through the codec; observed: seeds DEADBEEF and 5EED1234 fail, and so do 10 of 10 random-seed iterations.

The stock trigger reproduces the same refCount 2: the finder's longer test wraps a Directory subclass that fails doc-values reads of the new segment under SoftDeletesDirectoryReaderWrapper (about 150 more lines for the fault injector, so it is not the minimal repro).

Regression test: this same test, as its own class or as a method of TestFilterDirectoryReader.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0 and 9.12.3
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, stock 645d4fe14a20, seed DEADBEEF, assertions on (identical with -Ptests.asserts=false)
TestFilterDirectoryReaderReopenWrapFailure > testFailedWrapOnReopenReleasesSharedSegment FAILED
    java.lang.AssertionError: expected:<1> but was:<2>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:F1AACFCEB33224B0]: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.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.index.TestFilterDirectoryReaderReopenWrapFailure.testFailedWrapOnReopenReleasesSharedSegment(TestFilterDirectoryReaderReopenWrapFailure.java:41)
1 test completed, 1 failed

# release jars, seed DEADBEEF, assertions on and off: the same assertion
10.6.0, 9.12.3: java.lang.AssertionError: expected:<1> but was:<2>

# 10 random-seed iterations (seed 0FAB0BB1E27A3D35) on 645d4fe14a20: 10 of 10 fail

# with the fix (wrapDirectoryReader closes the inner reader and rethrows)
classes=8 tests=93 failures=0 errors=0 skipped=1 (the minimal test and the 7 closest reader suites; assertions on and off)

Why it looks unintended

  • openIfChanged makes you close only the reader you passed and a returned one; after a throw nothing is returned, so nobody can close the new reader.
  • Lucene's comparable failed-open paths release what they opened, and tests check it (LUCENE-5262: failed NRT opens must not leak file handles).
  • Reopen is final in the base class; subclasses get no cleanup duty.
The full argument209 words
  • Contract: DirectoryReader.java:236-238 (quoted above). Closing the provided reader no longer frees its segments, because "the IndexReader will not really be closed until decRef has been called for all outstanding references" (IndexReader.java:164-170).
  • Lucene cleans up on comparable paths: StandardDirectoryReader.java:96-105, "This may throw CorruptIndexException if there are too many docs, so it must be inside try clause so we close readers in that case", with IOUtils.closeWhileSuppressingExceptions(t, readers); SearcherManager.java:246, "NOTE: this decRefs incoming reader on throwing an exception" (:262-264).
  • Tests require it: TestDirectoryReaderReopen.testOverDecRefDuringReopen (:673-735, and the executor variant at :1530) injects a reopen failure and then closes a MockDirectoryWrapper, which fails on open files; TestIndexWriterReader.testNRTOpenExceptions (:1108-1110): "LUCENE-5262: test that several failed attempts to obtain an NRT reader don't leak file handles".
  • The trigger is legal: doWrapDirectoryReader declares throws IOException (FilterDirectoryReader.java:105, since LUCENE-6299, 2015), and the stock soft-deletes wrapper raises UncheckedIOException itself (SoftDeletesDirectoryReaderWrapper.java:124).
  • History: in the LUCENE-4902 review (2013) the doOpenIfChanged methods were made final so the base class owns reopen; nobody discussed failures, and the searches found no test, CHANGES or MIGRATE entry that accepts the leak. The same bug class was fixed or handled in SearcherTaxonomyManager (#9291, LUCENE-8244) and the SearcherManager constructor (#4871, LUCENE-3798).
The devil's-advocate check175 words

The strongest case for "intended" (the devil's-advocate checker and the works-as-designed reviewer): doWrapDirectoryReader receives in, so a subclass whose wrap step can fail should close it itself; the constructor does not close in on a throw either; the javadoc promises nothing about the exceptional path; and a throwing application wrapper could be called misuse.

Why it fails: the doWrapDirectoryReader javadoc (FilterDirectoryReader.java:96-100) says only "Implementations should just return an instantiation of themselves, wrapping the passed in DirectoryReader", and the class javadoc (:23-31) says nothing about cleanup either; the reopen methods are final, so the base class owns reopen (a subclass could still catch inside its own doWrapDirectoryReader, close in and rethrow, but nothing asks it to); and Lucene's own SoftDeletesDirectoryReaderWrapper.doWrapDirectoryReader (SoftDeletesDirectoryReaderWrapper.java:67-82) does no cleanup, so the stock class leaks wherever the duty is placed. In the constructor case the caller still holds in; after a failed reopen the caller never had the new inner reader. The misuse argument does not touch the stock soft-deletes I/O-error path.

Suggested fix

Recommended Close the inner reader in wrapDirectoryReader when wrapping throws

Close the inner reader in wrapDirectoryReader when doWrapDirectoryReader throws, then rethrow: one try/catch with IOUtils.closeWhileSuppressingExceptions(t, in). It covers all six reopen overloads and every subclass. Validated through gradle: the repro passes, and so do the 93 tests of the closest reader suites, with assertions on and off.

private final DirectoryReader wrapDirectoryReader(DirectoryReader in) throws IOException {
  if (in == null) {
    return null;
  }
  try {
    return doWrapDirectoryReader(in);
  } catch (Throwable t) {
    // the new inner reader is not returned to anybody: release it
    IOUtils.closeWhileSuppressingExceptions(t, in);
    throw t;
  }
}
All options and trade-offs3 options · 482 words

The hunt's fix (the same added and removed lines, re-made as a git diff against 645d4fe14a20), applied in a scratch worktree of 645d4fe14a20 and run through gradle (OpenJDK 25.0.3, seed DEADBEEF) with the minimal test and the closest reader suites.

Close the inner reader in wrapDirectoryReader when wrapping throws (validated by the checks) (recommended)

In FilterDirectoryReader.wrapDirectoryReader (:107-109), wrap doWrapDirectoryReader(in) in a try/catch; on any Throwable, call IOUtils.closeWhileSuppressingExceptions(t, in) and rethrow (+11/-1 with the import). With the built-in reopen implementations, in is the new reader that in.doOpenIfChanged(...) just returned, which the caller would have owned and nobody holds after the throw, so closing it is exactly the missing release. This relies on the normal ownership convention for returned readers, which holds for the built-in implementations but is not an API guarantee for an arbitrary custom inner DirectoryReader.

private final DirectoryReader wrapDirectoryReader(DirectoryReader in) throws IOException {
  if (in == null) {
    return null;
  }
  try {
    return doWrapDirectoryReader(in);
  } catch (Throwable t) {
    // the new inner reader is not returned to anybody: release it
    IOUtils.closeWhileSuppressingExceptions(t, in);
    throw t;
  }
}

Trade-offs

  • Covers all six reopen overloads, SoftDeletesDirectoryReaderWrapper and every application subclass at once, and nested wrappers (each level closes the reader it just received; reasoned, not run). It mirrors StandardDirectoryReader.open (:96-105).
  • If a subclass already called in.close(), the fix's close is a no-op (IndexReader.close() is idempotent, if (!closed), IndexReader.java:351-356); if it called in.decRef() instead, the fix decrements again (IndexReader.java:215-222). No in-tree subclass's doWrapDirectoryReader does either.
  • No cost on the normal path (one try block). Catching Throwable also releases on OutOfMemoryError, as StandardDirectoryReader.open does.

What the checks validated

Through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, --offline, seed DEADBEEF): the minimal test fails on stock and passes with the fix, assertions on and off. With the fix, the minimal test plus TestFilterDirectoryReader, TestDirectoryReaderReopen, TestSoftDeletesDirectoryReaderWrapper, TestExitableDirectoryReader, TestDirectoryReader, TestReaderClosed and TestSearcherManager (8 classes, 93 tests, 1 skipped) pass with 0 failures, assertions on and off. Core's checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass with the fix. Earlier, the hunt's shadow fix check passed every repro (the finder's, the independent reproduction's, the cross-model review's and the intended-behaviour check's tests, the history instrument on 4.3.0 and main, and the security probe with 0 leaked inputs) and kept the independent reproduction's controls green.

Clean up in each subclass

Add the same try/catch to SoftDeletesDirectoryReaderWrapper.doWrapDirectoryReader and document a cleanup duty in the doWrapDirectoryReader javadoc. Not run.

Trade-offs

Fixes the stock trigger only; every application wrapper must repeat it, although the base class owns reopen (final overloads).

Close in in the FilterDirectoryReader constructor (not recommended)

Close in when wrapper.wrap(...) throws in the constructor (:91-94). Not run.

Trade-offs

It would also close a reader that a direct caller of new SomeWrapper(reader) still owns, which changes the constructor's contract.

Downstream note

A downstream production stack trace (elastic/elasticsearch#160888, Lucene 10.5.1) shows a remote-storage read error thrown from exactly this wrap step during a refresh, so products that keep retrying refreshes against such a fault leak once per attempt.

How it was found

Formal: an AI tool modelled reader reference counting, segment sharing and reopen in TLA+ from the code, with injectable faults. With an exception in the wrap step, the TLC model checker found a violation of the no-leak invariant; the modelled fix passes. A public-API JUnit test then confirmed it on the real classes.

The method in full108 words

Method: formal, TLA+ model checking (TLC) in a hunt unit (ReaderLifecycle.tla, a model of IndexReader reference counting, segment-core sharing and StandardDirectoryReader / FilterDirectoryReader reopen, built from the code with a file:line map).

  • With the wrapThrow fault (eight injectable faults plus a fault-free run were checked), the NoLeak invariant fails in the sequential configuration (32 distinct states) and the executor configuration (142); the same configurations with the modelled fix pass.
  • The counterexample was replayed as a public-API JUnit test on the stock jars, then minimized to the one-test repro.
  • The fault edge itself was added while modelling the code, which a stricter reading could call semi-formal.

Prior reports, history and checks

Prior reports

No prior report found (202 logged queries over GitHub, JIRA, the mailing lists, downstream projects and the web, 2026-10-26; re-checked on GitHub 2026-11-01). elastic/elasticsearch#160888 shows the trigger in production but not the leak.

Searches3 searches

Context only: #5967 (LUCENE-4902, introduced the class; reopen made final; exceptions not discussed), #9291 (LUCENE-8244) and #4871 (LUCENE-3798) (the same bug class in SearcherTaxonomyManager and the SearcherManager constructor, fixed or already handled), #7754 (LUCENE-6696, a different FilterDirectoryReader refcount bug on the close path, fixed in 5.3), #9284 (LUCENE-8237, added the soft-deletes wrapper), #7276 (LUCENE-6214) and #12059 (refresh exceptions thrown before the wrap step), OpenSearch PR #21834 (a wrapper that throws from doWrapDirectoryReader by design; no leak discussed). elastic/elasticsearch#160888 (2026-10-02) carries a production stack trace through exactly this failure point but investigates only a missing file; it was closed in favour of a private tracker. Gaps: GitHub search returns at most 50 results per query; private trackers are invisible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; the web2026-10-26two runs of a scripted prior-report search (45 and 51 queries), 56 manual GitHub searches, 35 list-archive, 6 JIRA and 9 web-search queries (202 in all)no report; context #5967, #9291, #4871, #7754, #9284, #7276, #12059, elastic/elasticsearch#160888, OpenSearch #21834
Open PRs (apache/lucene)2026-10-26changed files of all 409 open PRs, plus those opened sincenone touches wrapDirectoryReader or the reopen exception path
GitHub issue and PR search (apache/lucene); upstream file history2026-11-01FilterDirectoryReader, doWrapDirectoryReader, SoftDeletesDirectoryReaderWrapper leak sorted by update; commits on FilterDirectoryReader.javanothing new; the file last changed on 2026-04-01 (#15874); main 2f424fd428da still has the line

History

Introduced with the class: 1923a0dd6a02, LUCENE-4902 (2013-04-05), first released in 4.3.0; the line never changed. The stock soft-deletes trigger exists from 7.4.0 (LUCENE-8237). Affected: 4.3.0 to 10.6.0 and main. Not fixed upstream.

Versions tested6 rows

Introduced by 1923a0dd6a02 "LUCENE-4902: Add FilterDirectoryReader" (Alan Woodward, 2013-04-05, trunk) and its branch_4x copy 33742fc0194 the same day; a JIRA patch (migrated as #5967), no PR. git blame -w and git log -L on wrapDirectoryReader find only this commit and 4e53fae38e99 (LUCENE-6299, 2015, which added throws IOException and left the cleanup unchanged). Not a regression.

The stock trigger arrived with SoftDeletesDirectoryReaderWrapper (LUCENE-8237, ed62b990d8b, 2018-04-09), first released in 7.4.0. All 117 tagged releases from 4.3.0 to 10.5.2 in the history clone (which has no 8.8.2 to 8.11.4 tags; 8.11.4 was run) and the 10.6.0 tag have the identical line; upstream main 2f424fd428da (2026-10-30) still has it.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails through gradle, assertions on and off
10.6.0, 9.12.3 affectedthe minimal test fails on the release jars, assertions on and off
10.5.2, 8.11.4, 7.4.0 affectedboth triggers leak (history instruments; the finder's test also on 10.5.2)
7.3.1, 7.0.0, 6.6.6, 5.0.0, 4.10.4 affectedthe application-wrapper trigger leaks; no soft-deletes wrapper before 7.4.0
4.3.0 affectedfirst release with the class: leaks; with the fix shadowed, no leak
4.2.1 not affectedno FilterDirectoryReader (compile error)

Independent checks

Confirmed with high confidence by the study's judge after an independent reproducer, an intended-behaviour devil's advocate and reviews by another model family (OpenAI GPT-5.6, reading only); kept by a credibility audit; re-run through gradle and on the 10.6.0 jars for this doc, with the fix validated.

Checks and verdicts331 words

Each checker that executed wrote its own test from the claim; the adjudication re-ran the independent reproducer's tests.

Each independent check and its verdict
CheckVerdictWhat it found
finder (TLA+ model, TLC) candidateNoLeak violated with the wrap-step fault (32 and 142 distinct states); the modelled fix passes. JUnit fails on main (assertions on and off), 10.5.2 and 9.12.3; the fix passes.
refute review (OpenAI GPT-5.6, reading only) not real, low (procedural)Could not run code; it agreed that the root cause holds, the fix fixes it and the inputs are in contract. Its one objection (the fault injector extended an internal class) was fixed and the repro re-run.
Independent reproducer realOwn public-API test in another package fails 3 of 3 on main, 10.5.2 and 9.12.3, including a near-real-time variant that pins 11 merged-away files; controls pass 4 of 4; the fix passes.
Works-as-designed review (OpenAI GPT-5.6, reading only) real, mediumIntended argument weak; a separate run re-ran the finder's test (fail) and its own open-files test (fail, control passes; pass with the fix).
Intended-behaviour devil's advocate realBuilt and rejected the works-as-designed case; own open-files test fails on main and 10.5.2, both leak tests in all 20 random-seed iterations; the fix passes.
adjudication confirmed, highRe-ran the independent reproduction's leak tests (fail 3 of 3, assertions on and off), controls (pass 4 of 4) and the fix (pass 3 of 3).
security probe not security-sensitiveStock SearcherManager, real file systems, main, 10.5.2, 9.12.3 and fixed code: a resource leak only; results unchanged.
credibility audit 4 confirmedFresh seeds on main ea57360912ed: fail with assertions on and off; the fix passes.
repro minimization verifiedThe one-test repro fails on 645d4fe14a20 with assertions on and off (two seeds); the fix passes.
this doc (2026-11-01) reproduced, fix validatedGradle on 645d4fe14a20, stock and fixed; 10.6.0 and 9.12.3 jars; 10 random seeds; the stock soft-deletes trigger on 645d4fe14a20.

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

Finding record

LFV-495, snapshot of 2026-11-06

Registry title
When DirectoryReader.openIfChanged on a FilterDirectoryReader (for example SoftDeletesDirectoryReaderWrapper) fails while wrapping the reopened reader, the newly opened inner reader is never closed, so the segment readers it shares with the old reader, and its files, leak
Area
core / index reader reopen (FilterDirectoryReader.wrapDirectoryReader on every doOpenIfChanged path; SoftDeletesDirectoryReaderWrapper as the stock trigger)
Priority
Low, rank 1091: A FilterDirectoryReader reopen that fails during wrapping leaks the new inner reader and its files, a bounded core exception-path leak.
Impact class
minor
Impact note
Applications that refresh a FilterDirectoryReader through DirectoryReader.openIfChanged, SearcherManager or ReaderManager (in stock Lucene a SoftDeletesDirectoryReaderWrapper, so opt-in soft deletes; otherwise an application wrapper) leak, on every refresh that fails in the wrap step after the inner reopen succeeded (an I/O error or OutOfMemoryError while the soft-deletes wrapper reads a new or changed segment's soft-deletes doc values, or an application SubReaderWrapper that throws), the newly opened inner reader with its extra reference on every shared segment reader and its new segment readers, so closing the old reader no longer frees them (executed: 100 failed refreshes left 104 index inputs open after the manager and the writer were closed, MMapDirectory mappings survived close and garbage collection, and in near-real-time mode the writer could not delete merged-away segment files, 11 files, or 368 with indexing during the failures); growth is linear in failed refresh attempts, any index size, the manager keeps serving the previous reader, and there are no wrong results.
Summary
You reopen a FilterDirectoryReader, for example a SoftDeletesDirectoryReaderWrapper, with DirectoryReader.openIfChanged (directly or through SearcherManager or ReaderManager), and the call fails after the inner reader was reopened, while the wrapper wraps it: for example an I/O error while the soft-deletes wrapper reads the new segment's soft-deletes doc values. You expect the failed call to release what it opened, so that closing your old reader, the only one you hold ('The provided reader is not closed (you are responsible for doing so); if a new reader is returned you also must eventually close it'), frees every segment reader and file. Instead FilterDirectoryReader.wrapDirectoryReader neither returns nor closes the inner reader it was just given, so that reader keeps an extra reference on every shared segment (refCount 2 after the failure, still 1 after you close the old reader) and the new segments' files stay open.
Introduced
1923a0dd6a027c5a054d248dcc46a047bca43765 (trunk, svn r1465072, 'LUCENE-4902: Add FilterDirectoryReader', by romseygeek; CHANGES also credits jpountz and uschindler) and its branch_4x copy 33742fc0194fef8a6a91fffb4adb44f9ec4ef78c (r1465075); JIRA LUCENE-4902, mirrored as apache/lucene#5967; no PR, it predates the GitHub workflow [read: git blame -w on main FilterDirectoryReader.java:108 and git log -L on wrapDirectoryReader find only this commit and 4e53fae38e99 (LUCENE-6299, 2015, added 'throws IOException', cleanup unchanged); exec: 4.2.1 lacks the class (compile error), 4.3.0 leaks, 4.3.0 with the fix shadowed does not]. Not a regression. The stock trigger arrived later with SoftDeletesDirectoryReaderWrapper (LUCENE-8237, ed62b990d8b, first released in 7.4.0), whose wrap step reads doc values and turns an IOException into UncheckedIOException (2013-04-05 (1923a0dd6a02 on trunk and 33742fc0194 on branch_4x, same day; 4.3.0 tagged 2013-05-06); stock SoftDeletesDirectoryReaderWrapper trigger from 2018-04-09 (LUCENE-8237))
First affected version
4.3.0 [exec] (the first release with FilterDirectoryReader: an application SubReaderWrapper that throws on reopen leaks, the no-fault control passes, and the fix shadowed into 4.3.0 removes the leak); 4.2.1 lacks the class [exec: compile error]; the stock SoftDeletesDirectoryReaderWrapper read-error trigger from 7.4.0 [exec] (7.3.1 lacks that class [exec: compile error])
Affected versions
every release from 4.3.0 to 10.6.0 and main ea57360912ed; never fixed. [exec] era-adapted history instruments (each with a no-fault control that must share segment _0 and then release it; every control passed): application-wrapper trigger leaks on 4.3.0, 4.10.4, 5.0.0, 6.6.6, 7.0.0, 7.3.1, 7.4.0, 8.11.4, 9.12.3, 10.5.2 and main; SoftDeletesDirectoryReaderWrapper read-error trigger leaks on 7.4.0, 8.11.4, 9.12.3, 10.5.2 and main (-ea and -da); main with the fix shadowed does not leak; the finder's JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check's fails on main and 10.5.2; the one-test minimal repro fails on the 10.6.0 release jars with -ea and -da (2026-11-01). [read] all 117 tagged releases from 4.3.0 in the history clone have the identical line 'return in == null ? null : doWrapDirectoryReader(in);' with no cleanup (8.8.2 to 8.11.4 are not tagged there; 8.11.4 executed). Not fixed upstream: main a9779ef3c193 unchanged (read 2026-10-26); no open PR changes wrapDirectoryReader. All runs: JDK 25, SHA-1-verified release jars, aarch64 Linux
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
formal: TLA+/TLC
Upstream and prior reports
none found (2026-10-26, read-only, on top of the finder's scripted prior-report search, the intended-behaviour check's 13 GitHub queries and the adjudication's searches): 202 logged queries (two scripted prior-report searches of 45 and 51 queries over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, JIRA, downstream repositories and the web; 56 manual GitHub searches over apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch and Solr; 35 list-archive and 6 JIRA queries; web searches), the changed files of all 409 open apache/lucene PRs plus those opened since (none touches wrapDirectoryReader or the reopen exception path), CHANGES.txt and the upstream history of both files. Related, not this bug: #5967 / LUCENE-4902 (introduced the class; the reopen methods were made final so the base class owns reopen; exceptions not discussed), #7754 / LUCENE-6696 (a FilterDirectoryReader close-path refcount bug, fixed in 5.3), #9291 / LUCENE-8244 and #4871 / LUCENE-3798 (the same bug class in SearcherTaxonomyManager and SearcherManager, fixed or already handled), #9284 / LUCENE-8237 (added SoftDeletesDirectoryReaderWrapper; no failure-cleanup discussion), #7276 / LUCENE-6214 and #12059 (exceptions during a refresh, thrown before the wrap step); elastic/elasticsearch#160888 (closed 2026-10-02 in favour of a private tracker) carries a production stack trace through exactly this failure point (an UncheckedIOException from SoftDeletesSubReaderWrapper.wrap inside wrapDirectoryReader during openIfChanged, Lucene 10.5.1) but discusses only why a file was missing, not the leak; OpenSearch PR #21834 shows a second trigger pattern (a wrapper whose doWrapDirectoryReader throws UnsupportedOperationException) without discussing a leak. Not fixed upstream (main a9779ef3c193: FilterDirectoryReader.java last changed 2026-04-01, #15874, instanceof patterns only).
Lucene commit tested
main 645d4fe14a20 through gradle in a scratch worktree: the minimal test stock and with the fix, assertions on and off, and the closest reader suites with the fix; the minimal test on the 10.6.0 and 9.12.3 release jars (assertions on and off) and over 10 random seeds on 645d4fe14a20; the finder's stock soft-deletes trigger test on 645d4fe14a20. Earlier, on main ea57360912ed (the four reader files are identical at 645d4fe14a20): the finder's, the independent reproduction's, the cross-model review's and the intended-behaviour check's tests, the security probe, and era-adapted history instruments on 4.3.0 through 10.5.2. All runs on Linux aarch64 with OpenJDK 25.0.3. Upstream main 2f424fd428da (2026-10-30) was read, not run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/FilterDirectoryReader.java:91-94 (at ea57360912ed) (constructor: super(in.directory(), wrapper.wrap(in.getSequentialSubReaders()), null) runs the wrap step before 'in' is assigned), :96-106 (doWrapDirectoryReader javadoc 'Implementations should just return an instantiation of themselves, wrapping the passed in DirectoryReader', declared throws IOException; no cleanup duty), :107-109 (wrapDirectoryReader: 'return in == null ? null : doWrapDirectoryReader(in);', no try/catch), :111-144 (all six final doOpenIfChanged overloads call wrapDirectoryReader(in.doOpenIfChanged(...)))
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:236-243 (at ea57360912ed) (openIfChanged javadoc: 'The provided reader is not closed (you are responsible for doing so); if a new reader is returned you also must eventually close it'), :245-248 (openIfChanged delegates to oldReader.doOpenIfChanged())
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesDirectoryReaderWrapper.java:67-80 (at ea57360912ed) (doWrapDirectoryReader builds a new wrapper around the reopened reader, no cleanup), :101-126 (SoftDeletesSubReaderWrapper.wrap(List) and wrap(LeafReader): IOException rethrown as UncheckedIOException at :124), :129-152 (static wrap reads the soft-deletes doc values; FixedBitSet(maxDoc) at :139)
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:96-105 (at ea57360912ed) (open: catch Throwable and close readers, comment ':99 it must be inside try clause so we close readers in that case'), :148 (NRT reader takes writer.incRefDeleter), :322 (reopen shares an unchanged segment with oldReader.incRef()), :548-563 (doClose releases the deleter reference)
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:165-232 (at ea57360912ed) (incRef / tryIncRef / decRef javadoc: a reader is closed when decRef has run for all outstanding references)
  • lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:246-265 (at ea57360912ed) (getSearcher: 'NOTE: this decRefs incoming reader on throwing an exception'); lucene/core/src/test/org/apache/lucene/index/TestDirectoryReaderReopen.java:673, :1530 (testOverDecRefDuringReopen and ...WithExecutor: an injected reopen failure, then MockDirectoryWrapper's open-file check on close); lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java testNRTOpenExceptions (LUCENE-5262: failed NRT opens must not leak file handles)
  • lucene/core/src/java/org/apache/lucene/index/ExitableDirectoryReader.java (at ea57360912ed) (the other in-tree non-test FilterDirectoryReader; its wrap step does no I/O, so only an OutOfMemoryError reaches this path; per the security record)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/FilterDirectoryReader.java:23-31, (at 645d4fe14a20) 91-94, 96-109, 111-144
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:236-238, (at 645d4fe14a20) 245-249
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:148, (at 645d4fe14a20) 174-184, 233, 241-243, 296, 317-323, 456-466, 477-511, 548-568
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:545-559, (at 645d4fe14a20) 614-615; ReadersAndUpdates.java:231-247 (near-real-time readers from the reader pool)
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesDirectoryReaderWrapper.java:67-82, (at 645d4fe14a20) 113-126 (reuse at 116-120), 129-152
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:164-170 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/ExitableDirectoryReader.java:39 (at 645d4fe14a20) (the other non-test in-tree subclass)
  • https://github.com/elastic/elasticsearch/issues/160888 (stack trace, read 2026-11-01)
  • lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:111; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:132 (doc-values data opened while the segment reader is built)
Minimal repro
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:394-395 (at 645d4fe14a20) (null cache helper allowed)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:236-238 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:164-170 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:96-105 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:244-267 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestDirectoryReaderReopen.java:673-735, (at 645d4fe14a20) 1530
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:1108-1110 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/FilterDirectoryReader.java:96-109 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesDirectoryReaderWrapper.java:67-82, (at 645d4fe14a20) 121-125
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:351-356 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:96-105 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:215-222 (at 645d4fe14a20) (decRef)
History
  • a Lucene clone tag releases/lucene/10.6.0 lucene/core/src/java/org/apache/lucene/index/FilterDirectoryReader.java:108
  • https://github.com/apache/lucene/blob/2f424fd428da21822ac60b2c51763907f83dff01/lucene/core/src/java/org/apache/lucene/index/FilterDirectoryReader.java#L108 (read 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

Made from the code at 645d4fe14a20 and an executed probe, in parallel with this doc.

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.