A one-letter prefix query is enough: the walk that should find the largest matching term follows a loop in the automaton forever.
- Lucene compiles a prefix, wildcard or regexp pattern into an automaton over bytes.
PrefixQuery("b")becomes two states: state 0 goes to state 1 onb, and state 1 accepts and loops to itself on every byte0x00-0xff. It accepts infinitely many terms. - Terms sort byte by byte, and a prefix sorts before its extensions:
b<b 00<b ff<b ff ff<c. floor(input)returns the largest accepted term that is <= the input, or null if there is none. Inside Lucene only FuzzyQuery calls it, with finite automata.
floor("c") on the two-state automaton of PrefixQuery("b"): after appending b, the push-down walk takes the accept state's 0x00-0xff self-loop again and again, appending 0xff until the heap runs out. Select the still to open it at full size. Generated with AI, checked against the code.- Compile
PrefixQuery("b")withgetCompiled()and callfloor("c"). The automaton accepts every term that starts withb, and all of them sort belowc. - You expect null at once:
b<b ff<b ff ff< ... never ends, so no largest such term exists, and the javadoc says null means "no floor term". - Instead
floorappendsb, then follows the accept state's0x00-0xffself-loop and appends0xffagain and again. Its push-down loop stops only at a state with no outgoing transitions, and that state never comes. - The output grows until
OutOfMemoryError: 3.2 s with a 512 MB heap, about 13 s with 2 GB. With 6 GB it reaches Java's 2 GiB array limit after 26.5 s and throwsIllegalArgumentException. - Where a floor exists the answer is right (
floor("bb")isbb).WildcardQuery("b*")withcandRegexpQuery("c*")withdloop the same way; FuzzyQuery, Lucene's only caller, passes finite automata.
Only code that calls the experimental floor() itself on a DFA-backed infinite automaton, such as a compiled prefix query or a wildcard or regexp pattern that compiles to a DFA. FuzzyQuery, Lucene's only caller, passes finite automata; no query parser or search path reaches it. When hit, the thread allocates until OutOfMemoryError, which can disturb the rest of the JVM.
Mechanism, terms and numbers554 words
CompiledAutomaton.floor(input, output) promises the largest accepted term that is <= the input, or null when there is no floor term. On an infinite language whose accepted terms below the input have no largest element, it never returns. The smallest example is a stock query: new PrefixQuery(new Term("f", "b")).getCompiled().floor(new BytesRef("c"), new BytesRefBuilder()). Every term starting with b is accepted and sorts below c, and each one is beaten by a longer one, so there is no floor term. floor hands the case to addTail, whose push-down loop follows the largest transition until it reaches a state with no outgoing transitions; here that is the accept state's 0x00-0xff self-loop, so it appends 0xff forever and the output grows until OutOfMemoryError (or, with a heap that can hold a ~2 GiB array, IllegalArgumentException from ArrayUtil.oversize). On these DFA-backed automata, whenever floor returns, its answer is correct. Lucene's only caller, FuzzyTermsEnum, passes finite Levenshtein automata, and no query parser or search path reaches the method, so only code that calls the experimental floor() itself is exposed. The loop dates from LUCENE-3030 (2011): all 16 releases run, from 4.0.0-ALPHA to 10.6.0, and main fail. A step counter that returns null once the walk has taken more steps than the automaton has states (so it is on a cycle) fixes it.
floor(CompiledAutomaton.java:390-480) walks the input. When the walk fails, it pops back to a state with a transition below the current label and callsreturn addTail(state, output, idx, label);(:470).addTail(:277-339) appends the largest label below the lead label (:283-311), then pushes down to the branch's largest term: "Push down to last accept state" (:316-338). It returns only at a state with zero transitions (:319); otherwise it takes the last (max) transition (:329), grows the output (:330), appendstransition.max(:334) and moves on (:335). Nothing bounds the walk, and the publicfinitefield (:97-101) is never read.- For
PrefixQuery("b")(PrefixQuery.java:44-58; the self-loop is:53-54) and input"c": no transition forcin state 0, soaddTailappendsb, moves to state 1, and the loop takes state 1's only transition, the self-loop, appending0xffforever.
With no dead states (the class's stated requirement for a DFA), the max-label walk either reaches a leaf, and the result is the branch's largest term, or enters a cycle. In a cycle every term of the branch is beaten by a longer one, and every other candidate is smaller, so no floor term exists at all. An executed check over 400 random regexps and 84 inputs each (33,600 calls) found 7,884 diverging calls, exactly the 7,884 calls for which a bounded oracle finds no floor, and 0 wrong answers. The same happens for WildcardQuery("b*") with c (its automaton is converted from UTF-32 to UTF-8, so it appends U+10FFFF's 4-byte UTF-8 form f4 8f bf bf again and again) and RegexpQuery("c*") with d (it appends c); both compile to DFA-backed instances. The bug needs that shape: since 10.0.0 a non-deterministic input is kept as an NFA (CompiledAutomaton.java:253-257), and there floor throws NullPointerException before it reaches addTail (LFV-891, a separate bug).
Exposure and likelihood227 words
- Who is exposed. Application code that calls
floor()on a DFA-backedNORMALinstance of an infinite language: thegetCompiled()automaton (10.0.0 and later) of aPrefixQuerywith a non-empty prefix, or of aWildcardQueryorRegexpQuerywhose pattern compiles to a DFA (a non-deterministic pattern is NFA-backed and gets LFV-891'sNullPointerExceptioninstead), ornew CompiledAutomaton(...)of an infinite DFA without dead states (all versions), with an input whose lower set of accepted terms has no maximum. - Not reached by Lucene. The only in-tree caller,
FuzzyTermsEnum.java:185, passes Levenshtein automata built withfinite=true(FuzzyAutomatonBuilder.java:63,73); no query, codec orTerms.intersectpath callsfloor. A read-only GitHub code search found no downstream caller on an infinite automaton. - Effect. A few seconds of allocation, then
OutOfMemoryError(3.2 s at a 512 MB heap, about 13 s at 2 GB), which can hit unrelated work in the same JVM; with a 6 GB heap a ~2 GiB buffer and thenIllegalArgumentExceptionafter 26.5 s. No wrong answers on these automata, no index involved. - Scale. A one-letter prefix and a one-byte input. The study's 23% divergence rate is for its synthetic sample (regexps over {a,b,c}, inputs over {a,b,c,d} of length 1-3), not a real-world rate.
- Likelihood plausible, priority low: an opt-in direct call to an
@lucene.experimentalclass; no public report was found, although the code is 15 years old.
One JUnit class, one method, public API only: compile PrefixQuery("b") and call floor("c"). It checks only that the call returns, so it passes with the recommended fix (return null); the exception option needs expectThrows(IllegalArgumentException.class, ...) instead. No index, no threads, no randomness; it fails 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.util.automaton;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.PrefixQuery;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.BytesRefBuilder;
public class TestCompiledAutomatonFloorPrefix extends LuceneTestCase {
public void testFloorReturnsOnPrefixAutomaton() {
CompiledAutomaton compiled = new PrefixQuery(new Term("f", "b")).getCompiled();
try {
compiled.floor(new BytesRef("c"), new BytesRefBuilder());
} catch (OutOfMemoryError e) {
throw new AssertionError(
"expected floor(\"c\") on PrefixQuery(\"b\") to return null or a term, but it kept"
+ " appending bytes until "
+ e,
e);
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestCompiledAutomatonFloorPrefix -Ptests.seed=DEADBEEF
- Expected
floor("c")returns at once, with null: no accepted term belowcis the largest.- Observed
AssertionError: ... kept appending bytes until java.lang.OutOfMemoryError: Java heap space, fromCompiledAutomaton.addTail:330<-floor:470, after 3.2 s at the 512 MB test heap.
More files, environment, full output378 words
Copy the file into lucene/core/src/test/org/apache/lucene/util/automaton/ of a Lucene checkout and run the command. It needs 10.0.0 or later (AutomatonQuery.getCompiled()); older releases fail the same way through a hand-built automaton and the public CompiledAutomaton constructor.
Lucene's test JVMs run with -XX:+HeapDumpOnOutOfMemoryError, so a failing gradle run also writes a heap dump of about 200 MB (java_pid*.hprof) into lucene/core/build/tests-cwd/; delete it afterwards.
The same test with a standalone runner fails the same way, with the same frames addTail:330 <- floor:470: on main and 10.5.2 with assertions on and off (12.5-13.2 s at -Xmx2g; on main also 2.7 s at -Xmx256m, assertions off), on 10.6.0 with assertions on (18.8 s at -Xmx2g) and on 10.0.0 with assertions off (3.3 s at -Xmx256m). With -Xmx6g and assertions off it fails on main after 26.5 s with IllegalArgumentException: requested array size 2147483632 exceeds maximum array in java (2147483631) at ArrayUtil.oversize:146 <- ArrayUtil.grow:448 <- BytesRefBuilder.grow:60 <- addTail:330 <- floor:470.
Regression test: this same test, for the recommended null fix: with it the test passes in under 0.1 s. Because it only checks that the call returns, it can be tightened to assertNull(compiled.floor(new BytesRef("c"), new BytesRefBuilder())), which also checks the value. For the exception option the matching test is expectThrows(IllegalArgumentException.class, () -> compiled.floor(new BytesRef("c"), new BytesRefBuilder())).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.0.0, 10.5.2, 10.6.0: fail; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the loop is plain Java) |
cp $REPRO/TestCompiledAutomatonFloorPrefix.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/automaton/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestCompiledAutomatonFloorPrefix -Ptests.seed=DEADBEEF
# assertions off fails the same way: -Ptests.asserts=false; a failing run leaves a ~200 MB java_pid*.hprof in lucene/core/build/tests-cwd/
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on, 512 MB test heap (test 3.18 s)
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid18556.hprof ...
Heap dump file created [200496935 bytes in 0.308 secs]
TestCompiledAutomatonFloorPrefix > testFloorReturnsOnPrefixAutomaton FAILED
java.lang.AssertionError: expected floor("c") on PrefixQuery("b") to return null or a term, but it kept appending bytes until java.lang.OutOfMemoryError: Java heap space
at __randomizedtesting.SeedInfo.seed([DEADBEEF:65344359139D9DA4]:0)
at org.apache.lucene.util.automaton.TestCompiledAutomatonFloorPrefix.testFloorReturnsOnPrefixAutomaton(TestCompiledAutomatonFloorPrefix.java:32)
Caused by:
java.lang.OutOfMemoryError: Java heap space
at org.apache.lucene.util.ArrayUtil.growExact(ArrayUtil.java:436)
at org.apache.lucene.util.ArrayUtil.grow(ArrayUtil.java:448)
at org.apache.lucene.util.BytesRefBuilder.grow(BytesRefBuilder.java:60)
at org.apache.lucene.util.automaton.CompiledAutomaton.addTail(CompiledAutomaton.java:330)
at org.apache.lucene.util.automaton.CompiledAutomaton.floor(CompiledAutomaton.java:470)
at org.apache.lucene.util.automaton.TestCompiledAutomatonFloorPrefix.testFloorReturnsOnPrefixAutomaton(TestCompiledAutomatonFloorPrefix.java:30)
1 test completed, 1 failed
BUILD FAILED in 1m 7s- The javadoc promises the largest accepted term <= the input, or null if there is none; no finite-only precondition, no exception.
- The constructors accept infinite automata ("set to false if infinite or you don't know"), and
flooranswers them correctly when a floor exists. - Lucene's finite-only automaton code checks for cycles; SECURITY.md calls such out-of-scope issues valid bug reports.
The full argument389 words
- Contract (
CompiledAutomaton.java:384-389): "Finds largest term accepted by this Automaton, that's <= the provided input term. ... The returned result is either the provided output, or null if there is no floor term (ie, the provided input term is before the first term accepted by this Automaton)." No finite-language precondition and no exception; the parenthetical spells out only the before-the-first-term case and does not address a lower set with no maximum, so null is the natural reading, and a documented exception would also be defensible (the second fix option). - Infinite automata are accepted. The class javadoc's only DFA requirement is "must not have dead states" (
:36-43). Both constructors say "Set finite to true if the automaton is finite, otherwise set to false if infinite or you don't know" (:137-141,:146-150), and everyAutomatonQuerycompiles withfinite=false(AutomatonQuery.java:99). On #11813 (merged 2022), rmuir wrote "PrefixQuery is always infinite" and "If you dont know that your automaton is finite, pass false to CompiledAutomaton, it is safe."floorignores the flag. - It works on infinite automata except in the no-maximum case: on the same
PrefixQuery("b")automaton it returnsbfor"b"andbbfor"bb", and null for""and"a", where no accepted term is <= the input. The loop's own comment, "Push down to last accept state" (:316), assumes the walk ends at a leaf. - Lucene's convention for finite-only code:
FiniteStringsIteratordocuments that cycles may cause anIllegalArgumentException("but this is not guaranteed!",FiniteStringsIterator.java:28-29) and throws "automaton has cycles" (:119-120);Operations.topoSortStates"detects cycles, it will never attempt to use infinite RAM" (Operations.java:1347-1360). - Never tested on infinite languages:
TestCompiledAutomatonbuilds itsfloorfixtures only withAutomata.makeStringUnionandfinite=true(TestCompiledAutomaton.java:44-45). No test, comment, CHANGES entry, issue or PR says infinite languages are unsupported. - Project stance:
SECURITY.md:46says "Issues that fall outside the scope above are still valid bug reports and hardening suggestions." Its example, "a change that prevents an endless loop or replaces a low-level failure with a cleanCorruptIndexExceptionon a broken index is welcome, provided it does not negatively impact performance", is about broken indexes, and it adds that robustness improvements "must not slow down the hot paths"; the fix's cost is one compare per appended byte.
The devil's-advocate check170 words
The strongest case for "intended" (the cross-model refute review and the tie-break): the javadoc's parenthetical defines null as "the input is before the first accepted term", so the author assumed a floor exists otherwise, which holds only for finite languages; the only caller and every test use finite automata; the class is @lucene.experimental; and the largest term does not mathematically exist, so there is no answer to give.
Why it fails: an unwritten assumption is not a contract, and the constructors invite infinite automata. "No largest term" is what "no floor term" means, and the javadoc's only answer for it is null; even under the narrowest reading, an unanswerable input calls for a documented restriction or a clean exception, as Lucene's own finite-only algorithms do, not unbounded allocation. @lucene.experimental is about API stability, not permission to hang. The reviewer agreed with the root cause and the legality of the inputs, rated the intended-behaviour argument weak, and gave not real only because it could not run code. Verdict: a defect.
Recommended Bound the push-down walk by the number of states, and return null past it
Count the push-down walk's steps and return null once they exceed the number of states (the walk is then on a cycle, which is exactly the no-floor case); extend the javadoc's null case to match. With it the repro, 252 automaton and fuzzy-query tests and the core lint checks pass.
// Push down to last accept state
int steps = 0;
while (true) {
numTransitions = automaton.getNumTransitions(state);
if (numTransitions == 0) {
... // unchanged: return the term
} else {
if (++steps > automaton.getNumStates()) {
// the max-label walk is on a cycle: no floor term
return null;
}
... // unchanged: append transition.max and move on
}
}All options and trade-offs4 options · 718 words
One fix was built and validated through gradle in a scratch worktree of 645d4fe14a20. The other options are sketches, not built.
Bound the push-down walk by the number of states, and return null past it (validated by the checks) (recommended)
Add a step counter to the push-down loop of addTail (+9/-1 in CompiledAutomaton.java: 7 lines in the loop and 2 javadoc lines added, 1 javadoc line removed). It does not track visited states; it counts the walk's transitions and returns null once there are more than the automaton has states. A walk that long must have revisited a state, and because it always takes the last transition, a revisit means a cycle: the branch has no largest term and every other candidate is smaller, so there is no floor term. The floor javadoc's null case gains "or, for an infinite language, the accepted terms before it have no largest element".
// Push down to last accept state
int steps = 0;
while (true) {
numTransitions = automaton.getNumTransitions(state);
if (numTransitions == 0) {
... // unchanged: return the term
} else {
if (++steps > automaton.getNumStates()) {
// the max-label walk is on a cycle: no floor term
return null;
}
... // unchanged: append transition.max and move on
}
}Trade-offs
- Changes only calls that never returned; every call that returns today returns the same answer. Before it returns null it writes about the input length + 1 + the number of states bytes (plus the builder's usual over-allocation).
- Cost: one increment and compare per appended byte. Lucene calls
flooronly whenFuzzyTermsEnumdrops to a smaller edit distance, and its finite automata never take the new branch. - A caller cannot tell this null from "input before the first term"; option B keeps them apart. On the null path
outputhas been grown and written to without its length being set; harmless forFuzzyTermsEnum, which passes a fresh builder and uses only the return value, but worth a line in the review. - Relies on the documented no-dead-states requirement, which
CompiledAutomatondoes not enforce: on an automaton that breaks it, a walk into a dead cycle would return null where today it loops. Such automata already breakfloorin another way (a walk ending in a non-accepting leaf fails the assert at:321with-eaand returns a non-accepted term with-da, read); the fix does not change that. - Touches the same method as open PR #16361 (a binary search for the first scan of
addTail), but the two diffs do not overlap and apply cleanly in either order.
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with theOutOfMemoryErrorabove. - With the fix:
org.apache.lucene.util.automaton.*andTestFuzzyQuery, 17 classes, 252 tests, 0 failures, 2 skipped, with assertions on and off; the repro passes in 0.082 s,TestCompiledAutomaton14/14,TestFuzzyQuery13/13. checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestof:lucene:corepass with the fix and the repro in place.- With the same logic, the no-floor cases
PrefixQuery("b")/c,WildcardQuery("b*")/candRegexpQuery("c*")/dreturn, and answers where a floor exists are unchanged (tie-break). The independent fact check called them on the fixed build directly: all three return null, and the five controls give the same answers as stock.
The same step counter, but throw new IllegalArgumentException(...) instead of return null, as FiniteStringsIterator and Operations.topoSortStates do for cycles.
Trade-offs
Keeps null for "before the first term" only, so callers can tell the cases apart; but it adds an exception, and a @throws, for a case the javadoc already maps to null. Judged an equally valid upstream choice by the tie-break. With it, the regression test must become expectThrows(IllegalArgumentException.class, ...).
Check the public finite flag at the start of floor.
Trade-offs
Too broad: it rejects calls that work today on infinite automata where a floor exists (for example PrefixQuery("b") with bb), and every AutomatonQuery compiles with finite=false, even for finite patterns.
Document the limitation and leave the loop.
Trade-offs
Anyone who misses the note still allocates until OutOfMemoryError.
Lucene's query parsers and searches never reach it, but an application that calls floor on the automaton of a user-supplied pattern would let that user trigger the OutOfMemoryError, so such code should avoid or bound the call until this is fixed.
A fuzzing harness compared CompiledAutomaton.floor against an exact, set-based answer, and its first replay seed never returned. An independent verifier rebuilt the trigger by hand and showed, over 33,600 random calls, that floor diverges exactly when no floor term exists and never returns a wrong answer.
The method in full156 words
Method: Jazzer fuzzing with an exact-set oracle (the study's non-formal baseline tier, 2026-10-22). The AutomatonMiscFuzzer harness checked several automaton utilities against exact set oracles, CompiledAutomaton.floor among them; the first seed of its replay driver did not return.
- A separate verifier read only the triggering sequence and wrote its own tests: a hand-built two-state byte DFA for
b a*andRegExp("ba*")with input"bb"(out of memory at-Xmx256min about 1.5 s;IllegalArgumentExceptionat-Xmx6gafter 22-25 s), with four controls that pass. - Its characterization compared 33,600
floorcalls (400 random regexps x 84 inputs) with a bounded-length oracle: 7,884 diverged, exactly the no-floor cases, and 0 answers were wrong; 15,000 calls on Levenshtein automata, the shape FuzzyQuery uses, never diverged. - The hunt then cut the repro down to one stock-query test (
PrefixQuery("b"),"c"), re-ran it on current main and releases, ran a cross-model review and a second adjudication, and validated a fix.
No prior report found (2026-10-31: 99 tool queries over GitHub issues and PRs, commits, the JIRA archive, the mailing lists, Elasticsearch, OpenSearch, Solr and the web, plus 28 manual searches and the files of all 409 open PRs). Closest: open PR #16361, which speeds up another loop of the same method.
Searches2 searches
Verdict new: no issue, PR, review comment, commit, CHANGES entry, list thread, JIRA entry, downstream report or web page says that CompiledAutomaton.floor fails to return on an infinite language or grows until OutOfMemoryError. #16361 / #16360 (open, stale) replace the first transition scan of addTail with a binary search and never mention cycles; Elasticsearch #160101 names floor only for a thread-safety point. Context: #11813 (rmuir, 2022: "pass false to CompiledAutomaton, it is safe"), #4104 (LUCENE-3030, the origin), #4537 (2011 nightly OOMs in automaton construction, not floor), #6721 (LUCENE-5659, another unbounded automaton method, fixed 2014). The dev@ thread and tracking issue #16937 never mention this bug, and no maintainer said they knew of it.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene incl. the migrated JIRA archive), commits, Elasticsearch, OpenSearch, Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, the web | 2026-10-31 | a scripted prior-report search run of 99 queries (729 distinct hits, comments of the top 31 read), 20 manual GitHub variants, 4 GitHub-wide and 4 code searches, the file lists of all 409 open apache/lucene PRs, upstream CHANGES.txt | no report; closest #16361 / #16360 (same method, another loop) |
| Study search (GitHub, JIRA) | 2026-10-22 | 15 GitHub and 4 JIRA queries | no report; only #16360 / #16361 |
Introduced with the class itself (LUCENE-3030, b7846ffaa1f, 2011-08-20) and first released in 4.0.0-ALPHA. Sixteen releases from 4.0.0-ALPHA to 10.6.0, and main, were run, and all fail; 3.6.x has no automaton package. Never fixed; not a regression.
Versions tested6 rows
Introduced by b7846ffaa1ff8cd050feaa924e3a6ab12a43549b "LUCENE-3030: add BlockTreeTermsReader/Writer, a more efficient tree-structure for the terms dictionary" (Michael McCandless, trunk, 2011-08-20), which added CompiledAutomaton with today's floor javadoc and the push-down loop. No pull request (Subversion era); issue LUCENE-3030 = #4104, whose comments never mention floor. git blame -w -M -C on main still attributes while (true) (:317) and return addTail(...) (:470) to it. Later changes were mechanical: the new automaton API (LUCENE-5752, 2014), BytesRefBuilder (LUCENE-5864, 2014), reformatting (LUCENE-9570, 2020).
The release runs used a hand-built b a* DFA through the public constructor with floor("bb") and a finite control that passes; the minimal repro needs getCompiled(), which exists from 10.0.0.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0; 10.5.2; 10.0.0 | affected | minimal repro fails (OutOfMemoryError at addTail:330 <- floor:470): main and 10.5.2 with assertions on and off, 10.6.0 with assertions on, 10.0.0 with assertions off |
| 9.12.3, 9.1.0 | affected | the same call through the protected AutomatonQuery.compiled field fails |
| 9.0.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4, 4.10.0, 4.9.1, 4.0.0, 4.0.0-BETA | affected | hand-built b a* DFA, floor("bb"): OutOfMemoryError; the finite control passes |
| 4.0.0-ALPHA | first release | the same failure (addTail:218 <- floor:335) |
| 3.6.2, 3.6.0 | not affected | no org.apache.lucene.util.automaton package |
upstream main 2f424fd428da | read | the same push-down loop; last change to the file is #16390 (ramBytesUsed, 2026-07-14) |
The study's verifier, a public-list rerun, a main refresh, the repro minimization, a re-verification, a tie-break, this doc's gradle run and an independent fact check each reproduced it. The cross-model review (OpenAI GPT-5.6) could not run code; it agreed with the root cause and rated the intended-behaviour argument weak.
Checks and verdicts316 words
Every executed check ran on stock builds. The cross-model reviewers' tools could not run code, so no executed cross-model check exists.
| Check | Verdict | What it found |
|---|---|---|
| Study verifier (own tests and characterization) | reproduced, new bug | Hand-built b a* DFA and RegExp("ba*"): out of memory at 256 MB, IllegalArgumentException at 6 GB; 4 controls pass; 33,600 calls, 7,884 diverged = the no-floor cases, 0 wrong. |
| Public-list rerun (main b83101cdd2) | reproduces | The verifier's tests: 6 run, 2 fail. |
| Main refresh (a9779ef3c193) | still reproduces | Same failures; characterization counts identical. |
| Repro minimization | verified | One stock-query test; fails on main with assertions on and off. |
| Re-verification (645d4fe14a20) | real | Fails on main on/off, 10.5.2 on/off and 10.6.0; a probe fails on 9.12.3 and 9.1.0. |
| Cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real (procedural) | Ran no Java. Agreed with the root cause and that the inputs are legal; rated the intended argument weak. Weighed and rejected by the tie-break. |
| Second adjudication | real, high | Repro fails on main on/off and at 6 GB; own test: three stock-query no-floor cases fail, controls pass, on main and 10.5.2; fix sketch passes the repro and 27 upstream tests. |
| This doc (gradle) | reproduced, fix validated | Repro fails on stock main; with the fix 252 tests pass, assertions on and off, and the core lint checks pass. |
| Independent fact check | reproduced, fix validated | Gradle on its own extraction of 645d4fe14a20: the repro fails with assertions on and off; with the fix 252 tests and the lint checks pass, and the three no-floor cases return null. It found four wording slips, corrected in this doc. |
| Cross-model fact check (OpenAI GPT-5.6, read-only) | real; corrections | Ran no code; checked the saved records. Asked to limit the reach to DFA-backed automata and to fix the test-compatibility, diff-size and security-citation wording; done in this doc. |
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 429 KB
Finding record, Lucene and upstream sources, repro files and diagram notes506 words
- Registry title
- CompiledAutomaton.floor never terminates on some infinite languages and runs until OutOfMemoryError
- Area
- util / automaton
- Priority
- Low, rank 825: Kept in Low but raised a little: CompiledAutomaton.floor loops until OutOfMemoryError on ordinary infinite languages such as a prefix, a JVM-killing hang once called, but its only in-tree caller passes finite automata, query parsing cannot reach it, and nothing was reported since the code arrived in 2011.
- Impact class
- hang
- Impact note
- Only direct callers of floor() on such infinite languages; its only in-tree caller (FuzzyTermsEnum) passes finite automata, and it is not reachable from query parsing.
- Summary
- You call CompiledAutomaton.floor on an infinite language in which the strings below the input have no largest element, for example the language b a* with input bb. You expect the floor term or null. Instead the push-down loop never terminates and grows memory until OutOfMemoryError.
- Introduced
- LUCENE-3030 (b7846ffaa1f) (2011-08-20)
- First affected version
- 4.0.0-ALPHA [exec] (LUCENE-3030, b7846ffaa1f, 2011; the first GA release is 4.0.0); 3.6.0 and 3.6.2 have no automaton package [exec]
- Affected versions
- every release from 4.0.0-ALPHA to 10.6.0 and main 645d4fe14a20 [exec: 16 releases run, 4.0.0-ALPHA, 4.0.0-BETA, 4.0.0, 4.9.1, 4.10.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0, all fail]; [read] no commit has changed the loop's exit since it arrived, so the releases in between are affected too; never fixed
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that could only read the code (its “not real” is procedural); not yet triaged by a person.
- Found by
- non-formal baseline: fuzzing/PBT
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
645d4fe14a20(the repro runner with assertions on and off, and gradle); the releases 4.0.0-ALPHA, 4.0.0-BETA, 4.0.0, 4.9.1, 4.10.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (SHA-1-verified jars); 3.6.0 and 3.6.2 (no automaton package); the fix in a scratch worktree of645d4fe14a20. Upstream main2f424fd428da(2026-10-30) has the same code (read).
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:277-339, :384-389, :390-480 at 645d4fe14a20 (read)lucene/core/src/java/org/apache/lucene/search/PrefixQuery.java:44-58 at 645d4fe14a20 (read)lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:253-257 at 645d4fe14a20 (NFA branch, read)
- Who is affected
lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:176-187 at 645d4fe14a20 (read)lucene/core/src/java/org/apache/lucene/search/FuzzyAutomatonBuilder.java:63,73 at 645d4fe14a20 (read)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-43, :97-101, :137-150, :316, :384-389 at 645d4fe14a20 (read)lucene/core/src/java/org/apache/lucene/search/AutomatonQuery.java:99, :161 at 645d4fe14a20 (read)lucene/core/src/java/org/apache/lucene/util/automaton/FiniteStringsIterator.java:28-29, :119-120; Operations.java:1347-1360 at 645d4fe14a20 (read)lucene/core/src/test/org/apache/lucene/util/automaton/TestCompiledAutomaton.java:44-45 at 645d4fe14a20 (read)SECURITY.md:46
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:176-218 at 645d4fe14a20 (read)
- TestCompiledAutomatonFloorPrefix.java (one test class):
repro/LFV-030/src/org/apache/lucene/util/automaton/TestCompiledAutomatonFloorPrefix.java(39 lines)
The diagram follows the trace of the minimal repro (PrefixQuery("b"), input "c").
assets/LFV-030/bug.png(429 KB)
