Dictionarymust sort all.dicentries before it builds its word table. The constructors that take a tempDirectory, andHunspellStemFilterFactory, sort on disk (SortingStrategy.offline), so a large dictionary needs little memory.- The load opens a temp file in that directory (
<prefix>_dat_0.tmp), writes every.dicentry to it, closes it, sorts it into a second temp file, reads that back, and deletes both. - The other strategy,
SortingStrategy.inMemory(), sorts a list in memory and uses no files. A successful load leaves nothing behind either way.
.dic read fails, and the only step that closes the file never runs. Select the still to open it at full size. Generated with AI, checked against the code.- Load a dictionary with
new Dictionary(tempDir, "dict", affix, dic), the constructor that sorts the.dicentries in a temp file insidetempDir. Here the.dicstream throws anIOExceptionon its first read, a failure the constructor documents. - You expect the constructor to throw that
IOExceptionand release what it created, as after a successful load and as 9.8.0 did: closingtempDirafterwards succeeds. - Instead the temp output
dict_dat_0.tmp, opened just before the.dicis read, stays open: closing the test directory fails withthere are still 1 open files: {dict_dat_0.tmp=1}. The caller got no object, so it cannot close the file. - With a real directory each failed load keeps one descriptor until GC and one temp file on disk (5 failed loads: 5 files, 5 descriptors). Through
HunspellStemFilterFactorythe file is deleted but stays open: 10 failed loads, 10 descriptors on deleted files.
Users of the opt-in hunspell analysis component who load through the Dictionary(Directory, ...) constructors or HunspellStemFilterFactory (offline sorting is what they always use), when a load fails after its temp file was opened: a .dic read error, a full temp disk, or an undefined AM alias. Each failure holds one descriptor until GC; the load itself fails loudly.
Mechanism, terms and numbers587 words
The Dictionary(Directory tempDir, ...) constructors and HunspellStemFilterFactory sort the .dic entries on disk: Dictionary's constructor calls sortingStrategy.start(), which opens a temp IndexOutput in tempDir, and then mergeDictionaries(...), which reads the .dic and writes every entry to it, with no failure handling. Only finishAndSort() closes the output. So when reading the .dic throws (an IOException the constructor documents), or a write to tempDir fails, the constructor throws and the output stays open; the caller has no object to close, and FSDirectory.close() does not close it. Each failed load keeps one file descriptor open until garbage collection and, in a caller-supplied temp directory, leaves the file on disk; through the factory, which deletes its private temp directory, the file is unlinked but its descriptor stays open. Before #12834 (9.8.0) the entries were written inside a try-with-resources and the output was closed on failure; every release from 9.9.0 to 10.6.0 and main has the leak. The load itself fails loudly, and there are no wrong results. A catch (Throwable t) { ...; throw t; } cleanup at each step of the offline load fixes it.
- Offline sorting (
SortingStrategy.offline(tempDir, prefix)): what theDictionary(Directory, ...)constructors (Dictionary.java:213) andHunspellStemFilterFactoryuse. Itsstart()opens the tempIndexOutput;finishAndSort()writes the footer, closes it, sorts, and returns a supplier that reads the sorted file and deletes it onclose(). - Temp output: the
IndexOutputfromtempDir.createTempOutput(prefix, "dat", ...), a file such asdict_dat_0.tmpwith an open file handle.
- The constructor runs
EntryAccumulator acc = sortingStrategy.start();(Dictionary.java:267), which opens the temp output and its writer (SortingStrategy.java:71-72). - Then
mergeDictionaries(dictionaries, decoder, acc);(:268) reads the.dic(:1016,:1019) and writes each entry (:1034,SortingStrategy.java:77-80). It is outside anytry: only the supplier returned byfinishAndSort()is in a try-with-resources (:269). - If it throws (a
.dicread error, a write error intempDir, or an undefinedAMalias, LFV-1012),finishAndSort()never runs, and it is the only code that closes the output (SortingStrategy.java:83-88). - The constructor throws, so the caller has no
Dictionaryand no handle;FSDirectory.close()only marks the directory closed and retries pending deletes; it closes no open outputs (FSDirectory.java:277-280). The descriptor stays until the output's channel is garbage-collected; the file stays intempDir. HunspellStemFilterFactory.informloads into a fresh directory underjava.io.tmpdirand callsIOUtils.rm(tempPath)infinally(HunspellStemFilterFactory.java:109-113): the open file is unlinked, and its descriptor stays open until GC.- The same gap exists at later steps (fault injection, below): a failing footer write or writer close, a failing delete of the unsorted input after the sort, a failing open of the sorted file, and a failing reader close in the supplier's
close(). Failures inside the sort itself and while reading the sorted entries are cleaned up.
Minimal test (empty .aff, .dic stream throws on its first read, MockDirectoryWrapper): cannot close: there are still 1 open files: {dict_dat_0.tmp=1}, caused by unclosed IndexOutput: dict_dat_0.tmp from SortingStrategy$1.start(SortingStrategy.java:71) and Dictionary.<init>(Dictionary.java:267). Plain FSDirectory, 5 failed loads: 5 files and 5 open descriptors after Directory.close(); descriptors 0 after System.gc(); files still there after a later successful load. Factory, 10 failed inform() calls: 10 descriptors on .../HunspellNNN/hunspell_dat_0.tmp (deleted). Fault injection (a fault-free baseline and 227 injected failures, 225 of them I/O errors): 80 of the 228 checks leak on main, 0 with the recommended fix.
Exposure and likelihood195 words
- Who is hit. Applications that load hunspell dictionaries through
Dictionary(Directory, String, InputStream, InputStream),Dictionary(Directory, String, InputStream, List<InputStream>, boolean)orHunspellStemFilterFactory(SPI namehunspellStem, for example inCustomAnalyzer). Users of theSortingStrategy.inMemory()constructor are not affected. - What it takes. A load that fails after the temp file was opened: an I/O error while reading the
.dicstream, a full or failing temp disk, or a.dicentry with an undefinedAMmorphological alias (LFV-1012). These are exceptional events, so it is rated rare. Failures while parsing the.affhappen before the file is opened and leak nothing. - What happens. The load fails loudly with its original exception; the leak is silent. One file descriptor per failed load until GC. With a caller-supplied persistent temp directory, one small temp file per failure stays on disk (not removed by
Directory.close()or a later successful load). Through the factory the file is unlinked at once and only the descriptor remains. An application that retries a failing load accumulates one descriptor per attempt until a GC runs. - Not claimed. Windows was not tested (an open file cannot be deleted there, so the factory's
IOUtils.rmmay behave differently).
One JUnit test on the public API: an empty .aff, a .dic stream whose first read throws IOException, loaded with new Dictionary(tempDir, "dict", affix, dic) into a MockDirectoryWrapper, then tempDir.close(). Single thread, about 1 s; fails on main, 10.6.0 and 9.9.0, passes on 9.8.0.
/*
* 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.analysis.hunspell;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.store.MockDirectoryWrapper;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDictionaryFailedLoadClosesTempOutput extends LuceneTestCase {
public void testDictionaryReadErrorClosesTempOutput() throws Exception {
MockDirectoryWrapper tempDir = new MockDirectoryWrapper(random(), new ByteBuffersDirectory());
InputStream affix = new ByteArrayInputStream(new byte[0]);
InputStream dictionary =
new InputStream() {
@Override
public int read() throws IOException {
throw new IOException("simulated .dic read error");
}
};
expectThrows(IOException.class, () -> new Dictionary(tempDir, "dict", affix, dictionary));
// expected: tempDir closes; actual: "cannot close: there are still 1 open files"
tempDir.close();
}
}
cp TestDictionaryFailedLoadClosesTempOutput.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.hunspell.TestDictionaryFailedLoadClosesTempOutput -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same result)
- Expected
- The constructor throws the
IOExceptionandtempDir.close()succeeds, as after a successful load and on 9.8.0. - Observed
MockDirectoryWrapper: cannot close: there are still 1 open files: {dict_dat_0.tmp=1}, the outputSortingStrategy.offline().start()opened; the same with assertions off.
More files, environment, full output228 words
random() only sets MockDirectoryWrapper's internal knobs (random I/O failures are off by default); the test failed with every seed tried (DEADBEEF, 5EED1234, and 20 of 20 iterations from 5EED1006). It pins the 9.9.0 regression, the open output (its directory is in memory, so it shows an unclosed handle, not an OS descriptor); it does not assert that the closed file is gone, because that part is older and would make 9.8.0 fail too.
As the regression test: the same test, for example as a new method in TestDictionary. Note that at this commit gradle derives the default of tests.asserts from the seed, and DEADBEEF picks assertions off; the commands below set it explicitly.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0 and 9.9.0 and passes on 9.8.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp TestDictionaryFailedLoadClosesTempOutput.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.hunspell.TestDictionaryFailedLoadClosesTempOutput -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same result)
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true, verbatim (the same with -Ptests.asserts=false)
TestDictionaryFailedLoadClosesTempOutput > testDictionaryReadErrorClosesTempOutput FAILED
java.lang.RuntimeException: MockDirectoryWrapper: cannot close: there are still 1 open files: {dict_dat_0.tmp=1}
at __randomizedtesting.SeedInfo.seed([DEADBEEF:2E5D5E3A4F318B44]:0)
at org.apache.lucene.test_framework@11.0.0-SNAPSHOT/org.apache.lucene.tests.store.MockDirectoryWrapper.close(MockDirectoryWrapper.java:884)
at org.apache.lucene.analysis.hunspell.TestDictionaryFailedLoadClosesTempOutput.testDictionaryReadErrorClosesTempOutput(TestDictionaryFailedLoadClosesTempOutput.java:40)
Caused by:
java.lang.RuntimeException: unclosed IndexOutput: dict_dat_0.tmp
at org.apache.lucene.test_framework@11.0.0-SNAPSHOT/org.apache.lucene.tests.store.MockDirectoryWrapper.addFileHandle(MockDirectoryWrapper.java:782)
at org.apache.lucene.test_framework@11.0.0-SNAPSHOT/org.apache.lucene.tests.store.MockDirectoryWrapper.createTempOutput(MockDirectoryWrapper.java:761)
at org.apache.lucene.analysis.hunspell.SortingStrategy$1.start(SortingStrategy.java:71)
at org.apache.lucene.analysis.hunspell.Dictionary.<init>(Dictionary.java:267)
at org.apache.lucene.analysis.hunspell.Dictionary.<init>(Dictionary.java:213)
at org.apache.lucene.analysis.hunspell.Dictionary.<init>(Dictionary.java:192)
at org.apache.lucene.analysis.hunspell.TestDictionaryFailedLoadClosesTempOutput.lambda$testDictionaryReadErrorClosesTempOutput$0(TestDictionaryFailedLoadClosesTempOutput.java:38)
at org.apache.lucene.test_framework@11.0.0-SNAPSHOT/org.apache.lucene.tests.util.LuceneTestCaseParent._expectThrows(LuceneTestCaseParent.java:1587)
at org.apache.lucene.test_framework@11.0.0-SNAPSHOT/org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1427)
at org.apache.lucene.test_framework@11.0.0-SNAPSHOT/org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
at org.apache.lucene.analysis.hunspell.TestDictionaryFailedLoadClosesTempOutput.testDictionaryReadErrorClosesTempOutput(TestDictionaryFailedLoadClosesTempOutput.java:38)- Until 9.8.0 a try-with-resources closed the temp output on failure; #12834 moved the writer out of it.
- The neighbouring steps clean up on failure: the sort deletes its input; the sorted file is closed and deleted.
- Maintainers wanted no leftovers: the factory deletes its temp dir "because the temp dir is likely not empty on exception" (Michael McCandless, LUCENE-6829).
The full argument245 words
- It worked before. 9.8.0 wrote the entries inside
try (ByteSequencesWriter writer = new ByteSequencesWriter(output))(9.8.0Dictionary.java:992). #12834 moved the writer intoSortingStrategy.offline().start()and its close intofinishAndSort(); its review threads (Dawid Weiss and Peter Gromov, 2023-11-22) discuss only the sorted file's cleanup. On 9.9.0 jars with only the parent commit'sDictionary.javaswapped in, the open output and the factory descriptor leak disappear. - The neighbouring steps release on failure.
sortWordsOfflinedeletes its input when the sort throws (SortingStrategy.java:116-121, the catch-and-rethrow of #14633); the sorted file is closed and deleted by the try-with-resources atDictionary.java:269;OfflineSorterwraps its directory "So we can remove any partially written temp files on exception" (OfflineSorter.java:295). - Maintainer intent. In LUCENE-6829 (#7887, 2015-10-13) Michael McCandless wanted the factory "to recursively remove the temp dir, and any files under it, because the temp dir is likely not empty on exception", and Dawid Weiss answered "we always attempt to remove the temp. stuff ... Otherwise you'd have temp files piling up without any visible reason?". That cleanup is still in the factory; since 9.9.0 the still-open output defeats it.
- Contract.
@throws IOException Can be thrown while reading from the InputStreams(Dictionary.java:186,:203,:223): a read error is an expected outcome, not misuse. - Test oracle.
MockDirectoryWrapper.close()refuses to close a directory with open files. No hunspell test makes the.dicphase fail (TestDictionary.testInvalidDatafails in the.aff, before the temp file is opened).
The devil's-advocate check117 words
The strongest case for "intended" (an intended-behaviour checker, and read-only reviewers from another model family, OpenAI GPT-5.6): no javadoc promises cleanup after a failed load; the caller owns tempDir and can delete it, as the factory does; HunspellStemFilterFactory is @lucene.experimental; and the load fails loudly while GC reclaims the descriptor.
Why it fails: it speaks to severity, not intent. A caller can delete files but cannot close an output it never received, and FSDirectory.close() does not close it; the same code closed it before 9.9.0; every neighbouring step releases on failure; and the 2015 maintainers explicitly did not want temp files piling up. Hence a minor leak on a failure path, a bug of low priority.
Recommended A. Release on failure at every step of the offline load (catch Throwable and rethrow)
Release on failure at every step of the offline load, in the catch (Throwable t) { ...; throw t; } style of #14633: a new abort(Throwable) closes the writer and deletes the temp file when reading the .dic fails; later steps get the same cleanup. Validated: the repro and the hunspell tests pass; 0 of 228 fault-injection checks leak.
EntryAccumulator acc = sortingStrategy.start();
- mergeDictionaries(dictionaries, decoder, acc);
+ try {
+ mergeDictionaries(dictionaries, decoder, acc);
+ } catch (Throwable t) {
+ acc.abort(t);
+ throw t;
+ }
try (EntrySupplier sorted = acc.finishAndSort()) {All options and trade-offs3 options · 605 words
Two options were run through gradle in a scratch worktree of 645d4fe14a20 and through the fault-injection harness; the full one is recommended.
A. Release on failure at every step of the offline load (catch Throwable and rethrow) (validated by the checks) (recommended)
Dictionary.java:267-268 wraps mergeDictionaries and calls a new EntryAccumulator.abort(Throwable) (a no-op default; the offline accumulator closes the writer and deletes the temp file, adding its own exceptions to t as suppressed; as in IOUtils, an Error thrown while releasing propagates instead, with t attached). In SortingStrategy.offline(): finishAndSort aborts when the footer write or the writer close fails, deletes the sorted file when deleting the unsorted input or opening the sorted file fails, and the supplier's close() becomes IOUtils.close(reader, () -> tempDir.deleteFile(sortedFile)), the form Dawid Weiss suggested in the #12834 review. +39/-8 lines in two files. The constructor hunk:
EntryAccumulator acc = sortingStrategy.start();
- mergeDictionaries(dictionaries, decoder, acc);
+ try {
+ mergeDictionaries(dictionaries, decoder, acc);
+ } catch (Throwable t) {
+ acc.abort(t);
+ throw t;
+ }
try (EntrySupplier sorted = acc.finishAndSort()) {Trade-offs
- Same style as the existing
sortWordsOfflinecleanup (#14633) and the later success-flag removals (#15134); no public API change (EntryAccumulatoris package-private). - Cleanup exceptions are added to the original exception as suppressed, so the caller still sees the real cause; only an
Errorduring cleanup is thrown in its place, with the original attached (the convention ofIOUtils.closeWhileSuppressingExceptionsanddeleteFilesSuppressingExceptions,IOUtils.java:114-136,:247-267). - Nothing changes on the success path or for
SortingStrategy.inMemory().
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seedDEADBEEF: without the fix the repro fails with-Ptests.asserts=trueandfalse; with it, it passes with both (assertions on: in the package run below). Standalone runs agree with-eaand-da. - Closest suites: every test class of the hunspell package (
TestDictionary,TestHunspellStemFilterFactory,TestSpellChecking,TestStemmer, ...), assertions on: 47 classes with the repro, 159 tests, 0 failures, 13 skipped (classes that need external dictionaries or corpora, or are@Ignored); the same with the seed's default (assertions off). ./gradlew :lucene:analysis:common:check -x testwith the fix and the test in place passes (google-java-format, ecj lint, forbidden APIs, checksum checks). The full test suite was not run.- Fault injection on the stock jars with the fix's two classes swapped in: 228 checks (a fault-free baseline and 227 injected failures), 0 leak (stock: 80).
The constructor hunk and abort(Throwable) from A, without the changes to finishAndSort, sortWordsOffline and the supplier's close().
Trade-offs
Smallest change and enough for the minimal test's case, a failure while the .dic is read (all 60 reading-phase leaks of the fault run go away), but 20 of the 228 checks still leak at the later steps (footer write, writer close, input delete, sorted-file open, reader close); a full disk that the 8 KiB output buffer reports only at the footer write or the writer close is one of them.
What the checks validated
The repro passes in gradle (the seed's default, assertions off, alone and in the hunspell package: 159 tests, 0 failures) and standalone with -ea and -da. Fault injection: 20 of 228 checks leak.
The shape of the original fix sketch.
Trade-offs
Equivalent behaviour, but upstream has been replacing this pattern with catch-and-rethrow (#14633, #15134); a cleanup failure is swallowed instead of being attached to the original exception. Validated earlier by the hunt (the 4-test JUnit 4/4, 76 upstream hunspell tests, 0 of 228 checks leak).
Products that retry a failing dictionary or analysis-configuration load in a loop accumulate one open descriptor per attempt until GC, and one file per attempt with a persistent temp directory.
Non-formal first signal: an executed exception-safety probe that made hunspell loads fail at chosen bytes saw the temp file stay open. The hunt then model-checked the load's resource lifecycle in TLA+ (56 violating traces on 6 steps) and replayed it on the real code with fault injection: 80 of 228 checks leaked, matching the model.
The method in full174 words
Method: non-formal first signal, then formal characterisation. On 2026-10-28 an exception-safety probe (HunspellTempLeakProbe) made the .dic stream fail at byte 2, 8 or 14 and the temp directory fail after 0, 5 or 20 bytes; every case left hunspell_dat_0.tmp open and in the temp directory (13 of 20 checks failed, also on the 10.6.0 release candidate). The hunt unit for analysis loaders then wrote a TLA+ model of the offline load with an exception allowed at every throwing step (TLC: 168 distinct states, 56 traces violating the release invariant on 6 edges; the fixed variant satisfies it; 5 vacuity probes) and replayed it on the real jar with model-based bounded-exhaustive fault injection: one IOException at each of 142 directory and file operations and at every byte of the .dic (38) and .aff (45) streams, plus two content-driven exceptions and a fault-free baseline: 228 checks, 80 leaking, 0 mismatches with the model. A public-API JUnit test followed; independent checkers reproduced it with their own tests, and it was cut to the one-test repro.
No prior report found in GitHub issues and PRs (incl. the JIRA archive), all 415 open PRs, the Lucene mailing lists, downstream trackers or the web (2026-10-29). #7887 (LUCENE-6829, 2015) is context for the older leftover-file part, not a report.
Searches4 searches
Closest items, none a report of this bug: #12834 (the introducing PR; its review covers only the sorted file); #7887 (LUCENE-6829, 2015: maintainers expected a closed leftover temp file after an exception and made the factory delete its temp dir; no open output existed then; context for the older part); LUCENE-5906 (2014, temp-file leaks plugged in the old File-based hunspell code); #5237 (LUCENE-4165, 2012, caller-supplied dictionary readers left open); #8598 (LUCENE-7547, the same class in another module); #6717 (LUCENE-5655, open: make the temp dir configurable); #10769 (LUCENE-9730, temp dir lookup); #14633 and #15134 (success-flag cleanups, not this path); open PR #16422 (affix parsing only). Related findings: LFV-790 (the same leak shape in the suggest module, separate fix site) and LFV-1012 (the undefined AM alias, one trigger of this leak). Limits: GitHub search does not index code or diffs; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (incl. the migrated JIRA issues) and commits | 2026-10-29 | three scripted prior-report searches (36 issue/PR and 6 commit queries) plus 30 direct GitHub searches: Dictionary, SortingStrategy, HunspellStemFilterFactory, finishAndSort, mergeDictionaries, "hunspell temp", "too many open files", "still open files"; full reads of #12834 and #7887 | no report; context only (#7887, #12834) |
| Open PRs | 2026-10-29 | changed files of all 415 open PRs and of every PR created since 2026-10-20 | only #16422 touches Dictionary.java (affix parsing); none touches SortingStrategy.java or the factory |
| JIRA archive, mailing lists, downstream trackers, web | 2026-10-29 | 8 scripted and 5 REST JIRA queries (LUCENE, SOLR); 44 queries over dev@, java-user@, issues@ and commits@ plus all 25 java-user@ hunspell threads; three downstream search servers; 6 scripted and 3 manual web queries | nothing |
| Upstream branches | 2026-10-29; main rechecked 2026-11-01 | commit lists of the three files on main, branch_10x and branch_10_6 | no later change to the three files; on 2026-11-01 upstream main was 2f424fd428d (13 commits after 645d4fe), with no change in the hunspell package |
The open output is a regression from #12834 (f460d612b5e, 2023-11-24), first released in 9.9.0; every release through 10.6.0 and main has it. A closed file left in the caller's temp directory is older (since 6.0.0; 4.8.0-5.5.5 in java.io.tmpdir).
Versions tested6 rows
Introduced by #12834 "hunspell: allow in-memory entry sorting for faster dictionary loading" (Peter Gromov, co-authored and approved by Dawid Weiss, merged 2023-11-24): main f460d612b5e, branch_9x backport 41da5c0b6a9. It moved the temp output's creation into SortingStrategy.offline().start() and its close into finishAndSort(), and left mergeDictionaries(...) outside any try. git tag --contains: 9.9.0-9.12.3 and 10.0.0-10.6.0. On 9.9.0 jars with only the parent's Dictionary.java swapped in, the behaviour is that of 9.8.0. Older layer: since 4.8.0 (LUCENE-5468) a closed temp file is left after a failed load, in java.io.tmpdir until 5.5.5 and in the caller's tempDir from 6.0.0 (LUCENE-6829), when the factory started deleting its own temp directory; so through the factory it was clean from 6.0.0 until 9.9.0.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (standalone and gradle, 20/20 iterations); passes with fix A or B |
| 10.6.0 | affected | minimal repro fails with assertions on and off (release jars) |
| 9.9.0 to 10.5.2 | first affected 9.9.0 | the finder's 4-test JUnit fails 3 of 4 on 9.9.0, 9.12.3, 10.0.0 and 10.5.2; the minimal repro fails on 9.9.0; timeline probe: 1 file and 1 open descriptor (direct), 3 descriptors (factory, 3 failures) |
| 6.0.0 to 9.8.0 | closed file left | the minimal repro passes on 9.8.0; timeline probe on 6.0.0, 7.7.3, 8.11.4, 9.0.0, 9.8.0: 1 closed file left, 0 open descriptors; factory clean |
| 4.8.0, 5.5.5 | file in java.io.tmpdir | timeline probe: 1 closed file in java.io.tmpdir (direct), 3 (factory, 3 failures), 0 open descriptors |
| 4.7.2 | no Dictionary class | API absent |
Confirmed with high confidence: the finder, an independent reproducer, an intended-behaviour devil's advocate and an adjudication all reproduced it. A read-only reviewer from another model family gave a procedural "not real" (it could not run code) but agreed on the root cause.
Checks and verdicts251 words
Everyone agrees on the root cause and that the impact is minor: GC reclaims the descriptor, and the load fails loudly either way.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | 4-test JUnit fails 3 of 4 on main with assertions on and off (20/20 iterations); the success control passes; TLA+ model (56 violating traces on 6 edges) and fault injection (228 checks, 80 leak, 0 mismatches). |
| cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real, low (procedural) | Could not run code, so it could not reproduce; agreed on the root cause and called the inputs in contract. A separate re-run: 3 of 4 fail. |
| independent reproducer | real | Its own JUnit, written from the claim, fails 3 of 4 on main (assertions on and off), 10.5.2, 9.12.3 and 9.9.0; 9.8.0 partial; 4/4 with the fix; GC reclaims the descriptors. |
| cross-model review, intended lens (OpenAI GPT-5.6, read-only) | real, medium | Rated the works-as-designed case (contractual silence, the caller owns the temp dir, the factory is experimental) weak. |
| intended-behaviour devil's advocate | real, high | Built the strongest intended case and rejected it; a plain-FSDirectory probe kept 5 files and 5 descriptors after Directory.close(); main and 10.5.2 fail 3 of 4. |
| adjudication | confirmed, high | Re-ran the reproducer's and the finder's JUnit with a new seed: 3 of 4 fail in each. |
| Registration check and credibility audit | confirmed | Re-runs on main fail 3 of 4 with assertions off; 4/4 with the fix shadowed. |
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 460 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,439 words
- Registry title
- A hunspell Dictionary load that fails with the offline sorting of the Dictionary(Directory, ...) constructors leaves its temp IndexOutput open (regression since 9.9.0, #12834): through HunspellStemFilterFactory every failed inform() keeps a file descriptor on the deleted temp file until GC, and with a caller-supplied tempDir the temp file also stays behind
- Area
- analysis-common / hunspell Dictionary loading (SortingStrategy.offline, HunspellStemFilterFactory)
- Priority
- Low, rank 1110: A failure-path IndexOutput and temp-file leak on an already-failing opt-in hunspell Dictionary load, in the offline-sort temp-file class.
- Impact class
- minor
- Impact note
- Hits applications that load hunspell dictionaries through the Dictionary(Directory, ...) constructors (offline sorting is what those constructors use) or through HunspellStemFilterFactory (CustomAnalyzer 'hunspellStem'), and only when a load fails after the temp file was created, for example an IOException from the .dic stream or a write error in tempDir (disk full); each failed load holds one file descriptor until garbage collection and, on a caller-supplied persistent tempDir, leaves one small temp file on disk, so an application that keeps retrying a failing load accumulates them; the load itself fails loudly with its original exception, a successful load leaves nothing, and SortingStrategy.inMemory() is not affected.
- Summary
- You load a hunspell Dictionary with a temp Directory (or through HunspellStemFilterFactory) and the load fails while the .dic entries are being written to the offline-sort temp file, for example because the .dic InputStream throws an IOException, which the constructor documents, or a write to the temp directory fails; you expect the failed constructor to close and delete the temp file it created, as it does on success and at the neighbouring steps. Instead Dictionary calls sortingStrategy.start(), which opens the temp IndexOutput, and then mergeDictionaries(...) with no failure handling, so the output stays open: MockDirectoryWrapper refuses to close ('there are still 1 open files: {dictionary_dat_0.tmp=1}'), and 10 failed HunspellStemFilterFactory.inform() calls leave 10 descriptors open on deleted hunspell_dat_0.tmp files until GC. The open handle is a regression from #12834 (9.9.0), which moved the entry writer out of its try-with-resources; the closed temp file that the direct constructor leaves in a caller-supplied tempDir is older (6.0.0).
- Introduced
- f460d612b5e43fe5567d4f4a87a380e324b4e8ee (main; apache/lucene#12834 'hunspell: allow in-memory entry sorting for faster dictionary loading', author donnerpeter, co-authored by dweiss, approved by dweiss, merged 2023-11-24), backported to branch_9x as 41da5c0b6a976da5cb803c6ae355153a72bfcaaa (parent 778b6f85570, #12825). It moved createTempOutput into SortingStrategy.offline().start() and the writer close into finishAndSort(), and the constructor now runs mergeDictionaries(...) outside any try; before it, the entries were written inside try (ByteSequencesWriter writer = new ByteSequencesWriter(output)) (9.8.0 Dictionary.java:253-254, 992) [read: git show, blame of Dictionary.java:267-271 and SortingStrategy.java:67-128 on 645d4fe] [exec: 9.9.0 jars with the Dictionary.java of the parent 778b6f85570 shadowed behave like 9.8.0 (closed file left, no open output, factory clean); stock 9.9.0 leaks the open output and the factory descriptors]. Older layer, not this regression: a closed temp file left after a failed load exists since 4.8.0 (LUCENE-5468, 803226ece41, in java.io.tmpdir) and, since 6.0.0 (LUCENE-6829, c855dd5a33f), in the caller's tempDir, where the factory removes it with IOUtils.rm [exec: timeline probe 4.8.0 to 9.8.0]. (2023-11-24 (author date of f460d612b5e on main and of the 9.x backport 41da5c0b6a9))
- First affected version
- 9.9.0 for the open IndexOutput and the factory descriptor leak [exec: 9.9.0 fails 3 of 4, 9.8.0 fails only the files-left assertion and its factory test passes]; the closed temp file that the direct constructor leaves in a caller-supplied tempDir is older (6.0.0 [exec]; 4.8.0-5.5.5 left it in java.io.tmpdir, also via the factory [exec])
- Affected versions
- [exec] main 645d4fe14a20 (-ea and -da: finder, the independent reproduction, the intended-behaviour check, judge and re-runs), the 10.6.0 release candidate branch_10_6 4ae946e5a9b (core and analysis-common jars: timeline probe 1 file and 1 open descriptor for the direct constructor, 3 descriptors for 3 failed factory loads; exception-safety probe 13 of 20 checks fail; fault-injection harness the same 80 leaks), 10.5.2, 10.0.0, 9.12.3 and 9.9.0 (finder JUnit 3 of 4 fail; the independent reproduction's JUnit 3 of 4 fail on 10.5.2, 9.12.3 and 9.9.0). Partial [exec]: 9.8.0, 9.0.0, 8.11.4, 7.7.3 and 6.0.0 leave only the closed temp file in tempDir (no open output, factory clean); 4.8.0 and 5.5.5 leave a closed unsorted*dat in java.io.tmpdir, also via the factory. Not affected: 4.7.2 (no Dictionary class). [read] git tag --contains: 9.9.0-9.12.3 (10 tags) and 10.0.0-10.5.2 (12 tags) carry the start() / mergeDictionaries / finishAndSort sequence. All runs JDK 25 on aarch64 Linux. Not fixed upstream: main head is still 645d4fe; branch_10x and branch_10_6 have no later change to the three files (2026-10-29).
- 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; also reached by TLA+/TLC, exhaustive check
- Upstream and prior reports
- none found (searched 2026-10-29, read-only: three scripted prior-report searches with 36 GitHub issue/PR, 6 commit, 4 downstream, 44 mailing-list (dev@, java-user@, issues@, commits@), 8 JIRA and 6 web queries, 30 direct GitHub searches over apache/lucene, Elasticsearch, OpenSearch and Solr, 5 JIRA REST queries, all 25 java-user@ hunspell threads, the changed files of all 415 open apache/lucene PRs, and full reads of #12834 and #7887; the finder's 51 queries, the independent reproduction's and the intended-behaviour check's searches and the adjudication's search also found nothing; refresh 2026-10-29: main head still 645d4fe, no PR or issue since 2026-10-25 touches hunspell loading). Related, not reports of this bug: #12834 (the introducing PR; its review covers only the sorted file's cleanup), #7887 (LUCENE-6829, 2015: while moving the temp files to a Directory, maintainers noted that the temp dir is likely not empty after an exception and made HunspellStemFilterFactory remove its private temp dir recursively; no open output existed then), LUCENE-5906 (#6968, 2014: temp-file leaks plugged in the old File-based hunspell code), #5237 (LUCENE-4165, 2012: caller-supplied dictionary readers left open), #8598 (LUCENE-7547, same class in another module), #6717 (LUCENE-5655, open: make the temp dir configurable), #10769 (LUCENE-9730), #14633 and #15134 (later success-flag cleanups on main that do not touch this path); the only open PR on Dictionary.java, #16422, changes affix parsing only.
- Lucene commit tested
- main
645d4fe14a20(the minimal repro with assertions on and off standalone and through gradle, 20 iterations; both fix options in a scratch worktree; fault injection, 228 checks: a fault-free baseline and 227 injected failures, 225 of them I/O errors, stock and with each fix); release jars: the minimal repro on 10.6.0 (assertions on and off) and 9.9.0 (fails) and 9.8.0 (passes); the finder's 4-test JUnit on 9.8.0, 9.9.0, 9.12.3, 10.0.0 and 10.5.2; a release-agnostic timeline probe from 4.7.2 to the 10.6.0 release commit4ae946e5a9b.
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:178-187 (at 645d4fe14a20) and :195-204 (constructor javadoc: '@param tempDir Directory to use for offline sorting', '@throws IOException Can be thrown while reading from the InputStreams', 'You have to close the provided InputStreams yourself'), :213 (the Directory constructors use SortingStrategy.offline(tempDir, tempFileNamePrefix)), :267-271 (acc = sortingStrategy.start(); mergeDictionaries(dictionaries, decoder, acc) outside any failure handling; only the EntrySupplier from finishAndSort() is in a try-with-resources)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/SortingStrategy.java:62-72 (at 645d4fe14a20) (offline(): 'creates temporary files in the given directory'; start() calls createTempOutput and wraps it in a ByteSequencesWriter that only finishAndSort closes), :83-88 (finishAndSort: writeFooter, writer.close, sortWordsOffline, openChecksumInput, no failure cleanup), :104-108 (EntrySupplier.close: reader.close() then deleteFile(sorted)), :112-123 (sortWordsOffline deletes its input when the sort throws: the failure cleanup at the neighbouring step), :135-138 (inMemory(): no temp files)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/HunspellStemFilterFactory.java:51 (at 645d4fe14a20) (@lucene.experimental), :105-107 (dictionary and affix opened through ResourceLoader.openResource), :109-113 (fresh FSDirectory under Dictionary.getDefaultTempDir(), new Dictionary(...), IOUtils.rm(tempPath) in finally: unlinks the still-open temp file)
- The finding in 30 seconds
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:213, :262-271, :1010-1037, :1060, :1078 (at 645d4fe14a20)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/SortingStrategy.java:62-128 (start :70-72, addEntry :77-80, finishAndSort :83-110, close :105-108, sortWordsOffline :112-124) (at 645d4fe14a20)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/HunspellStemFilterFactory.java:96-123 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:277-280 (at 645d4fe14a20)
- Who is affected
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:178-224 (constructors, javadoc) (at 645d4fe14a20)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/HunspellStemFilterFactory.java:51-58, :96-123 (at 645d4fe14a20)
- Minimal repro
build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:239-247 (tests.asserts default from the seed) (at 645d4fe14a20)
- Why it looks unintended
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:186, :203, :223, :269; SortingStrategy.java:116-121; HunspellStemFilterFactory.java:51 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:295 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:883 (at 645d4fe14a20)a Lucene clone: git show releases/lucene/9.8.0:lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java (:253-254, :992)GitHub API read-only GET 2026-11-01: repos/apache/lucene/pulls/12834/comments
- Suggested fix
GitHub API read-only GET 2026-11-01: repos/apache/lucene/pulls/12834/comments (Dawid Weiss, 2023-11-22)
- TestDictionaryFailedLoadClosesTempOutput.java (JUnit, one test):
repro/LFV-1006/src/org/apache/lucene/analysis/hunspell/TestDictionaryFailedLoadClosesTempOutput.java(42 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-1006/bug.png(460 KB)
