All findings · Lucene finding · LFV-493 · lengthgoal-zero-goal-summary-throws

UnifiedHighlighter: a LengthGoalBreakIterator length goal of 0 throws "offset out of bounds" for hits with no match

Give the UnifiedHighlighter a LengthGoalBreakIterator with a length goal of 0, and a hit with text in the field but no match gets IllegalArgumentException: offset out of bounds instead of the default summary, so the whole highlight call fails. It broke in 8.5.0 and still fails on 10.6.0 and main.

Summary

Priority
Low, rank 648A loud highlight failure only for a LengthGoalBreakIterator goal of exactly 0 on no-match hits, a rare-value crash like the maxPassages=0 row LFV-265.
Impact
crash
Since
8.5.0 (2020, LUCENE-9093); 8.4.1 and older return the summaryintroduced 2020-01-01 · about 6 years
Affected
every release from 8.5.0 to 10.6.0, and main
Status
documented
Found by
bounded-exhaustive testing of the highlighter against its javadoc

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

The default summary for a hit without a match is built by stepping the break iterator forward from offset 0. With a length goal of 0 the first step asks for a break before the start of the text, and Java rejects it.

Background: passages, length goals and the default summary

  1. The UnifiedHighlighter cuts a field's text into passages with a java.text.BreakIterator (by default one passage per sentence) and returns the passages that contain matches, with the matches marked.
  2. LengthGoalBreakIterator wraps that iterator to make passages roughly a chosen length, the length goal: createMinLength (at least that long) or createClosestToLength (as close to it as the sentence breaks allow).
  3. When a hit has text in the field but no match, the highlighter returns a default summary instead: the first maxPassages passages, found by calling the iterator's next() from offset 0.
Summary of the bug. A UnifiedHighlighter with LengthGoalBreakIterator.createMinLength(sentences, 0, 0.5f) highlights "First sentence. Second sentence." for a query that does not match; expected: the default summary "First sentence. ". The summary starts at offset 0, next() aims at 0 + 0 = 0 and asks the sentence iterator for following(-1), which throws IllegalArgumentException: offset out of bounds. Goal 1, and goal 0 up to 8.4.1, return the first sentence; the fix lets next() advance by at least 1.
The minimal repro step by step: the 32-character text with sentence breaks at 0, 16 and 32; no match, so the default summary calls next(); goal 0 gives target 0 and the call following(-1), which Java rejects; goal 1, and goal 0 up to 8.4.1, return the first sentence; the one-line fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a UnifiedHighlighter with LengthGoalBreakIterator.createMinLength(sentenceIterator, 0, 0.5f), a length goal of 0, and highlight the 32-character text "First sentence. Second sentence." for a query that does not match it, with maxPassages 1.
  2. You expect the default summary, the first sentence "First sentence. ": the plain sentence iterator and goal 1 return it, and so did goal 0 up to 8.4.1.
  3. You get IllegalArgumentException: offset out of bounds, and the whole call fails. With a searcher, one hit without a match costs every hit in the call its highlight.
  4. Why: the summary starts at offset 0, so next() aims at 0 + goal = 0 and asks the sentence iterator for the first break after offset 0 - 1 = -1. Java's BreakIterator.following(-1) rejects that offset.
  5. Highlighting a match with goal 0 still works (<b>Second</b> sentence.); only the no-match summary throws. 8.5.0 rewrote next() (LUCENE-9093); 8.4.1 returned the summary.

Who is affected

Applications that build the UnifiedHighlighter themselves with LengthGoalBreakIterator and a goal of 0 (for example a fragment-size setting of 0 passed through) and keep the default summary for hits with text but no match. Opt-in and rare; the failure is loud and nothing is corrupted. Solr never passes goal 0 to this class.

Mechanism, terms and numbers485 words

LengthGoalBreakIterator.createMinLength and createClosestToLength accept any int length goal, and with a goal of 0 the UnifiedHighlighter highlights matches normally in every case tested. When a hit has non-empty text in the highlighted field and no match there, the highlighter instead builds the default summary, documented as "the first maxPassages sentences", by calling the iterator's next() from offset 0. With goal 0, next() aims at offset 0 + 0 = 0 and asks the wrapped iterator for following(0 - 1), that is following(-1), which java.text.BreakIterator rejects. So IllegalArgumentException: offset out of bounds escapes and the whole highlight call fails. On "First sentence. Second sentence." with maxPassages 1 the expected summary is "First sentence. ", which 8.4.1 returns. It broke in 8.5.0 with LUCENE-9093 (branch_8x commit 5874b9c7933; 4c9cc2cefd7 on master), which rewrote next(), and still fails on 10.6.0 and main. It takes an application that builds the highlighter itself with this iterator and a goal of 0; the failure is loud and nothing is corrupted. Negative goals also throw on this path (seen on main and 10.5.2); that wider behaviour is outside this report.

The code (main 645d4fe14a20, lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/)

  1. FieldHighlighter.java:77-79 returns null for an empty field, so only non-empty text gets further. :81 sets the text; with no match, highlightOffsetsEnums returns no passages and :89-93 asks for getSummaryPassagesNoHighlight(maxNoHighlightPassages == -1 ? maxPassages : maxNoHighlightPassages). -1 is the default (UnifiedHighlighter.java:116, :153).
  2. FieldHighlighter.java:113-116: pos = breakIterator.current() (asserted to be 0 at :114), then breakIterator.next(). The highlighter wraps the user's iterator in a SplittingBreakIterator (UnifiedHighlighter.java:1135), which forwards next() (SplittingBreakIterator.java:104-106).
  3. LengthGoalBreakIterator.java:148-152: "Called by getSummaryPassagesNoHighlight to generate default summary." return following(currentCache, currentCache + lengthGoal); With currentCache 0 and goal 0 the target is 0.
  4. LengthGoalBreakIterator.java:166-173: the target is below the end of the text (32), so the helper looks for the first break at or after it as baseIter.following(targetIdx - 1), here following(-1).
  5. A BreakIterator offset must lie between the start and the end of the text (both included); -1 is before the start. The JDK sentence iterator checks it in RuleBasedBreakIterator.checkOffset; Lucene's CustomSeparatorBreakIterator.java:74-76 does the same. Both throw IllegalArgumentException("offset out of bounds"), and nothing catches it.

With goal 1 or more the target is at least 1, so targetIdx - 1 is at least 0. In 8.4.1, next() was following(current()), which delegated straight to baseIter.following(0) for goal 0 and goal 1 alike, so both returned the first sentence.

By code reading only (not reachable today, because the first step already throws): with goal 0 and the JDK sentence iterator, a next() from a later break p would ask for following(p - 1) and get p back. A fix therefore has to advance by at least 1, not only guard offset 0. (With CustomSeparatorBreakIterator as base, following(p - 1) skips to the break after p instead, CustomSeparatorBreakIterator.java:62-88.)

Exposure and likelihood285 words
  • Who is hit. Users of withBreakIterator or an overridden getBreakIterator that return LengthGoalBreakIterator.createMinLength or createClosestToLength with goal 0, on the default summary path (maxNoHighlightPassages -1) for hits with non-empty field text and no match, with highlight, highlightFields or highlightWithoutSearcher. Both factories and both the JDK sentence iterator and CustomSeparatorBreakIterator as base fail.
  • Default vs opt-in. Opt-in: the default break iterator is the JDK sentence iterator (UnifiedHighlighter.java:112-113), which is not affected; LengthGoalBreakIterator is @lucene.experimental. The summary path itself is on by default (UnifiedHighlighter.java:634-641).
  • How it shows. One unmatched hit throws, and the whole call fails, so matched hits in the same call get no highlight either. In the probe the next call was unaffected and the same highlighter, searcher and index stayed usable.
  • Scale. No size needed: a 32-character text and one non-matching query.
  • Downstream. Solr maps hl.fragsize 0 to WholeBreakIterator (the whole field), uses the plain base iterator for any other fragsize <= 1 (negatives included) and wraps with this class only from 2 up, so it never wraps with goal 0. No use of the class was found in Elasticsearch or OpenSearch (code search). So the exposure found is in applications that use the Lucene API directly.
  • Likelihood: rare. Goal 0 is an unusual value, the failure appears on the first unmatched hit (likely in testing), and no prior report was found in the recorded searches, more than six years after 8.5.0.
  • Not only 0. Every negative goal the security probe tried (-1, -7, -1000, Integer.MIN_VALUE) also throws on the summary path, on main and 10.5.2. Those were not swept across releases, so this doc claims only goal 0 as the 8.5.0 regression (see Fixes).

Minimal repro

One JUnit method on the public API, in the highlighter's test package like the upstream tests: no index, highlightWithoutSearcher on a 32-character text with a query that does not match, goal 0. Fixed input, one thread; it fails with every seed tried, with assertions on and off, and passes unchanged with the fix.

TestLengthGoalZeroGoalSummary.java (lucene/highlighter, package org.apache.lucene.search.uhighlight, 1 test)48 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.search.uhighlight;

import java.text.BreakIterator;
import java.util.Locale;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestLengthGoalZeroGoalSummary extends LuceneTestCase {

  public void testZeroLengthGoalDefaultSummary() throws Exception {
    UnifiedHighlighter highlighter =
        UnifiedHighlighter.builderWithoutSearcher(new StandardAnalyzer())
            .withBreakIterator(
                () ->
                    LengthGoalBreakIterator.createMinLength(
                        BreakIterator.getSentenceInstance(Locale.ROOT), 0, 0.5f))
            .build();
    TermQuery noMatch = new TermQuery(new Term("body", "missing"));
    Object summary;
    try {
      summary =
          highlighter.highlightWithoutSearcher(
              "body", noMatch, "First sentence. Second sentence.", 1);
    } catch (IllegalArgumentException e) {
      // expected: the default summary (first sentence); actual: IllegalArgumentException
      throw new AssertionError("expected the summary \"First sentence. \" but got " + e, e);
    }
    assertEquals("First sentence. ", summary);
  }
}
Command
cp $REPRO/TestLengthGoalZeroGoalSummary.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestLengthGoalZeroGoalSummary -Ptests.seed=DEADBEEF
Expected
The default summary, the first sentence: "First sentence. ".
Observed
IllegalArgumentException: offset out of bounds from LengthGoalBreakIterator.following(LengthGoalBreakIterator.java:173), called by next() (:151).

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

More files, environment, full output276 words

Copy the test into lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/ of a Lucene checkout and run it with gradle (command below). highlightWithoutSearcher takes the same FieldHighlighter.highlightFieldForDoc -> getSummaryPassagesNoHighlight path as a searched hit without a match; the hunt's and the independent reproducer's tests also showed it through an indexed document and with createClosestToLength and CustomSeparatorBreakIterator.

Executed for this doc (2026-10-30): gradle on a clean 645d4fe14a20 (fails); the standalone runner on the stock main jars with -ea and -da (fails); the 10.6.0, 10.5.2 and 9.12.3 release jars (fail, same frames). The test does not compile on 8.x or 9.0.0 (the builder API and the test-framework package first appear in 9.1.0); the 8.x and 9.0.0 results come from version-neutral probes. The test catches the exception only to name the expected value in the failure message; the cause is kept.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0, 10.5.2 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (the only platform it ran on; nothing in the bug is platform-specific)
Command (environment)
cp $REPRO/TestLengthGoalZeroGoalSummary.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestLengthGoalZeroGoalSummary -Ptests.seed=DEADBEEF

Observed output

# gradle, main 645d4fe14a20, clean scratch worktree, -Ptests.seed=DEADBEEF
TestLengthGoalZeroGoalSummary > testZeroLengthGoalDefaultSummary FAILED
    java.lang.AssertionError: expected the summary "First sentence. " but got java.lang.IllegalArgumentException: offset out of bounds
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:51624CE1C572F627]:0)
        at org.apache.lucene.search.uhighlight.TestLengthGoalZeroGoalSummary.testZeroLengthGoalDefaultSummary(TestLengthGoalZeroGoalSummary.java:44)

        Caused by:
        java.lang.IllegalArgumentException: offset out of bounds
            at java.base/sun.text.RuleBasedBreakIterator.checkOffset(RuleBasedBreakIterator.java:716)
            at java.base/sun.text.RuleBasedBreakIterator.following(RuleBasedBreakIterator.java:730)
            at org.apache.lucene.search.uhighlight.LengthGoalBreakIterator.following(LengthGoalBreakIterator.java:173)
            at org.apache.lucene.search.uhighlight.LengthGoalBreakIterator.next(LengthGoalBreakIterator.java:151)
            at org.apache.lucene.search.uhighlight.SplittingBreakIterator.next(SplittingBreakIterator.java:106)
            at org.apache.lucene.search.uhighlight.FieldHighlighter.getSummaryPassagesNoHighlight(FieldHighlighter.java:116)
            at org.apache.lucene.search.uhighlight.FieldHighlighter.highlightFieldForDoc(FieldHighlighter.java:92)
            at org.apache.lucene.search.uhighlight.UnifiedHighlighter.highlightWithoutSearcher(UnifiedHighlighter.java:1107)
            at org.apache.lucene.search.uhighlight.TestLengthGoalZeroGoalSummary.testZeroLengthGoalDefaultSummary(TestLengthGoalZeroGoalSummary.java:40)

1 test completed, 1 failed

# standalone runner, stock main jars: the same exception and frames with -ea and with -da;
# the 10.6.0, 10.5.2 and 9.12.3 release jars: the same exception at LengthGoalBreakIterator.java:173 <- :151

Why it looks unintended

  • No lower bound: the factories accept any goal and check only the alignment; upstream tests highlight matches with goal 0.
  • The class names the summary's next() as a supported call; the summary is documented as "the first maxPassages sentences".
  • It worked up to 8.4.1; the commit that broke it narrowed the summary test's goals from 0-9 to 6-9.
The full argument249 words
  • No documented or enforced bound. createMinLength: "Breaks will be at least {@code minLength} apart (to the extent possible)" (LengthGoalBreakIterator.java:43-46); createClosestToLength: "Breaks will be on average {@code targetLength} apart" (:53-57). The constructor validates only fragmentAlignment (:73-75).
  • The failing path is a named caller. "the only methods that will get called are ... getSummaryPassagesNoHighlight: current(), first(), next()" (:80-82); next() is commented "Called by getSummaryPassagesNoHighlight to generate default summary" (:148). FieldHighlighter.java:104-107: "By default this just returns the first {@link #maxPassages} sentences".
  • Goal 0 is legal upstream. TestLengthGoalBreakIterator.java:100 uses random().nextInt(4) as the goal (0 included) and :143 asserts highlightMinLen(CONTENT, query, 0, 0.f) is " Cc <b>dd</b>.". No test expects an exception for any goal.
  • A regression, with the test narrowed in the same commit. All 34 releases 6.4.0-8.4.1 return the summary for goal 0. The class's original summary test (LUCENE-7620) drew the goal from random().nextInt(10); LUCENE-9093 changed it to 6 + random().nextInt(4) in the commit that introduced the failure, with no reason given in #10135 or the lucene-solr #1123 review.
  • The class prefers not to throw. Its javadoc: "Some assumptions are checked with Java assertions" (:28-29). When adding the class, its author, David Smiley, wrote on #8671 (LUCENE-7620): "I added assertions not exceptions because if per chance this circumstance happens in production, it's really okay to return possibly the wrong break and have a passage that isn't quite the ideal size rather than throw some exception."
The devil's-advocate check169 words

The strongest case for "intended or misuse" (the cross-model lenses, the independent reproducer, the devil's advocate and the adjudication): the class is @lucene.experimental and "not a general purpose BreakIterator" (LengthGoalBreakIterator.java:27-31); a goal of 0 is degenerate, since "at least 0 apart" adds nothing over the base iterator; Solr never wraps the iterator for fragsize <= 1; and no current summary test uses goal 0.

Why it fails: the failing path is the UnifiedHighlighter's own default summary, which the class names as its caller; the value is accepted, and upstream's own tests use it on the highlight path; 8.4.1 returned the right summary; and the error is an internal JDK offset check on targetIdx - 1, raised on a later, unrelated hit, not an argument check. At most the case supports rejecting goals below 1 up front (fix option B). That is a plausible maintainer choice and a fix direction, not evidence that the current exception is intended; either A or B removes the late exception.

Suggested fix

Recommended next() advances by at least 1 (one line)

Let next() advance by at least 1: following(currentCache, currentCache + Math.max(1, lengthGoal)) in LengthGoalBreakIterator (one line). Goal 0 then gives the next sentence break, exactly as in 8.4.1; goals of 1 or more and the highlight path are unchanged. Validated: the repro passes and all 723 highlighter tests pass.

   // Called by getSummaryPassagesNoHighlight to generate default summary.
   @Override
   public int next() {
-    return following(currentCache, currentCache + lengthGoal);
+    return following(currentCache, currentCache + Math.max(1, lengthGoal));
   }
All options and trade-offs3 options · 623 words

Two options plus a workaround. Option A was validated with gradle in a clean scratch worktree at 645d4fe14a20; option B was run too and breaks two upstream tests. Not part of this report (seen by the security probe on main and 10.5.2, not swept across releases, not analysed): on the summary path every goal it tried other than 1 throws "offset out of bounds" (0, -1, -7, -1000, Integer.MIN_VALUE, Integer.MAX_VALUE); on the highlight path -1000 throws, and so do Integer.MIN_VALUE and Integer.MAX_VALUE with some alignments (apparently the target arithmetic going negative or overflowing). Option B would also cover the negative values.

next() advances by at least 1 (one line) (validated by the checks) (recommended)

In LengthGoalBreakIterator.next() (:151) aim at currentCache + Math.max(1, lengthGoal).

   // Called by getSummaryPassagesNoHighlight to generate default summary.
   @Override
   public int next() {
-    return following(currentCache, currentCache + lengthGoal);
+    return following(currentCache, currentCache + Math.max(1, lengthGoal));
   }

Trade-offs

  • Goal 0 then behaves like goal 1 on the summary path (the next base break), which is what 8.4.1 returned. No change for goals of 1 or more.
  • The highlight path (following(int), preceding(int)) is untouched, so goal-0 highlights stay as today.
  • It also covers the later-break case (a next() from break p can no longer return p).
  • By code reading, it also turns every negative goal into goal 1 on the summary path (not run); it does not touch the Integer.MAX_VALUE overflow cases.
  • Silent: a goal of 0 stays accepted. Maintainers who want degenerate goals rejected would choose B instead.

What the checks validated

Executed 2026-10-30 in a clean scratch worktree at 645d4fe14a20 with gradle: the minimal test passes (it fails there without the fix), and the saved logs of ./gradlew :lucene:highlighter:test report 723 passing tests and 0 failures with seed DEADBEEF and with seed 1E9600C4 (run with -Ptests.asserts=false). An independent fact check repeated it in a fresh worktree (seed FC493DA2, -Ptests.asserts=false): 723 tests in 35 suites, 0 failures, the new test among them. The hunt had already seen its own and the independent reproducer's tests pass with the same line, and 470 upstream highlighter tests under 3 seeds on ea57360912ed.

Reject goals below 1 in the constructor (validated by the checks)

In the private constructor, before the alignment check: if (lengthGoal < 1) throw new IllegalArgumentException("lengthGoal must be >= 1, got " + lengthGoal);

Trade-offs

  • A clear error when the iterator is built, instead of a JDK offset error on a later hit; also rejects negative goals, but not the Integer.MAX_VALUE overflow cases.
  • An API behaviour change: goal 0 works today on the highlight path, and upstream tests rely on it. It needs those tests changed and a CHANGES note, and an application passing 0 would then fail on every call instead of only on unmatched hits. Its regression test would be an expectThrows on the factory, not the minimal test.

What the checks validated

Run 2026-10-30, same worktree, seed DEADBEEF: TestLengthGoalBreakIterator.testMinLen fails at :143 (always) and testTargetLen at :100 (when random().nextInt(4) draws 0, as with this seed), both with "lengthGoal must be >= 1, got 0". The minimal test still fails, now with that message, which is B's intended behaviour.

Workaround for users today (no Lucene change) (validated by the checks)

Use the base iterator when the goal is below 1 (Solr does this for 1 and below, except that 0 gives the whole field), or call withMaxNoHighlightPassages(0) to switch the summary off; an unmatched hit then gets null instead of a summary.

Trade-offs

The second loses the default summary for unmatched hits.

What the checks validated

Executed: createMinLength(sentences, 0, 0.5f) with withMaxNoHighlightPassages(0) returns null for the unmatched text instead of throwing (main, -ea and -da).

Downstream note

Solr maps hl.fragsize 0 to a whole-text iterator and never wraps for fragsize <= 1; an application that maps its own fragment-size setting into these factories should do the same, or clamp it to at least 1.

How it was found

Bounded-exhaustive testing of the real highlighter jar against a reference written from the javadoc: every text of up to 7 code points from a, a space, a period and one non-BMP letter, against 90 LengthGoalBreakIterator settings (goals 0, 1, 2, 3 and 5). Every goal-0 summary threw and no other goal did; it was then reduced to one JUnit test.

The method in full172 words

Method: bounded-exhaustive translation validation (a semi-formal method, no proof), on the stock main jars of ea57360912ed.

  • A reference semantics for the UnifiedHighlighter's passage layer was written from the javadoc (break iterators, the length-goal rule, the default summary, the formatter), independent of the code.
  • The harness ran the real highlighter on every single-valued text of up to 7 code points over {a, space, period, U+1D400} and every 2-3-valued content up to 5 code points, with 90 LengthGoalBreakIterator configurations (both factories x goals 0, 1, 2, 3, 5 x alignment 0, 0.5, 1 x three bases), about 18 million checks in these three tiers (the 2-3-valued tier with 54 of the configurations), with assertions on and off.
  • One oracle was "no exception on legal input": IllegalArgumentException: offset out of bounds appeared only for goal 0, only on the summary path. The other failures of the run belong to four separate bugs with different fix sites.
  • The finder then wrote a public-API JUnit test; it was later reduced to the single test shown here.

Prior reports, history and checks

Prior reports

No prior report found in the recorded searches (2026-10-26, re-checked 2026-10-30) in GitHub issues, PRs and commits including the JIRA archive, the mailing lists, downstream trackers or the web. Closest, not reports: the introducing change #10135 (LUCENE-9093) and its review lucene-solr #1123, and the class's introduction #8671 (LUCENE-7620).

Searches4 searches

Related, not this bug: #10135 / LUCENE-9093 and lucene-solr #1123 (the introducing change and its review; nobody mentions goal 0 on the summary path, one remark covers the minimal length on the highlight path only), #8671 / LUCENE-7620 (the class's introduction; its summary test covered goal 0), SOLR-9935 (Solr's hl.fragsize mapping). No open PR touches LengthGoalBreakIterator.java or FieldHighlighter.java. Coverage limits: GitHub search does not index inline review comments or PR diffs (the #1123 review was read directly), list search matches words rather than phrases, the web tool returned only generic pages. No maintainer has commented on this bug.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs/commits incl. the JIRA archive, dev@, java-user@, issues@, commits@, downstream repositories, web)2026-10-26two runs, 45 and 52 queries: class and method names, the exception text, fragsize 0, default summary, LUCENE-9093 regression18 hits classified, none the same bug
GitHub (apache/lucene, apache/solr, archived lucene-solr, Elasticsearch, OpenSearch), JIRA, mailing lists2026-10-2615 manual apache/lucene queries, issue searches downstream, code search for the class, LUCENE and SOLR JIRA, Lucene and Solr lists; full threads of #10135, lucene-solr #1123 and #8671only the introducing change, the class's introduction and SOLR-9935
Open PRs2026-10-26changed files of all 409 open apache/lucene PRsnone touches LengthGoalBreakIterator.java or FieldHighlighter.java
GitHub, re-check2026-10-30issue and PR search on apache/lucene for LengthGoalBreakIterator, "offset out of bounds" highlighter and getSummaryPassagesNoHighlight; apache/solr issues for LengthGoalBreakIterator; newest commits on both files at upstream main 2f424fd428daonly #9684, #10135 and #8671 (not reports), no PR, nothing on apache/solr; LengthGoalBreakIterator.java last changed 2021 (d1d60e2db655), FieldHighlighter.java 2026-05-14 (dbf0d1426f13); not fixed

History

A regression in 8.5.0 from LUCENE-9093 (4c9cc2cefd7 / branch_8x 5874b9c7933, 2020-01-01), which rewrote next(). Every release from 8.5.0 to 10.6.0 and main fails; all 34 releases from 6.4.0 to 8.4.1 return the summary.

Versions tested5 rows

Introduced by LUCENE-9093 (now #10135, "Unified highlighter with word separator never gives context to the left"), the changes of lucene-solr #1123, committed directly on 2020-01-01: 4c9cc2cefd7 on master (first released in 9.0.0) and 5874b9c7933 on branch_8x (first released in 8.5.0). It changed next() from return following(current()); to return following(currentCache, currentCache + lengthGoal);. The throwing call baseIter.following(targetIdx - 1) dates from LUCENE-7620 (ea49989524e on master, ff5fdcde422 on branch_6x, first released in 6.4.0), where its argument could not be negative. CHANGES.txt lists LUCENE-9093 under 8.5.0 as a passage-sizing change, with no new exception (lucene/CHANGES.txt:5904-5906). Swapping only LengthGoalBreakIterator.java flips the result both ways: 8.4.1 jars with the post-commit file fail, 8.5.0 jars with the pre-commit file pass. The 7.x line never got the change.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle; standalone -ea and -da)
10.6.0, 10.5.2, 9.12.3 (release jars) affectedthe minimal test fails, same exception and frames
8.5.0 to 10.5.2 52 releaseshistory probes fail on every release: 8.5.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2
6.4.0 to 8.4.1 34 releasesgoal 0 returns the summary "First sentence. "; 6.3.0 has no LengthGoalBreakIterator
8.4.1 / 8.5.0 with one file swapped bisect8.4.1 + post-commit LengthGoalBreakIterator.java fails; 8.5.0 + pre-commit file passes

Independent checks

An independent reproducer with its own tests, an intended-behaviour devil's advocate, reading-only reviews by a model from another family, an adjudication and a fresh-seed registry re-run all confirm it; this doc re-ran the one test on main, 10.6.0, 10.5.2 and 9.12.3 and validated the fix with gradle.

Checks and verdicts411 words

The cross-model reviews ran reading only and could not run Java; the refute lens therefore returned a procedural "not real" while agreeing on the root cause and that the inputs are in contract. No executing cross-model review exists, so all execution evidence comes from the hunt's own AI tools and a separate run.

Each independent check and its verdict
CheckVerdictWhat it found
finder (bounded-exhaustive translation validation) candidateEvery goal-0 summary threw, no goal of 1 or more did; its JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; with fix A it passes and 470 upstream highlighter tests pass under 3 seeds.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) procedural "not real" (low)Could not run Java and it needs a repro of its own; root cause agrees, inputs in contract, intended-behaviour case weak; a separate run re-ran the repro: 1 of 1 fails.
independent reproducer realOwn 6-test JUnit (both factories, both bases, an indexed document, 2 controls): 4 of 6 fail on main (-ea, -da), 10.5.2 and 9.12.3; 6 of 6 pass with fix A; 8.4.1 passes, 8.5.0 and 8.11.4 fail.
cross-model review, intended lens (OpenAI GPT-5.6, reading only) real (medium)Intended-behaviour case weak; a separate run re-ran the finder's test (fails with -ea and -da, passes with the fix) and an 8.4.1-pass / 8.5.0-fail probe.
intended-behaviour devil's advocate real (high)Built the strongest works-as-designed case and rejected it; found that the original summary test covered goal 0 until the introducing commit narrowed it; own bisect passes on 8.4.1 and fails on 8.5.0.
adjudication confirmed (high)Re-ran the independent reproducer's test with a new seed: 4 of 6 fail with -ea and -da, 6 of 6 pass with fix A; checked the introducing diff.
Registration check and credibility audit keepRegistration check, fresh seeds 1E9600A1 to 1E9600A7: the finder's and the reproducer's tests fail the same way with -ea and -da and pass with the fix. Credibility audit: re-ran the finder's test with assertions on and off (fails both times) and kept the bug.
this doc reproduced; fix A validatedThe minimal test fails with gradle on a clean 645d4fe14a20, on the stock jars with -ea and -da, and on 10.6.0, 10.5.2 and 9.12.3. With fix A it passes and the highlighter module's 723 tests pass (two seeds, assertions on and off); fix B breaks two upstream tests.

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,369 words

Finding record

LFV-493, snapshot of 2026-11-06

Registry title
UnifiedHighlighter with a LengthGoalBreakIterator length goal of 0 throws IllegalArgumentException 'offset out of bounds' instead of returning the default summary for every hit that has no match in the field (8.5.0 regression from LUCENE-9093)
Area
highlighter / UnifiedHighlighter break iterators (LengthGoalBreakIterator.next on the no-match default-summary path, FieldHighlighter.getSummaryPassagesNoHighlight)
Priority
Low, rank 648: A loud highlight failure only for a LengthGoalBreakIterator goal of exactly 0 on no-match hits, a rare-value crash like the maxPassages=0 row LFV-265.
Impact class
crash
Impact note
Applications on any release from 8.5.0 through 10.6.0 (10.6.0 [exec]) or on main that build the UnifiedHighlighter themselves with LengthGoalBreakIterator.createMinLength or createClosestToLength and a length goal of 0 (for example a fragment-size setting of 0 passed straight through), and keep the default no-match summary (maxNoHighlightPassages -1), get IllegalArgumentException 'offset out of bounds' from the whole highlight call whenever any hit has no match in the highlighted field; highlighting matches with goal 0 still works, nothing is corrupted, no index size is needed, and Solr (which never wraps the iterator for hl.fragsize <= 1, by code reading of its UnifiedSolrHighlighter) and Elasticsearch and OpenSearch (which do not use the class, by code search) are not exposed.
Summary
You give the UnifiedHighlighter LengthGoalBreakIterator.createMinLength(base, 0, a) or createClosestToLength(base, 0, a): both factories accept a goal of 0, and with it matches are highlighted exactly as with the base iterator. When a hit has no match in the field, you expect the documented default summary, the first maxPassages sentences (for example 'First sentence. '). Instead next() computes the target 0 + 0 = 0 and calls the base iterator's following(-1), which every java.text.BreakIterator rejects, so IllegalArgumentException 'offset out of bounds' escapes and the whole highlight call fails; goal 0 worked on this path from 6.4.0 through 8.4.1.
Introduced
4c9cc2cefd7f3593c4b4e1e5a087e3d206298989 on master and its branch_8x twin 5874b9c7933233712da14c5a5b9bb4f916eb77f8 (LUCENE-9093 'UnifiedHighlighter LengthGoalBreakIterator frag align. Matches in passages should be centered better on average', issue now apache/lucene#10135; the changes of apache/lucene-solr#1123, which GitHub shows as closed because the change was committed directly with 'Closes #1123') [read: git log -S 'currentCache + lengthGoal' finds only this commit, git blame on main attributes LengthGoalBreakIterator.java:151 (the next() body) and :166 (the helper's signature) to it, git tag --contains; exec: swapping only LengthGoalBreakIterator.java flips the result both ways (8.4.1 jars with the post-commit file fail, 8.5.0 jars with the pre-commit file pass)]. It changed next() from 'return following(current());' to 'return following(currentCache, currentCache + lengthGoal);'. The throwing call baseIter.following(targetIdx - 1) (:173) dates from LUCENE-7620 (ea49989524e, 6.4.0), where its argument could not be negative; LUCENE-9093 made it reachable with -1 by routing next() through it (2020-01-01)
First affected version
8.5.0 [exec] (first release that contains 5874b9c7933; first 9.x release 9.0.0 [exec]); last unaffected release 8.4.1 [exec]; the class exists since 6.4.0 (LUCENE-7620), and the probe does not compile on 6.3.0, which lacks it [exec]
Affected versions
every release from 8.5.0 on: 8.5.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.6.0 (10.6.0 [exec] on its release jars, in the bug doc's runs), and main ea57360912ed [exec: the follow-up's public-API history probes fail on all 53 releases and on main, also with -da on 8.5.0 and main; the finder's JUnit fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3, and its 8.x main() on 8.5.0 and 8.11.4]; not affected: all 34 releases 6.4.0-8.4.1 [exec, pass], including the whole 7.x line, which never received the change; not fixed upstream [read, not executed: next() and following(int,int) unchanged at upstream main f4cc6ac2097c, and the last commit on LengthGoalBreakIterator.java is d1d60e2db655 (2021) on main, branch_10x, branch_10_6 and branch_10_5]; 10.6.0, the one release after 10.5.2, was executed only in the bug doc's runs (its release jars: fail)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record, plus the finder's run: two runs of the scripted prior-report search (45 and 52 queries over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, and 7 web queries; 123 distinct hits in the second run, none this bug); 15 manual apache/lucene GitHub searches; issue searches on apache/solr, the archived apache/lucene-solr, elastic/elasticsearch and opensearch-project/OpenSearch, re-running the apache/solr search the adjudication noted as rate-limited; code search for the class downstream; Lucene and Solr list and JIRA searches; the full threads of #10135, lucene-solr #1123 and #8671; the changed files of all 409 open apache/lucene PRs; 18 hits classified, all same_bug false); not fixed upstream (read, not executed: at upstream main f4cc6ac2097c next() is unchanged at :151 and following(int,int) still calls baseIter.following(targetIdx - 1) at :173; no open PR touches LengthGoalBreakIterator.java or FieldHighlighter.java); closest items, not reports: #10135 / LUCENE-9093 and its review lucene-solr #1123 (the introducing change; nobody mentions a goal of 0 on the summary path), #8671 / LUCENE-7620 (the class's introduction; its summary test covered goal 0) and SOLR-9935 (Solr's hl.fragsize mapping, which never wraps the iterator for fragsize <= 1)
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT), 2026-10-30: the minimal test with gradle in a clean scratch worktree (fails), with fix A (passes, and all 723 tests of :lucene:highlighter:test pass with two seeds, assertions on and off) and with fix B (two upstream tests fail); the standalone runner on the stock jars with -ea and -da (fails); the 10.6.0, 10.5.2 and 9.12.3 release jars (fail). The hunt and the history sweep ran on main ea57360912ed, where the cited lines are the same, and on every release from 6.3.0 to 10.5.2. Neither file changed upstream through main 2f424fd428da (2026-10-30).

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/LengthGoalBreakIterator.java:27-31 (at ea57360912ed) (class javadoc: 'not a general purpose BreakIterator; it's only designed to work in a way compatible with the UnifiedHighlighter', @lucene.experimental), :43-51 (createMinLength 'Breaks will be at least minLength apart (to the extent possible)'), :53-62 (createClosestToLength), :73-75 (only fragmentAlignment is validated; no check on lengthGoal), :80-82 (getSummaryPassagesNoHighlight's current(), first(), next() named as supported calls), :148-152 (next() = following(currentCache, currentCache + lengthGoal)), :166-173 (following(int,int): targetIdx below the end index -> baseIter.following(targetIdx - 1))
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:89-93 (at ea57360912ed) (no passages -> getSummaryPassagesNoHighlight(maxNoHighlightPassages == -1 ? maxPassages : maxNoHighlightPassages)), :104-108 (javadoc: 'By default this just returns the first maxPassages sentences'), :109-127 (summary loop: pos == 0 asserted, then breakIterator.next() at :116)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/CustomSeparatorBreakIterator.java:73-75 (at ea57360912ed) (following(pos) throws IllegalArgumentException 'offset out of bounds' for pos < begin index); lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestLengthGoalBreakIterator.java:100 (highlightClosestToLen(CONTENT, query, random().nextInt(4), align), goal 0 included), :143 (highlightMinLen(CONTENT, query, 0, 0.f) == ' Cc <b>dd</b>.'), :192-200 (summary tests use goals 6 + nextInt(4) and 12 + nextInt(4))
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:77-81, :86, :89-93, :104-116
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/LengthGoalBreakIterator.java:43-62, :73-75, :80-82, :148-152, :166-173
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:112-113, :116, :153, :1096-1107, :1135; SplittingBreakIterator.java:104-106; CustomSeparatorBreakIterator.java:62-88
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:112-113, :116, :153, :634-641; LengthGoalBreakIterator.java:31
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/LengthGoalBreakIterator.java:27-31, :43-62, :73-75, :80-82, :148
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:104-107
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestLengthGoalBreakIterator.java:100, :143, :189-201
  • https://github.com/apache/lucene/issues/8671 (comment by David Smiley, migrated from LUCENE-7620; read 2026-10-30)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:634-641 (getMaxNoHighlightPassages javadoc: 0 gives a null highlight)
History
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:5904-5906

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the 8.4.1 tag, the minimal repro and an executed probe on the stock main jars (assertions on and off), the 10.6.0 jar and with the fix; five steps (the user's call, the summary path, inside next(), goal 1 and 8.4.1, the fix sketch) and a summary card, which is the PNG.

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.