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.
FSTCompletionBuildercollects entries (a term and a weight bucket), andbuild()compiles them into anFSTCompletion: an automaton for prefix autocomplete inlucene/suggest, marked@lucene.experimental.FSTCompletion.lookup(prefix, num)returns aList<Completion>; each completion prints asterm/bucket, for exampleapple/1.FSTCompletionLookup, theLookupimplementation built on it, copies those completions into a new list of its own.- An empty prefix has no suggestions, and neither does an instance built with no entries: the upstream test
testEmptyInputexpects an empty result.
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.- Build an
FSTCompletionfrom the single entryappleand calllookup("", 1): you get an empty list. Your code appends a fallback suggestion to it,[apple/1], as autocomplete code often does when nothing matches. - You expect only your own list to change: an independent
FSTCompletion, built fromzebraor from no entries at all, should still answer an empty key with[]. - 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, andFSTCompletionLookupreturns it for empty keys too. - Why:
lookupreturns the static fieldEMPTY_RESULTitself whenever the key is empty or the instance has no automaton. It is one mutableArrayListshared 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.
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();.
- 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)." - Returned as the result.
lookup(CharSequence key, int num)(:197-210) starts withif (key.isEmpty() || automaton == null) { return EMPTY_RESULT; }(:198-200): the object itself, not a copy. The early return also skipsnum, so after several appends every empty lookup returns all of them (by reading, not executed). - No entries means a null automaton.
FSTCompletionBuilder.build()(FSTCompletionBuilder.java:190-197) callsbuildAutomaton, which returnsnullwhen no entry was added (:223), and theFSTCompletionconstructor accepts it (FSTCompletion.java:113-122). Such an instance returnsEMPTY_RESULTfor every key. - Non-empty results are different.
:206and:208returnlookup(key)...limit(num).toList(), an unmodifiable list, a new one for each non-empty result (java.util.ImmutableCollections$ListNon 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. - The Lookup wrapper copies, then shows the pollution.
FSTCompletionLookup.lookup(FSTCompletionLookup.java:262-281) callscompletion.lookup(key, num)and copies each completion into a newArrayList<LookupResult>, soLookupusers cannot cause the pollution, but after someone else did, their empty-key lookups return the added entries. - Who else uses it. In
lucene/*/src/javaonlyFSTCompletionBuilderandFSTCompletionLookupreferenceFSTCompletion; neither modifies a result.
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)):
| call | before | after |
|---|---|---|
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.experimentalFSTCompletionwhose code modifies a list thatlookupreturned 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).
FSTCompletionmay be used by several threads at once (FSTCompletion.java:380-381), so such appends to an unsynchronizedArrayListcan also race with other threads reading it, for example aConcurrentModificationException(by reading, not executed). - Default vs opt-in. Lucene's own code does not modify a result (in
lucene/*/src/javaonlyFSTCompletionLookupcallslookup, 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. TheLookupentry 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).
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.
/*
* 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));
}
}
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
FSTCompletionanswerslookup("", 1)with[]. - Observed
- It answers
[apple/1]:AssertionError: expected:<[]> but was:<[apple/1]>.
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).
| lucene | main e1faeba08bde (12.0.0-SNAPSHOT); also run unchanged against the 10.6.0 and 9.12.3 release jars (test compiled with JDK 25) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (plain Java; only this platform was run) |
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
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- Nothing documents the sharing:
lookuppromises "the suggestions" for the key, the field is private, and the list acceptsaddAllsilently. - Sibling suggesters and the predecessor
FSTLookupreturn 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
testEmptyInputexpects 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, andCHANGES.txt,MIGRATE.mdand the package docs say nothing either. The field isprivate, so a caller cannot know it got a shared object;java.util.Listmakes 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) andAnalyzingSuggester(:679) return the unmodifiableCollections.emptyList()for empty lookup results. - History. The predecessor
FSTLookup, released in 3.3.0-3.6.2, returned an unmodifiableCollections.emptyList()(executed on 3.5.0 and 3.6.0). SOLR-2888 (92de7e88486, 2011) replaced it with the mutableArrayList"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 areImmutableCollections$ListN, so theArrayListis now the odd type out;List.of()has that same class (executed). - Upstream precedent. #13055, #13087 and #13092 (2024) made other
static finalcollections immutable. Those sweeps coveredSetandMapconstants, private ones included, but noListfield, 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.
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.
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
TestFSTCompletionpass with assertions on (5EED108B) and off (5EED108C): 2 suites, 15 tests, 0 failures, 1 skipped (testRandomis@Nightly); - the whole
:lucene:suggest:testwith 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
TestFSTCompletiontests the standalone JUnit runner ran, and the independent reproducer's own test pass with the same fix.
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.
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.
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.
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 staticEMPTY_RESULTitself (FSTCompletion.java:77-81,:197-200) and proposed a two-thread test. A first probe turned that idea into a single-threaded cross-instancemain()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).
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.
| Where | When | How | Found |
|---|---|---|---|
| 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-02 | two 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_RESULT | no report; the FSTCompletion items #11328, #11575, #11576, #844, #4692, #4371 and #6531 were read and are unrelated |
| Open PRs and recent activity | 2026-11-02 | the 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_6 | none 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 lists | 2026-11-02 | 7 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 lists | no report; SOLR-2888 introduced the field without discussing it |
| Nearest items (read) | 2026-11-02 | #11328 (LUCENE-10292), #12978, #5633 (LUCENE-4567), SOLR-2888 | background only: other Lookups return Collections.emptyList(); #12978 made non-empty results unmodifiable without discussion; a WFSTCompletionLookup NPE on an empty FST |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main e1faeba08bde | affected | this doc's test fails 1 of 1 through gradle with assertions on and off |
| 10.6.0 | affected | this 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 | affected | the 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 probe | a version-neutral main() probe with the same steps fails: [apple/1] instead of [] |
| 3.6.0 | first release | first release with FSTCompletion; the probe fails |
| 3.5.0 | predecessor | no FSTCompletion (the probe does not compile); the predecessor FSTLookup returns an unmodifiable empty list (also on 3.6.0) |
main + List.of() fix | fix | the test passes; so do TestFSTCompletion and the whole lucene/suggest suite |
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.
| Check | Verdict | What it found |
|---|---|---|
| first probe (from a cross-model idea) | reproduced | A 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 | reproduced | Reduced 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 | real | Own 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 low | OpenAI 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, Low | Built the misuse case and showed why it fails; replay fails on main (on and off) and 10.6.0. |
| adjudication | confirmed (high), minor, Low | Re-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 | reproduced | Gradle 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. |
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 477 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,315 words
- 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 wholelucene/suggestsuite (assertions on) and:lucene:suggest:check -x test.
- 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, :208lucene/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)
- TestFSTCompletionEmptyResult.java (lucene/suggest, public API):
repro/LFV-1089/TestFSTCompletionEmptyResult.java(36 lines)
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.
assets/LFV-1089/bug.png(477 KB)
![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.](../assets/LFV-1089/bug.png)