All findings · Lucene finding · LFV-1224 · closewhilesuppressing-self-suppression-iae

IOUtils.closeWhileSuppressingExceptions stops closing when a close() rethrows the exception being handled

When Lucene cleans up after a failure, IOUtils.closeWhileSuppressingExceptions(t, a, b) closes a and b and attaches their failures to t. If a.close() throws t itself, as the JVM's shared OutOfMemoryError can, the helper throws IllegalArgumentException: Self-suppression not permitted and never closes b.

Summary

Priority
Low, rank 836On the cleanup path of a failed reader open or codec reader/writer (and on main the IndexWriter constructor, where the write lock stays held), a close() that rethrows the very Throwable being handled, as the JVM's shared OutOfMemoryError can, becomes IllegalArgumentException 'Self-suppression not permitted' and leaves the remaining resources open, in 10.4.0 through 10.6.0 and on main. It runs on default settings, but the trigger is rare and the failure is loud with the original kept as the cause.
Impact
minor
Since
10.4.0 (#15445; on main #14633, 2025-05-13)introduced 2025-05-13 · about 1 year
Affected
10.4.0 to 10.6.0 and main
Status
documented
Found by
a proof-obligation audit of IOUtils's contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

The helper closes each closeable and attaches each close failure to ex with ex.addSuppressed(e). Java does not let a Throwable suppress itself. When a close() throws ex itself, that call throws IllegalArgumentException out of the loop, so the closeables after it are skipped and the caller's own rethrow never runs.

Diagram of IOUtils.closeWhileSuppressingExceptions(oom, first, second). The handled exception is an OutOfMemoryError. first.close() throws that same OutOfMemoryError object; the helper calls oom.addSuppressed(oom) at IOUtils.java:129, which throws IllegalArgumentException 'Self-suppression not permitted', and the loop ends: second.close() never runs and the caller gets the IllegalArgumentException instead of the OutOfMemoryError. Expected: both closeables closed and the same OutOfMemoryError rethrown.
The repro's cleanup loop: the first close() throws the same OutOfMemoryError object that is being handled, ex.addSuppressed(ex) throws IllegalArgumentException, and the loop ends before the second close(). Select the still to open it at full size. Generated with AI, checked against the code.
  1. A Lucene operation fails with a Throwable t, say an OutOfMemoryError inside DirectoryReader.open. Its cleanup calls IOUtils.closeWhileSuppressingExceptions(t, first, second) to close what it opened, then rethrows t.
  2. first.close() throws the same object t. Out of heap this is normal: in a 64 MB-heap probe the first 4 OutOfMemoryErrors were distinct objects and every later one was one shared object.
  3. You expect what the javadoc promises: both closeables are closed and the same OutOfMemoryError is rethrown.
  4. Instead the helper calls t.addSuppressed(t) (IOUtils.java:129). Java throws IllegalArgumentException: Self-suppression not permitted, which leaves the loop: second.close() never runs, and the caller gets the IllegalArgumentException, with t only as its cause.
  5. On main the IndexWriter constructor cleans up this way. With two config components that throw one IOException object, new IndexWriter throws the IllegalArgumentException and keeps the write lock: a second new IndexWriter on the same directory fails with LockObtainFailedException.

Who is affected

Failure paths that clean up through this helper, with default settings: DirectoryReader.open from 10.4.0, codec readers and writers in 10.5 and 10.6, and 100 call sites on main, including the IndexWriter constructor. It needs one exception object thrown twice, as the JVM does out of heap. The error is loud; the leaked resources are silent. Rare, so priority Low.

Mechanism, terms and numbers445 words

After a failure, Lucene code calls IOUtils.closeWhileSuppressingExceptions(t, closeables...) to close what it had opened and then rethrows t (for example StandardDirectoryReader.java:102-104, and on main the IndexWriter constructor, IndexWriter.java:1206). The helper attaches each close failure e to t with t.addSuppressed(e) (IOUtils.java:129) and never checks that e is a different object. Throwable.addSuppressed rejects its own receiver with IllegalArgumentException("Self-suppression not permitted"), the argument as cause. So when a close() throws the very object being handled, that exception leaves the loop: the closeables after it are never closed, and the caller gets the IllegalArgumentException instead of t. This breaks the method's javadoc, "Even if a Error is thrown all given closeable are closed" (:109-110). The same object is thrown twice when the JVM is out of heap and reuses one OutOfMemoryError, or when an application component rethrows one stored exception. It is in every release with the helper, 10.4.0 to 10.6.0, and main; an identity check before addSuppressed fixes it.

Terms

  • IOUtils.closeWhileSuppressingExceptions(ex, closeables...): a cleanup helper (IOUtils is @lucene.internal, IOUtils.java:48) used on failure paths as catch (Throwable t) { IOUtils.closeWhileSuppressingExceptions(t, ...); throw t; }. It closes everything and records each close failure on ex; when ex is an Error, it rethrows that Error itself.
  • Suppressed exceptions: ex.addSuppressed(e) attaches e to ex. The JDK throws IllegalArgumentException("Self-suppression not permitted", e) when e == ex (JDK 25 Throwable.java:1100-1101).

The bug

// IOUtils.java:114-137 (condensed)
Error firstError = ex instanceof Error err ? err : null;   // :116  an Error ex is the one to throw
for (Closeable object : objects) {
  try {
    if (object != null) object.close();                   // :121
  } catch (Throwable e) {
    if (firstError == null && e instanceof Error err) {
      firstError = err;
      firstError.addSuppressed(ex);                        // :127
    } else {
      ex.addSuppressed(e);                                 // :129  e == ex: IllegalArgumentException,
    }                                                      //       thrown out of the loop
  }
}
if (firstError != null) throw firstError;                 // :134-135  never reached

The repro, step by step:

stepfirstErrorwhat happens
call with ex = oomoom (:116)
first.close() throws oomoomfirstError is set, so the else branch: oom.addSuppressed(oom) (:129)
Throwable.addSuppressedthrows IllegalArgumentException, cause oom, from inside the catch: it leaves the loop
second.close()never runs
throw firstError, the caller's throw tnever reached: the caller gets the IllegalArgumentException

A non-Error ex (an IOException that close() rethrows) takes the same :129 branch and fails the same way; the method should return normally there. Distinct exception objects behave as documented. The :127 call cannot self-suppress: it runs only when ex is not an Error and e is.

Exposure and likelihood271 words
  • Reach by version. 10.4.0: one in-tree caller, StandardDirectoryReader.open (behind DirectoryReader.open of a directory or commit). 10.5.0 to 10.5.2: five caller files. 10.6.0: six non-sandbox caller files (StandardDirectoryReader, Lucene90DocValuesProducer, Lucene99HnswVectorsWriter, Lucene104ScalarQuantizedVectorsReader and Writer, backward-codecs Lucene102BinaryQuantizedVectorsReader). Main: 72 non-sandbox source files with 100 call sites, including the IndexWriter constructor.
  • Trigger. One exception object thrown both by the failed operation and by a later close(). (a) The JVM's shared OutOfMemoryError: in a JDK 25 probe at -Xmx64m the first 4 heap OutOfMemoryErrors were distinct and every later one was the same object, with no stack trace. (b) An application Directory, IndexOutput, IndexDeletionPolicy or MergeScheduler that rethrows one stored exception: on main, IndexWriter.addDocument + commit() on a FilterDirectory whose postings output throws one IOException object twice surfaced the IllegalArgumentException from Lucene104PostingsWriter.<init>, where 10.6.0 surfaced the IOException.
  • Effect. Loud: an IllegalArgumentException whose cause is the original. Silent: the closeables after the one that threw stay open, such as the remaining segment readers of a failed DirectoryReader.open (file handles), or on main the IndexWriter write lock, so a second IndexWriter on the directory gets LockObtainFailedException: lock instance already obtained in the same process.
  • Caveat (by reasoning, not run). Under real heap exhaustion, allocating the IllegalArgumentException might itself fail, so the shared OutOfMemoryError would escape instead; the remaining closeables are skipped either way. Lucene CI mails have printed this IllegalArgumentException from other sites under heap exhaustion (2020, 2025).
  • Scale and likelihood. No scale: one call, single thread, assertions on or off. Rare: no public report of it was found since the helper shipped in 10.4.0.

Minimal repro

One JUnit test, single thread, no random values: an OutOfMemoryError, a first closeable whose close() throws that same object, and a second that records its close. It calls the public static helper of the @lucene.internal class IOUtils directly. It fails with assertions on and off on main and passes with the fix.

TestCloseWhileSuppressingSameThrowable.java (one test, lucene/core)41 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.util;

import java.io.Closeable;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestCloseWhileSuppressingSameThrowable extends LuceneTestCase {

  public void testCloseRethrowingHandledError() {
    // Expected: second is closed and oom is rethrown.
    // Actual: IllegalArgumentException "Self-suppression not permitted", second never closed.
    OutOfMemoryError oom = new OutOfMemoryError();
    AtomicBoolean secondClosed = new AtomicBoolean();
    Closeable first =
        () -> {
          throw oom;
        };
    Closeable second = () -> secondClosed.set(true);
    Throwable thrown =
        expectThrows(
            Throwable.class, () -> IOUtils.closeWhileSuppressingExceptions(oom, first, second));
    assertTrue("second closeable not closed", secondClosed.get());
    assertSame(oom, thrown);
  }
}
Command
cd $LUCENE
cp $REPRO/TestCloseWhileSuppressingSameThrowable.java lucene/core/src/test/org/apache/lucene/util/
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestCloseWhileSuppressingSameThrowable -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
second is closed and the same OutOfMemoryError is rethrown.
Observed
AssertionError: second closeable not closed: the call threw IllegalArgumentException: Self-suppression not permitted before reaching second.

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

More files, environment, full output144 words

The test draws no random values; it gave the same result with every seed and assertion mode run. It belongs in lucene/core/src/test/org/apache/lucene/util/ and passes :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs). It is the minimal test of the hunt with its one comment split into two lines.

The routes through new IndexWriter and IndexWriter.commit (application components that throw one exception object twice) are supporting evidence, not part of the repro.

Regression test: this same test.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); the same test also fails on 10.4.0, 10.5.2 and 10.6.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestCloseWhileSuppressingSameThrowable > testCloseRethrowingHandledError FAILED
    java.lang.AssertionError: second closeable not closed
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:AB6C3CE6ACC13F74]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.util.TestCloseWhileSuppressingSameThrowable.testCloseRethrowingHandledError(TestCloseWhileSuppressingSameThrowable.java:38)

# what was thrown (an earlier one-test variant with assertSame first, main, assertions on and off):
java.lang.AssertionError: expected same:<java.lang.OutOfMemoryError: shared instance> was not:<java.lang.IllegalArgumentException: Self-suppression not permitted>

Why it looks unintended

  • The javadoc: "Even if a Error is thrown all given closeable are closed" (IOUtils.java:109-110). Here the rest are skipped.
  • The closeWhileHandlingException code these paths used before still closed everything when one close() rethrew the handled object; the JDK's FilterOutputStream.close guards this exact case.
  • Plain try-with-resources also replaces the exception here, but it still closes every resource.
The full argument201 words
  • The contract. IOUtils.java:109-110 (the varargs overload, which delegates here, carries only the first sentence, :98). The code deliberately rethrows an Error ex (:116, :135); TestIOUtils.testCloseExposesErrors (TestIOUtils.java:76-110) asserts it is the same instance, but only with close failures that are distinct objects.
  • The replaced code handled the repro's case. Before the conversions (main #14633, 2025-05; StandardDirectoryReader.open in #14913, 2025-07-14; on the 10.x line #15445), these paths used a success flag and closeWhileHandlingException, which collects close failures with useOrSuppress and rethrows the first Error (10.3.2 IOUtils.java:113-135, :449-456). When one close() rethrows the handled object, that code closes everything and rethrows it; the new helper fails on that first rethrow. If two closes throw the same object, the old code also fails, in useOrSuppress (LFV-564's site; run against the 10.3.2 core jar).
  • Stated intent. CHANGES, LUCENE-5654: "Fix various close() methods that could suppress throwables such as OutOfMemoryError, instead returning scary messages that look like index corruption."
  • JDK precedent. FilterOutputStream.close: if (flushException != closeException) closeException.addSuppressed(flushException); (JDK 25 java/io/FilterOutputStream.java:195-196).
  • History. The review of #14633 (17 comments and reviews) never discusses a close() that rethrows ex; no javadoc, test, CHANGES or MIGRATE entry forbids it.
The devil's-advocate check166 words

The strongest case: plain try-with-resources (JLS 14.20.3.1) also calls primary.addSuppressed(closeFailure) without an identity check, so Java itself throws "Self-suppression not permitted" in this situation; IOUtils is @lucene.internal; the trigger is narrow; and the JDK's AutoCloseable.close javadoc advises "if it would cause problems for an exception to be suppressed, the AutoCloseable.close method should not throw it" (JDK 25 java/lang/AutoCloseable.java:82-84).

Why it fails: try-with-resources still closes every resource after that failure, while this helper skips the rest, which its own javadoc rules out. The skipped closes cannot. The AutoCloseable advice is aimed at application components; it cannot apply to the JVM's shared OutOfMemoryError. Being internal does not remove the reach, because the helper sits on default failure paths of public APIs. The executed cross-model review (OpenAI GPT-5.6) rated the intended-behaviour argument weak; a later cross-model fact check rated it moderate (a maintainer could reasonably close the report as won't-fix), though not enough to make it intended. What survives is a severity argument, hence priority Low.

Suggested fix

Recommended Skip suppression when close() rethrows an object the helper already holds

Skip addSuppressed when close() throws an object the helper already holds (e == ex || e == firstError), a 3-line change in IOUtils.java. Validated: the repro passes with assertions on and off; with it, TestIOUtils, TestDirectoryReader and TestIndexWriterExceptions pass (87 tests, 0 failures, 3 skipped), and so does :lucene:core:check -x test.

--- a/lucene/core/src/java/org/apache/lucene/util/IOUtils.java
+++ b/lucene/core/src/java/org/apache/lucene/util/IOUtils.java
@@ -121,7 +121,9 @@
           object.close();
         }
       } catch (Throwable e) {
-        if (firstError == null && e instanceof Error err) {
+        if (e == ex || e == firstError) {
+          // the same Throwable was rethrown by close(): nothing to suppress
+        } else if (firstError == null && e instanceof Error err) {
           // don't try and suppress it - this Error should be the thing that is thrown
           firstError = err;
           firstError.addSuppressed(ex);
All options and trade-offs3 options ยท 500 words

Validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-03): the repro fails stock with assertions on and off and passes with the fix, on and off.

Related fix sites in the same file, not part of this finding: IOUtils.useOrSuppress (:561, LFV-564) and both deleteFilesSuppressingExceptions overloads (:258-260, :352, main only; same pattern by reading). One upstream change could guard them all.

Workaround until fixed: application components should not rethrow one stored exception object from both the failing call and close(); throw a new exception that wraps or suppresses the stored one. Nothing helps for the shared OutOfMemoryError.

Skip suppression when close() rethrows an object the helper already holds (validated by the checks) (recommended)

In the catch, do nothing when e == ex (for the usual catch/cleanup/rethrow callers no record is needed: the object is already the one the caller will throw) or e == firstError (two closes threw the same Error while ex is not one; stock code then attaches it to ex, which that Error already carries as suppressed, a redundant cyclic record visible through getSuppressed() that the guard deliberately drops; by reading). Everything else is unchanged.

--- a/lucene/core/src/java/org/apache/lucene/util/IOUtils.java
+++ b/lucene/core/src/java/org/apache/lucene/util/IOUtils.java
@@ -121,7 +121,9 @@
           object.close();
         }
       } catch (Throwable e) {
-        if (firstError == null && e instanceof Error err) {
+        if (e == ex || e == firstError) {
+          // the same Throwable was rethrown by close(): nothing to suppress
+        } else if (firstError == null && e instanceof Error err) {
           // don't try and suppress it - this Error should be the thing that is thrown
           firstError = err;
           firstError.addSuppressed(ex);

Trade-offs

Smallest change; fixes both halves (every closeable is closed, the caller's own exception survives). It covers only this method; the sibling sites need the same check.

What the checks validated

Gradle, scratch worktree of 5adff78bb168: the repro passes with assertions on and off (stock fails, on and off); the repro, TestIOUtils (12), TestDirectoryReader (34) and TestIndexWriterExceptions (40, 3 skipped): 87 tests, 0 failures; :lucene:core:check -x test passes. Standalone runs of the same diff passed the minimal test with assertions on and off.

One guarded suppression helper for every IOUtils site

A private suppress(Throwable into, Throwable t) that skips t == into, used here, in both deleteFilesSuppressingExceptions overloads and in useOrSuppress (LFV-564's site).

Trade-offs

One consistent rule for LFV-564 and this bug, but a larger diff that touches methods outside this finding; the cycle case (e == firstError) still needs its own check here. Not run.

Keep closing even when suppression fails

Catch the exception that addSuppressed throws, hold it, and rethrow it only after every closeable has been closed. A bare try/finally would not do: the exception would still leave the loop.

Trade-offs

Every closeable is closed, but the caller still gets the IllegalArgumentException instead of its own exception; the identity check fixes both halves with less code. Not run.

Downstream note

Services that run Lucene close to heap exhaustion may log Self-suppression not permitted instead of the OutOfMemoryError and leak file handles or the write lock inside the process; no query, document or index input selects this path.

How it was found

A proof-obligation audit of IOUtils's contract checked each promise of closeWhileSuppressingExceptions (all closeables closed, close failures suppressed in ex, an Error ex rethrown) against the code, and found that no addSuppressed call checks for the same object. A one-test JUnit then witnessed it on main; independent checks reproduced it with their own tests.

The method in full110 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro), on main 5adff78bb168, 2026-11-03.

  • The obligation: for every closeable, close() is called, whatever earlier closes threw (:109-110). It fails when the catch block itself can throw: ex.addSuppressed(e) (:129) throws for e == ex, a case the JDK specifies.
  • A one-test JUnit witnessed it on main with assertions on and off; a JVM probe showed the shared OutOfMemoryError is real JVM behaviour. An independent reproducer, an executed review by another model family (OpenAI GPT-5.6) and a devil's-advocate verifier wrote their own tests from the claim, including two routes through IndexWriter.

Prior reports, history and checks

Prior reports

No prior report: a scripted prior-art run (143 hits), 35 GitHub GraphQL searches, the changed files of all 410 open PRs, the mailing lists with every post-2025 "Self-suppression" CI mail read, and the introducing PRs' discussions (2026-11-03), rechecked the same day.

Searches3 searches

No report found in the documented searches says that closeWhileSuppressingExceptions throws "Self-suppression not permitted" or skips later closeables. Nearest, none of them this bug: builds@ Lucene-nightly-main-Linux #997 (2025-12-15), the same JDK rule through IOUtils.useOrSuppress from IOUtils.close, with no frame of this method (why LFV-564 is "known"); builds@ Lucene-10.x-Windows #2395 (2025-08-19), a try-with-resources line in SegmentMerger; #14633, the introducing PR, whose review never discusses the case. elastic/elasticsearch#158009 and opensearch-project/OpenSearch#23224 fixed the same mistake in those projects' own helpers: context for the fix, not Lucene reports.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream projects; dev@, java-user@, issues@, commits@, builds@; JIRA archive; web2026-11-03a scripted prior-report search run (56 queries, 143 distinct hits, every medium-overlap hit read), 35 GraphQL and REST searches, 20 direct list searchesonly the related items above
Open PRs2026-11-03the changed files of all 410 open apache/lucene PRsonly #12295 touches IOUtils.java, and only rethrowAlways
Recheck (GitHub)2026-11-03method and message queries; PRs created or updated since 2026-11-01; upstream file historynothing new; upstream main 1c2072f0284 has the same method body

History

Introduced with the method: on main by #14633 (7d3c7bb6c7c, 2025-05-13), on the 10.x line by #15445 (c5a4d07e554, 2025-11-22), first released in 10.4.0. Executed: 10.4.0, 10.5.2, 10.6.0 and main fail; 10.3.2 and 9.12.3 lack the method. Not fixed upstream.

Versions tested5 rows

Introduced with the method itself. Main: 7d3c7bb6c7c, #14633 by thecoop, "Catch and re-throw Throwable rather than using a success boolean", 2025-05-13; git blame of IOUtils.java:114-137 gives it for every line, and no release contains it (there is no 11.x release). 10.x line: c5a4d07e554, #15445 by BryceKan3 (the branch_10x PR of #15428, an ExecutorService option for DirectoryReader.open), 2025-11-22, which copied the method unchanged and used it in StandardDirectoryReader.open. The IOUtils.java of 7d3c7bb6c7c over the main jars fails the earlier one-test variant; its parent 9640dd8d055 has no such method. 10.5.0 and 10.5.1 have a byte-identical body (read). In 10.4.0 to 10.6.0 the failing call is at IOUtils.java:170. Upstream main 1c2072f0284, branch_11x and branch_10_6 have the same body; the newest upstream commit on the file, ba7f659b1d8 (#15105, 2025-08-21), does not touch it.

Versions and branches executed, with results
Version or commitVerdictResult
9.12.3, 10.3.2 no APIthe method does not exist (the test does not compile)
10.4.0 first: 10.4.0the minimal test fails with assertions on and off; one in-tree caller (StandardDirectoryReader.open)
10.5.2, 10.6.0 affectedthe minimal test fails, assertions on and off (10.5.0 and 10.5.1 identical by reading); five and six caller files
main 7d3c7bb6c7c (#14633) vs its parent 9640dd8d055 introducing commitits IOUtils.java over the main jars fails the earlier one-test variant; the parent has no such method
main 5adff78bb168 affectedthe one-test repro fails through gradle, assertions on and off; passes with the fix

Independent checks

An independent reproducer, an executed review by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication all rated it real; the adjudication confirmed it (high confidence, priority Low). This doc's gradle runs reproduced it on main and validated the fix.

Checks and verdicts305 words

The cross-model review ran on main ea57360912ed, whose IOUtils.java equals 5adff78bb168's, and its tests were re-run on 5adff78bb168 with the same result; every other check ran on 5adff78bb168 or a release.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOne-test JUnit fails on main (on, off) and 10.6.0; probes show both overloads and a non-Error ex fail, distinct instances pass, and the JVM shares one OutOfMemoryError after four.
independent reproducer realOwn test fails on main (on, off), 10.4.0, 10.5.2 and 10.6.0; the stack ends at IOUtils.java:129; IndexWriter.commit on main surfaces the IllegalArgumentException through a FilterDirectory that rethrows one IOException.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own helper test and own new IndexWriter test (an IndexDeletionPolicy and a MergeScheduler throwing one object) fail; the distinct-instance control passes; intended-behaviour argument rated weak.
devil's-advocate verifier real (medium-high)The try-with-resources parity covers only the exception replacement, not the skipped closes; nothing documents a close() that rethrows ex as allowed.
adjudication confirmed (high), LowRe-ran the reproducer's test (off): fail; a probe shows the IndexWriter write lock is not released after the failed constructor.
this doc (gradle) reproduced; fix validatedGradle in a clean scratch worktree of 5adff78bb168: the repro fails with assertions on and off and passes with the fix; the related suites and :lucene:core:check -x test pass with it.
fact checks of this doc (independent, and OpenAI GPT-5.6) real; wording fixedRe-ran the doc's gradle command (fails on and off; passes with the fix; 87 tests, 3 skipped, 0 failures) and the minimal test on 10.4.0, 10.5.2 and 10.6.0 (fail). Corrected: the replaced helper also fails when two closes throw the same object; fix option C needs a catch, not a bare try/finally.

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

Finding record

LFV-1224, snapshot of 2026-11-06

Registry title
IOUtils.closeWhileSuppressingExceptions throws IllegalArgumentException 'Self-suppression not permitted' and skips the remaining closeables when a close() throws the very Throwable being handled (for example the JVM's shared OutOfMemoryError), because it calls ex.addSuppressed(e) without an identity check
Area
core / util (IOUtils failure-path cleanup helper closeWhileSuppressingExceptions; reached from StandardDirectoryReader.open, the IndexWriter constructor and codec readers and writers)
Priority
Low, rank 836: On the cleanup path of a failed reader open or codec reader/writer (and on main the IndexWriter constructor, where the write lock stays held), a close() that rethrows the very Throwable being handled, as the JVM's shared OutOfMemoryError can, becomes IllegalArgumentException 'Self-suppression not permitted' and leaves the remaining resources open, in 10.4.0 through 10.6.0 and on main. It runs on default settings, but the trigger is rare and the failure is loud with the original kept as the cause.
Impact class
minor
Impact note
Hits a failure path that cleans up through this helper (10.6.0: StandardDirectoryReader.open and five codec reader and writer classes; main: 72 non-sandbox source files with 100 call sites, including the IndexWriter constructor) when the failed operation and a later close() throw the same Throwable object, as the JVM's shared stackless OutOfMemoryError does once its preallocated ones are used up, or as an application component that rethrows a cached failure does: the caller gets IllegalArgumentException (with the original as its cause) instead of the original Throwable, and the closeables after the throwing one are never closed, which on main leaks the IndexWriter write lock in the same process (judge probe); default settings, no scale, rare trigger, a loud failure; in 10.4.0 through 10.6.0 and on main.
Summary
After a failure, Lucene calls IOUtils.closeWhileSuppressingExceptions(ex, closeables...) to close what it had opened and attach any close failures to ex, for example when opening a DirectoryReader fails. If one close() throws the very object ex (the JVM reuses one OutOfMemoryError object once memory is exhausted), you expect what the method documents: every closeable is closed and, for an Error, that same Error is rethrown. Instead ex.addSuppressed(ex) throws IllegalArgumentException 'Self-suppression not permitted', which escapes the loop, so the remaining closeables are never closed and the caller sees the IllegalArgumentException instead of the original failure (kept only as its cause).
Introduced
7d3c7bb6c7cef2e7d130162ff8c990407c3a8750 (main, #14633 by thecoop, 'Catch and re-throw Throwable rather than using a success boolean'), which added closeWhileSuppressingExceptions with the unguarded firstError = ex / ex.addSuppressed(e) in its first version; on the 10.x line c5a4d07e5540d0c8adce0af390275c8fd731b52a (#15445 by BryceKan3, the branch_10x PR of #15428), which copied the method unchanged and shipped first [read: git log -S and git blame of IOUtils.java:114-137, every line 7d3c7bb6c7c; exec: the IOUtils.java of 7d3c7bb6c7c over the main jars fails the repro, its parent 9640dd8d055 lacks the method] (main 2025-05-13 (author date = commit date); branch_10x 2025-11-22 (author date = commit date))
First affected version
10.4.0 [exec] (tagged 2026-02-20; contains c5a4d07e554; the first release with the method). The main commit is in no release (there is no 11.x release). Releases before 10.4.0 do not have the method (10.3.2 and 9.12.3: compile error, API absent [exec]).
Affected versions
10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 (4ae946e5a9b, released 2026-10-29) and main from 7d3c7bb6c7c (2025-05-13) to 5adff78bb168 (12.0.0-SNAPSHOT); not fixed upstream (upstream main 1c2072f0284, branch_11x and branch_10_6 have the identical method body, and upstream main still has the unguarded calls at IOUtils.java:127 and :129, check 2026-11-03). [exec] fail: 10.4.0 (-ea, -da), 10.5.2 (-ea by the independent reproduction, -da), 10.6.0 (-ea, -da), main 5adff78bb168 (-ea, -da), and the IOUtils.java of 7d3c7bb6c7c over the main jars (-da, mixed build); [read] 10.5.0 and 10.5.1 (byte-identical method body). Not affected [exec]: 10.3.2 and 9.12.3 (method absent). Not new in 10.6 (10.4.0 and 10.5.2 fail). In-tree reach grew per release [read]: 10.4.0 one non-test caller (StandardDirectoryReader.open), 10.6.0 six non-sandbox caller files, main 72 non-sandbox caller files (the IndexWriter constructor and the write-lock leak route are main-only). All runs used JDK 25.0.3 on Linux aarch64.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): a scripted prior-report search (16 GitHub issue/PR, 3 commit, 2 downstream, 25 mailing-list, 4 JIRA-archive and 6 web queries; 143 distinct hits, 0 high and 19 medium overlap, every medium hit and the top 40 read), 35 GitHub GraphQL searches (20 on apache/lucene, 15 on related repositories and all of GitHub), GitHub REST searches, the changed files of all 410 open apache/lucene PRs (only #12295 touches IOUtils.java, and only rethrowAlways), 20 direct lists.apache.org searches over dev@, builds@, issues@, java-user@ and commits@ with the bodies of both post-2025-05 'Self-suppression' CI mails and of all 21 issues@ PR mails that add helper calls read in full, the full discussions of #14633, #14995, #15675, #16278, #16303 and #15445, and the full git history. Related, not this bug: the builds@ CI mail Lucene-nightly-main-Linux #997 (2025-12-15) shows the same JDK rule through IOUtils.useOrSuppress (LFV-564's fix site) with no closeWhileSuppressingExceptions frame; the builds@ mail Lucene-10.x-Windows #2395 (2025-08-19) comes from a try-with-resources line in SegmentMerger; elastic/elasticsearch#158009 and opensearch-project/OpenSearch#23224 fix the same mistake in those projects' own helpers. Not fixed upstream: the newest upstream main commit on IOUtils.java is ba7f659b1d8 (#15105, author date 2025-08-21), which did not touch the method, and no PR created or updated since 2026-11-01 touches IOUtils.java.
Lucene commit tested
main 5adff78bb168 (the one-test repro through gradle in a clean scratch worktree, assertions on and off, unfixed and with the identity-check fix; with the fix also TestIOUtils, TestDirectoryReader, TestIndexWriterExceptions and :lucene:core:check -x test, 2026-11-03). Earlier checks: standalone runs of the repro on main (assertions on and off); the Maven Central jars 10.4.0, 10.5.2 and 10.6.0 (the minimal test fails there with assertions on and off) and 10.3.2 and 9.12.3 (method absent); the IOUtils.java of the introducing commit over the main jars (fail); supporting routes through new IndexWriter and IndexWriter.commit on main. The cross-model review ran on main ea57360912ed, whose IOUtils.java equals 5adff78bb168's, and its tests were re-run on 5adff78bb168. All runs used OpenJDK 25.0.3 on Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/IOUtils.java:48 (at 5adff78bb168) (@lucene.internal), :97-106 (varargs overload: 'Closes all given Closeables, suppressing all thrown Throwables in ex'; delegates to the Iterable overload), :108-137 (Iterable overload: javadoc :109-110 'Even if a Error is thrown all given closeable are closed'; firstError = ex when ex is an Error :116; catch block :122-131 with firstError.addSuppressed(ex) :127 and ex.addSuppressed(e) :129, both without an identity check, fix site; throw firstError :135)
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:102-104 (at 5adff78bb168) (catch (Throwable t) { IOUtils.closeWhileSuppressingExceptions(t, readers); throw t; })
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1206 (at 5adff78bb168) (constructor failure path: IOUtils.closeWhileSuppressingExceptions(t, config.getMergeScheduler(), writeLock))
  • JDK 25 java.lang.Throwable.addSuppressed (throws IllegalArgumentException 'Self-suppression not permitted' when the argument is the receiver; cited by the independent reproduction, the intended-behaviour check and the GPT reviewer)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/IOUtils.java:48 (at 5adff78bb168) (@lucene.internal), :97-106 (varargs overload), :108-137 (javadoc :109-110; :116, :121, :127, :129, :134-135)
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:102-104; (at 5adff78bb168) IndexWriter.java:1206 (callers)
  • JDK 25 src.zip java/lang/Throwable.java:1100-1101 (self-suppression check)
Who is affected
  • a Lucene clone git grep closeWhileSuppressingExceptions at releases/lucene/10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0; a Lucene main checkout grep (72 files, 100 call sites outside sandbox and test-framework)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/IOUtils.java:97-98, (at 5adff78bb168) :109-110, :116, :135
  • lucene/core/src/test/org/apache/lucene/util/TestIOUtils.java:76-110 (at 5adff78bb168) (testCloseExposesErrors)
  • a Lucene clone releases/lucene/10.3.2 lucene/core/src/java/org/apache/lucene/util/IOUtils.java:113-135 (closeWhileHandlingException(Iterable)), :449-456 (useOrSuppress) and index/StandardDirectoryReader.java:90-108 (success flag)
  • lucene/CHANGES.txt:12410-12412 (at 5adff78bb168) (LUCENE-5654)
  • JDK 25 src.zip java/io/FilterOutputStream.java:195-196
  • JDK 25 src.zip java/lang/AutoCloseable.java:82-84
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/IOUtils.java:247-268, (at 5adff78bb168) :345-356 (deleteFilesSuppressingExceptions), :557-564 (useOrSuppress)
History
  • a Lucene clone releases/lucene/10.4.0 and 10.6.0 lucene/core/src/java/org/apache/lucene/util/IOUtils.java:170
Diagrams
  • lucene/core/src/java/org/apache/lucene/util/IOUtils.java:114-137 (at 5adff78bb168)

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

Repro files

About the diagrams

A step-by-step picture of the repro, made in parallel with this doc from the code at 5adff78bb168 and executed traces; the PNG is the summary still.

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.