All findings · Lucene finding · LFV-1089 · fstcompletion-empty-result-shared-mutable-list

FSTCompletion.lookup returns one shared, mutable list for empty results, so adding to it leaks into every other FSTCompletion in the JVM

FSTCompletion.lookup answers every empty key, and every key on an instance with no entries, with the same private static ArrayList. Add one fallback suggestion to that empty result and every other FSTCompletion in the JVM, even one built from a different dictionary, returns it too: [apple/1] instead of [].

Summary

Priority
Low, rank 863A silent JVM-wide leak between FSTCompletion instances in every release since 3.6.0. It needs application code that modifies the empty list returned by the experimental FSTCompletion.lookup, which no in-tree caller does. FSTCompletionLookup cannot trigger it, though its empty-key lookups then return the added entries too, so it is Low.
Impact
minor
Since
3.6.0 (SOLR-2888, 2012)introduced 2011-12-01 · over 14 years
Affected
3.6.0 to 10.6.0 and main; not fixed upstream
Status
documented
Found by
non-formal: a cross-model reviewer's idea that named the shared static empty list, then a probe and a one-test JUnit

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

Autocomplete code often adds fallback suggestions when FSTCompletion.lookup finds nothing. Doing that to an empty-key result changes the answer of every other FSTCompletion in the JVM, because all empty answers are one shared, mutable list.

Background: FST autocomplete in Lucene

  1. FSTCompletionBuilder collects entries (a term and a weight bucket), and build() compiles them into an FSTCompletion: an automaton for prefix autocomplete in lucene/suggest, marked @lucene.experimental.
  2. FSTCompletion.lookup(prefix, num) returns a List<Completion>; each completion prints as term/bucket, for example apple/1. FSTCompletionLookup, the Lookup implementation built on it, copies those completions into a new list of its own.
  3. An empty prefix has no suggestions, and neither does an instance built with no entries: the upstream test testEmptyInput expects an empty result.
Summary card. Panel 1, what the user does (an apple/zebra example, executed): build one FSTCompletion from apple and one from zebra, append the fallback [apple/1] to the first one's empty-key result; expected: the zebra instance's lookup("", 5) is [] (it only has zebra); actual: [apple/1], with no error, on main with assertions on and off. Panel 2, why: the empty-key lookups of both instances and the lookup of an instance with no entries all return the same EMPTY_RESULT object, one ArrayList that now holds [apple/1]. Fix strip: return List.of() and drop EMPTY_RESULT; the user's addAll then throws UnsupportedOperationException instead of leaking, the empty lookup stays [], and the repro test, this example and the 13 upstream TestFSTCompletion tests pass.
An apple/zebra example in one card (an illustration, not the repro test): two dictionaries, one empty result, one addAll, and every empty answer in the JVM now holds [apple/1], because all of them are the same static list. Returning List.of() fixes it. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build an FSTCompletion from the single entry apple and call lookup("", 1): you get an empty list. Your code appends a fallback suggestion to it, [apple/1], as autocomplete code often does when nothing matches.
  2. You expect only your own list to change: an independent FSTCompletion, built from zebra or from no entries at all, should still answer an empty key with [].
  3. Instead it answers [apple/1], with no error, with assertions on and off, on main and in every release since 3.6.0. An instance with no entries now returns [apple/1] for any key, and FSTCompletionLookup returns it for empty keys too.
  4. Why: lookup returns the static field EMPTY_RESULT itself whenever the key is empty or the instance has no automaton. It is one mutable ArrayList shared by every instance, so the change stays until some code clears the list or the JVM restarts. Non-empty results have been unmodifiable since 10.0.0.

Who is affected

Code that uses FSTCompletion directly and adds to a result that came back empty, such as fallback suggestions. Every other FSTCompletion in the JVM then returns those entries for empty keys, silently, until the list is cleared or the JVM restarts. Lucene's own code never modifies a result; FSTCompletionLookup cannot cause it but returns the added entries.

Mechanism, terms and numbers562 words

FSTCompletion (module lucene/suggest, @lucene.experimental) is the FST autocomplete engine behind FSTCompletionLookup. Its lookup(key, num) returns one private static final ArrayList field, EMPTY_RESULT, whenever the key is empty or the instance was built with no entries (its automaton is then null). That list is the same object for every call on every instance (a static field exists once per loaded class, so in a typical application once per JVM), and it is mutable. So when a caller adds to the list it got for an empty key, for example a fallback suggestion, every other FSTCompletion, even one built from a different dictionary, returns that entry for empty keys from then on, with no error, until some code clears the list or the JVM restarts; an instance with no entries returns it for every key, and FSTCompletionLookup, which copies results, returns the polluted entries for empty keys too. Lucene's own code does not modify a result, so it takes application code that writes into a returned list. It has been there since FSTCompletion was created (SOLR-2888, first released in 3.6.0); since 10.0.0 every non-empty result is unmodifiable, so the empty one is the only list that silently accepts the change. The fix is one line: return List.of();.

The mechanism (main e1faeba08bde, lucene/suggest/.../fst/)

  1. One static list. FSTCompletion.java:77-81: private static final ArrayList<Completion> EMPTY_RESULT = new ArrayList<>();, with the comment "An empty result. Keep this an ArrayList to keep all the returned lists of single type (monomorphic calls)."
  2. Returned as the result. lookup(CharSequence key, int num) (:197-210) starts with if (key.isEmpty() || automaton == null) { return EMPTY_RESULT; } (:198-200): the object itself, not a copy. The early return also skips num, so after several appends every empty lookup returns all of them (by reading, not executed).
  3. No entries means a null automaton. FSTCompletionBuilder.build() (FSTCompletionBuilder.java:190-197) calls buildAutomaton, which returns null when no entry was added (:223), and the FSTCompletion constructor accepts it (FSTCompletion.java:113-122). Such an instance returns EMPTY_RESULT for every key.
  4. Non-empty results are different. :206 and :208 return lookup(key)...limit(num).toList(), an unmodifiable list, a new one for each non-empty result (java.util.ImmutableCollections$ListN on JDK 25, executed). An unmatched non-empty prefix such as "q" takes this path too and gets an unmodifiable empty list (executed; on JDK 25 that is the JDK's own shared immutable empty list, which nobody can change). Among the paths inspected, only the early return hands out a list a caller can change.
  5. The Lookup wrapper copies, then shows the pollution. FSTCompletionLookup.lookup (FSTCompletionLookup.java:262-281) calls completion.lookup(key, num) and copies each completion into a new ArrayList<LookupResult>, so Lookup users cannot cause the pollution, but after someone else did, their empty-key lookups return the added entries.
  6. Who else uses it. In lucene/*/src/java only FSTCompletionBuilder and FSTCompletionLookup reference FSTCompletion; neither modifies a result.

What one caller's change does (executed, main jars, assertions on)

Three instances, apple (from "apple"), zebra (from "zebra") and none (no entries), and zebraLookup = new FSTCompletionLookup(null, null, zebra, true). One caller runs apple.lookup("", 5).addAll(apple.lookup("a", 1)):

callbeforeafter
zebra.lookup("", 5)[][apple/1]
zebraLookup.lookup("", false, 5)[][apple/1]
none.lookup("z", 5)[][apple/1]
zebra.lookup("z", 5)[zebra/1] (unaffected)

zebra.lookup("", 5) is the very list the caller changed (== is true). The zebra dictionary never contained "apple".

Exposure and likelihood276 words
  • Who is exposed. Direct users of the @lucene.experimental FSTCompletion whose code modifies a list that lookup returned for an empty key, or that an instance with no entries returned for any key. The natural pattern is "if the list is empty, add fallback (default, recent, per-user) suggestions".
  • Version nuance. Since 10.0.0 (#12978) modifying a non-empty result throws UnsupportedOperationException, so only "modify when empty" code is silently hit. On 9.x and older every result was a mutable list the caller could change and only the empty one was shared, so any code that modified results was hit whenever a result was empty.
  • Silent and lasting. No error; the change reaches every instance of the class (a class-wide static: one list per class loader, in a typical application the whole JVM) and stays until some code clears the list or the JVM restarts. If the application keeps appending, the shared list keeps growing (by reading, not executed). FSTCompletion may be used by several threads at once (FSTCompletion.java:380-381), so such appends to an unsynchronized ArrayList can also race with other threads reading it, for example a ConcurrentModificationException (by reading, not executed).
  • Default vs opt-in. Lucene's own code does not modify a result (in lucene/*/src/java only FSTCompletionLookup calls lookup, and it copies); no setting, codec or scale is involved. It needs direct use of an experimental class plus application code that writes into a returned list. The Lookup entry point, FSTCompletionLookup, cannot trigger it, but returns the polluted entries for empty keys (executed).
  • How likely. Rare: the pattern is ordinary Java, but no in-tree caller does it and nobody has reported it since 3.6.0 (2012).

Minimal repro

One JUnit test for lucene/suggest, public API only, no index: build an FSTCompletion from apple, add [apple/1] to its empty-key result, then check that a different, empty FSTCompletion still returns []. Deterministic and single-threaded; it fails the same way with assertions on and off.

TestFSTCompletionEmptyResult.java (lucene/suggest, public API)36 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.search.suggest.fst;

import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestFSTCompletionEmptyResult extends LuceneTestCase {

  public void testEmptyResultIsNotShared() throws Exception {
    FSTCompletionBuilder builder = new FSTCompletionBuilder();
    builder.add(new BytesRef("apple"), 1);
    FSTCompletion apple = builder.build();
    try {
      apple.lookup("", 1).addAll(apple.lookup("a", 1));
    } catch (UnsupportedOperationException _) {
    }
    // expected: another, empty FSTCompletion returns []; actual on main: [apple/1]
    assertEquals(List.of(), new FSTCompletionBuilder().build().lookup("", 1));
  }
}
Command
cp $REPRO/TestFSTCompletionEmptyResult.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests TestFSTCompletionEmptyResult -Ptests.seed=5EED1089 -Ptests.asserts=true
# also with -Ptests.asserts=false; nothing depends on the seed
Expected
A different, empty FSTCompletion answers lookup("", 1) with [].
Observed
It answers [apple/1]: AssertionError: expected:<[]> but was:<[apple/1]>.

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

More files, environment, full output373 words

This is the hunt's minimal repro with one change: the unused catch parameter is named _ instead of ignored, because Lucene's ecj lint rejects unused exception parameters; with it the test passes :lucene:suggest:check -x test. _ as a catch parameter needs Java 22 or later: fine on main (minimum Java 25); a source backport to 10.x (minimum Java 21) would need another form. The try/catch makes the test pass with either fix: an unmodifiable empty result throws on addAll, a fresh one absorbs it.

Executed: gradle on main e1faeba08bde with assertions on (seed 5EED1089) and off (5EED108A), both fail; the standalone runner on the 10.6.0 release jars with assertions on and off and on 9.12.3, all fail; the hunt's runs on main fail the same way. With the List.of() fix the test passes.

Use the same test as the regression test. It covers the empty-key trigger with an instance that has no entries as the victim; FSTCompletionLookup is shown only by the illustration probe. Its verdict does not depend on test order: on an unfixed tree it fails even if an earlier test already changed the shared list, and on a fixed tree nothing is shared. On an unfixed tree it does leave [apple/1] in the shared list, so later tests in the same JVM that look up an empty key (such as TestFSTCompletion.testEmptyInput) could fail too (by reading, not executed); the whole-suite gradle run was on the fixed tree only. To make it clean up after itself on an unfixed tree, clear apple.lookup("", 1) in a finally block that catches UnsupportedOperationException (not run).

Environment

lucenemain e1faeba08bde (12.0.0-SNAPSHOT); also run unchanged against the 10.6.0 and 9.12.3 release jars (test compiled with JDK 25)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (plain Java; only this platform was run)
Command (environment)
cp $REPRO/TestFSTCompletionEmptyResult.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests TestFSTCompletionEmptyResult -Ptests.seed=5EED1089 -Ptests.asserts=true
# also with -Ptests.asserts=false; nothing depends on the seed

Observed output

TestFSTCompletionEmptyResult > testEmptyResultIsNotShared FAILED
    java.lang.AssertionError: expected:<[]> but was:<[apple/1]>
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:120)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
        at org.apache.lucene.search.suggest.fst.TestFSTCompletionEmptyResult.testEmptyResultIsNotShared(TestFSTCompletionEmptyResult.java:34)
1 test completed, 1 failed

# the same with -Ptests.asserts=false, and through the standalone runner on 10.6.0 (assertions on and off) and 9.12.3

Why it looks unintended

  • Nothing documents the sharing: lookup promises "the suggestions" for the key, the field is private, and the list accepts addAll silently.
  • Sibling suggesters and the predecessor FSTLookup return an unmodifiable empty list; the shared mutable one came with a 2011 rewrite to keep one result type, which no longer holds since 10.0.0.
  • Upstream's testEmptyInput expects no suggestions there.
The full argument249 words
  • Contract. FSTCompletion.lookup (FSTCompletion.java:189-196): "@return Returns the suggestions, sorted by their approximated weight first (decreasing) and then alphabetically (UTF-8 codepoint order)." Lookup.lookup (Lookup.java:191): "@return a list of possible completions". Nothing says the list is shared, read-only or must not be modified, and CHANGES.txt, MIGRATE.md and the package docs say nothing either. The field is private, so a caller cannot know it got a shared object; java.util.List makes mutators optional, but this list supports them, so the defect is undocumented, surprising aliasing rather than a broken mutability promise.
  • Test. TestFSTCompletion.testEmptyInput (TestFSTCompletion.java:238-241) asserts that an instance with no entries returns no suggestions for an empty key.
  • Siblings. WFSTCompletionLookup (:163, :180) and AnalyzingSuggester (:679) return the unmodifiable Collections.emptyList() for empty lookup results.
  • History. The predecessor FSTLookup, released in 3.3.0-3.6.2, returned an unmodifiable Collections.emptyList() (executed on 3.5.0 and 3.6.0). SOLR-2888 (92de7e88486, 2011) replaced it with the mutable ArrayList "to keep all the returned lists of single type (monomorphic calls)", a choice about the runtime class; none of its 21 JIRA comments, the commit message or the CHANGES entry mentions sharing. Since #12978 (10.0.0) non-empty results are ImmutableCollections$ListN, so the ArrayList is now the odd type out; List.of() has that same class (executed).
  • Upstream precedent. #13055, #13087 and #13092 (2024) made other static final collections immutable. Those sweeps covered Set and Map constants, private ones included, but no List field, so this one was left as it was.
The devil's-advocate check196 words

The strongest "intended / misuse" case (built by the devil's-advocate verifier; every reviewer rated it moderate but insufficient): the shared list was deliberate, a constant nobody was meant to modify; the class is @lucene.experimental, List mutators are optional, and no in-tree code modifies a result; since 10.0.0 non-empty results are unmodifiable, which suggests results are read-only; and maintainers called similar hardening cosmetic (#13092, dweiss: "These are cosmetic changes but they're worth backporting"; #13087, uschindler, on a public static Set constant: changing it "would be a downstream bug").

Why it fails: nothing documents a read-only rule, and unlike #13087's public constants this list is private and reaches the caller as its own result. From 3.6.0 to 9.12.3 every non-empty result was a fresh mutable list the caller owned, so modifying results worked; only the empty case aliased global state. #12978, which made non-empty results unmodifiable, had no description, no review comments and no CHANGES entry, so it is no documented contract. The change succeeds silently, crosses instances built from different data, and stays until some code clears the list. Net: not intended; a maintainer may well call the fix hardening, which is why it is Low.

Suggested fix

Recommended Return List.of() and drop EMPTY_RESULT (fix A)

Return List.of() instead of the shared field, and delete the field. Empty results become immutable like every non-empty result since 10.0.0, so code that appends to one gets UnsupportedOperationException instead of leaking. The repro, TestFSTCompletion, the whole lucene/suggest suite and :lucene:suggest:check -x test pass.

All options and trade-offs3 options ยท 465 words

One fix was built and validated in a scratch worktree of e1faeba08bde; the alternatives are listed with their trade-offs.

Return List.of() and drop EMPTY_RESULT (fix A) (validated by the checks) (recommended)

In FSTCompletion.lookup, return List.of() for an empty key or a null automaton and delete the field. The JDK's immutable empty list cannot leak between callers, and it has the same runtime class as the non-empty Stream.toList() results, so the field comment's "single type" aim is met again. On current JDKs List.of() returns one shared immutable empty list, so no allocation is added (an implementation detail, not an API guarantee). java.util.ArrayList stays imported for cacheRootArcs.

--- a/lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java
+++ b/lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java
@@ -74,12 +74,6 @@ public class FSTCompletion {
   /** Default number of buckets. */
   public static final int DEFAULT_BUCKETS = 10;
 
-  /**
-   * An empty result. Keep this an {@link ArrayList} to keep all the returned lists of single type
-   * (monomorphic calls).
-   */
-  private static final ArrayList<Completion> EMPTY_RESULT = new ArrayList<>();
-
   /** Finite state automaton encoding all the lookup terms. See class notes for details. */
   private final FST<Object> automaton;
 
@@ -196,7 +190,7 @@ public class FSTCompletion {
    */
   public List<Completion> lookup(CharSequence key, int num) {
     if (key.isEmpty() || automaton == null) {
-      return EMPTY_RESULT;
+      return List.of();
     }
 
     if (!higherWeightsFirst && rootArcs.length > 1) {

Trade-offs

A behaviour change for code that appends to an empty result: it now gets UnsupportedOperationException (executed with the illustration probe against the fixed classes), which is what it already gets for any non-empty result since 10.0.0.

What the checks validated

  • stock: the test fails 1 of 1 with assertions on (seed 5EED1089) and off (5EED108A);
  • fix A: the test plus TestFSTCompletion pass with assertions on (5EED108B) and off (5EED108C): 2 suites, 15 tests, 0 failures, 1 skipped (testRandom is @Nightly);
  • the whole :lucene:suggest:test with assertions on (5EED108D): 32 suites, 237 tests, 7 skipped, 0 failures;
  • :lucene:suggest:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) passes with the test in the tree;
  • in the hunt, the repro, the 13 upstream TestFSTCompletion tests the standalone JUnit runner ran, and the independent reproducer's own test pass with the same fix.

Return a fresh new ArrayList<>() per call (option B)

Also removes the sharing, and keeps code that appends to an empty result working.

Trade-offs

One small allocation per empty lookup, and two result flavours on 10.x and later (mutable when empty, unmodifiable when not). Closer to the behaviour of a line where non-empty results are still mutable (9.x). Not validated.

Return Collections.emptyList() (option C, not recommended)

Unmodifiable like A and what the sibling suggesters use.

Trade-offs

A third runtime class (Collections$EmptyList), against the field comment's stated aim. Not validated.

Downstream note

An application that appends its own (for example per-user) suggestions to an empty result would see them returned by every other FSTCompletion in the same JVM; returning List.of() removes this.

How it was found

An idea from an independent reviewer of another model family (OpenAI GPT-5.6) named FSTCompletion's static EMPTY_RESULT and proposed a two-thread test; a single-threaded probe then showed one instance's change in another. A hunt unit reduced it to one public-API test; separate verifiers, reviewers and an adjudication confirmed it.

The method in full139 words

Method: non-formal (a cross-model idea, then executed probes and JUnit tests).

  • Ideation, 2026-11-02: a concurrency idea from an independent reviewer of another model family (OpenAI GPT-5.6), "Concurrent FST lookup must not expose a globally mutable empty result", named FSTCompletion's static EMPTY_RESULT itself (FSTCompletion.java:77-81, :197-200) and proposed a two-thread test. A first probe turned that idea into a single-threaded cross-instance main() probe; on main (assertions on and off), 10.6.0, 9.12.3 and 9.1.0 it showed another instance returning the added entry, and a separate re-run agreed.
  • A hunt unit, 2026-11-02: reduced it to one single-threaded public-API JUnit test, checked the history back to 3.6.0 and the runtime classes of the returned lists, and validated the List.of() fix.
  • An independent reproducer, cross-model reviews, a devil's-advocate verifier, an adjudication and an audit then re-ran it (see the checks below).

Prior reports, history and checks

Prior reports

No public report found (searched 2026-11-02): GitHub issues, PRs and commits including the migrated JIRA archive, JIRA, the Lucene and Solr mailing lists, downstream projects, the web and all open PRs. Every Lucene item that mentions FSTCompletion was read; none is this bug. Lucene.NET's port has the same list, unreported.

Searches4 searches

None of the hits is the same bug. Search limits: keyword search only; GitHub search does not index inline review comments of PRs that never name these terms; private reports are not visible. A re-check the same day found no new issue or PR and the same FSTCompletion.java on upstream main.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; apache/solr, apache/lucene-solr, Elasticsearch, OpenSearch, Lucene.NET; all of GitHub)2026-11-02two scripted prior-report searches with different phrasings (90 queries in total) and 43 manual queries, for example FSTCompletion EMPTY_RESULT, FSTCompletion unmodifiable, FSTCompletion mutable, FSTCompletion lookup empty, EMPTY_RESULTno report; the FSTCompletion items #11328, #11575, #11576, #844, #4692, #4371 and #6531 were read and are unrelated
Open PRs and recent activity2026-11-02the changed files of all 410 open apache/lucene PRs; every PR updated since 2026-10-30; the history of FSTCompletion.java on main, branch_11x and branch_10_6none touches FSTCompletion.java (the few that touch lucene/suggest, such as #681 on fst/ExternalRefSorter.java, are unrelated); the last change to the file, #16056, does not touch the empty result
JIRA (LUCENE, SOLR) and mailing lists2026-11-027 JIRA queries and the comments of SOLR-2888, SOLR-2378, LUCENE-2995, LUCENE-3807, LUCENE-10539, LUCENE-10540, LUCENE-3618 and LUCENE-3714; 24 lists.apache.org searches over six Lucene and Solr listsno report; SOLR-2888 introduced the field without discussing it
Nearest items (read)2026-11-02#11328 (LUCENE-10292), #12978, #5633 (LUCENE-4567), SOLR-2888background only: other Lookups return Collections.emptyList(); #12978 made non-empty results unmodifiable without discussion; a WFSTCompletionLookup NPE on an empty FST

History

Present since FSTCompletion was created by SOLR-2888 (2011; first released in 3.6.0, 2012), so in every release from 3.6.0 to 10.6.0 and on main; not a regression. Executed on 14 releases and main, all fail; the predecessor FSTLookup returned an unmodifiable empty list. Not fixed upstream.

Versions tested7 rows

Introduced by SOLR-2888 (JIRA title "FSTSuggester refactoring: utf8 storage, external sorts (OOM prevention), code cleanups"): trunk 92de7e88486 by Dawid Weiss (2011-12-01) and the branch_3x backport d3e380f824a committed by Robert Muir (2012-01-17), which shipped first, in 3.6.0. No pull request (svn era). The commit created FSTCompletion with the static ArrayList and the early return, replacing the predecessor FSTLookup's unmodifiable Collections.emptyList(); both lines are still the blamed ones on main apart from cosmetic edits (LUCENE-5512, LUCENE-9570, and #12978 rewriting the condition to key.isEmpty()). #844 (2022) changed only the non-empty path; #12978 (78b4f75a2c6, 2023-12-30, 10.0.0) made non-empty results unmodifiable and left the field alone; #16056 (2026-05-14), the last change to the file, does not touch it. All 127 release tags that contain FSTCompletion.java have the static list (8.8.2-8.11.3 are not in the local tag set; 8.8.1 and 8.11.4 on either side are affected). Upstream main still has the same FSTCompletion.java (re-checked 2026-11-02).

Versions and branches executed, with results
Version or commitVerdictResult
main e1faeba08bde affectedthis doc's test fails 1 of 1 through gradle with assertions on and off
10.6.0 affectedthis doc's test fails 1 of 1 with assertions on and off (release jars)
10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedthe hunt's test (or, on 9.12.3, this doc's) fails 1 of 1
3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0 history probea version-neutral main() probe with the same steps fails: [apple/1] instead of []
3.6.0 first releasefirst release with FSTCompletion; the probe fails
3.5.0 predecessorno FSTCompletion (the probe does not compile); the predecessor FSTLookup returns an unmodifiable empty list (also on 3.6.0)
main + List.of() fix fixthe test passes; so do TestFSTCompletion and the whole lucene/suggest suite

Independent checks

An independent reproducer, cross-model reviews (OpenAI GPT-5.6, executed), a devil's-advocate verifier, an adjudication and an audit agree on the root cause, and each re-ran a failing test. This doc's gradle runs reproduced it on main e1faeba08bde and validated the fix.

Checks and verdicts316 words

Every executed check saw another instance's empty-key lookup return the added entry, from the same lines, FSTCompletion.java:81 and :198-199, and every one that ran the fix saw it pass. The cross-model reviews ran on an earlier main checkout, ea57360912ed, whose FSTCompletion.java is identical to e1faeba08bde.

Each independent check and its verdict
CheckVerdictWhat it found
first probe (from a cross-model idea) reproducedA main() probe on main (assertions on and off), 10.6.0, 9.12.3 and 9.1.0: another instance returns the added entry; a separate re-run agreed.
finder reproducedReduced it to one public-API JUnit test: fails on main (assertions on and off), 10.6.0, 10.5.2 and 9.12.3; passes with the fix; 13 upstream tests pass.
independent reproducer realOwn test (an instance with no entries, looked up with a non-empty key, pollutes another instance's empty-key lookup): fails on main (on and off), 10.6.0, 10.5.2 and 9.12.3 ([one/1] instead of []); passes with the fix.
cross-model review, refute lens real (high)OpenAI GPT-5.6, executed: its own test fails with assertions on and off; intended argument moderate; asked to narrow the title to the empty-key / no-entry early return (done).
cross-model review, intended lens real (high), severity lowOpenAI GPT-5.6, executed: its own test fails on main, 10.5.2 and 9.12.3 ([left/7] instead of []); fix checked; intended argument moderate but insufficient.
devil's advocate real, LowBuilt the misuse case and showed why it fails; replay fails on main (on and off) and 10.6.0.
adjudication confirmed (high), minor, LowRe-ran on main with assertions off (fails) and with the fix (passes).
credibility audit confirmed (Low)Re-ran on main (fails); impact rated minor, not inflated; one test, public API, deterministic.
this doc reproducedGradle on main (on and off), the standalone JUnit runner on 10.6.0 (on and off) and 9.12.3; fix A validated; illustration probe.

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

Finding record

LFV-1089, snapshot of 2026-11-06

Registry title
FSTCompletion.lookup returns one shared, mutable static ArrayList for an empty key or an instance with no entries, so modifying that result leaks into every other FSTCompletion in the JVM
Area
suggest / FST completion (FSTCompletion.lookup empty result)
Priority
Low, rank 863: A silent JVM-wide leak between FSTCompletion instances in every release since 3.6.0. It needs application code that modifies the empty list returned by the experimental FSTCompletion.lookup, which no in-tree caller does. FSTCompletionLookup cannot trigger it, though its empty-key lookups then return the added entries too, so it is Low.
Impact class
minor
Impact note
Hits only code that uses the @lucene.experimental FSTCompletion class directly and modifies a list that lookup returned for an empty key (or that an instance built with no entries returned for any key), for example by appending fallback suggestions: from then on every FSTCompletion in the JVM, including FSTCompletionLookup's empty-key lookups (which copy the list), silently returns the added entries until some code clears the list or the JVM restarts; no in-tree code modifies a result, no scale or non-default setting is needed, and every release from 3.6.0 through 10.6.0 and main is affected.
Summary
You build two FSTCompletion instances from different inputs ('apple' and 'zebra'), call lookup("", 5) on the first and add a fallback suggestion to the empty list it returns, and expect the second instance's lookup("", 5) to still return no suggestions. Instead it returns [apple/1], with no error: lookup hands out the same private static mutable ArrayList (EMPTY_RESULT) for every empty key and for every instance with no entries, so the change shows up in every FSTCompletion in the JVM. Since 10.0.0 non-empty results are unmodifiable, so only the empty result accepts the change silently.
Introduced
92de7e884868f91b42689aa344c51b1b8aa8fdbb (trunk, svn r1209265) and its branch_3x backport d3e380f824a59857c3cd72e7f789e6f5682e7853 (svn r1232289; the backport shipped first, in 3.6.0), SOLR-2888 'FSTSuggester refactoring: utf8 storage, external sorts (OOM prevention), code cleanups' (svn era, no pull request). The commit created FSTCompletion with 'private static final ArrayList<Completion> EMPTY_RESULT = new ArrayList<Completion>()' (field javadoc: keep it an ArrayList so all returned lists have one type, 'monomorphic calls') and 'return EMPTY_RESULT' for an empty key or a null automaton, replacing the released predecessor FSTLookup's unmodifiable Collections.emptyList(); both lines are still the blamed ones on main apart from cosmetic edits (LUCENE-5512 diamond, LUCENE-9570 reformat) [read: git log -S EMPTY_RESULT, git log -L77,81, git blame -C -C -M on FSTCompletion.java in the full-history clone]. #12978 (78b4f75a2c6, author date 2023-12-30, first in 10.0.0) made non-empty results unmodifiable and left EMPTY_RESULT unchanged [read]. (2011-12-01 (trunk 92de7e88486, author date = commit date); 2012-01-17 (branch_3x backport d3e380f824a))
First affected version
3.6.0 [exec: the history probe fails 'expected:<[]> but was:<[apple/1]>' on the 3.6.0 jars], the first release that contains FSTCompletion (3.5.0 has no such class [exec: compile error]); the class it replaced, FSTLookup (released 3.3.0-3.6.2), returns an unmodifiable Collections.emptyList() [exec on 3.5.0 and 3.6.0: pass], so the bug came with the SOLR-2888 rewrite and no release of FSTCompletion was ever free of it
Affected versions
every release that ships FSTCompletion: 3.6.0-3.6.2 and 4.0.0-alpha through 10.6.0 (released 2026-10-29, 4ae946e5a9b), plus main e1faeba08bde (12.0.0-SNAPSHOT). Executed, all failing with 'expected:<[]> but was:<[apple/1]>': the repro JUnit on main (-ea and -da), 10.6.0 (-ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.1.0 (-da); a version-neutral main() history probe with the same steps (it passes on main with the fix) on 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.6.0 and main [exec]; the other releases by a source check of all 127 release tags that contain FSTCompletion.java (8.8.2-8.11.3 are not in the local tag set; 8.8.1 [read] and 8.11.4 [exec] on either side are affected) [read]. On 9.x and earlier every result was a mutable list the caller could change, but only the empty one was shared; since 10.0.0 (#12978) the empty result is the only mutable one. Not new in 10.6 (10.5.2 fails the same test). Not fixed upstream: FSTCompletion.java on upstream main (checked 2026-11-02) is blob be9a4281f4b8, identical to e1faeba08bde, last changed by #16056 (2026-05-14), which does not touch EMPTY_RESULT [read through the GitHub API]. Executed on Linux aarch64 with JDK 25.0.3 only.
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
non-formal
Upstream and prior reports
none found (2026-11-02, on top of the finder's scripted prior-report search and the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches; rechecked the same day): two scripted prior-report searches with different phrasings (90 queries: 32 GitHub, 6 commit, 4 downstream, 40 mailing-list, 8 JIRA, plus 6 web), 28 manual GitHub issue/PR queries on apache/lucene, 10 on apache/solr, apache/lucene-solr, Elasticsearch, OpenSearch and Lucene.NET, 5 across all of GitHub, 7 JIRA REST queries over LUCENE and SOLR (comments of SOLR-2888, SOLR-2378, LUCENE-2995, LUCENE-3807, LUCENE-10539, LUCENE-10540, LUCENE-3618 and LUCENE-3714 read), 24 lists.apache.org searches over six Lucene and Solr lists, 9 web queries, the changed files of all 410 open apache/lucene PRs (none touches FSTCompletion.java) and every PR updated since 2026-10-30; every apache/lucene item that mentions FSTCompletion (#11328, #11575, #11576, #844, #4692, #4371, #6531) was read and is unrelated; CHANGES.txt on main has no entry. Not fixed upstream (same FSTCompletion.java blob on upstream main).
Lucene commit tested
main e1faeba08bde (2026-11-02) through gradle in a scratch worktree with assertions on and off; the 10.6.0 (assertions on and off) and 9.12.3 release jars through the standalone JUnit runner. The hunt also ran its earlier versions of the test on 10.5.2, 10.0.0 and 9.1.0, and a version-neutral history probe on 12 releases from 3.6.0 to 10.6.0 (all fail). The fix was validated in the scratch worktree: the test, TestFSTCompletion (assertions on and off), the whole lucene/suggest suite (assertions on) and :lucene:suggest:check -x test.

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java:34-39 (at e1faeba08bde) (class javadoc, @lucene.experimental at :39), :77-81 (field javadoc 'An empty result. Keep this an ArrayList to keep all the returned lists of single type (monomorphic calls).' and 'private static final ArrayList<Completion> EMPTY_RESULT = new ArrayList<>();': fix site), :189-196 (lookup javadoc '@return Returns the suggestions, sorted by their approximated weight first (decreasing) and then alphabetically'; no ownership or read-only statement), :197-199 (if (key.isEmpty() || automaton == null) return EMPTY_RESULT), :206 and :208 (non-empty results via Stream.toList(), unmodifiable)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionBuilder.java:190-197 (at e1faeba08bde) (build() returns new FSTCompletion(automaton)), :223 (automaton is null when count == 0, i.e. no entries were added)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:262-281 (at e1faeba08bde) (lookup copies the completions into a new ArrayList of LookupResult, so it cannot pollute the shared list but returns already-polluted entries)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/WFSTCompletionLookup.java:163, (at e1faeba08bde) :180 and lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:679 (sibling suggesters return the unmodifiable Collections.emptyList())
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:238-241 (at e1faeba08bde) (testEmptyInput: an instance built with no entries returns no matches for an empty key)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java:35-40 (at e1faeba08bde) (@lucene.experimental), :77-81 (EMPTY_RESULT field and comment), :113-122 (constructor accepts a null automaton), :189-196 (lookup javadoc), :197-210 (lookup; early return :198-200; Stream.toList() at :206, :208)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionBuilder.java:190-197 (at e1faeba08bde) (build), :223 (null automaton when count == 0)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:262-281 (at e1faeba08bde) (copies completions into a new ArrayList of LookupResult)
Minimal repro
  • Lucene main e1faeba08bde gradle/validation/ecj-lint/ecj.javadocs.prefs:136 (unusedExceptionParameter=error)
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java:77-81, (at e1faeba08bde) :189-196, :206, :208
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:186-196 (at e1faeba08bde) (lookup javadoc)
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:238-241 (at e1faeba08bde) (testEmptyInput)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/WFSTCompletionLookup.java:163, (at e1faeba08bde) :180 (lookup); lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:679 (at e1faeba08bde) (lookup)

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

Repro files

About the diagrams

A light summary card (the PNG), made from a separate probe program run on main e1faeba08bde with assertions on and off and with the fix as a control.

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.