All findings · Lucene finding · LFV-029 · runautomaton-zero-state-aioobe

RunAutomaton throws ArrayIndexOutOfBoundsException on a 0-state automaton

new CharacterRunAutomaton(new Automaton.Builder().finish()) should give a matcher that rejects every string, but the constructor throws ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0. Every release since 5.0.0 is affected; queries are guarded, but pattern tokenizers such as new SimplePatternTokenizer("(a&b){2}") hit it.

Summary

Priority
Low, rank 1143RunAutomaton throws ArrayIndexOutOfBoundsException when built directly on a 0-state automaton (queries are guarded by CompiledAutomaton), loud and needing a degenerate empty-language automaton.
Impact
crash
Since
5.0.0 (2015-02, LUCENE-5959)introduced 2014-09-18 · over 12 years
Affected
5.0.0 to 10.6.0 (all 108 releases) and main; 4.10.x not affected
Status
documented
Found by
non-formal baseline: regex differential fuzzing (Jazzer), then deterministic JUnit tests

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

RunAutomaton reserves one placeholder state for an input with no states, then loops over it and asks the input for state 0's transitions. Automaton.next reads states[0], but Builder.finish() allocated that array with exactly 0 entries, so it throws. With spare capacity (new Automaton() reserves 4 slots) the same read silently returns zeros, meaning "no transitions".

Background: automata and run automata

  1. An Automaton stores each state's transitions as a pointer and a count: state i uses states[2i] and states[2i + 1]. An automaton with 0 states is legal; Automata.makeEmpty() returns one, the empty language.
  2. A RunAutomaton (CharacterRunAutomaton for strings, ByteRunAutomaton for bytes) copies a deterministic automaton into a flat lookup table for fast matching. Its start state is always state 0.
  3. Queries such as RegexpQuery build theirs through CompiledAutomaton; the pattern tokenizers SimplePatternTokenizer and SimplePatternSplitTokenizer build a CharacterRunAutomaton directly.
Two automata with 0 states feed new CharacterRunAutomaton. The one from new Automaton.Builder().finish() has a states[] array of length 0; RunAutomaton sets size = max(1, 0) = 1, loops over the placeholder state 0 and reads states[0], which throws ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0. The one from Automata.makeEmpty() has a states[] array of length 4, so the same read returns 0 transitions and the run automaton rejects every string.
A Builder-built empty automaton has a 0-slot states[]; RunAutomaton adds a placeholder state 0 and reads states[0] for it, which throws. makeEmpty() has 4 spare slots, so the same read returns zeros and works. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build an automaton that matches nothing with new Automaton.Builder().finish(): 0 states, deterministic, the empty language.
  2. Wrap it in new CharacterRunAutomaton(a). You expect a matcher that rejects every string, run("") = false, which is what the same call gives for Automata.makeEmpty(), also 0 states.
  3. You get ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0 from the constructor, with assertions on and off.
  4. Why: RunAutomaton gives a 0-state input one placeholder start state (size = max(1, 0) = 1) and then asks the input for state 0's transitions. Builder.finish() sized the input's state array exactly, 0 x 2 = 0 slots, so reading slot 0 fails. makeEmpty() survives only because new Automaton() reserves 4 spare slots.
  5. The same crash comes from ByteRunAutomaton, Operations.repeat(empty, 2, 2) and patterns built on a bounded repeat {n} or {n,m} (n >= 1) of something that can never match, so new SimplePatternTokenizer("(a&b){2}") throws too (the same operand under {n,}, * or + does not crash). Queries are safe: CompiledAutomaton swaps a 0-state automaton for a 1-state one first.

Who is affected

Code that builds a CharacterRunAutomaton or ByteRunAutomaton itself from an empty-language automaton made by Automaton.Builder, new Automaton(0, 0) or Operations.repeat, and the SimplePattern* tokenizers and factories given a pattern built on a bounded repeat {n} or {n,m} (n >= 1) of something that can never match, like (a&b){2}. Not the default; queries are guarded. Loud: an exception, no wrong results.

Mechanism, terms and numbers765 words

new CharacterRunAutomaton(new Automaton.Builder().finish()) throws ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0 instead of returning a run automaton that accepts nothing. The input is a valid deterministic automaton with 0 states, the empty language. RunAutomaton gives a 0-state input one synthetic, non-accepting start state (size = Math.max(1, a.getNumStates())), but then loops over that synthetic state and asks the source automaton for state 0's transitions; Automaton.next reads states[0] of the source. Automaton.Builder.finish() sizes states[] exactly (numStates * 2 = 0 entries), so the read fails. Automata.makeEmpty(), also 0 states, works only because new Automaton() pre-allocates 4 slots. The same crash comes from ByteRunAutomaton, new Automaton(0, 0), Operations.repeat(emptyLanguage, 2, 2) and string patterns built on a bounded repeat {n} or {n,m} (n >= 1) of an empty-language operand, so new SimplePatternTokenizer("(a&b){2}") throws too (the same operand under {n,}, *, + or plain concatenation does not crash). Queries are protected by CompiledAutomaton's 0-state guard. Every release since 5.0.0 is affected: LUCENE-5959 made finish() allocate exact-size arrays.

The code (main 645d4fe14a20)

// RunAutomaton.java:67-83 (constructor)
points = a.getStartPoints();                    // always holds MIN_CODE_POINT: points.length >= 1
size = Math.max(1, a.getNumStates());           // :68  1 for a 0-state automaton
accept = new FixedBitSet(size);
transitions = new int[size * points.length];
Arrays.fill(transitions, -1);                   // the synthetic state already rejects everything
Transition transition = new Transition();
for (int n = 0; n < size; n++) {                // :73  n = 0, but the source has no state 0
  if (a.isAccept(n)) { accept.set(n); }         // :74  java.util.BitSet.get(0) is false: no crash
  transition.source = n;
  transition.transitionUpto = -1;
  for (int c = 0; c < points.length; c++) {
    int dest = a.next(transition, points[c]);   // :80  -> Automaton.java:676 -> :694 states[2 * 0]
  • Automaton.java:690-694: the private next reads states[stateIndex] with stateIndex = 2 * state, so state 0 reads states[0].
  • Automaton() is this(2, 2) (Automaton.java:79-82, "creates an automaton with no states"): states has length 4, so the read returns leftover zeros, "0 transitions". That is why Automata.makeEmpty() (Automata.java:55-60) works.
  • Automaton(int numStates, int numTransitions) allocates states = new int[numStates * 2] (Automaton.java:91-92), and Automaton.Builder.finish() builds its result with exactly new Automaton(numStates, numTransitions) (Automaton.java:889-894, "Create automaton with the correct size."). A Builder with no states yields states.length == 0.
  • Regexp route: every bounded repeat {n} or {n,m} goes through Operations.repeat(a, min, max) (RegExp.java:672-674, Operations.java:291-323). For min == 1 it copies the operand into new Automaton() (:299-301), for min >= 2 it concatenates min copies (:302-308); either way it copies the result into a Builder and returns removeDeadStates(builder.finish()) (:311-312, :322), which hands a 0-state input back unchanged (:1010-1011). So for an empty-language operand such as (a&b), # or [^\D\d] and min >= 1 the result has 0 states, exact-size arrays, and reports itself deterministic: (a&b){1}, (a&b){1,3}, (a&b){2}, #{2,10} crash, while (a&b){2,}, (a&b)*, (a&b)+, (a&b)(a&b), x(a&b){1} and (a&b){0,2} do not. Kleene star and intersection return a 0-state input unchanged (:202-204, :396-401), so ((a&b){2})* and (a&b){2}&(a&b){2} crash too, while ((a&b){2})+ and (a&b){2}|(a&b){2} do not (probes on 645d4fe14a20).
  • Tokenizer route: SimplePatternTokenizer(String) ("See RegExp for the accepted syntax") builds new RegExp(regexp).toAutomaton(), checks only determinism and calls new CharacterRunAutomaton(dfa) (SimplePatternTokenizer.java:75-101); the same in SimplePatternSplitTokenizer.java:64-91. SimplePatternTokenizerFactory parses the pattern in its constructor, which succeeds, and the crash appears when create() is called (SimplePatternTokenizerFactory.java:73-75, 87-88).
  • Why queries are safe: CompiledAutomaton replaces a 0-state automaton with a fresh 1-state automaton before it builds a run automaton (CompiledAutomaton.java:154-157). AutomatonQuery builds one (AutomatonQuery.java:99), and RegexpQuery, WildcardQuery, PrefixQuery and TermRangeQuery extend it; FuzzyQuery uses CompiledAutomaton too (FuzzyAutomatonBuilder.java:63, :73). A search of every non-test source file for run-automaton constructions finds three other query-side places, none of which builds one from an exact-size 0-state automaton: TermInSetQuery (only for 2 or more terms, TermInSetQuery.java:185-195), the unified highlighter (Automata.makeString, or makeStringUnion, which returns makeEmpty() for no terms, Automata.java:642-643) and the sandbox TermAutomatonQuery (rewrites an empty automaton to MatchNoDocsQuery, TermAutomatonQuery.java:491-493).

The numbers

For new Automaton.Builder().finish(): getNumStates() = 0, states.length = 0, getStartPoints() = [0], so RunAutomaton sets size = 1 and reads states[0] once: Index 0 out of bounds for length 0. For Automata.makeEmpty(): 0 states but states.length = 4, so the read returns 0 transitions and run("") and run("a") are false. A survey of 15 ways to build a 0-state automaton crashes in exactly the cases with states.length = 0.

Exposure and likelihood170 words
  • Who. (a) Code that constructs a CharacterRunAutomaton or ByteRunAutomaton directly from an empty-language automaton built through Automaton.Builder, new Automaton(0, 0) or Operations.repeat(empty, min >= 1, max); (b) SimplePatternTokenizer, SimplePatternSplitTokenizer and their factories with a pattern built on a bounded repeat {n} or {n,m} (n >= 1) of an empty-language operand, such as (a&b){2}, (a&b){1}, #{2,10}, ([^\D\d]){2} or ((a&b){2})*; the same operand under {n,}, *, + or plain concatenation does not crash. Such patterns are valid RegExp syntax; they can never match.
  • Not default. It needs code that builds a run automaton itself, or a SimplePattern* tokenizer or factory configured with such a pattern. Queries build theirs through CompiledAutomaton or never from an exact-size 0-state automaton (source inventory); RegexpQuery on the same patterns returns 0 hits with no exception.
  • Effect. An unchecked ArrayIndexOutOfBoundsException from a constructor instead of a reject-everything matcher. Through a factory the factory constructor succeeds and create() throws. No wrong results, no corruption, no hang.
  • Scale. One call, no index.

Minimal repro

One JUnit test, public API only, no index, deterministic: build a 0-state automaton with new Automaton.Builder().finish(), wrap it in CharacterRunAutomaton, and expect run("") to be false. The try/catch only labels the failure.

TestRunAutomatonZeroStates.java34 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 TestRunAutomatonZeroStates extends LuceneTestCase {

  public void testRunAutomatonFromBuilderWithNoStates() {
    Automaton empty = new Automaton.Builder().finish();
    CharacterRunAutomaton run;
    try {
      run = new CharacterRunAutomaton(empty);
    } catch (ArrayIndexOutOfBoundsException e) {
      // Expected: a run automaton that accepts nothing; actual: the constructor throws AIOOBE.
      throw new AssertionError("new CharacterRunAutomaton(0-state automaton) threw " + e, e);
    }
    assertFalse(run.run(""));
  }
}
Command
cp $REPRO/TestRunAutomatonZeroStates.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/automaton/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestRunAutomatonZeroStates -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same exception)
Expected
new CharacterRunAutomaton(...) returns a run automaton and run("") is false, as for Automata.makeEmpty().
Observed
ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0 from RunAutomaton.<init>(RunAutomaton.java:80), with assertions on and off.

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

More files, environment, full output258 words
  • Smallest input. The Builder with no states is the smallest automaton there is; one call, one thread, no randomness. Same result with assertions on and off and with two seeds (DEADBEEF, C0FFEE).
  • API. Public only (Automaton.Builder, CharacterRunAutomaton); the package org.apache.lucene.util.automaton is for locality, no package-private API is used.
  • Releases. The same test fails on 10.5.2 and 10.6.0; a main() form of it fails on every release from 5.0.0 to 10.6.0 (all 108) and passes on 4.10.0 to 4.10.4.
  • As the regression test: this same method. Upstream it can live in TestAutomaton or a small new class; the try/catch may go, the AIOOBE then shows as the test error (not run in that form).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.5.2, 10.6.0 and every release from 5.0.0; 4.10.4 passes
jdkOpenJDK 25.0.3
osLinux aarch64
Command (environment)
cp $REPRO/TestRunAutomatonZeroStates.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/automaton/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestRunAutomatonZeroStates -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same exception)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestRunAutomatonZeroStates > testRunAutomatonFromBuilderWithNoStates FAILED
    java.lang.AssertionError: new CharacterRunAutomaton(0-state automaton) threw java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:5E2D81154F1689AE]:0)
        at org.apache.lucene.util.automaton.TestRunAutomatonZeroStates.testRunAutomatonFromBuilderWithNoStates(TestRunAutomatonZeroStates.java:30)

        Caused by:
        java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
            at org.apache.lucene.util.automaton.Automaton.next(Automaton.java:694)
            at org.apache.lucene.util.automaton.Automaton.next(Automaton.java:676)
            at org.apache.lucene.util.automaton.RunAutomaton.<init>(RunAutomaton.java:80)
            at org.apache.lucene.util.automaton.CharacterRunAutomaton.<init>(CharacterRunAutomaton.java:27)
            at org.apache.lucene.util.automaton.TestRunAutomatonZeroStates.testRunAutomatonFromBuilderWithNoStates(TestRunAutomatonZeroStates.java:27)
1 test completed, 1 failed
# gradle, assertions off (-Ptests.asserts=false): the same AssertionError and cause
# 10.5.2 release jars, assertions on and off: the same exception at the same line numbers
# with the recommended fix: BUILD SUCCESSFUL, 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • The constructors document one failure only, IllegalArgumentException for non-deterministic input; a 0-state automaton is deterministic, and Automata.makeEmpty() returns one.
  • RunAutomaton reserves a start state for this case (Math.max(1, a.getNumStates())); Lucene's test framework uses new CharacterRunAutomaton(makeEmpty()) as its empty stop set.
  • Only hidden spare capacity decides: makeEmpty() works, Builder().finish() crashes, and 4.10.4 passes the same check (main() form).
The full argument205 words
  • Contract. RunAutomaton.java:55-60: "Constructs a new RunAutomaton from a deterministic Automaton. ... @throws IllegalArgumentException if the automaton is not deterministic". CharacterRunAutomaton.java:21-25 ("Construct from a DFA") and ByteRunAutomaton.java:22-35 document only the same exception.
  • 0-state automata are first-class. Automaton.java:79 "creates an automaton with no states"; Automata.java:55 "Returns a new (deterministic) automaton with the empty language" (0 states); Operations special-cases getNumStates() == 0 in many places, e.g. Operations.java:202-205, "Repeating the empty automata will still only accept the empty automata".
  • RunAutomaton means to support them. size = Math.max(1, a.getNumStates()) (RunAutomaton.java:68) exists only to give a 0-state input a start state; the class javadoc says "The initial state is always 0" (:38).
  • Lucene relies on it. MockTokenFilter.EMPTY_STOPSET, "Empty set of stopwords", is new CharacterRunAutomaton(makeEmpty()) (test framework, MockTokenFilter.java:43-44); it works only because makeEmpty() carries spare capacity.
  • Tests. TestRegExp.testRepeatWithEmptyLanguage (TestRegExp.java:107-117) builds #{2,10} and checks only toString(); nothing asserts a run automaton must refuse such input.
  • History. The crash appeared with LUCENE-5959 (#7021), a 5.0.0 memory optimization that made finish() exact-size; it did not discuss 0-state automata. 4.10.4 passes the same check (its main() form).
  • Precedent. LUCENE-6054 (#7116, "RegExp.toAutomaton fails on #*"), an earlier empty-language edge case, was fixed under Bug Fixes.
The devil's-advocate check197 words

Strongest case for "not a bug": RunAutomaton and Automaton are @lucene.experimental low-level APIs, and callers should go through CompiledAutomaton, which already handles 0 states; direct construction from a 0-state automaton is unsupported. For the tokenizers, the class javadoc says "The provided regex should match valid token characters" (SimplePatternTokenizer.java:33-34), so a maintainer may see a never-matching pattern as invalid configuration and prefer a clear IllegalArgumentException.

Why it fails: @lucene.experimental is about API stability, not correctness; no javadoc states that precondition; the CompiledAutomaton guard shows the case is known but is not a contract on RunAutomaton; Lucene's own MockTokenFilter and the pattern tokenizers construct run automata directly; and SimplePatternTokenizer is a public, string-configured API that reaches the crash with valid RegExp syntax. That javadoc sentence contrasts token characters with separators; it does not say the pattern must be able to match, and an AIOOBE from inside RunAutomaton is not a validation error. A cross-model reviewer (OpenAI GPT-5.6) built the API argument, rated it weak and voted real with high confidence; a second cross-model fact check rated the intended-behaviour case moderate because of the javadoc sentence and still found fix A the most defensible default.

Suggested fix

Recommended A. Loop only over the source's real states

In the RunAutomaton constructor, loop over the source's real states (n < a.getNumStates()), not n < size. The placeholder state 0 already rejects everything, so a 0-state input gets the table makeEmpty() gets today; other automata are unchanged. Validated with gradle: the test fails before, passes after; 404 automaton, query and tokenizer tests show 0 failures.

// RunAutomaton.java:73
-    for (int n = 0; n < size; n++) {
+    // size is max(1, numStates); only query states that exist in the source automaton. A 0-state
+    // automaton keeps the synthetic state 0 as non-accepting with all transitions = -1.
+    final int numStates = a.getNumStates();
+    for (int n = 0; n < numStates; n++) {
All options and trade-offs3 options ยท 443 words

One validated option and two alternatives that were not run. A is the smallest change at the place that invents the extra state; B and C only move or narrow the guard.

A. Loop only over the source's real states (validated by the checks) (recommended)

Keep size = Math.max(1, a.getNumStates()) for the tables, but query the source only for states it has. The synthetic state 0 is already initialised as non-accepting with every transition -1 by new FixedBitSet(size) and Arrays.fill(transitions, -1).

// RunAutomaton.java:73
-    for (int n = 0; n < size; n++) {
+    // size is max(1, numStates); only query states that exist in the source automaton. A 0-state
+    // automaton keeps the synthetic state 0 as non-accepting with all transitions = -1.
+    final int numStates = a.getNumStates();
+    for (int n = 0; n < numStates; n++) {

Trade-offs

  • For every automaton with at least one state numStates == size: same table, same cost; constructor only, no hot-path change.
  • Fixes every caller at once: direct CharacterRunAutomaton / ByteRunAutomaton users, both pattern tokenizers and their factories.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF: the minimal test fails before the change and passes after it, with assertions on and off.
  • :lucene:core:test over org.apache.lucene.util.automaton.* plus TestRegexp*, TestAutomatonQuery*, TestWildcard*, TestTermInSetQuery, TestFuzzyQuery, TestTermsEnum*: 28 classes, 322 tests, 4 skipped, 0 failures.
  • :lucene:analysis:common:test over org.apache.lucene.analysis.pattern.* (incl. TestSimplePatternTokenizer, TestSimplePatternSplitTokenizer): 11 classes, 82 tests, 2 skipped, 0 failures.
  • :lucene:core:checkGoogleJavaFormat: clean.
  • Earlier and independently: the study's 14 tests and Lucene's 220 prebuilt core automaton tests pass with it; the cross-model reviewer's SimplePatternTokenizer("(a&b){2}") test and TestRegExp (9/9) pass with it.

B. Give Automaton(int, int) / Builder.finish() a minimum capacity

Allocate at least 2 states[] slots even for 0 states in Automaton(int, int) (Automaton.java:92), the only place states[] is allocated (Builder.finish() uses it at :894; elsewhere it is only grown, :209, :359). Not run.

Trade-offs

It would cover every way of building a 0-state automaton, and with a 2-slot minimum only 0-state automata get more memory. But it hides the read of a state that does not exist instead of removing it (RunAutomaton would still read state 0 of a 0-state source and rely on spare zeros), and it changes a shared core constructor for a RunAutomaton problem. Not recommended.

C. Guard in the tokenizers, as CompiledAutomaton does

Replace a 0-state automaton with a 1-state non-accepting one in SimplePatternTokenizer and SimplePatternSplitTokenizer before new CharacterRunAutomaton(dfa). Not run.

Trade-offs

Fixes the tokenizer path only; direct CharacterRunAutomaton / ByteRunAutomaton users keep the crash. Not recommended alone.

Downstream note

Downstream products that build a SimplePatternTokenizer from a user-configured pattern get an unchecked ArrayIndexOutOfBoundsException for a degenerate pattern such as (a&b){2}, not a validation error; queries are not affected.

How it was found

Non-formal. A Jazzer fuzzer that compares Lucene regular expressions with java.util.regex and checks run automata against each other crashed on a repeated empty character class. An independent verifier reduced it to the 0-state automaton, mapped the triggers and releases, and tried a fix; the hunt cut it to one test and re-ran it on current main.

The method in full109 words
  • The study's fuzzing unit ran RegexDiffFuzzer (Jazzer): random regex syntax trees rendered both as Lucene RegExp and as java.util.regex and compared, plus self-consistency across Operations.run, ByteRunAutomaton, CompiledAutomaton and the automaton operations. It reported the AIOOBE with ([^\D\d]){2} and Builder().finish() as triggers.
  • An independent verifier wrote 14 tests from scratch (triggers, controls, a survey of 15 constructions, the tokenizer and factory paths, query and highlighter controls), probed release jars, and validated the loop-bound fix against Lucene's 220 core automaton tests.
  • The hunt minimised it to one method, re-ran it on main and 10.5.2, bisected the introducing commit on both branches, swept every release, and had a cross-model review.

Prior reports, history and checks

Prior reports

No report found (2026-10-22 and 2026-11-01): GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, downstream projects, the web, and the changed files of all 409 open PRs. Two open PRs edit the same constructor but keep the loop.

Searches4 searches

Closest, none this bug: #7021 (LUCENE-5959, introduced it; nothing about 0 states); #7116 (LUCENE-6054, #*, different code); #8517 (LUCENE-7465, added SimplePatternTokenizer; no empty-pattern discussion); #16564 and #13072 (open PRs on the same constructor, keep n < size); #11646 (a different RunAutomaton bug); #14224, #3791, #13714, #9334 (empty-language cases in other code); a downstream simple_pattern issue with a different failure ("please determinize the incoming automaton first"). Gap: GitHub search works on tokens and cannot match stack frames exactly.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene incl. the migrated JIRA archive, apache/lucene-solr)2026-10-22/23 and 2026-11-01about 30 study phrasings; 45 scripted queries and 49 extra searches: class names with ArrayIndexOutOfBoundsException / AIOOBE, "RunAutomaton.<init>", "Index 0 out of bounds for length 0" (paged to the end), no-states and empty-language phrasings; changed files of all 409 open PRsno report; #16564 and #13072 touch the constructor without fixing it
Commits and JIRA archive2026-11-016 commit queries, 18 JIRA queries; LUCENE-5959, 6054, 7465, 8287 and 5752 read in fullno report
Mailing lists (dev@, java-user@, issues@, commits@, builds@)2026-11-0120 scripted queries plus manual crash-trace queriesno report
Downstream projects and the web2026-10-22 and 2026-11-015 downstream queries over three search projects, 6 web queriesone unrelated simple_pattern issue

History

Introduced by LUCENE-5959 (#7021, 2014-09-18), a memory optimization that made Builder.finish() allocate exact-size arrays; first released in 5.0.0. All 108 releases from 5.0.0 to 10.6.0 and main fail; 4.10.0 to 4.10.4 pass. Not fixed upstream.

Versions tested7 rows
  • Introduced: LUCENE-5959, "Optimized memory management in Automaton.Builder.finish()" (#7021, svn era, no PR): trunk 0bf7d74eac7d (first released in 6.0.0) and the identical branch_5x backport ba9a5fb56251, which 5.0.0 ships; both are labeled "LUCENE-5960: move CHANGES entry under 5.0". Bisected on both branches: the parent passes, the commit fails, assertions on and off.
  • Latent since 4.10.0: LUCENE-5752 added the int[] Automaton, Builder, the Math.max(1, ...) synthetic state with its n < size loop, and the CompiledAutomaton guard; the read of state 0 was harmless while every automaton had spare capacity.
  • Crash frame: Automaton.step up to 8.4.x, Automaton.next from 8.5.0; the message is the same everywhere.
  • Upstream: the involved files are unchanged up to upstream main f815e679bf6c of 2026-11-01 (a snapshot); the open PRs #16564 and #13072 keep the loop.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails with assertions on and off (gradle and standalone); passes with fix A
10.6.0 affectedfails with assertions on and off
10.5.2 affectedfails with assertions on and off
5.1.0 to 10.5.x (106 releases) affectedfail with the same AIOOBE (the 97 tagged ones in the sweep, assertions on; the 9 untagged 8.8.2 to 8.11.4 releases in a later run, assertions on and off)
5.0.0 first releasefails with assertions on and off
4.10.0 to 4.10.4 not affectedpass: Builder wraps new Automaton() with spare capacity
ba9a5fb56251 / 0bf7d74eac7d (LUCENE-5959) introducing commitsfail; their parents pass

Independent checks

The study verifier, two re-runs on newer main, the minimal repro, the current-main verification and a cross-model reviewer (OpenAI GPT-5.6, own tests) all call it real; this doc's gradle runs and fact checks confirm the failure and the fix.

Checks and verdicts219 words

Gap: the cross-model reviewer ran on main ea57360912ed; the automaton files are the same at 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier realOwn 14 tests, 7 fail on main; release probe 4.10.4 OK, 5 sampled releases from 5.0.0 to 10.1.0 AIOOBE; fix passes.
main refresh a9779ef3c193 still reproducesStudy tests, 14 run, the same 7 fail.
minimal repro verifiedOne test; fails with assertions on and off and two seeds; the fix passes.
current-main verification realMain 645d4fe14a20 and 10.5.2 fail with assertions on and off; the fix passes.
cross-model review, refute lens real, highOpenAI GPT-5.6: own direct and SimplePatternTokenizer tests fail, controls pass, fix passes; intended-behaviour argument weak.
doc gradle runs fails before, passes afterScratch worktree of 645d4fe14a20; 322 core and 82 pattern-tokenizer tests with fix A: 0 failures (6 skipped).
independent fact check real, as documentedOwn repro runs on main, 10.5.2 and 10.6.0 (assertions on and off), the gradle command before and after fix A, trigger probes; two wording errors (regexp trigger scope, fix B trade-offs), both corrected.
cross-model fact check realOpenAI GPT-5.6, read-only: found the sweep had skipped 8.8.2 to 8.11.4 (since run: all fail) and asked for narrower query and factory wording; corrected.

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

Finding record

LFV-029, snapshot of 2026-11-06

Registry title
RunAutomaton throws ArrayIndexOutOfBoundsException on a 0-state automaton
Area
util / automaton
Priority
Low, rank 1143: RunAutomaton throws ArrayIndexOutOfBoundsException when built directly on a 0-state automaton (queries are guarded by CompiledAutomaton), loud and needing a degenerate empty-language automaton.
Impact class
crash
Impact note
Only code that builds a CharacterRunAutomaton or ByteRunAutomaton itself from a 0-state automaton with exact-size arrays (Automaton.Builder, new Automaton(0, 0), Operations.repeat), or a SimplePatternTokenizer, SimplePatternSplitTokenizer or their factories given a never-matching bounded-repeat pattern such as (a&b){2}; queries are guarded by CompiledAutomaton; released since 5.0.0.
Summary
You build an automaton with zero states through Automaton.Builder and create a RunAutomaton from it. You expect a run automaton that accepts nothing. Instead the constructor throws ArrayIndexOutOfBoundsException.
Introduced
LUCENE-5959 (0bf7d74eac7d) (2014-09-18)
First affected version
5.0.0
Affected versions
5.0.0 to 10.6.0 (all 108 lucene-core releases in that range) and main 645d4fe14a20 [exec]; 4.10.0 to 4.10.4 not affected [exec]
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 agreed; 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 (2026-10-28): the minimal test through gradle with assertions on and off, before and after the recommended fix, in a scratch worktree (2026-11-01), and standalone (2026-10-29 and 2026-11-01); 10.5.2 release jars with assertions on and off; all 108 releases 5.0.0 to 10.6.0 (every lucene-core version Maven Central lists in that range: the 99 tagged in the history clone in the sweep, the 9 untagged 8.8.2 to 8.11.4 releases in a later run with assertions on and off) and 4.10.0 to 4.10.4; the LUCENE-5959 commits and their parents on trunk and branch_5x.

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/automaton/RunAutomaton.java:37-41, :55-83 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:79-95, :144-146, :626-628, :675-694, :889-894 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:55-60; Operations.java:291-323; CompiledAutomaton.java:154-157 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:75-101; SimplePatternSplitTokenizer.java:64-91; SimplePatternTokenizerFactory.java:73-75, :87-88 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:154-157 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/AutomatonQuery.java:99, TermInSetQuery.java:185-195, FuzzyAutomatonBuilder.java:63, :73; lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:550-552, :642-643; lucene/sandbox/src/java/org/apache/lucene/sandbox/search/TermAutomatonQuery.java:491-493 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/automaton/RunAutomaton.java:37-41, :55-68; CharacterRunAutomaton.java:21-28; ByteRunAutomaton.java:22-38 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:79-82; Automata.java:55-60; Operations.java:201-205 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/analysis/MockTokenFilter.java:43-44 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/util/automaton/TestRegExp.java:107-117 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/automaton/RunAutomaton.java:68-83 (at 645d4fe14a20)
Diagrams
  • lucene/core/src/java/org/apache/lucene/util/automaton/RunAutomaton.java:68-80; Automaton.java:79-92, :690-694, :889-894 (at 645d4fe14a20)

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

Repro files

About the diagrams

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.