All findings · Lucene finding · LFV-030 · compiledautomaton-floor-nontermination

CompiledAutomaton.floor never returns on some infinite languages and grows its output until the heap or the array-size limit runs out

CompiledAutomaton.floor never returns when the accepted terms below the input have no largest one, such as the compiled PrefixQuery("b") with input "c": it appends 0xff bytes until OutOfMemoryError (or, on a heap that can hold a ~2 GiB array, IllegalArgumentException). Lucene itself never calls it that way; direct callers hit it in every release since 4.0.0-ALPHA.

Summary

Priority
Low, rank 825Kept 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
hang
Since
4.0.0-ALPHA (LUCENE-3030, 2011)introduced 2011-08-20 · over 15 years
Affected
every release 4.0.0-ALPHA to 10.6.0, and main
Status
documented
Found by
non-formal baseline: Jazzer fuzzing with an exact-set oracle

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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

A one-letter prefix query is enough: the walk that should find the largest matching term follows a loop in the automaton forever.

Background: automata and floor

  1. Lucene compiles a prefix, wildcard or regexp pattern into an automaton over bytes. PrefixQuery("b") becomes two states: state 0 goes to state 1 on b, and state 1 accepts and loops to itself on every byte 0x00-0xff. It accepts infinitely many terms.
  2. Terms sort byte by byte, and a prefix sorts before its extensions: b < b 00 < b ff < b ff ff < c.
  3. 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.
Diagram of the bug: floor("c") on the two-state automaton of PrefixQuery("b"). State 0 goes to the accepting state 1 on b, and state 1 loops to itself on every byte 0x00-0xff. The push-down walk appends b and then 0xff again and again, and the output buffer grows until OutOfMemoryError.
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.

The finding, step by step

  1. Compile PrefixQuery("b") with getCompiled() and call floor("c"). The automaton accepts every term that starts with b, and all of them sort below c.
  2. 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".
  3. Instead floor appends b, then follows the accept state's 0x00-0xff self-loop and appends 0xff again and again. Its push-down loop stops only at a state with no outgoing transitions, and that state never comes.
  4. 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 throws IllegalArgumentException.
  5. Where a floor exists the answer is right (floor("bb") is bb). WildcardQuery("b*") with c and RegexpQuery("c*") with d loop the same way; FuzzyQuery, Lucene's only caller, passes finite automata.

Who is affected

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.

Mechanism (main 645d4fe14a20)

  • 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 calls return 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), appends transition.max (:334) and moves on (:335). Nothing bounds the walk, and the public finite field (:97-101) is never read.
  • For PrefixQuery("b") (PrefixQuery.java:44-58; the self-loop is :53-54) and input "c": no transition for c in state 0, so addTail appends b, moves to state 1, and the loop takes state 1's only transition, the self-loop, appending 0xff forever.

Exactly when it happens

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-backed NORMAL instance of an infinite language: the getCompiled() automaton (10.0.0 and later) of a PrefixQuery with a non-empty prefix, or of a WildcardQuery or RegexpQuery whose pattern compiles to a DFA (a non-deterministic pattern is NFA-backed and gets LFV-891's NullPointerException instead), or new 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 with finite=true (FuzzyAutomatonBuilder.java:63,73); no query, codec or Terms.intersect path calls floor. 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 then IllegalArgumentException after 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.experimental class; no public report was found, although the code is 15 years old.

Minimal repro

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.

TestCompiledAutomatonFloorPrefix.java (one test class)39 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.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);
    }
  }
}
Command
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 below c is the largest.
Observed
AssertionError: ... kept appending bytes until java.lang.OutOfMemoryError: Java heap space, from CompiledAutomaton.addTail:330 <- floor:470, after 3.2 s at the 512 MB test heap.

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

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

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.0.0, 10.5.2, 10.6.0: fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the loop is plain Java)
Command (environment)
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/

Observed output

# 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

Why it looks unintended

  • 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 floor answers 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 every AutomatonQuery compiles with finite=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." floor ignores the flag.
  • It works on infinite automata except in the no-maximum case: on the same PrefixQuery("b") automaton it returns b for "b" and bb for "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: FiniteStringsIterator documents that cycles may cause an IllegalArgumentException ("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: TestCompiledAutomaton builds its floor fixtures only with Automata.makeStringUnion and finite=true (TestCompiledAutomaton.java:44-45). No test, comment, CHANGES entry, issue or PR says infinite languages are unsupported.
  • Project stance: SECURITY.md:46 says "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 clean CorruptIndexException on 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.

Suggested fix

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 floor only when FuzzyTermsEnum drops 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 output has been grown and written to without its length being set; harmless for FuzzyTermsEnum, 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 CompiledAutomaton does not enforce: on an automaton that breaks it, a walk into a dead cycle would return null where today it loops. Such automata already break floor in another way (a walk ending in a non-accepting leaf fails the assert at :321 with -ea and 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 the OutOfMemoryError above.
  • With the fix: org.apache.lucene.util.automaton.* and TestFuzzyQuery, 17 classes, 252 tests, 0 failures, 2 skipped, with assertions on and off; the repro passes in 0.082 s, TestCompiledAutomaton 14/14, TestFuzzyQuery 13/13.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of :lucene:core pass with the fix and the repro in place.
  • With the same logic, the no-floor cases PrefixQuery("b")/c, WildcardQuery("b*")/c and RegexpQuery("c*")/d return, 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.

Throw IllegalArgumentException past the same bound (not built)

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

Document "finite languages only" and reject finite == false up front (not recommended)

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.

Javadoc only (not recommended)

Document the limitation and leave the loop.

Trade-offs

Anyone who misses the note still allocates until OutOfMemoryError.

Downstream note

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.

How it was found

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* and RegExp("ba*") with input "bb" (out of memory at -Xmx256m in about 1.5 s; IllegalArgumentException at -Xmx6g after 22-25 s), with four controls that pass.
  • Its characterization compared 33,600 floor calls (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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
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 web2026-10-31a 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.txtno report; closest #16361 / #16360 (same method, another loop)
Study search (GitHub, JIRA)2026-10-2215 GitHub and 4 JIRA queriesno report; only #16360 / #16361

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0; 10.5.2; 10.0.0 affectedminimal 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 affectedthe 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 affectedhand-built b a* DFA, floor("bb"): OutOfMemoryError; the finite control passes
4.0.0-ALPHA first releasethe same failure (addTail:218 <- floor:335)
3.6.2, 3.6.0 not affectedno org.apache.lucene.util.automaton package
upstream main 2f424fd428da readthe same push-down loop; last change to the file is #16390 (ramBytesUsed, 2026-07-14)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (own tests and characterization) reproduced, new bugHand-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) reproducesThe verifier's tests: 6 run, 2 fail.
Main refresh (a9779ef3c193) still reproducesSame failures; characterization counts identical.
Repro minimization verifiedOne stock-query test; fails on main with assertions on and off.
Re-verification (645d4fe14a20) realFails 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, highRepro 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 validatedRepro 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 validatedGradle 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; correctionsRan 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.

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 notes506 words

Finding record

LFV-030, snapshot of 2026-11-06

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 of 645d4fe14a20. Upstream main 2f424fd428da (2026-10-30) has the same code (read).

Lucene and upstream sources

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)

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

Repro files

About the diagrams

The diagram follows the trace of the minimal repro (PrefixQuery("b"), input "c").

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.