All findings · Lucene finding · LFV-1006 · hunspell-offline-sort-temp-output-not-released-on-failure

Hunspell: a Dictionary load that fails while reading the .dic leaves its offline-sort temp output open (regression in 9.9.0)

When a hunspell Dictionary load with a temp Directory fails while reading the .dic, the constructor throws without closing the temp file it opened for sorting. Each failure keeps one file descriptor open until GC and can leave the file on disk; a regression in 9.9.0.

Summary

Priority
Low, rank 1110A failure-path IndexOutput and temp-file leak on an already-failing opt-in hunspell Dictionary load, in the offline-sort temp-file class.
Impact
minor
Since
9.9.0 (#12834, 2023-11-24: main f460d612b5e, 9.x backport 41da5c0b6a9)introduced 2023-11-24 · about 2 years
Affected
9.9.0-10.6.0 and main (a closed leftover file since 6.0.0)
Status
documented
Found by
non-formal: an executed exception-safety probe, then TLA+ model checking and fault injection

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Background: how a hunspell dictionary is loaded with a temp directory

  1. Dictionary must sort all .dic entries before it builds its word table. The constructors that take a temp Directory, and HunspellStemFilterFactory, sort on disk (SortingStrategy.offline), so a large dictionary needs little memory.
  2. The load opens a temp file in that directory (<prefix>_dat_0.tmp), writes every .dic entry to it, closes it, sorts it into a second temp file, reads that back, and deletes both.
  3. The other strategy, SortingStrategy.inMemory(), sorts a list in memory and uses no files. A successful load leaves nothing behind either way.
Diagram of the bug: the hunspell Dictionary constructor opens the temp file dict_dat_0.tmp in the temp directory, then reads the .dic stream; the read throws IOException, the constructor throws, and finishAndSort, the only step that closes the file, never runs, so the file stays open in the temp directory. Through HunspellStemFilterFactory the file is deleted but its descriptor stays open until garbage collection. The fix closes and deletes the file when reading fails.
The offline load step by step: the temp file is opened, then the .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.

The finding, step by step

  1. Load a dictionary with new Dictionary(tempDir, "dict", affix, dic), the constructor that sorts the .dic entries in a temp file inside tempDir. Here the .dic stream throws an IOException on its first read, a failure the constructor documents.
  2. You expect the constructor to throw that IOException and release what it created, as after a successful load and as 9.8.0 did: closing tempDir afterwards succeeds.
  3. Instead the temp output dict_dat_0.tmp, opened just before the .dic is read, stays open: closing the test directory fails with there are still 1 open files: {dict_dat_0.tmp=1}. The caller got no object, so it cannot close the file.
  4. 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 HunspellStemFilterFactory the file is deleted but stays open: 10 failed loads, 10 descriptors on deleted files.

Who is affected

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.

Terms

  • Offline sorting (SortingStrategy.offline(tempDir, prefix)): what the Dictionary(Directory, ...) constructors (Dictionary.java:213) and HunspellStemFilterFactory use. Its start() opens the temp IndexOutput; finishAndSort() writes the footer, closes it, sorts, and returns a supplier that reads the sorted file and deletes it on close().
  • Temp output: the IndexOutput from tempDir.createTempOutput(prefix, "dat", ...), a file such as dict_dat_0.tmp with an open file handle.

The bug

  1. The constructor runs EntryAccumulator acc = sortingStrategy.start(); (Dictionary.java:267), which opens the temp output and its writer (SortingStrategy.java:71-72).
  2. Then mergeDictionaries(dictionaries, decoder, acc); (:268) reads the .dic (:1016, :1019) and writes each entry (:1034, SortingStrategy.java:77-80). It is outside any try: only the supplier returned by finishAndSort() is in a try-with-resources (:269).
  3. If it throws (a .dic read error, a write error in tempDir, or an undefined AM alias, LFV-1012), finishAndSort() never runs, and it is the only code that closes the output (SortingStrategy.java:83-88).
  4. The constructor throws, so the caller has no Dictionary and 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 in tempDir.
  5. HunspellStemFilterFactory.inform loads into a fresh directory under java.io.tmpdir and calls IOUtils.rm(tempPath) in finally (HunspellStemFilterFactory.java:109-113): the open file is unlinked, and its descriptor stays open until GC.
  6. 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.

The numbers

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) or HunspellStemFilterFactory (SPI name hunspellStem, for example in CustomAnalyzer). Users of the SortingStrategy.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 .dic stream, a full or failing temp disk, or a .dic entry with an undefined AM morphological alias (LFV-1012). These are exceptional events, so it is rated rare. Failures while parsing the .aff happen 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.rm may behave differently).

Minimal repro

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.

TestDictionaryFailedLoadClosesTempOutput.java (JUnit, one test)42 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.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();
  }
}
Command
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 IOException and tempDir.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 output SortingStrategy.offline().start() opened; the same with assertions off.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0 and 9.9.0 and passes on 9.8.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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)

Observed output

# 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)

Why it looks unintended

  • 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.0 Dictionary.java:992). #12834 moved the writer into SortingStrategy.offline().start() and its close into finishAndSort(); 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's Dictionary.java swapped in, the open output and the factory descriptor leak disappear.
  • The neighbouring steps release on failure. sortWordsOffline deletes 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 at Dictionary.java:269; OfflineSorter wraps 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 .dic phase fail (TestDictionary.testInvalidData fails 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.

Suggested fix

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 sortWordsOffline cleanup (#14633) and the later success-flag removals (#15134); no public API change (EntryAccumulator is package-private).
  • Cleanup exceptions are added to the original exception as suppressed, so the caller still sees the real cause; only an Error during cleanup is thrown in its place, with the original attached (the convention of IOUtils.closeWhileSuppressingExceptions and deleteFilesSuppressingExceptions, 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, seed DEADBEEF: without the fix the repro fails with -Ptests.asserts=true and false; with it, it passes with both (assertions on: in the package run below). Standalone runs agree with -ea and -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 test with 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).

B. Fix only the reading phase (the 9.9.0 regression) (validated by the checks)

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.

C. The same cleanup with boolean success flags and finally

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).

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. the migrated JIRA issues) and commits2026-10-29three 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 #7887no report; context only (#7887, #12834)
Open PRs2026-10-29changed files of all 415 open PRs and of every PR created since 2026-10-20only #16422 touches Dictionary.java (affix parsing); none touches SortingStrategy.java or the factory
JIRA archive, mailing lists, downstream trackers, web2026-10-298 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 queriesnothing
Upstream branches2026-10-29; main rechecked 2026-11-01commit lists of the three files on main, branch_10x and branch_10_6no 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

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (standalone and gradle, 20/20 iterations); passes with fix A or B
10.6.0 affectedminimal repro fails with assertions on and off (release jars)
9.9.0 to 10.5.2 first affected 9.9.0the 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 leftthe 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.tmpdirtimeline probe: 1 closed file in java.io.tmpdir (direct), 3 (factory, 3 failures), 0 open descriptors
4.7.2 no Dictionary classAPI absent

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate4-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 realIts 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, mediumRated the works-as-designed case (contractual silence, the caller owns the temp dir, the factory is experimental) weak.
intended-behaviour devil's advocate real, highBuilt 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, highRe-ran the reproducer's and the finder's JUnit with a new seed: 3 of 4 fail in each.
Registration check and credibility audit confirmedRe-runs on main fail 3 of 4 with assertions off; 4/4 with the fix shadowed.

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

Finding record

LFV-1006, snapshot of 2026-11-06

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 commit 4ae946e5a9b.

Lucene and upstream sources

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)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.