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.
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.- A Lucene operation fails with a
Throwablet, say anOutOfMemoryErrorinsideDirectoryReader.open. Its cleanup callsIOUtils.closeWhileSuppressingExceptions(t, first, second)to close what it opened, then rethrowst. first.close()throws the same objectt. Out of heap this is normal: in a 64 MB-heap probe the first 4OutOfMemoryErrors were distinct objects and every later one was one shared object.- You expect what the javadoc promises: both closeables are closed and the same
OutOfMemoryErroris rethrown. - Instead the helper calls
t.addSuppressed(t)(IOUtils.java:129). Java throwsIllegalArgumentException: Self-suppression not permitted, which leaves the loop:second.close()never runs, and the caller gets theIllegalArgumentException, withtonly as its cause. - On main the
IndexWriterconstructor cleans up this way. With two config components that throw oneIOExceptionobject,new IndexWriterthrows theIllegalArgumentExceptionand keeps the write lock: a secondnew IndexWriteron the same directory fails withLockObtainFailedException.
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.
IOUtils.closeWhileSuppressingExceptions(ex, closeables...): a cleanup helper (IOUtilsis@lucene.internal,IOUtils.java:48) used on failure paths ascatch (Throwable t) { IOUtils.closeWhileSuppressingExceptions(t, ...); throw t; }. It closes everything and records each close failure onex; whenexis anError, it rethrows thatErroritself.- Suppressed exceptions:
ex.addSuppressed(e)attachesetoex. The JDK throwsIllegalArgumentException("Self-suppression not permitted", e)whene == ex(JDK 25Throwable.java:1100-1101).
// 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 reachedThe repro, step by step:
| step | firstError | what happens |
|---|---|---|
call with ex = oom | oom (:116) | |
first.close() throws oom | oom | firstError is set, so the else branch: oom.addSuppressed(oom) (:129) |
Throwable.addSuppressed | throws IllegalArgumentException, cause oom, from inside the catch: it leaves the loop | |
second.close() | never runs | |
throw firstError, the caller's throw t | never 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(behindDirectoryReader.openof 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,Lucene104ScalarQuantizedVectorsReaderandWriter, backward-codecsLucene102BinaryQuantizedVectorsReader). Main: 72 non-sandbox source files with 100 call sites, including theIndexWriterconstructor. - Trigger. One exception object thrown both by the failed operation and by a later
close(). (a) The JVM's sharedOutOfMemoryError: in a JDK 25 probe at-Xmx64mthe first 4 heapOutOfMemoryErrors were distinct and every later one was the same object, with no stack trace. (b) An applicationDirectory,IndexOutput,IndexDeletionPolicyorMergeSchedulerthat rethrows one stored exception: on main,IndexWriter.addDocument+commit()on aFilterDirectorywhose postings output throws oneIOExceptionobject twice surfaced theIllegalArgumentExceptionfromLucene104PostingsWriter.<init>, where 10.6.0 surfaced theIOException. - Effect. Loud: an
IllegalArgumentExceptionwhose cause is the original. Silent: the closeables after the one that threw stay open, such as the remaining segment readers of a failedDirectoryReader.open(file handles), or on main theIndexWriterwrite lock, so a secondIndexWriteron the directory getsLockObtainFailedException: lock instance already obtainedin the same process. - Caveat (by reasoning, not run). Under real heap exhaustion, allocating the
IllegalArgumentExceptionmight itself fail, so the sharedOutOfMemoryErrorwould escape instead; the remaining closeables are skipped either way. Lucene CI mails have printed thisIllegalArgumentExceptionfrom 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.
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.
/*
* 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);
}
}
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
secondis closed and the sameOutOfMemoryErroris rethrown.- Observed
AssertionError: second closeable not closed: the call threwIllegalArgumentException: Self-suppression not permittedbefore reachingsecond.
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.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); the same test also fails on 10.4.0, 10.5.2 and 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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>- The javadoc: "Even if a Error is thrown all given closeable are closed" (
IOUtils.java:109-110). Here the rest are skipped. - The
closeWhileHandlingExceptioncode these paths used before still closed everything when oneclose()rethrew the handled object; the JDK'sFilterOutputStream.closeguards 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 anErrorex(: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.openin #14913, 2025-07-14; on the 10.x line #15445), these paths used asuccessflag andcloseWhileHandlingException, which collects close failures withuseOrSuppressand rethrows the firstError(10.3.2IOUtils.java:113-135,:449-456). When oneclose()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, inuseOrSuppress(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 25java/io/FilterOutputStream.java:195-196). - History. The review of #14633 (17 comments and reviews) never discusses a
close()that rethrowsex; 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.
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.
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.
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.
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.
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 thecatchblock itself can throw:ex.addSuppressed(e)(:129) throws fore == 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
OutOfMemoryErroris 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 throughIndexWriter.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; downstream projects; dev@, java-user@, issues@, commits@, builds@; JIRA archive; web | 2026-11-03 | a scripted prior-report search run (56 queries, 143 distinct hits, every medium-overlap hit read), 35 GraphQL and REST searches, 20 direct list searches | only the related items above |
| Open PRs | 2026-11-03 | the changed files of all 410 open apache/lucene PRs | only #12295 touches IOUtils.java, and only rethrowAlways |
| Recheck (GitHub) | 2026-11-03 | method and message queries; PRs created or updated since 2026-11-01; upstream file history | nothing new; upstream main 1c2072f0284 has the same method body |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 9.12.3, 10.3.2 | no API | the method does not exist (the test does not compile) |
| 10.4.0 | first: 10.4.0 | the minimal test fails with assertions on and off; one in-tree caller (StandardDirectoryReader.open) |
| 10.5.2, 10.6.0 | affected | the 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 commit | its IOUtils.java over the main jars fails the earlier one-test variant; the parent has no such method |
| main 5adff78bb168 | affected | the one-test repro fails through gradle, assertions on and off; passes with the fix |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | One-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 | real | Own 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), Low | Re-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 validated | Gradle 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 fixed | Re-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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 516 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,378 words
- 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 alsoTestIOUtils,TestDirectoryReader,TestIndexWriterExceptionsand: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); theIOUtils.javaof the introducing commit over the main jars (fail); supporting routes throughnew IndexWriterandIndexWriter.commiton main. The cross-model review ran on mainea57360912ed, whoseIOUtils.javaequals5adff78bb168's, and its tests were re-run on5adff78bb168. All runs used OpenJDK 25.0.3 on Linux aarch64.
- 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, :135lucene/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-196JDK 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)
- TestCloseWhileSuppressingSameThrowable.java (one test, lucene/core):
repro/LFV-1224/TestCloseWhileSuppressingSameThrowable.java(41 lines)
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.
assets/LFV-1224/bug.png(516 KB)
