Automaton.Builderlets you add arcs in any order;finish()sorts them into anAutomaton. It stores each arc as four ints, (source, dest, min label, max label), in one growingint[];nextTransitionmarks the end of the used part, andaddTransitionwrites four ints there and moves it by 4.- An epsilon arc from
sourcetodestmeans "fromsourceyou may also do whateverdestdoes next".Builder.addEpsilon(source, dest)builds it without a real epsilon: it scans the array and, for every arc that leavesdest, appends a copy that leavessource.
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.- Build a one-state automaton with
Automaton.Builder: state 0 accepts and has one arc0 -a-> 0, so it accepts"","a","aa"and so on. - 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) andfinish()to still accept"aaa". The siblingAutomaton.addEpsilon(0, 0)does return on the state being built. - Instead it never returns. With a 512 MB heap the test dies with
OutOfMemoryError: Java heap spaceafter about 1 s; a probe that counts copies stopped it at 1,000,000 copies, with 4,000,004 ints of arcs recorded. - Why: the loop runs while
upto < nextTransitionand 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.
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.
addEpsilon(source, dest)loopsfor (int upto = 0; upto < nextTransition; upto += 4)(Automaton.java:810, the fix site). For each record withtransitions[upto] == dest(:811) it callsaddTransition(source, dest', min, max)(:812-813), which grows the array if needed (ArrayUtil.grow,:796-797), writes the record and advancesnextTransitionby 4 (:799-802).- The bound is read again on every pass. With
source == dest, each appended record starts atdestagain, so the scan reaches it and appends another one. - With one record
(0, 0, 'a', 'a'): atupto = 0the record matches and a copy goes to index 4 (nextTransition4 -> 8); atupto = 4the 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 copiesnextTransitionis 4,000,004 and the array has grown 91 times. ArrayUtil.growkeeps enlarging the array untilOutOfMemoryError: Java heap space(ArrayUtil.java:319). By reading, only a heap that holds anint[]ofArrayUtil.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 asArrayUtil.oversize'sIllegalArgumentException(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; theAutomatonclass is@lucene.experimental(Automaton.java:43). One natural way to get there: a Kleene star written as "copya, thenaddEpsilon(f, start)for every accept statef". Whena's start state also accepts, one of those calls isaddEpsilon(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.addEpsilonnever passsource == dest:Operations.repeat(a, min, max)links earlier accept states to the start of a freshly copieda(Operations.java:313-319), andAnalyzingSuggester.replaceSepwalks an acyclic token automaton in topological order (AnalyzingSuggester.java:271-302).ConcatenateGraphFilterand the otherOperationsandAutomatasites use the non-BuilderAutomaton.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.
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.
/*
* 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"));
}
}
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 spacefromBuilder.addTransition<-Builder.addEpsilon(Automaton.java:812), assertions on and off.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (nothing platform-specific) |
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
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)- 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 forsource == dest. Nothing in the javadoc, package docs, tests,CHANGES.txt,MIGRATE.mdor 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 forsource == 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 cleanIllegalStateException("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 callsBuilder.addEpsilonat all (the test callers,TestOperations.java:608and the test framework'sAutomatonTestUtil.java:391, useAutomaton.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.
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.
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, seedDEADBEEF: 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.automatontest package, 16 classes incl. the repro (TestAutomaton85,TestRegExpParsing63,TestOperations23, which coversrepeat(a, min, max), ...): 239 tests, 0 failures, 2 skipped; suggest'sorg.apache.lucene.search.suggest.analyzingpackage (TestAnalyzingSuggester23,TestFuzzySuggester14, ..., which exercisereplaceSep): 89 tests, 0 failures, 1 skipped.checkGoogleJavaFormaton 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,TestMinimizeandTestDeterminism; with the bound read once, the 4.10.3 source passes too.
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.
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 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.
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.addEpsilonagainst its javadoc for every pair of states that meets its one precondition. Termination needs the loop bound to be fixed, butaddTransitionadvances it, and forsource == destevery appended record matches again. - The finder then wrote a public-API JUnit test, a probe that counts
addTransitioncalls, 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").
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-03; re-checked the same day | two 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 queries | no report; the nearest items are listed above |
| Open and recent PRs | 2026-11-03 | the 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-01 | only #14360 touches Automaton.java, with no Builder change |
| Mailing lists, downstream, web | 2026-11-03 | dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch and Solr; web queries | nothing about this bug; the downstream projects never mention addEpsilon |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle: fails with assertions on and off; passes with fix A |
| 9.12.3, 10.6.0 | affected | the JUnit test fails (10.6.0 with assertions on and off) |
| 10.5.2 | independent test | addEpsilon(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() form | OutOfMemoryError from Builder.addEpsilon |
| 4.10.3 + bound read once | line isolation | passes; the unmodified 4.10.3 source shadowed the same way fails |
| 4.10.2 | API absent | compile error: Builder has no addEpsilon |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | The 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 | real | Own 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, high | Steelmanned 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), Low | Re-runs on main fail with assertions on and off and pass with the fix; one minimal deterministic test. |
| this doc | reproduced; fix A validated | Gradle 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. |
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 446 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,468 words
- 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 withOutOfMemoryError; with fix A: pass; with fix A, core'sutil.automatontest package, 239 tests, 0 failures, 2 skipped, and suggest'sanalyzingtest 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 onea57360912ed, whoseAutomaton.javais byte-identical to5adff78bb168's.
- 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
- TestBuilderAddEpsilonToItself.java (lucene/core, package org.apache.lucene.util.automaton, 1 test):
repro/LFV-1214/TestBuilderAddEpsilonToItself.java(32 lines)
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).
assets/LFV-1214/bug.png(446 KB)
