All findings · Lucene finding · LFV-264 · searchertaxonomymanager-self-suppression-masks-exception

SearcherTaxonomyManager refresh throws "Self-suppression not permitted" and loses the taxonomy error when closing the new reader also fails

If one SearcherTaxonomyManager refresh fails twice, first reopening the taxonomy and then closing the index reader it just reopened, maybeRefresh() throws IllegalArgumentException: Self-suppression not permitted and the taxonomy's IOException is lost. The cleanup calls t2.addSuppressed(t2) where t1.addSuppressed(t2) was meant; every release since 7.4.0 has it.

Summary

Priority
Low, rank 1080SearcherTaxonomyManager throws a self-suppression IllegalArgumentException that hides the taxonomy failure, but only when two I/O failures hit one refresh.
Impact
wrong error
Since
7.4.0 (the LUCENE-8244 leak fix, 2018-04-28); 7.3.1 is not affectedintroduced 2018-04-28 · about 8 years
Affected
7.4.0 to 10.6.0, and main
Status
documented
Found by
code reading of the NRT refresh exception paths, then a JUnit repro

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

One refresh, two failures: the taxonomy reopen throws IndexNotFoundException, then closing the new index reader throws an IOException. The caller should get the IndexNotFoundException; it gets IllegalArgumentException: Self-suppression not permitted.

Background: how SearcherTaxonomyManager refreshes

  1. SearcherTaxonomyManager keeps an IndexSearcher and a TaxonomyReader, the facet module's dictionary of category labels, at matching points in time. maybeRefresh() moves both to newer commits; its javadoc says it throws IOException when refreshing fails.
  2. A refresh reopens the index reader first, then the taxonomy reader. If the taxonomy reopen fails, the code closes the index reader it just opened (so its files do not leak) and rethrows the taxonomy error.
  3. Java's a.addSuppressed(b) attaches a secondary failure b to the primary failure a. A throwable cannot suppress itself: b.addSuppressed(b) always throws IllegalArgumentException("Self-suppression not permitted").
Diagram of the bug: maybeRefreshBlocking reopens the index reader, then the taxonomy reader. The taxonomy reopen throws IndexNotFoundException (t1). The cleanup closes the new index reader, which throws IOException (t2). The cleanup then calls t2.addSuppressed(t2), which Java rejects, so the caller gets IllegalArgumentException: Self-suppression not permitted, caused by t2, and t1 is lost. With t1.addSuppressed(t2) the caller gets IndexNotFoundException with the close failure suppressed.
One refresh with two failures: the taxonomy reopen throws, the cleanup's close throws, and t2.addSuppressed(t2) replaces both with "Self-suppression not permitted". Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Keep a searcher and its facet taxonomy in step with SearcherTaxonomyManager and call maybeRefreshBlocking() after new commits. Each refresh reopens the index reader first, then the taxonomy reader.
  2. One refresh fails twice. The taxonomy reopen throws IndexNotFoundException (in the repro its newest commit file is deleted, as in the upstream test testExceptionDuringRefresh). Then closing the index reader just reopened fails: every file close inside it throws an injected IOException (two in the test run), and Lucene's IOUtils.close keeps the first and suppresses the rest, so the manager sees one close failure.
  3. You expect the documented IOException: the taxonomy's IndexNotFoundException, with the close failure attached to it as a suppressed exception.
  4. Instead you get an unchecked IllegalArgumentException: Self-suppression not permitted, caused by the close failure. The IndexNotFoundException is in neither the cause nor the suppressed list: the cleanup calls t2.addSuppressed(t2), which Java always rejects, so throw t1 never runs.
  5. Nothing leaks and, once the faults are gone, the next refresh works; but catch (IOException e) misses it: a refresh task scheduled every 5 ms that catches only IOException is cancelled after this 1 refresh, and its searcher then stays stale; with only the taxonomy failure it catches the IOException and keeps running (93 runs in the test).

Who is affected

Applications that use taxonomy facets through SearcherTaxonomyManager (the opt-in facet module), 7.4.0 to 10.6.0 and main, when one refresh suffers two failures at once, in practice two I/O errors. The root cause is lost, and code catching the documented IOException misses the unchecked exception. Nothing leaks and the index is not damaged.

Mechanism, terms and numbers421 words

SearcherTaxonomyManager refreshes an IndexSearcher and its facet TaxonomyReader together: it reopens the index reader, then the taxonomy reader. If the taxonomy reopen throws, it closes the index reader it just opened and rethrows the taxonomy error, a cleanup added in 7.4.0 by LUCENE-8244 to stop a file-handle leak. When that close also throws, the cleanup calls t2.addSuppressed(t2) (SearcherTaxonomyManager.java:209) where t1.addSuppressed(t2) was meant. Java rejects self-suppression with IllegalArgumentException("Self-suppression not permitted"), so maybeRefresh() and maybeRefreshBlocking() throw that unchecked exception, caused by the close failure, instead of the documented IOException, and the taxonomy failure is lost from the whole chain. Nothing leaks, the index is not damaged and, once the faults are gone, the next refresh works; but code that catches only IOException misses it, and a scheduled refresh task that does so is cancelled, leaving its searcher stale. 7.3.1 passes, 7.4.0 fails and 7.4.0 with the one-token fix passes; every release executed (7.4.0, 7.6.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2) fails, and 10.6.0 and main have the same line. Low priority: it needs two failures in one refresh.

Mechanism (Lucene main 645d4fe14a20)

// SearcherTaxonomyManager.refreshIfNeeded, condensed
IndexReader newReader = DirectoryReader.openIfChanged(dr, refreshCommit);   // :195  index first
...
try {
  tr = TaxonomyReader.openIfChanged(ref.taxonomyReader);                     // :204  taxonomy: throws t1
} catch (Throwable t1) {
  try {
    IOUtils.close(newReader);                                                // :207  close: throws t2
  } catch (Throwable t2) {
    t2.addSuppressed(t2);                                                    // :209  always throws
  }
  throw t1;                                                                  // :211  skipped
}
  1. ReferenceManager.doMaybeRefresh calls refreshIfNeeded (ReferenceManager.java:167). Line 195 reopens the index reader; line 204 reopens the taxonomy reader, which throws t1.
  2. Line 207 closes the new index reader. When that throws t2, line 209 calls t2.addSuppressed(t2).
  3. Throwable.addSuppressed starts with if (exception == this) throw new IllegalArgumentException(SELF_SUPPRESSION_MESSAGE, exception); (JDK Throwable.java:1099-1101; javadoc: "a throwable cannot suppress itself", since Java 7). So line 209 always throws, with t2 as the cause.
  4. That exception leaves the inner catch, so throw t1 is skipped and t1 is dropped. It is unchecked, so it passes refreshIfNeeded's throws IOException and comes out of maybeRefresh / maybeRefreshBlocking.
  5. doMaybeRefresh's finally blocks release the current reference, notify listeners and unlock (ReferenceManager.java:180-187): the manager is unchanged and usable.

What the caller gets on main, with assertions on and off:

java.lang.IllegalArgumentException: Self-suppression not permitted
    at java.base/java.lang.Throwable.addSuppressed(Throwable.java:1101)
    at org.apache.lucene.facet.taxonomy.SearcherTaxonomyManager.refreshIfNeeded(SearcherTaxonomyManager.java:209)
    ...
Caused by: java.io.IOException: simulated close failure        <- the close failure (t2)
    ...
                                                                <- IndexNotFoundException (t1): nowhere

(The arrows are ours.) A search of Lucene's Java sources for the same-identifier pattern x.addSuppressed(x) finds no other place.

Exposure and likelihood333 words
  • Who. Applications that use taxonomy facets through SearcherTaxonomyManager, with any of its constructors, on 7.4.0 through 10.6.0 and main.
  • When. Only when one refresh suffers two failures at once: the taxonomy reopen throws (an I/O error, an unreadable taxonomy commit, or any other Throwable) and closing the index segment files opened moments earlier also throws. Each is an ordinary environmental fault, but the second is uncommon on its own, so the pair is rare; the recorded searches found no report of it.
  • What goes wrong. An unchecked IllegalArgumentException instead of the documented IOException, and the root cause, the taxonomy failure, is lost from logs and diagnostics; the message points at the JDK, not at the taxonomy.
  • Code that catches IOException misses it (executed on main with assertions on and off, 10.5.2 and 9.12.3): a ScheduledExecutorService task that calls maybeRefresh() every 5 ms and catches only IOException is cancelled by the JDK after the double-failure refresh (1 tick); in that setup, where nothing inspects the task's ScheduledFuture, nothing is logged, and a document deleted in the new commit is still returned until the application refreshes by hand. With only the taxonomy failure, the task catches one IOException and keeps running (93 ticks); with the fix it keeps running too (77 ticks).
  • Lucene's own ControlledRealTimeReopenThread ends on any refresh IOException (it wraps it in a RuntimeException), so for it only the exception type and message change.
  • By reading only: if the taxonomy failure is an Error, such as an OutOfMemoryError, and the close also fails, the caller gets the IllegalArgumentException instead of the Error.
  • What it does not do: no leak (the new reader's files are closed) and no index damage. The failed refresh keeps the previous searcher, as any failed refresh does, and once the faults are gone a later refresh succeeds (executed with transient faults). Results go stale only while nothing refreshes, for example after the application's refresh task died on the unchecked exception (above).

Minimal repro

One JUnit test, public API plus the test framework's MockDirectoryWrapper: it deletes the taxonomy's newest segments_N, as the upstream testExceptionDuringRefresh does, makes file closes inside SegmentReader.doClose throw an IOException, and expects the taxonomy's IndexNotFoundException from maybeRefreshBlocking().

TestSearcherTaxonomyManagerDoubleFailure.java66 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.facet.taxonomy;

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyWriter;
import org.apache.lucene.index.IndexNotFoundException;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.SegmentInfos;
import org.apache.lucene.index.SegmentReader;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.store.MockDirectoryWrapper;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.IOUtils;

public class TestSearcherTaxonomyManagerDoubleFailure extends LuceneTestCase {

  public void testTaxonomyFailureSurvivesReaderCloseFailure() throws Exception {
    MockDirectoryWrapper indexDir = newMockDirectory();
    Directory taxoDir = newDirectory();
    IndexWriter w = new IndexWriter(indexDir, newIndexWriterConfig());
    DirectoryTaxonomyWriter tw = new DirectoryTaxonomyWriter(taxoDir);
    w.commit();
    tw.commit();
    SearcherTaxonomyManager mgr = new SearcherTaxonomyManager(indexDir, taxoDir, null);

    tw.addCategory(new FacetLabel("a", "b"));
    w.addDocument(new Document());
    tw.commit();
    w.commit();

    taxoDir.deleteFile(SegmentInfos.readLatestCommit(taxoDir).getSegmentsFileName());
    MockDirectoryWrapper.Failure closeFails =
        new MockDirectoryWrapper.Failure() {
          @Override
          public void eval(MockDirectoryWrapper dir) throws IOException {
            if (doFail && callStackContains(SegmentReader.class, "doClose")) {
              throw new IOException("simulated close failure");
            }
          }
        };
    indexDir.failOn(closeFails);
    closeFails.setDoFail();

    // expected: the taxonomy's IndexNotFoundException; actual: IllegalArgumentException (self-suppression)
    expectThrows(IndexNotFoundException.class, mgr::maybeRefreshBlocking);

    closeFails.clearDoFail();
    IOUtils.close(w, tw, mgr, indexDir, taxoDir);
  }
}
Command
cp $REPRO/TestSearcherTaxonomyManagerDoubleFailure.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/taxonomy/
cd $LUCENE && ./gradlew :lucene:facet:test --tests org.apache.lucene.facet.taxonomy.TestSearcherTaxonomyManagerDoubleFailure -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
Expected
maybeRefreshBlocking() throws the taxonomy's IndexNotFoundException, with the close IOException attached as suppressed.
Observed
IllegalArgumentException: Self-suppression not permitted, caused by the injected close IOException; the IndexNotFoundException appears nowhere (assertions on and off).

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

More files, environment, full output196 words

One test class, one method, single thread, nothing random that matters; the only subclass is the test framework's MockDirectoryWrapper.Failure, no reader or manager class. The close failure fires on every file close inside SegmentReader.doClose (two in the test run, which reach the manager as one close failure, the second suppressed in the first), which during maybeRefreshBlocking() runs only when the manager closes the index reader it just reopened after the taxonomy reopen failed. The failure is cleared before cleanup.

This same test is the suggested regression test: it fails on stock main and passes with both fixes.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with either fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (module prefixes and some frames cut)
TestSearcherTaxonomyManagerDoubleFailure > testTaxonomyFailureSurvivesReaderCloseFailure FAILED
    junit.framework.AssertionFailedError: Unexpected exception type, expected IndexNotFoundException but got java.lang.IllegalArgumentException: Self-suppression not permitted
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:C54840C3490FFE44]:0)
        ...
        at org.apache.lucene.facet.taxonomy.TestSearcherTaxonomyManagerDoubleFailure.testTaxonomyFailureSurvivesReaderCloseFailure(TestSearcherTaxonomyManagerDoubleFailure.java:61)

        Caused by:
        java.lang.IllegalArgumentException: Self-suppression not permitted
            at java.base/java.lang.Throwable.addSuppressed(Throwable.java:1101)
            at org.apache.lucene.facet.taxonomy.SearcherTaxonomyManager.refreshIfNeeded(SearcherTaxonomyManager.java:209)
            at org.apache.lucene.facet.taxonomy.SearcherTaxonomyManager.refreshIfNeeded(SearcherTaxonomyManager.java:40)
            at org.apache.lucene.search.ReferenceManager.doMaybeRefresh(ReferenceManager.java:167)
            at org.apache.lucene.search.ReferenceManager.maybeRefreshBlocking(ReferenceManager.java:240)
            ...

            Caused by:
            java.io.IOException: simulated close failure
                at org.apache.lucene.facet.taxonomy.TestSearcherTaxonomyManagerDoubleFailure$1.eval(TestSearcherTaxonomyManagerDoubleFailure.java:53)
                ...
                at org.apache.lucene.index.SegmentReader.doClose(SegmentReader.java:217)
                ...
                at org.apache.lucene.facet.taxonomy.SearcherTaxonomyManager.refreshIfNeeded(SearcherTaxonomyManager.java:207)
                ... 7 more

1 test completed, 1 failed

Why it looks unintended

  • x.addSuppressed(x) can never succeed, and the block ends with throw t1: the intent is to rethrow the taxonomy error with the close failure attached.
  • maybeRefresh and maybeRefreshBlocking document @throws IOException; the upstream test added with this code asserts the taxonomy's IndexNotFoundException for the single-failure case.
  • The sibling DirectoryTaxonomyReader.doOpenIfChanged does the same cleanup with IOUtils.closeWhileSuppressingExceptions(t, r2), which keeps the primary failure.
The full argument194 words
  • The code states its intent. The block closes the new reader and then throw t1 (SearcherTaxonomyManager.java:211). t2.addSuppressed(t2) can never succeed (the JDK rejects it by contract), so no design calls it; the evident intent is t1.addSuppressed(t2).
  • The contract. maybeRefresh and maybeRefreshBlocking: "@throws IOException if refreshing the resource causes an IOException" (ReferenceManager.java:203, :231); refreshIfNeeded: "@throws IOException if the refresh operation failed" (:72). Nothing documents an IllegalArgumentException.
  • The test that came with this code. TestSearcherTaxonomyManager.testExceptionDuringRefresh (:343-366), added by the same LUCENE-8244 commit, deletes the taxonomy's newest segments_N and asserts expectThrows(IndexNotFoundException.class, mgr::maybeRefreshBlocking). Its close succeeds, and no upstream test makes that close fail.
  • The codebase idiom. DirectoryTaxonomyReader.doOpenIfChanged handles the same situation with catch (Throwable t) { IOUtils.closeWhileSuppressingExceptions(t, r2); throw t; } (DirectoryTaxonomyReader.java:221-223).
  • History. The line came in the LUCENE-8244 patch ("Do not leak open file descriptors in SearcherTaxonomyManager's refresh on exception", 7.4.0 Bug Fixes); its issue (#9291) holds only the patch note and two commit notices (the JIRA record adds the 2022 migration notice), so nobody discussed this line there. Before that fix the taxonomy failure propagated (and the reader leaked).
The devil's-advocate check248 words

The strongest "works as intended or not worth it" case (the refute and intended-lens reviews and the devil's-advocate verifier): it needs two simultaneous failures; nothing leaks and the IllegalArgumentException still carries the close failure as its cause; the classes involved are @lucene.experimental: ReferenceManager (ReferenceManager.java:35), whose maybeRefresh javadoc is the contract quoted above, TaxonomyReader (TaxonomyReader.java:57) and DirectoryTaxonomyReader (DirectoryTaxonomyReader.java:61), while SearcherTaxonomyManager itself has no such tag; Java throws clauses are not exhaustive and no exception priority is documented for two failures at once; and the repro deletes an index file, while Lucene's SECURITY.md treats index files as trusted.

Why it fails: @lucene.experimental says the API may change between releases, not that a call which can never succeed is designed behaviour; none of this makes x.addSuppressed(x) meaningful, the block itself says throw t1, and the test that came with it asserts the taxonomy's exception. The deleted segments_N is the upstream test's own way to make the taxonomy reopen fail, and there Lucene asserts a clean IndexNotFoundException; an independent reproducer gets the same result with a plain injected IOException during DirectoryTaxonomyReader.doOpenIfChanged and no file deleted. Rarity and the unharmed state argue for Low priority, which is how it is classed, not for intent. The cross-model lenses rated the intended-behaviour argument weak; a later cross-model fact check rated it moderate, citing the stale searcher left by a dead refresh task, the experimental tags and fix B's Error handling, all three covered in this doc.

Suggested fix

Recommended B. Use IOUtils.closeWhileSuppressingExceptions(t1, newReader), the module's idiom

Replace the hand-written cleanup with IOUtils.closeWhileSuppressingExceptions(t1, newReader); throw t1;, the idiom DirectoryTaxonomyReader.doOpenIfChanged already uses: the taxonomy error is rethrown with the close failure attached. The repro passes with assertions on and off, and the facet module's 307 tests run with 0 failures (7 skipped). The one-token t1.addSuppressed(t2) also works and suits backports.

       } catch (Throwable t1) {
-        try {
-          IOUtils.close(newReader);
-        } catch (Throwable t2) {
-          t2.addSuppressed(t2);
-        }
+        IOUtils.closeWhileSuppressingExceptions(t1, newReader);
         throw t1;
       }
All options and trade-offs2 options · 377 words

Both options were run through gradle in a scratch worktree of 645d4fe14a20 and both fix the repro. B is recommended for main; A is the smallest change and the one for branches that lack the helper. Both change only the double-failure path: a single taxonomy failure still propagates unchanged (the upstream testExceptionDuringRefresh passes with both).

B. Use IOUtils.closeWhileSuppressingExceptions(t1, newReader), the module's idiom (validated by the checks) (recommended)

In SearcherTaxonomyManager.refreshIfNeeded (SearcherTaxonomyManager.java:205-212), -5/+1.

       } catch (Throwable t1) {
-        try {
-          IOUtils.close(newReader);
-        } catch (Throwable t2) {
-          t2.addSuppressed(t2);
-        }
+        IOUtils.closeWhileSuppressingExceptions(t1, newReader);
         throw t1;
       }

Trade-offs

  • The same code as the sibling DirectoryTaxonomyReader.java:221-223; it removes the hand-written block that held the typo.
  • By reading IOUtils.java:114-137: the close failure is added to t1 as suppressed and t1 is rethrown; if the close throws an Error and t1 is not one, that Error is thrown instead, with t1 suppressed.
  • The helper exists from 10.4.0 on (added by #14633; absent at the 10.3.2 and 9.12.3 tags), so a backport to an older branch would use A.

What the checks validated

  • Stock 645d4fe14a20: the repro fails with assertions on and off.
  • With B: the repro and TestSearcherTaxonomyManager pass with assertions on and off (7 tests, 0 failures, 1 skipped: testNRT is @Nightly); the whole :lucene:facet:test passes with assertions on (40 suites, 307 tests, 0 failures, 7 skipped).
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of :lucene:facet pass once the repro's long comment on line 60 is wrapped onto two lines, which the formatter asks for (comment only; the fixed class was not flagged).

A. One token: t1.addSuppressed(t2) (validated by the checks)

In SearcherTaxonomyManager.java:209, +1/-1.

         } catch (Throwable t2) {
-          t2.addSuppressed(t2);
+          t1.addSuppressed(t2);
         }

Trade-offs

  • The evident intent and the smallest change; it applies unchanged to every affected branch back to 7.4.
  • An Error thrown by the close is attached to t1 as suppressed instead of being thrown; the hand-written block stays.

What the checks validated

  • With A: the repro and TestSearcherTaxonomyManager pass with assertions on and off (7 tests, 0 failures, 1 skipped).
  • Earlier checks ran it on main ea57360912ed (the finder's, the independent reproducer's and the stock-classes tests, the periodic-refresh probe: all pass) and on 7.4.0 (passes).

Downstream note

A read-only code search found no use of SearcherTaxonomyManager in Solr, Elasticsearch or OpenSearch, so the exposure is mainly applications that use Lucene's facet module directly.

How it was found

Code reading of the refresh exception paths of Lucene's near-real-time reference managers, while writing a TLA+ model of ReferenceManager and SearcherManager. The model does not cover SearcherTaxonomyManager and no model-checker trace showed this, so it is a code-reading find; a JUnit test then confirmed it.

The method in full142 words

Method: code reading (the study's non-formal tier). A hunting AI tool working on the near-real-time reference managers wrote a TLA+ model of ReferenceManager and SearcherManager with refresh and reader-close failures, and read the refresh exception paths of the subclasses while doing so. It noticed t2.addSuppressed(t2) in SearcherTaxonomyManager.refreshIfNeeded.

  • The model covers ReferenceManager and SearcherManager, not SearcherTaxonomyManager, and no TLC trace produced this signal; so the find is credited to code reading, not to the model.
  • The finder confirmed it with a JUnit test that uses two protected extension points (a DirectoryTaxonomyReader subclass whose doOpenIfChanged throws, and a FilterDirectoryReader whose reopened instances fail in doClose).
  • Independent checks then reproduced it without subclassing any reader: one with plain injected I/O errors on the public API, one with the upstream testExceptionDuringRefresh scenario plus injected close errors; that scenario became the minimal repro.

Prior reports, history and checks

Prior reports

No prior report: issues and PRs, the JIRA archive, the dev@, java-user@, issues@, commits@ and builds@ lists and the web (151 queries, 2026-10-26; refreshed 2026-11-01) found only LUCENE-8244 (#9291), the change that introduced the line. No open PR touches the file.

Searches4 searches

Verdict new. The only related item is the introducing change, which reported a different defect (the file-handle leak); its thread never mentions suppression or a double failure. Every "Self-suppression not permitted" CI trace on the lists comes from another call site.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-26; refreshed 2026-11-01the scripted prior-report search plus 37 GraphQL searches: exact strings (t2.addSuppressed, addSuppressed(t2), "Self-suppression not permitted"), class and method names, LUCENE-8244, recent activity; refresh: SearcherTaxonomyManager (updated since 2026-10-20, and open PRs), Self-suppression, addSuppressed, t1.addSuppressedonly #9291 (LUCENE-8244, introduced the line); #14633 (added IOUtils.closeWhileSuppressingExceptions), #16278 / #16303 (suppressed close failures elsewhere), #13976 (open, SearcherManager and its test): not this bug
Open and closed-unmerged PRs by touched file2026-10-26; refreshed 2026-11-01the file lists of all 413 open and 617 closed-unmerged PRs; refresh: the 58 open PRs updated since 2026-10-26no PR changes the line; none of the refreshed PRs touches the file
Mailing lists (lists.apache.org)2026-10-26dev@, java-user@, issues@, commits@, builds@, solr-user@ (18 direct searches plus the scripted search's list queries); 21 CI mails read in fullonly the three LUCENE-8244 commit mails; the 11 "Self-suppression not permitted" CI traces come from other call sites
JIRA archive and the web2026-10-26the LUCENE-8244 record and Self-suppression / addSuppressed text searches; 10 web queries; downstream code searchesno report

History

Introduced on 2018-04-28 by the LUCENE-8244 leak fix (4fba55c8642; backport 9f2eda7a1fc), first released in 7.4.0. 7.3.1 passes, 7.4.0 fails, and 7.4.0 with the one-token fix passes; every release executed (nine from 7.4.0 to 10.5.2) fails, and 10.6.0 and main have the same line.

Versions tested7 rows

Introduced by 4fba55c8642 "LUCENE-8244: Do not leak open file descriptors in SearcherTaxonomyManager's refresh on exception" (Mike McCandless, 2018-04-28) and its same-day branch_7x backport 9f2eda7a1fc, which 7.4.0 contains. No PR: the patch attached to the JIRA issue (now #9291) already had t2.addSuppressed(t2). git log --all -S 't2.addSuppressed(t2)' finds these two commits and aad44bc6e36 (2021, "Leave a note saying where branch_8x lives."), which deleted every file of the old lucene-solr branch_8x; no commit changes or fixes the line. The line is at every release tag from 7.4.0 to 10.6.0 and at none from 7.0.0 to 7.3.1; the clone has no tags for 8.8.2 to 8.11.4, so 8.8.2 to 8.11.3 were neither run nor read (they lie between 8.8.1, line present, and 8.11.4, fails). Every run used JDK 25; the deciding JDK rule has been specified since Java 7.

Versions and branches executed, with results
Version or commitVerdictResult
7.3.1 not affectedpasses: the taxonomy IOException propagates (7.3.1 never closes the new reader; it leaks 4 index files instead, the old LUCENE-8244 bug)
7.4.0 affectedfails with assertions on and off: "Self-suppression not permitted", taxonomy failure missing
7.4.0 + t1.addSuppressed(t2) causalitypasses: the taxonomy IOException with the close failure suppressed
7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affectedfail (release jars)
10.5.2 affectedfails with assertions on and off
10.6.0, branch_10_6, branch_11x, upstream main 2f424fd428d by readingthe same file as 645d4fe14a20 (line 209 unchanged)
main 645d4fe14a20 affectedthe minimal test fails through gradle with assertions on and off; passes with either fix

Independent checks

Confirmed with high confidence: the finder, an independent reproducer, a devil's-advocate verifier and an adjudication each reproduced it, two of them without subclassing any reader. A reviewer from another model family, unable to run code, returned a procedural "not real" while agreeing with the root cause.

Checks and verdicts391 words

Everyone agrees on the root cause and that the harm is a wrong exception only.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit with a DirectoryTaxonomyReader subclass and a FilterDirectoryReader fails on main (assertions on and off, 20/20 iterations), 10.5.2 and 9.12.3; passes with t1.addSuppressed(t2).
cross-model review, refute lens (OpenAI GPT-5.6, read-only) not real, low (procedural)Could not run code; its notes say the verdict "is not a technical refutation". Root cause agrees, inputs in contract, intended argument weak. A separate run re-ran the repro: fails.
independent reproducer realOwn test from the claim alone, public API plus injected I/O errors: fails on main (assertions on and off, 25/25 random seeds), 10.5.2 and 9.12.3; the control passes; no leaked files; the fix passes.
cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, mediumIntended-behaviour argument weak; the fix fixes it. A separate run re-ran the repro on main: fails; with the fix: passes.
devil's-advocate verifier real, low severityThe upstream testExceptionDuringRefresh plus injected close IOExceptions fails on main (assertions on and off, 4 seeds), 10.5.2 and 9.12.3, and passes with the fix; no intended-behaviour argument survived.
adjudication confirmed, highRe-ran the reproducer's test (fails with assertions on and off; passes with the fix) and the stock-classes test (fails). Wrong error, low severity.
history, security screen, audit, re-checks 7.4.0 first affected; security none; confirmed20 history runs (7.3.1 passes, 7.4.0 to 10.5.2 and main fail, 7.4.0 with the one-token fix passes); the periodic-refresh probe (task cancelled after 1 tick; the fix keeps it running); the credibility audit re-runs; the 2026-10-26 current-main re-check; the 2026-10-29 minimization on 645d4fe14a20.
this doc (2026-11-01) reproduced, both fixes validatedGradle on 645d4fe14a20: the minimal test fails with assertions on and off; fix B passes it, the facet module's 307 tests run with 0 failures (7 skipped), and lint passes; fix A passes it and TestSearcherTaxonomyManager.
fact checks of this doc (2026-11-01) bug real; text correctedAn independent fact check re-ran the minimal test and both fixes (assertions on and off, through gradle and standalone) and counted the injected close failures; a cross-model fact check (OpenAI GPT-5.6) re-read the code, history and logs. Both found the bug real and not security-sensitive; the text errors they found are corrected here.

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

Finding record

LFV-264, snapshot of 2026-11-06

Registry title
SearcherTaxonomyManager refresh calls t2.addSuppressed(t2), so when the taxonomy reopen fails and closing the just-reopened index reader also fails, maybeRefresh throws IllegalArgumentException ('Self-suppression not permitted') and the taxonomy failure is lost
Area
facet / taxonomy (SearcherTaxonomyManager.refreshIfNeeded exception path)
Priority
Low, rank 1080: SearcherTaxonomyManager throws a self-suppression IllegalArgumentException that hides the taxonomy failure, but only when two I/O failures hit one refresh.
Impact class
wrong error
Impact note
Applications on Lucene 7.4.0 through 10.6.0 (10.6.0 by reading) or main that use SearcherTaxonomyManager (taxonomy facets with NRT, an opt-in module) are hit only when one refresh suffers two failures at once, the taxonomy reopen fails and closing the just-reopened index reader also fails (in practice two I/O errors), and then maybeRefresh() or maybeRefreshBlocking() throws an unchecked IllegalArgumentException whose cause is the secondary close failure instead of the documented IOException carrying the taxonomy failure, which is missing from the whole exception chain; nothing leaks, no data is wrong and the manager stays usable, but a caller that catches only IOException is bypassed (executed: a periodic ScheduledExecutorService refresh task that catches IOException is silently cancelled after that one refresh, so the searcher stays at its old point in time until the application refreshes again).
Summary
You use SearcherTaxonomyManager, and one refresh hits two I/O failures at once: reopening the taxonomy fails, and closing the index reader that was just reopened also fails. You expect maybeRefresh() to throw the taxonomy failure as an IOException, its documented contract, with the close failure attached as a suppressed exception. Instead it throws IllegalArgumentException('Self-suppression not permitted'), because the cleanup code calls t2.addSuppressed(t2) where t1.addSuppressed(t2) was meant, so the taxonomy failure is lost entirely and callers that catch IOException do not see it.
Introduced
4fba55c864298848a07c2971738947473484006e (master), 'LUCENE-8244: Do not leak open file descriptors in SearcherTaxonomyManager's refresh on exception', mikemccand, 2018-04-28; the same-day branch_7x backport 9f2eda7a1fc6d4cec70a8a76f3401a6bd53a48b1 is the commit that 7.4.0 contains. No pull request: the change went in as LUCENE-8244.patch on JIRA LUCENE-8244 (now #9291), and the attached patch already contains t2.addSuppressed(t2) [read: git blame of lines 203-212 and git log --all -S 't2.addSuppressed(t2)' in the full-history clone find only these two commits; exec: 7.3.1 passes, 7.4.0 fails, and 7.4.0 with the one-token fix shadowed passes]. A defect in the LUCENE-8244 fix itself: before it, the same path leaked the reopened index reader (the LUCENE-8244 bug) but did propagate the taxonomy failure (2018-04-28)
First affected version
7.4.0 [exec] (tag 2018-06-18; its CHANGES.txt lists LUCENE-8244 under Bug Fixes); last unaffected release 7.3.1 [exec] (tagged 2018-05-06 from branch_7_3, which never received the backport; it reports the taxonomy failure but leaks the reopened reader, the pre-existing LUCENE-8244 bug)
Affected versions
every release from 7.4.0 through 10.5.2, 10.6.0 (by reading), and main ea57360912ed [read: the line is present at every release tag 7.4.0..10.5.2 in the history clone (the 8.9.0-8.11.x tags are not in the clone; 8.11.4 was executed) and absent at every tag 7.0.0..7.3.1, git log --all -S shows no removal on any branch, and branch_10x and branch_10_6 (:209) and branch_9_12 (:174) still have it; exec: a public-API history probe fails on 7.4.0 (-ea and -da), 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da) and passes on 7.3.1; the finder's repro fails on 7.6.0, 8.11.4, 9.12.3, 10.5.2 and main (it does not compile on 7.5.0, whose SearcherTaxonomyManager has no reader-based constructor yet); the independent reproduction's and the intended-behaviour check's repros fail on main (-ea and -da), 10.5.2 and 9.12.3]; not fixed upstream. All runs used JDK 25; the deciding JDK behaviour (Throwable.addSuppressed(this) throws IllegalArgumentException) is specified since Java 7, so the 2018-era outcome follows from that contract but was not executed on Java 8
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26: 151 recorded queries, namely two scripted prior-report searches (55 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web, then 24 over the lists and JIRA only), 54 GitHub GraphQL searches (37 on apache/lucene, 17 downstream and global) and 18 direct lists.apache.org searches including builds@ and solr-user@; plus the changed files of all 413 open and all 617 closed-unmerged apache/lucene PRs, the JIRA REST record of LUCENE-8244, 21 CI failure mails read in full, and a full local history search; every medium-overlap hit and 17 recorded items read, none the same bug). The only related item is the introducing issue #9291 / LUCENE-8244 (mikemccand, 2018, the file-handle leak): its fix created the line, and its thread never mentions suppression or a double failure; every 'Self-suppression not permitted' CI trace comes from another call site. Not fixed upstream: upstream main was still at ea57360912ed with a byte-identical file at search time, the line is on branch_10x, branch_10_6 and branch_9_12, and no open or closed-unmerged PR changes it
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off (IllegalArgumentException: Self-suppression not permitted at SearcherTaxonomyManager.java:209, caused by the injected close failure); with fix B it passes, and the whole facet module passes (40 suites, 307 tests, 0 failures, 7 skipped) with the module's format, ECJ and forbidden-apis checks; with fix A the test and TestSearcherTaxonomyManager pass. The same SearcherTaxonomyManager.java is on upstream main 2f424fd428d, branch_11x, branch_10_6 and 10.6.0 (read). Earlier runs: a version-portable probe on 7.3.1 (pass) and 7.4.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main ea57360912ed (fail), the hunt's and verifiers' tests on ea57360912ed and a9779ef3c193, and the minimal test, standalone, on 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:188-225 (at ea57360912ed) (refreshIfNeeded: index reopen first, then the taxonomy), :203-212 (try TaxonomyReader.openIfChanged :204; catch (Throwable t1) :205; IOUtils.close(newReader) :207; catch (Throwable t2) :208; t2.addSuppressed(t2) :209; throw t1 :211)
  • lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:72-74 (at ea57360912ed) (refreshIfNeeded '@throws IOException if the refresh operation failed'), :203 and :231 (maybeRefresh and maybeRefreshBlocking '@throws IOException if refreshing the resource causes an IOException')
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestSearcherTaxonomyManager.java:343-364 (at ea57360912ed) (testExceptionDuringRefresh, added by LUCENE-8244: deletes the taxonomy segments file :363 and asserts IndexNotFoundException from maybeRefreshBlocking :364; single failure only); lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:222 (IOUtils.closeWhileSuppressingExceptions(t, r2), the sibling idiom); lucene/CHANGES.txt:7443-7444 (LUCENE-8244 entry in the Lucene 7.4.0 section, heading :7306), :7160-7161 (LUCENE-8459: the reader-based constructor allows a FilterDirectoryReader); lucene/core/src/java/org/apache/lucene/search/ControlledRealTimeReopenThread.java:214-215 (a refresh IOException is wrapped in RuntimeException, ending the thread)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:187-225 (195, 203-212; 209 the self-suppression)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:154-188 (167 refreshIfNeeded call; 180-187 finally blocks)
  • JDK 25 lib/src.zip java.base/java/lang/Throwable.java:246, 1094-1101 (addSuppressed self-suppression check)
  • a Lucene main checkout (645d4fe14a20) git grep -nP '\b(\w+)\.addSuppressed\(\1\)' -- '*.java': one hit, lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:209
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ControlledRealTimeReopenThread.java:212-216
Minimal repro
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestSearcherTaxonomyManager.java:343-366
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:203-212
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:72, 203, 231
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestSearcherTaxonomyManager.java:343-366 (testExceptionDuringRefresh; 361-364 delete segments file, expect IndexNotFoundException)
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:221-223 (closeWhileSuppressingExceptions idiom)
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:7476-7477 (LUCENE-8244, 7.4.0 Bug Fixes; section heading :7339, Bug Fixes :7454)
  • a Lucene main checkout (645d4fe14a20) @lucene.experimental: lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:35, lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:57, lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:61; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:34-41 (no tag)
  • SECURITY.md:30-32 (index files are trusted)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/IOUtils.java:97-137; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:221-223
History
  • a Lucene clone (fetched 2026-10-31): blob 64f906a8f03 of lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java at releases/lucene/10.6.0, origin/branch_10_6, origin/branch_11x, origin/main 2f424fd428d

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 the code at 645d4fe14a20 and the repro's input and output.

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.