All findings · Lucene finding · LFV-1214 · builder-addepsilon-self-loop-rereads-bound

Automaton.Builder.addEpsilon(s, s) never returns and exhausts the heap once state s has a transition

Read as documented, Automaton.Builder.addEpsilon(s, s) should change nothing, but once state s has an arc it never returns: each copy it appends is again an arc leaving s, which the loop copies too, until OutOfMemoryError. Only code that builds automata with the experimental Builder hits it, in releases 4.10.3 to 10.6.0 and main.

Summary

Priority
Low, rank 827Automaton.Builder.addEpsilon from a state to itself, which should change nothing, never returns and fills the heap until OutOfMemoryError once that state has a transition, in every release since 4.10.3. It is a hang, but only direct users of the experimental Builder who pass the same state twice reach it and no Lucene caller does, so it is rare.
Impact
minor
Since
4.10.3 (LUCENE-5963, 2014)introduced 2014-09-19 · over 12 years
Affected
4.10.3 to 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of Automaton.Builder's contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Background: how Automaton.Builder records arcs, and what addEpsilon does

  1. Automaton.Builder lets you add arcs in any order; finish() sorts them into an Automaton. It stores each arc as four ints, (source, dest, min label, max label), in one growing int[]; nextTransition marks the end of the used part, and addTransition writes four ints there and moves it by 4.
  2. An epsilon arc from source to dest means "from source you may also do whatever dest does next". Builder.addEpsilon(source, dest) builds it without a real epsilon: it scans the array and, for every arc that leaves dest, appends a copy that leaves source.
Diagram of the bug: Automaton.Builder stores arcs as records in one array. addEpsilon(0, 0) scans from the start; the record (0, 0, a, a) leaves state 0, so a copy is appended at the end and the end marker nextTransition moves on by one record. The copy also leaves state 0, so the scan copies it too, and the scan never reaches the end until the heap runs out. With the end read once before the loop, only one copy is made.
The builder's arc list during addEpsilon(0, 0). The scan reads the arc 0 -a-> 0 and appends a copy at the end; the copy also leaves state 0, so the next pass copies it again and the end moves away as fast as the scan. With the bound read once, the loop stops after one copy. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a one-state automaton with Automaton.Builder: state 0 accepts and has one arc 0 -a-> 0, so it accepts "", "a", "aa" and so on.
  2. Call addEpsilon(0, 0). It copies every arc that leaves state 0 onto state 0, so you expect it to return at once (at most adding a duplicate arc) and finish() to still accept "aaa". The sibling Automaton.addEpsilon(0, 0) does return on the state being built.
  3. Instead it never returns. With a 512 MB heap the test dies with OutOfMemoryError: Java heap space after about 1 s; a probe that counts copies stopped it at 1,000,000 copies, with 4,000,004 ints of arcs recorded.
  4. Why: the loop runs while upto < nextTransition and re-reads that bound on every pass. Copying arc 1 appends arc 2, which also leaves state 0; copying arc 2 appends arc 3, and so on, so the scan never reaches the end. With two different states the copies never match, which is why Lucene's own callers are fine.

Who is affected

Application code that builds automata with the experimental Automaton.Builder and calls addEpsilon(s, s) after s has an arc, for example a hand-written Kleene star addEpsilon(f, start) for every accept state f when the start state also accepts. No Lucene query, regex, analyzer or suggester path passes the same state twice. All releases from 4.10.3 to 10.6.0.

Mechanism, terms and numbers552 words

Automaton.Builder.addEpsilon(source, dest) documents: "Add a [virtual] epsilon transition between source and dest. Dest state must already have all transitions added because this method simply copies those same transitions over to source." (Automaton.java:805-808 at 5adff78bb168). It scans the builder's transition list and appends a copy of every transition that leaves dest (:809-815), but its loop bound upto < nextTransition (:810) is re-read on every pass while each append advances nextTransition (:795-803). With source == dest every appended copy again leaves dest, the scan reaches it and appends another, and the loop never ends: a one-state builder with the arc s -a-> s and a call addEpsilon(s, s) ends in OutOfMemoryError: Java heap space with a 512 MB heap, assertions on and off. Read as documented, an epsilon from a state to itself changes nothing, and the sibling Automaton.addEpsilon, whose javadoc is word for word the same, returns on the same call on the state being built because it reads its count once (:185-199). No Lucene caller passes source == dest; application code that builds its own automata with the experimental Builder does, for example a hand-written Kleene star when the start state accepts. The loop has been there since the method was added in 2014 (LUCENE-5963); releases 4.10.3 through 10.6.0 and main are affected (every inspected release tag from 4.10.3 on carries the loop line, and 12 releases were executed). Reading the bound once fixes it.

The bug

  1. addEpsilon(source, dest) loops for (int upto = 0; upto < nextTransition; upto += 4) (Automaton.java:810, the fix site). For each record with transitions[upto] == dest (:811) it calls addTransition(source, dest', min, max) (:812-813), which grows the array if needed (ArrayUtil.grow, :796-797), writes the record and advances nextTransition by 4 (:799-802).
  2. The bound is read again on every pass. With source == dest, each appended record starts at dest again, so the scan reaches it and appends another one.
  3. With one record (0, 0, 'a', 'a'): at upto = 0 the record matches and a copy goes to index 4 (nextTransition 4 -> 8); at upto = 4 the copy matches and another goes to index 8 (8 -> 12); and so on. The scan stays one record behind the end forever. The first growth of the default 64-int array happens at the 16th copy (64 -> 76 ints); after 1,000,000 copies nextTransition is 4,000,004 and the array has grown 91 times.
  4. ArrayUtil.grow keeps enlarging the array until OutOfMemoryError: Java heap space (ArrayUtil.java:319). By reading, only a heap that holds an int[] of ArrayUtil.MAX_ARRAY_LENGTH (about 8 GiB) and, during that last copy, the previous array too (about 15 GiB or more in total) would get as far as ArrayUtil.oversize's IllegalArgumentException (ArrayUtil.java:145-151); that was not run.

The arc does not have to be a self-loop: any arc that leaves s does it (an independent test with s0 -x-> s1 and addEpsilon(s0, s0) fails the same way). A state without arcs is fine: addEpsilon(s, s) then makes no copy and returns. With source != dest the copies start at source, never match dest, and the loop ends at the old end of the list.

Exposure and likelihood235 words
  • Who. Direct users of Automaton.Builder; the Automaton class is @lucene.experimental (Automaton.java:43). One natural way to get there: a Kleene star written as "copy a, then addEpsilon(f, start) for every accept state f". When a's start state also accepts, one of those calls is addEpsilon(start, start). A counting probe shows it: the star of "b" returns after 1 copy; the star of "b?", whose start state accepts, hits the 1,000,000-copy cap.
  • Effect. The call never returns and allocates until the JVM throws OutOfMemoryError: Java heap space, which can also hit other threads of the same JVM. No wrong automaton is ever returned. No scale is needed: one state and one arc.
  • Not affected. The only non-test callers of Builder.addEpsilon never pass source == dest: Operations.repeat(a, min, max) links earlier accept states to the start of a freshly copied a (Operations.java:313-319), and AnalyzingSuggester.replaceSep walks an acyclic token automaton in topological order (AnalyzingSuggester.java:271-302). ConcatenateGraphFilter and the other Operations and Automata sites use the non-Builder Automaton.addEpsilon. Lucene's own Kleene star, Operations.repeat(a), avoids the shape: it returns early when state 0 is the only accept state (Operations.java:207-209) and skips states merged into the start (if (stateMap[s] != 0), :255).
  • Likelihood: rare. No matching public report was found in the recorded searches, but whoever makes the call hangs every time.

Minimal repro

One JUnit test method on the public API: one accepting state with the arc s -a-> s, then addEpsilon(s, s) and Operations.run(finish(), "aaa"). Deterministic and single-threaded; with gradle's default 512 MB test heap it fails in about 1 s, assertions on and off.

TestBuilderAddEpsilonToItself.java (lucene/core, package org.apache.lucene.util.automaton, 1 test)32 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.tests.util.LuceneTestCase;

public class TestBuilderAddEpsilonToItself extends LuceneTestCase {

  public void testEpsilonFromStateToItself() {
    Automaton.Builder b = new Automaton.Builder();
    int s = b.createState();
    b.addTransition(s, s, 'a', 'a');
    b.setAccept(s, true);
    // expected: a no-op that returns; actual: never returns, OutOfMemoryError: Java heap space
    b.addEpsilon(s, s);
    assertTrue(Operations.run(b.finish(), "aaa"));
  }
}
Command
cp $REPRO/TestBuilderAddEpsilonToItself.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/automaton/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestBuilderAddEpsilonToItself -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure
Expected
addEpsilon(s, s) returns and the automaton accepts "aaa".
Observed
It never returns: java.lang.OutOfMemoryError: Java heap space from Builder.addTransition <- Builder.addEpsilon (Automaton.java:812), assertions on and off.

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

More files, environment, full output167 words

TestBuilderAddEpsilonToItself (one method, extends LuceneTestCase, package org.apache.lucene.util.automaton of the lucene/core tests). With the license header, addEpsilon is at line 29. The test needs no heap flag: gradle's test JVMs use tests.heapsize, 512 MB by default, so the call fails fast instead of hanging.

As the regression test, the same method fits in TestAutomaton.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.12.3 and 10.6.0: the same test fails; 10.5.2: the independent reproducer's own test (`s0 -x-> s1`, then `addEpsilon(s0, s0)`) fails; 4.10.3 to 10.0.0: a main() form of the same steps fails
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)
Command (environment)
cp $REPRO/TestBuilderAddEpsilonToItself.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/automaton/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestBuilderAddEpsilonToItself -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure

Observed output

TestBuilderAddEpsilonToItself > testEpsilonFromStateToItself FAILED
    java.lang.OutOfMemoryError: Java heap space
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:453316C862A39282]:0)
        at org.apache.lucene.util.ArrayUtil.growExact(ArrayUtil.java:319)
        at org.apache.lucene.util.ArrayUtil.growInRange(ArrayUtil.java:345)
        at org.apache.lucene.util.ArrayUtil.grow(ArrayUtil.java:377)
        at org.apache.lucene.util.automaton.Automaton$Builder.addTransition(Automaton.java:797)
        at org.apache.lucene.util.automaton.Automaton$Builder.addEpsilon(Automaton.java:812)
        at org.apache.lucene.util.automaton.TestBuilderAddEpsilonToItself.testEpsilonFromStateToItself(TestBuilderAddEpsilonToItself.java:29)

(gradle on main 5adff78bb168, seed DEADBEEF, assertions on, 1.15 s; with -Ptests.asserts=false the same error and stack, 1.17 s)

Why it looks unintended

  • The only documented precondition, that dest already has all its arcs, holds when source == dest; nothing excludes the case.
  • The sibling Automaton.addEpsilon, with the same javadoc word for word, reads its count once and returns.
  • A self epsilon changes nothing; one state with one arc is not an absurd input.
The full argument244 words
  • Contract. Builder.addEpsilon: "Add a [virtual] epsilon transition between source and dest. Dest state must already have all transitions added because this method simply copies those same transitions over to source." (Automaton.java:805-808). The precondition holds for source == dest. Nothing in the javadoc, package docs, tests, CHANGES.txt, MIGRATE.md or the commit history excludes the case. Nor does anything promise that a self call is supported: the case rests on the natural reading of the documented copy, which is well defined and finite for source == dest.
  • The class is meant for this kind of construction. The Builder javadoc: "Use this when you cannot create the Automaton directly because it's too restrictive to have to add all transitions leaving each state at once." (:761-765).
  • The sibling handles it. Automaton.addEpsilon (:185-199) has the same javadoc but reads its count once (int count = initTransition(dest, t);, :191). A self call there returns on the state being built, or throws a clean IllegalStateException ("from state (0) already had transitions added") once the state is finished.
  • History. The method came with LUCENE-5963 (#7025, 2014), written for one caller, AnalyzingSuggester.replaceSep, where source and dest always differ. The only later change is the 2020 reformat. The issue thread never discusses a self epsilon or the loop bound, and no upstream test calls Builder.addEpsilon at all (the test callers, TestOperations.java:608 and the test framework's AutomatonTestUtil.java:391, use Automaton.addEpsilon).
The devil's-advocate check230 words

The strongest case for "intended". Automaton is @lucene.experimental; Builder.addEpsilon is a minimal helper added for one caller; an epsilon from a state to itself is pointless; a copy into itself is the classic aliasing case other APIs leave undefined (java.util.List.addAll leaves the behaviour undefined if the argument is modified during the call, which it notes happens when the argument is this list and it is nonempty); "Dest state must already have all transitions added" can be read as "no arcs are added to dest afterwards", which the self call itself breaks; and the failure is loud.

Why it fails. @lucene.experimental marks API instability, not permission to hang. Unlike List.addAll, this javadoc states no aliasing exclusion, and its documented effect (copy dest's arcs to source) is well defined and finite for source == dest. The precondition is about dest's arcs before the copy, and they are all there. The sibling with the same contract handles the call. And the call arises in ordinary NFA code, such as a Kleene star whose start state accepts, with one state and one arc. That is a fair argument for Low priority and for accepting a documentation-only fix (option C), not evidence that the hang is intended. The independent reproducer, the cross-model review lenses (OpenAI GPT-5.6, "intended argument weak"), the devil's-advocate verifier and the adjudication all rejected it.

Suggested fix

Recommended Read the loop bound once (fix A)

Read the loop bound once (final int end = nextTransition;, then loop to end), as the sibling Automaton.addEpsilon does. Nothing changes when source and dest differ; for a self epsilon finish() merges the duplicates (one per arc of the state). The repro passes with assertions on and off, and core's util.automaton tests (239) and suggest's analyzing tests (89) pass.

    public void addEpsilon(int source, int dest) {
      // read the bound once: with source == dest the copies appended below also leave dest
      final int end = nextTransition;
      for (int upto = 0; upto < end; upto += 4) {
        if (transitions[upto] == dest) {
          addTransition(
              source, transitions[upto + 1], transitions[upto + 2], transitions[upto + 3]);
        }
      }
      if (isAccept(dest)) {
        setAccept(source, true);
      }
    }
All options and trade-offs3 options ยท 600 words

Fix A was built and checked through gradle in a scratch worktree at 5adff78bb168; B and C were not run. A maintainer may prefer B (an exact early return, no duplicate arc) or C (a javadoc line); A matches the sibling method and is a small change (one local for the bound, and the loop condition). The fix applies to the 10.x source unchanged: the loop line is identical in all 101 release tags of the history clone from 4.10.3 on.

Read the loop bound once (fix A) (validated by the checks) (recommended)

In Automaton.Builder.addEpsilon (Automaton.java:809-819), snapshot nextTransition before the loop and loop to the snapshot.

    public void addEpsilon(int source, int dest) {
      // read the bound once: with source == dest the copies appended below also leave dest
      final int end = nextTransition;
      for (int upto = 0; upto < end; upto += 4) {
        if (transitions[upto] == dest) {
          addTransition(
              source, transitions[upto + 1], transitions[upto + 2], transitions[upto + 3]);
        }
      }
      if (isAccept(dest)) {
        setAccept(source, true);
      }
    }

Trade-offs

A changed loop condition plus one local (and a comment). For source != dest the old loop already stopped at the same place, since records appended there never match dest. For source == dest it appends one duplicate of each arc of dest; finish() merges it, because finishCurrentState combines transitions with the same dest and overlapping labels (Automaton.java:247-295); with the fix the probe's finished state has 1 transition and accepts "aaa". So A is not a pure no-op inside the builder: until finish(), a self call on a state with k arcs holds k extra records, which finish() sorts and merges.

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on, and -Ptests.asserts=false: OutOfMemoryError) and passes with it (0.27 s and 0.26 s).
  • With the fix: core's whole org.apache.lucene.util.automaton test package, 16 classes incl. the repro (TestAutomaton 85, TestRegExpParsing 63, TestOperations 23, which covers repeat(a, min, max), ...): 239 tests, 0 failures, 2 skipped; suggest's org.apache.lucene.search.suggest.analyzing package (TestAnalyzingSuggester 23, TestFuzzySuggester 14, ..., which exercise replaceSep): 89 tests, 0 failures, 1 skipped. checkGoogleJavaFormat on core passes. The full core suite was not run; only OpenJDK 25.0.3 on Linux aarch64 was used.
  • Earlier: the hunt's shadow-class runs of the same change pass the repro (assertions on and off), the other verifiers' own tests and 112 tests of TestAutomaton, TestOperations, TestMinimize and TestDeterminism; with the bound read once, the 4.10.3 source passes too.

Return early when source == dest (fix B)

Add if (source == dest) return; at the top of Builder.addEpsilon.

Trade-offs

Exact for the resulting automaton: an epsilon from a state to itself changes neither the arcs nor the accept bit, and no duplicate arc is added. It covers only the self case, which is the only case the re-read bound can break, and leaves the loop as it is. One observable difference: Builder is not final and addTransition is public (Automaton.java:766, :795), so a subclass that overrides addTransition sees no call for a self epsilon under B, while A keeps one call per original arc, as documented. Not run.

Document that source and dest must differ (fix C)

Add "source and dest must be different states" to the javadoc, optionally with an IllegalArgumentException.

Trade-offs

Cheapest, but keeps a hang (or turns it into an exception) for a call whose documented effect is well defined and which the sibling method handles. Not run.

Downstream note

Downstream products that build their own epsilon-NFAs with Automaton.Builder inside a shared server JVM would see one such call exhaust the heap of that JVM; query, regex, analysis and index input cannot reach it.

How it was found

A proof-obligation audit of Automaton.Builder's contract: addEpsilon must copy dest's arcs to source and return for every pair of states that meets its one precondition. The obligation fails for source == dest, because the loop bound grows with the copies; a public-API JUnit test then witnessed the hang.

The method in full106 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder checked Builder.addEpsilon against its javadoc for every pair of states that meets its one precondition. Termination needs the loop bound to be fixed, but addTransition advances it, and for source == dest every appended record matches again.
  • The finder then wrote a public-API JUnit test, a probe that counts addTransition calls, and the bound-once fix that makes both pass.
  • An independent reproducer, an executed cross-model review with a refute and an intended-behaviour lens, a devil's-advocate verifier and an adjudication reproduced it with their own tests ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers or the web (searched 2026-11-03). Nearest: #7025 (LUCENE-5963), which added the method without discussing a self epsilon.

Searches3 searches

No matching public report was found in the documented searches. Nearest items, none a report of this bug: #7025 (LUCENE-5963, added Builder.addEpsilon; its thread never mentions source == dest or the loop bound), #7108 (LUCENE-6046, brought the method to 4.10 and calls it with source != dest), #225 (a size remark about the non-Builder Automaton.addEpsilon), #14205 ("avoid addEpsilon" in a test), #10061 (patch text only) and #14360 (open, adds toMermaid, no Builder change). Limits: keyword search; private or unindexed discussion is not covered.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-03; re-checked the same daytwo scripted prior-report searches (92 and 74 distinct hits, none of high relevance), manual searches (addEpsilon, "Automaton.Builder", "Automaton$Builder", epsilon transition, never returns, OutOfMemoryError and others), JIRA REST queriesno report; the nearest items are listed above
Open and recent PRs2026-11-03the changed files of the 138 open apache/lucene PRs updated since 2026-10-01, of older open automaton PRs, and of the 42 PRs updated since 2026-11-01only #14360 touches Automaton.java, with no Builder change
Mailing lists, downstream, web2026-11-03dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch and Solr; web queriesnothing about this bug; the downstream projects never mention addEpsilon

History

Introduced in 2014 by LUCENE-5963 (1c2918f66d7), which added Builder.addEpsilon with the re-read bound; it reached the 4.10 branch with LUCENE-6046. First released in 4.10.3; present through 10.6.0 and main. Not fixed upstream.

Versions tested6 rows

Introduced by 1c2918f66d7 (trunk, svn r1626241) and f147702b5b7 (branch_5x, r1626242), both 2014-09-19, "LUCENE-5963: more efficient AnalyzingSuggester.replaceSep" (now #7025; patch by Markus Heiden, committed by Michael McCandless; no pull request). The identical method reached the 4.10 branch in 779d258fc71 (r1636762, 2014-11-04, LUCENE-6046, now #7108), which made Operations.repeat(a, min, max) call it. The only later change is the 2020 reformat 2d6ad2fee6d. Not a regression: the loop was wrong from its first commit.

All 101 release tags from 4.10.3 on in the history clone carry the identical loop line; the clone has no tags for 8.8.2 to 8.11.4, of which only 8.11.4 was executed (it fails). With Automaton shadowed in the 4.10.3 jar, the unmodified tag source fails and the same source with only the bound read once passes. Upstream main 1c2072f0284 (2026-11-02) still has the loop at Automaton.java:810.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A
9.12.3, 10.6.0 affectedthe JUnit test fails (10.6.0 with assertions on and off)
10.5.2 independent testaddEpsilon(s0, s0) after an arc s0 -x-> s1: OutOfMemoryError
4.10.3, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 main() formOutOfMemoryError from Builder.addEpsilon
4.10.3 + bound read once line isolationpasses; the unmodified 4.10.3 source shadowed the same way fails
4.10.2 API absentcompile error: Builder has no addEpsilon

Independent checks

A finder, an independent reproducer, an executed cross-model review with a refute and an intended-behaviour lens (OpenAI GPT-5.6: real, high), a devil's-advocate verifier, an adjudication (confirmed, Low) and a final repro check reproduced it. This doc's gradle runs reproduced it on main and validated fix A.

Checks and verdicts222 words

The cross-model review ran on the older main ea57360912ed, where Automaton.java is byte-identical to 5adff78bb168's. Every other check ran on 5adff78bb168 or release jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedThe JUnit test fails on main (assertions on and off), 10.6.0 and 9.12.3; the counting probe stops addEpsilon(0, 0) at 1,000,000 calls; the fix passes it and 112 automaton tests.
independent reproducer realOwn test (s0 -x-> s1, then addEpsilon(s0, s0)) fails on main (on, off), 10.5.2 and 9.12.3; its own bound-once fix passes.
cross-model review, refute and intended lenses (OpenAI GPT-5.6, executed) real, high (both)Own tests fail (on, off); source != dest and sibling-method controls pass; the fix passes its tests and TestOperations (23); "intended argument weak".
devil's-advocate verifier real, highSteelmanned experimental status, one-caller origin, self-aliasing and a strict reading of the precondition, and rejected each; the 10.6.0 jars fail.
adjudication, separate re-runs, credibility audit and final repro check confirmed (high), LowRe-runs on main fail with assertions on and off and pass with the fix; one minimal deterministic test.
this doc reproduced; fix A validatedGradle without the fix fails (on, off); with it the repro passes, core's util.automaton package runs 239 tests and suggest's analyzing package 89, 0 failures.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,468 words

Finding record

LFV-1214, snapshot of 2026-11-06

Registry title
Automaton.Builder.addEpsilon(s, s) never returns and exhausts the heap when state s already has a transition, because its loop re-reads nextTransition while it appends copies that leave s again
Area
util / automaton (Automaton.Builder.addEpsilon)
Priority
Low, rank 827: Automaton.Builder.addEpsilon from a state to itself, which should change nothing, never returns and fills the heap until OutOfMemoryError once that state has a transition, in every release since 4.10.3. It is a hang, but only direct users of the experimental Builder who pass the same state twice reach it and no Lucene caller does, so it is rare.
Impact class
minor
Impact note
Only application code that builds automata with the experimental Automaton.Builder and adds an epsilon from a state to itself after that state has a recorded transition is hit (for example a Kleene star written as addEpsilon(accept, start) for every accept state when the start state also accepts): the call never returns and ends in OutOfMemoryError: Java heap space, with -ea and -da, on a one-state input with no scale needed; no Lucene query parser, RegExp, analyzer or suggester path passes source == dest, and every release from 4.10.3 through 10.6.0 is affected. (The final repro check set the impact class to minor, previously hang: the symptom is still a hang ending in OutOfMemoryError, but only direct users of the experimental Builder who pass source == dest reach it.)
Summary
You build an automaton with Automaton.Builder, give a state s one accepting self-loop arc (s -a-> s), and call addEpsilon(s, s). You expect it to return at once: its javadoc says it copies dest's arcs over to source, an epsilon from a state to itself adds nothing, and the sibling Automaton.addEpsilon returns on the same call. Instead the call never returns: its loop re-reads the growing transition count, every copy it appends again leaves s and is copied again, and it runs until OutOfMemoryError: Java heap space.
Introduced
1c2918f66d7 on trunk (svn r1626241) and f147702b5b7 on branch_5x (svn r1626242, how 5.0.0 got it): LUCENE-5963 'more efficient AnalyzingSuggester.replaceSep' (no pull request; the JIRA issue is now #7025; patch by Markus Heiden per CHANGES, committed by Michael McCandless), which created Builder.addEpsilon with its loop bound 'upto < nextTransition' re-read on every iteration; the identical method reached lucene_solr_4_10 in 779d258fc71 (svn r1636762, LUCENE-6046 'add maxDeterminizedStates to determinize', now #7108, which rewrote Operations.repeat(a, min, max) to call builder.addEpsilon), how 4.10.3 got it [read: git log -L over the method in the history clone shows only the introduction and the 2020 reformat 2d6ad2fee6d; the parent of 1c2918f66d7 has no Builder.addEpsilon] [exec: 4.10.2 lacks the method (compile error); 4.10.3 fails, also with Automaton shadowed by the unmodified 4.10.3 tag source, and passes with only the bound read once] (2014-09-19 (1c2918f66d7 at 15:04:13Z on trunk, f147702b5b7 at 15:05:12Z on branch_5x); 2014-11-04 (779d258fc71 at 22:29:27Z on lucene_solr_4_10))
First affected version
4.10.3 [exec] (tag commit 2014-12-20), the first release that has Builder.addEpsilon at all; 4.10.2 and earlier lack the method [exec: compile error on 4.10.2]; the first 5.x release, 5.0.0, also fails [exec]
Affected versions
every release that has the method: 4.10.3 and 4.10.4, 5.0.0-5.5.5, all 6.x, 7.x and 8.x, 9.0.0-9.12.3, 10.0.0-10.6.0 (released 2026-10-29, 4ae946e5a9b), and main 5adff78bb168 (12.0.0-SNAPSHOT). Executed, fail [exec]: 4.10.3 (-ea, -da), 4.10.4, 5.0.0 (-ea, -da), 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 (-ea, -da) and main (-ea, -da), always OutOfMemoryError from Builder.addTransition <- Builder.addEpsilon; the 4.10.3-9.0.0 and 10.0.0 runs use the same steps in a main() form, 9.12.3, 10.5.2, 10.6.0 and main use JUnit. [read]: all 101 release tags in the history clone from 4.10.3 on carry the identical loop line; 8.8.2-8.11.3 are not tagged there and lie between executed failing releases. Not affected: 4.10.2 and earlier (no such method). Not new in 10.6 (since 4.10.3). Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) has the same loop (Automaton.java:810), and the newest commit on the file is 86593e51b28 (#15961, author date 2026-04-19, WildcardQuery determinization, unrelated). All runs used JDK 25.0.3 on one aarch64 Linux host.
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
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's run and the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): two scripted prior-report searches (the second: 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 8 web queries, 74 distinct hits, 0 high; the finder's first: 92 distinct hits, 0 high), manual GitHub searches of apache/lucene (addEpsilon, "Automaton.Builder", "Automaton$Builder", epsilon transition, never returns, OutOfMemoryError and others), of Elasticsearch, OpenSearch, Solr and all of GitHub, JIRA REST and lists.apache.org searches, the changed files of the 138 open apache/lucene PRs updated since 2026-10-01 and of older open automaton PRs (only #14360, which adds toMermaid, touches Automaton.java, with no Builder change), and upstream history: no issue, PR, commit, JIRA entry, list thread, downstream issue or web page reports that Automaton.Builder.addEpsilon(s, s) never returns or discusses calling it with source == dest. Nearest items, none of them this bug: #7025 (LUCENE-5963, which added the method; its thread never discusses source == dest), #7108 (LUCENE-6046, which brought it to 4.10 and calls it with source != dest), #225 (a size remark about the non-Builder Automaton.addEpsilon), #14205 (avoid addEpsilon in a test), #10061 (patch code only). Same-day recheck: GitHub search 'addEpsilon' still returns only #14205, #225 and #10061; no item mentioning "Automaton.Builder" was updated since 2026-10-01; none of the 42 PRs updated since 2026-11-01 touches Automaton.java (#16564 touches other automaton files); upstream main 1c2072f0284 still has the loop.
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT), OpenJDK 25.0.3 on Linux aarch64: this doc's gradle runs (the repro without the fix, assertions on and off: fail with OutOfMemoryError; with fix A: pass; with fix A, core's util.automaton test package, 239 tests, 0 failures, 2 skipped, and suggest's analyzing test package, 89 tests, 0 failures, 1 skipped) and standalone JUnit runs; release jars 9.12.3, 10.5.2 and 10.6.0 (fail); main() forms of the same steps on 4.10.2 (compile error: no such method), 4.10.3, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0 and 10.0.0 (fail). The cross-model review ran on ea57360912ed, whose Automaton.java is byte-identical to 5adff78bb168's.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:805-808 (at 5adff78bb168) (Builder.addEpsilon javadoc: 'Add a [virtual] epsilon transition between source and dest. Dest state must already have all transitions added because this method simply copies those same transitions over to source.'), :809-819 (the loop 'for (int upto = 0; upto < nextTransition; upto += 4)' at :810; 'if (transitions[upto] == dest)' :811; addTransition(source, ...) :812-813), :795-803 (Builder.addTransition: ArrayUtil.grow, appends 4 ints, advances nextTransition), :761-766 (Builder javadoc: 'Use this when you cannot create the Automaton directly because it's too restrictive to have to add all transitions leaving each state at once.'), :185-199 (the sibling Automaton.addEpsilon, same javadoc, 'int count = initTransition(dest, t)' at :191), :43 (@lucene.experimental)
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:201-265 (at 5adff78bb168) (repeat(a): early return :207-209, 'if (stateMap[s] != 0)' :255, uses addTransition), :308-322 (repeat(a, min, max): builder.addEpsilon(s.value, numStates) at :317, dest is the start of a fresh copy); lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:290, :302 (replaceSep, the other Builder.addEpsilon caller, source != dest); lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:144-150 (oversize's IllegalArgumentException above MAX_ARRAY_LENGTH)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:769-770 (at 5adff78bb168) (transitions, nextTransition), :795-803 (Builder.addTransition), :805-819 (Builder.addEpsilon javadoc and loop; :810 the bound)
  • lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:145-151 (at 5adff78bb168) (oversize IllegalArgumentException), :318-321 (growExact)
Who is affected
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:43 (at 5adff78bb168) (@lucene.experimental)
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:201-265 (at 5adff78bb168) (repeat(a): :207-209 early return, :255 stateMap guard), :310-322 (repeat(a, min, max): builder.addEpsilon(s.value, numStates) at :317)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:268-310 (at 5adff78bb168) (replaceSep: Builder at :271, topoSortStates at :278, addEpsilon at :290 and :302)
Minimal repro
  • Lucene main 5adff78bb168 build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:132 (tests.heapsize default 512m)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:185-199 (at 5adff78bb168) (Automaton.addEpsilon, :191 count read once), :761-765 (Builder javadoc), :805-808 (Builder.addEpsilon javadoc)
  • lucene/core/src/test/org/apache/lucene/util/automaton/TestOperations.java:608, (at 5adff78bb168) lucene/test-framework/src/java/org/apache/lucene/tests/util/automaton/AutomatonTestUtil.java:391 (Automaton.addEpsilon)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:185-199 (at 5adff78bb168) (sibling reads count once), :247-295 (finishCurrentState merges same-dest overlapping transitions)
Diagrams
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:795-803, (at 5adff78bb168) :809-819

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 illustrates the repro's one-state builder (one record (0, 0, 'a', 'a')), the loop at Automaton.java:810-813 and the append in addTransition at :795-803; the numbers come from the diagram probe on main 5adff78bb168 (nextTransition 4 -> 8 -> 12, and 4,000,004 after 1,000,000 copies) and its run with the fix (1 copy, then return).

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.