All findings · Lucene finding · LFV-623 · opennlp-sentence-breakiterator-leading-whitespace-crash

OpenNLPSentenceBreakIterator crashes on text that starts with whitespace

Give OpenNLPSentenceBreakIterator a text that starts with a space, newline or tab, and walking it backward throws ArrayIndexOutOfBoundsException, while following(0) on a one-sentence text throws StackOverflowError. Every release since 7.3.0 is affected; Lucene's own OpenNLP tokenizer only walks forward and is not.

Summary

Priority
Low, rank 559A backward walk or following() at an offset inside leading whitespace throws on a rarely used, direct-only iterator API, a loud crash rather than a silent wrong value.
Impact
crash
Since
7.3.0 (LUCENE-2899, 2018)introduced 2017-12-15 · about 8 years
Affected
7.3.0 to 10.6.0 and main (12 releases executed, 10.6.0 by reading)
Status
documented
Found by
semi-formal: exhaustive check of the navigation calls

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

A BreakIterator hands out boundaries: offsets where a sentence starts or the text ends. This one gets its sentences from OpenNLP's detector, which leaves out leading whitespace.

How a sentence BreakIterator is walked

  1. setText(text) splits the text. The boundaries are the start of the text, the start of each later sentence, and the end of the text: 0, 33 and 60 for the two-sentence example below.
  2. first() returns the start of the text and next() the next boundary; last() returns the end and previous() the boundary before, or DONE once it is back at the first boundary.
  3. following(offset) returns the first boundary after any offset from the start to the end of the text; isBoundary(offset) is built on it.
Diagram of the text ' Sentence number 1 has 6 words. Sentence number 2, 5 words.' with the forward boundaries 0, 33 and 60 and the stored sentence starts 2 and 33: the backward walk visits 60, 33 and 2, then tries the start before the first one and throws ArrayIndexOutOfBoundsException.
Walking forward, the two leading spaces belong to the first sentence (boundaries 0, 33, 60), but the iterator stores the detector's trimmed start, 2. Walking backward lands on 2 and then reads the start before the first one. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You split " Sentence number 1 has 6 words. Sentence number 2, 5 words." (two leading spaces, 60 characters) with OpenNLPSentenceBreakIterator. Walking forward with first() and next() gives the boundaries 0, 33, 60.
  2. You walk backward with last() and previous(), the reverse loop from the BreakIterator javadoc, and expect 60, 33, 0, then DONE.
  3. You get 60, 33, 2 (not a boundary), then ArrayIndexOutOfBoundsException: Index -1. On the one-sentence text " Sentence number 1 has 6 words.", following(0) recurses until StackOverflowError. Without the leading spaces both calls work.
  4. Why: OpenNLP's sentence detector trims whitespace, so it reports the first sentence as starting at 2. setText stores 2, while first() and next() start at 0. previous() stops only at 0, so after 2 it reads the start before the first one (index -1); following(0) searches for the sentence that contains offset 0 and never finds one.

Who is affected

Code that uses this public BreakIterator directly: a backward walk, or following(), isBoundary() or next(-n) at an offset inside leading whitespace. There, any text that starts with whitespace followed by a sentence fails, every time. Indexing and query analysis through OpenNLPTokenizer are not affected; the Unified and FastVector highlighters did not fail in 41 executed calls on ordinary fields.

Mechanism, terms and numbers706 words

OpenNLPSentenceBreakIterator, the public java.text.BreakIterator of the OpenNLP analysis module, stores the sentence starts that OpenNLP's detector reports, and the detector trims whitespace. For a text that starts with whitespace the first stored start (2 for two leading spaces) therefore lies after the beginning of the text, which first() and next() treat as the first boundary. Every method that uses the stored first start then goes wrong: a backward walk returns the non-boundary 2 and then throws ArrayIndexOutOfBoundsException, following() (and the JDK's default isBoundary()) at an offset inside the whitespace recurses until StackOverflowError on a one-sentence text or, with two or more sentences, fails an internal assertion when assertions are enabled (with them off the result is right), and preceding() and next(-n) return 2. One leading space, newline, tab or no-break space is enough. OpenNLPTokenizer, the class's only user in Lucene, only walks forward and is not affected. Every release from 7.3.0 to 10.6.0 (10.6.0 by reading) and main is affected; a one-statement fix in setText was validated with the module's tests.

Terms

  • OpenNLPSentenceBreakIterator: a public final java.text.BreakIterator in the exported package org.apache.lucene.analysis.opennlp (OpenNLPSentenceBreakIterator.java:26-27). It keeps an array sentenceStarts with the start offset of each detected sentence and an index currentSentence.
  • The detector: NLPSentenceDetectorOp.splitSentences calls OpenNLP's SentenceDetectorME.sentPosDetect (NLPSentenceDetectorOp.java:39-47), which trims whitespace from each sentence span. For " Sentence number 1 has 6 words. Sentence number 2, 5 words." it returns the spans [2..32) and [33..60). Every opennlp-tools version Lucene has bundled (1.8.3 to 2.5.12) trims. A single leading space, newline, tab, no-break space (U+00A0) or em space (U+2003) is trimmed the same way.

The bug, step by step (code at 645d4fe14a20)

  1. setText stores the spans' starts unchanged (:195-205, the store at :203): sentenceStarts = [2, 33].
  2. first() returns the begin index, 0 (:44-48), and next() moves to sentenceStarts[1] = 33 and then to the end, 60 (:63-72). The forward walk counts the two spaces as part of sentence 0, so 2 is never returned forward.
  3. previous() returns DONE only when the current index equals the begin index (:119). From last() (60) it moves to sentenceStarts[1] = 33 (:126-127), then to sentenceStarts[0] = 2 (:129). 2 is not the begin index, so the next call reads sentenceStarts[-1]: ArrayIndexOutOfBoundsException: Index -1. A one-sentence text fails the same way after 32, 2.
  4. following(pos) accepts any offset from begin to end (:76-77), returns DONE in the last sentence (:81-87), and otherwise runs a binary search commented "there are at least two sentences" (:88-90). An offset inside the leading whitespace is below every stored start. With one sentence (sentenceStarts = [2]) the search is moveToSentenceAt(0, 0, -1), which calls itself with the same arguments forever (:97-102): StackOverflowError. With two sentences the assertion pos >= sentenceStarts[currentSentence] fails (:110) when assertions are on; with them off it returns 33, the right value, by accident.
  5. For a non-zero offset the JDK's default isBoundary(offset) calls following(offset - 1) (offset 0 returns true directly), so isBoundary(1) on a one-sentence text with one leading newline overflows the stack too; on a two-sentence text it fails the same assertion with -ea and returns false with -da. preceding(38) and next(-2) after last() return sentenceStarts[0] = 2 (:157, :175): no crash, but not a boundary.
  6. Trailing whitespace is harmless: last() and the end checks use the end index (:54, :64, :85), never a span end. And preceding() already handles an offset below the first stored start (:143-149, present since the class was added); previous(), following() and next(int) do not.

The numbers

On main 645d4fe14a20 with the module's test sentence model: forward walk [0, 33, 60]; backward walk 60, 33, 2, then ArrayIndexOutOfBoundsException; following(0) and following(1) on the one-sentence text: StackOverflowError; following(0) on the two-sentence text: AssertionError with -ea, 33 with -da; preceding(38) = 2 and next(-2) = 2. The same text without the leading spaces walks 58, 31, 0, DONE; with three trailing spaces 61, 31, 0, DONE.

Exposure and likelihood326 words
  • Who is exposed. Users of the opt-in lucene-analysis-opennlp module who navigate OpenNLPSentenceBreakIterator themselves, as a general java.text.BreakIterator. Not default; Lucene itself never does this.
  • Lucene's own paths are safe. OpenNLPTokenizer (OpenNLPTokenizer.java:52), the only in-tree user, drives the iterator through SegmentingTokenizerBase, which calls only setText, current() and next() (SegmentingTokenizerBase.java:114, :167, :194, :199); the failing texts tokenize and index normally (executed). UnifiedHighlighter and FastVectorHighlighter configured with this iterator made 32 and 9 calls on ordinarily tokenized fields without an exception: FieldHighlighter asks for boundaries only at offsets inside a match (FieldHighlighter.java:182, :186), which never lie in the leading whitespace, and BreakIteratorBoundaryScanner calls last() then one previous() (BreakIteratorBoundaryScanner.java:40-41), which stops before the failing step. Only a contrived field whose tokens cover the leading whitespace (EdgeNGramTokenizer(1, 3), query gram " S") made UnifiedHighlighter.highlight throw StackOverflowError.
  • What it takes. One whitespace character (space, newline, tab, no-break space, em space) before the first sentence, with OpenNLP's model-backed detector: the trim is in its code, not in the model, though every run used the module's test models. The no-model NLPSentenceDetectorOp() returns one untrimmed span (NLPSentenceDetectorOp.java:43-45) and is not affected, and whitespace-only text has no sentence and walks correctly. Deterministic: the backward walk and the one-sentence following() fail with assertions on and off, the two-sentence following() only with them on.
  • Effect. One failed call. ArrayIndexOutOfBoundsException is a RuntimeException; StackOverflowError is an Error, so catch (Exception e) does not stop it. It comes within milliseconds (at most 9 ms over 20 runs), nothing is written to an index, and the iterator works again after the next setText.
  • Versions. Every release from 7.3.0 (2018) to 10.6.0 (10.6.0 by reading), and main.
  • How likely. Rare: a public code search (2026-10-26) found no use of the class outside its tokenizer in Solr, Elasticsearch or OpenSearch, and the recorded prior-art search found no public report.

Minimal repro

The test shows that OpenNLPSentenceBreakIterator.following(0) on a one-sentence text that starts with two spaces recurses until StackOverflowError.

TestOpenNLPSentenceBreakIteratorLeadingSpace.java (JUnit test)36 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.analysis.opennlp;

import java.text.BreakIterator;
import org.apache.lucene.analysis.opennlp.tools.OpenNLPOpsFactory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.ClasspathResourceLoader;

public class TestOpenNLPSentenceBreakIteratorLeadingSpace extends LuceneTestCase {

  public void testFollowingInLeadingSpace() throws Exception {
    OpenNLPOpsFactory.getSentenceModel("en-test-sent.bin", new ClasspathResourceLoader(getClass()));
    BreakIterator bi =
        new OpenNLPSentenceBreakIterator(OpenNLPOpsFactory.getSentenceDetector("en-test-sent.bin"));
    String text = "  Sentence number 1 has 6 words.";
    bi.setText(text);
    // expected: text.length() or DONE (as without the leading spaces); actual: StackOverflowError
    int next = bi.following(0);
    assertTrue("following(0) = " + next, next == text.length() || next == BreakIterator.DONE);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-623/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/analysis/opennlp/build/libs/lucene-analysis-opennlp-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.analysis.opennlp.TestOpenNLPSentenceBreakIteratorLeadingSpace
# the module(s) analysis-opennlp also need their third-party runtime jars
Expected vs actual
Expected: text.length() or DONE (DONE is what the class returns without the leading spaces); actual on main 645d4fe: StackOverflowError in moveToSentenceAt (with -ea and -da).

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

More files, environment, full output36 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.StackOverflowError
       org.apache.lucene.analysis.opennlp.OpenNLPSentenceBreakIterator.moveToSentenceAt(OpenNLPSentenceBreakIterator.java:102)
       org.apache.lucene.analysis.opennlp.OpenNLPSentenceBreakIterator.moveToSentenceAt(OpenNLPSentenceBreakIterator.java:102)
       org.apache.lucene.analysis.opennlp.OpenNLPSentenceBreakIterator.moveToSentenceAt(OpenNLPSentenceBreakIterator.java:102)
       org.apache.lucene.analysis.opennlp.OpenNLPSentenceBreakIterator.moveToSentenceAt(OpenNLPSentenceBreakIterator.java:102)
       org.apache.lucene.analysis.opennlp.OpenNLPSentenceBreakIterator.moveToSentenceAt(OpenNLPSentenceBreakIterator.java:102)
       org.apache.lucene.analysis.opennlp.OpenNLPSentenceBreakIterator.moveToSentenceAt(OpenNLPSentenceBreakIterator.java:102)
       org.apache.lucene.analysis.opennlp.OpenNLPSentenceBreakIterator.moveToSentenceAt(OpenNLPSentenceBreakIterator.java:102)
       org.apache.lucene.analysis.opennlp.OpenNLPSentenceBreakIterator.moveToSentenceAt(OpenNLPSentenceBreakIterator.java:102)
       org.apache.lucene.analysis.opennlp.OpenNLPSentenceBreakIterator.moveToSentenceAt(OpenNLPSentenceBreakIterator.java:102)
       [...]

Why it looks unintended

  • previous() must return DONE at the first boundary, and following() lists only IllegalArgumentException; the class's own check accepts offset 0.
  • The class itself treats 0 as the first boundary: first() returns it and previous() stops there.
  • Upstream tests assert previous(), following(), preceding() and next(n); an earlier preceding() crash (#12210) was fixed as a bug.
The full argument308 words
  • Contract (JDK 25 javadoc). previous(): "Returns the boundary preceding the current boundary. If the current boundary is the first text boundary, it returns BreakIterator.DONE". following(int offset): "Returns the first boundary following the specified character offset"; the only listed exception is IllegalArgumentException for an offset "less than the first text boundary or greater than the last text boundary", and the class's own check accepts 0 (OpenNLPSentenceBreakIterator.java:76-77). The BreakIterator class javadoc's example "print each sentence in reverse order" is the last()/previous() loop that crashes.
  • The class's own boundaries. first() returns the begin index (:44-48), previous() stops there (:119), and the upstream tests assert first() equals the begin index (TestOpenNLPSentenceBreakIterator.java:127-128, :219). The stored first start is the only place that disagrees.
  • Public, tested as a general iterator. public final class in an exported package (module-info.java:25), no @lucene.internal or @lucene.experimental tag, javadoc "A BreakIterator that splits sentences using an OpenNLP sentence chunking model". The upstream test asserts previous(), following(), preceding() and next(n) (TestOpenNLPSentenceBreakIterator.java:84-115, :126-149).
  • Precedent. #12210, another contributor's report of an ArrayIndexOutOfBoundsException in this class's preceding(), was fixed as a bug by #13170 (first released in 10.0.0).
  • A coverage gap, not a decision. The upstream tests cover whitespace-only text and padding before a slice's begin index (testWhitespaceOnly, testSliceStart), never an iterated range that starts with whitespace followed by a sentence. With the fix all 33 upstream tests of the module still pass.
  • Anticipated in one method. preceding() has handled an offset below the first stored start since the class was added (:143-149); previous() and following() never did.
  • Reference behaviour. The JDK's sentence iterator walks the same text with a leading newline correctly (0, 32, 59 forward and backward), and this class handles trailing whitespace correctly.
The devil's-advocate check162 words

The strongest case for "intended or misuse": the class was added as a "shim class" so that OpenNLPTokenizer can extend SegmentingTokenizerBase (LUCENE-2899 patch comment), and the tokenizer only walks forward; the class already documents a deviation from the BreakIterator javadoc (the JDK-8015110 comments: DONE in the last sentence for following(), before the first start for preceding()); and callers could trim their text.

Why it fails: nothing marks the class internal or forward-only, its tests assert backward and random-access navigation, and #12210 came from a direct preceding() user. JDK-8015110 ("following(int) changes the current position when it returns DONE") justifies returning DONE, not crashes or a wrong first start, and the fix leaves that convention unchanged. No javadoc asks for trimmed text, setText accepts any text, and one newline triggers it. Verdict: not intended, a defect. The cross-model reviews (refute and works-as-designed) rated the "intended" case weak; the devil's advocate found that no argument survives, and the adjudication confirmed the bug.

Suggested fix

Recommended A. Store the begin index as the first sentence start

In setText, after storing the detector's starts, set sentenceStarts[0] to the text's begin index: the first sentence starts where first() already says it does. One statement. Validated: the 3 new tests, the upstream iterator suite and all 36 module tests pass with assertions on and off, and check passes.

       sentenceStarts[i] = spans[i].getStart() + text.getBeginIndex();
     }
+    if (sentenceStarts.length > 0) {
+      // the first sentence begins at the start of the text: first() and next() already treat any
+      // whitespace before the detected (trimmed) sentence start as part of the first sentence
+      sentenceStarts[0] = text.getBeginIndex();
+    }
   }
All options and trade-offs4 options · 803 words

Option A was applied in a scratch worktree of main 645d4fe14a20 and run through gradle with assertions on and off; options B and C are described only. The workaround needs no Lucene change and was run on unfixed main.

A. Store the begin index as the first sentence start (validated by the checks) (recommended)

In setText (OpenNLPSentenceBreakIterator.java:195-205), after the loop that stores spans[i].getStart() + text.getBeginIndex(), set sentenceStarts[0] = text.getBeginIndex() when there is at least one sentence. This is the finder's fix.

What it changes. Only texts that start with whitespace (otherwise sentenceStarts[0] already equals the begin index). Backward walks end ..., 0, DONE; following(0) on two sentences returns 33 with assertions on too; preceding(38) and next(-2) return 0 instead of 2; following() inside the whitespace of a one-sentence text returns DONE, the class's existing last-sentence convention. The detector's spans are untouched.

       sentenceStarts[i] = spans[i].getStart() + text.getBeginIndex();
     }
+    if (sentenceStarts.length > 0) {
+      // the first sentence begins at the start of the text: first() and next() already treat any
+      // whitespace before the detected (trimmed) sentence start as part of the first sentence
+      sentenceStarts[0] = text.getBeginIndex();
+    }
   }

Trade-offs

  • One statement at the root cause; every navigation method inherits the corrected start. No performance effect (one store per setText).
  • With the fix, the JDK's default isBoundary(1) on a one-sentence text with one leading character throws IllegalArgumentException instead of StackOverflowError: following(0) now returns the class's usual DONE, and isBoundary turns DONE into that exception. A one-sentence text without leading whitespace gets the same exception today. That last-sentence convention is upstream-asserted and separate from this bug.
  • preceding()'s branch for an offset below the first start (:143-149) becomes unreachable; it can stay or go.
  • A caller that used preceding() or next(-n) as a snippet start in the second sentence now gets offset 0, the leading whitespace included, instead of 2 (read, not run through a highlighter).

What the checks validated

  • The 3 new tests pass with assertions on and off (stock: 3 of 3 and 2 of 3 fail).
  • The upstream TestOpenNLPSentenceBreakIterator (9 tests) and all of :lucene:analysis:opennlp:test (36 tests: 33 upstream + 3 new) pass with assertions on and off.
  • :lucene:analysis:opennlp:check -x test passes (google-java-format, ecjLint, forbiddenApis, licenses), with the new test in place.
  • A plain-Java probe of every symptom: backward walks 60, 33, 0, DONE and 32, 0, DONE; following(0) 33 (two sentences) and DONE (one sentence); preceding(38) 0; next(-2) 0.
  • Earlier, in the hunt: the finder's and the reproducer's tests pass with it with assertions on and off (the adjudication's re-run with them on), the history probe passes with it on 7.3.0 and main, and the walk oracle's backward-walk and next(-k) checks report 0 failures and 0 crashes (its remaining next(k) failures are the separate LFV-622 bug).

B. Guard each navigation method instead

Keep the trimmed starts and treat sentence 0's boundary as the begin index wherever a start is returned or compared: previous() (:127, :129), following() and moveToSentenceAt() for offsets below the first start (:88-112), preceding() (:157) and next(int) (:175).

Trade-offs

Four methods must agree, and fixing only previous() and following() removes the crashes but leaves preceding() and next(-n) returning the non-boundary 2. Same end result as A with more code. Not validated.

C. Normalize the spans in NLPSentenceDetectorOp

Make NLPSentenceDetectorOp.splitSentences (NLPSentenceDetectorOp.java:39-47) start the first span at 0.

Trade-offs

Changes the output of a public method in the exported tools package, whose callers may rely on the trimmed spans; the iterator is its only in-tree caller (OpenNLPSentenceBreakIterator.java:199). Not recommended, not validated.

Workaround on released versions (no Lucene change) (validated by the checks)

Give the iterator a range that starts at the first non-whitespace character; offsets stay in the original text's coordinates because the class adds getBeginIndex() (:203):

int begin = 0;
while (begin < text.length()
    && (Character.isWhitespace(text.charAt(begin)) || Character.isSpaceChar(text.charAt(begin)))) {
  begin++;
}
bi.setText(new StringCharacterIterator(text, begin, text.length(), begin));

Trade-offs

The whitespace test must be at least as wide as OpenNLP's: the detector also trims U+00A0, for which Character.isWhitespace is false, hence the isSpaceChar term. The snippet is a method fragment (bi is the iterator, text the String) and needs import java.text.StringCharacterIterator. Offsets inside the skipped whitespace are then outside the range, so following() and preceding() there throw the documented IllegalArgumentException (:76-77, :137-138).

What the checks validated

On unfixed main this gives forward [2, 33, 60], backward [60, 33, 2] then DONE, and following(2) = 33, with no crash. The snippet exactly as printed was run with assertions on and off for the leading characters two spaces, newline plus tab, U+00A0, U+2003 and none: no crash, and every backward walk ends in DONE.

Downstream note

An application that walks this iterator over user-supplied text can be made to fail that call by any text that starts with whitespace; the StackOverflowError is an Error, so catch (Exception) does not stop it.

How it was found

A bounded-exhaustive check ran the real iterator with a stub sentence detector, calling following() and preceding() at every offset of 84 sentence layouts; every crash sat in a leading gap. A second oracle, "walking backward must mirror walking forward", failed on every layout with a leading gap and on none without.

The method in full164 words
  • Method (semi-formal). In the hunt's unit on the OpenNLP filters against the OpenNLP library, the finder ran the real OpenNLPSentenceBreakIterator against a stub sentence detector (the class only consumes the detector's spans): 84 configurations (1 to 14 sentences, start gaps 1 to 3, the first sentence at offset 0 or 2), following() and preceding() at every offset and next(k) from first(), 3,192 checks. The first signal: 84 crashes, all at offsets inside the leading gap, none without one.
  • Walk oracle. An internal-consistency check that does not depend on the class's documented JDK-8015110 deviation: the backward walk must equal the reversed forward walk, and next(±k) must equal k single steps. Its backward-walk check crashed on all 42 configurations with a leading gap and on none without; with the fix, 0 failures and 0 crashes (its next(k) failures are the separate LFV-622 bug, which needs its own fix).
  • Then a public-API JUnit with the real detector and the module's test model, and the checks below.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26: 290 queries over GitHub issues, PRs, commits and code, all 407 open PRs, JIRA, the Lucene lists and the web). Closest: #12210, a different crash in the same class's preceding(), fixed by #13170.

Searches7 searches

18 items were read; none describes this bug. The upstream file is unchanged through main 645d4fe14a2 (2026-10-28); a read-only GitHub refresh on 2026-10-29 (issue searches for the class name and five symptom phrasings) returned only #12210, #13170 and #3973; the 2026-10-26 search is the latest full one. Keyword search only; mailing-list hits read by subject and snippet; code search shows only its first 100 matches; private reports cannot be seen.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search2026-10-26two runs of 76 queries each plus the finder's earlier 51: GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA, the webno report; high hits only #12210 and #13170
GitHub (manual)2026-10-2638 GraphQL and 9 gh searches; GitHub-wide issue and code search; the file lists of all 407 open apache/lucene PRsno report; open PRs touching the file: #12295 (stale instanceof hunk) and #16911 (deletes branch_10x)
JIRA (LUCENE, SOLR, OPENNLP)2026-10-2616 REST searches; all comments of LUCENE-2899nothing about leading whitespace, previous() or following(); LUCENE-2899 calls the class a "shim class"
Mailing lists2026-10-2615 manual archive queries plus the tool's list queriesonly the 2022 filter-side OpenNLP sentence threads (other code)
Downstream and OpenNLP2026-10-26Solr, Elasticsearch, OpenSearch, Lucene.NET and OpenNLP repositoriesno report; the Lucene.NET port has the same code (read, not run); OpenNLP's span refactor keeps the trimming
Closest items (read)2026-10-26full threads#12210 / #13170 (preceding() search start, fixed in 10.0.0), #3973 (LUCENE-2899, added the class)
GitHub refresh (read-only)2026-10-29issue searches for the class name and five symptom phrasings; upstream main and the file's commit historyonly #12210, #13170 and #3973; upstream main still 645d4fe14a20, the file's last commit still #14216

History

In the class since it was added by LUCENE-2899 (3e2f9e62d77 / branch_7x b720e1ee3a5, 2017-12-15), first released in 7.3.0. Fails on all 12 executed releases from 7.3.0 to 10.5.2 and on main; the failing methods are unchanged at all 60 release tags read (8.8.2 to 8.11.3 inferred). Not fixed upstream.

Versions tested7 rows

Introduced with the class by LUCENE-2899, "Add OpenNLP Analysis capabilities as a module" (committed by Steve Rowe, 2017-12-15): b720e1ee3a5 on branch_7x (first release 7.3.0) and 3e2f9e62d77 on master (first release 8.0.0), the same file blob. A patch-era JIRA issue, migrated as apache/lucene#3973; no GitHub PR. git blame -w attributes every code line of setText, previous(), following() and moveToSentenceAt() to 3e2f9e62d77. The later commits are a formatting-only reformat (LUCENE-9570: a blank import line, the class javadoc, the two JDK-8015110 comments and the new String(...) call), a change of preceding()'s search start (#13170, 10.0.0) and an instanceof pattern (#14216). None changes the code of the failing methods.

What was executed. A plain main() probe using only API unchanged since 7.3.0, each release on its own jars and its own test sentence model, every run on OpenJDK 25.0.3, Linux aarch64; with every bundled opennlp-tools version from 1.8.3 to 2.5.12 the detector returns the spans [2,32) [33,60). The finder's JUnit also fails on 9.1.0, 9.4.2, 9.12.3, 10.0.0, 10.5.2 and main. At all 60 release tags from 7.3.0 to 10.5.2 in the local history clone, the methods involved match main once comments and whitespace are removed (read); 8.8.2 to 8.11.3 are inferred (tags not in the clone).

Not fixed upstream. Upstream main 645d4fe14a2 (2026-10-28), branch_10x, branch_10_6 and 10.5.2 carry the same file blob (read, not executed), so 10.6.0 (released 2026-10-29) and the unreleased lines are affected too.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectednew test 3 of 3 fail with assertions on, 2 of 3 off (gradle); probe: backward walk 60, 33, 2 then AIOOBE, following(0) StackOverflowError
main a9779ef3c193 affectedhistory probe, the finder's and the reproducer's JUnit fail (same file blob)
10.0.0 and 10.5.2 affectedhistory probe fails (10.5.2 with -ea and -da); the finder's and the reproducer's JUnit fail on 10.5.2
9.0.0, 9.1.0, 9.4.2 and 9.12.3 affectedhistory probe fails on 9.0.0 and 9.12.3; the finder's JUnit fails on 9.1.0, 9.4.2 and 9.12.3
7.3.0 to 8.11.4 (6 releases) affectedhistory probe fails on 7.3.0 (-ea and -da), 7.4.0, 7.5.0, 7.7.3, 8.0.0 and 8.11.4
7.2.1 no classthe class does not exist yet
fix A on 7.3.0 and on main not affectedhistory probe passes: [60, 33, 0], DONE, 33; on 645d4fe14a20 the 3 new tests and all 36 module tests pass

Independent checks

An independent reproducer (own test, different trigger), an intended-behaviour devil's advocate, a works-as-designed review by a reviewer from another model family and an adjudication all judged it real and re-ran it. A read-only refute review said "not real" only because it could not run code.

Checks and verdicts394 words

The hunt's re-runs used main a9779ef3c193, whose iterator file is byte-identical to 645d4fe14a20; the cross-model reviews could only read code. This doc's own fix validation ran on 645d4fe14a20 through gradle.

Each independent check and its verdict
CheckVerdictWhat it found
Cross-model refute review not real (low), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6), reading only: it could run no command, and its notes say the verdict is "procedural ... only because the required independent test could not be created or run". It agreed with the root cause and found the inputs within the contract (8 evidence items for real, 2 against: the missing run, and the tokenizer's forward-only use, which bears on reach).
Works-as-designed review real (low)The same reviewer family with an intended-behaviour lens, reading only: the "intended" argument is weak. The step re-ran the finder's JUnit on main: 3 of 3 fail with assertions on, 2 of 3 off.
Independent reproducer realOwn public-API test with a newline plus tab trigger and a check that the detector returns span starts [2, 33] with the prefix and [0, 31] without: 5 of 6 fail on main with assertions on, 4 of 6 off, the control passes; 10.5.2 and 9.12.3 fail; 6 of 6 pass with the fix. Also found preceding(38) = 2 and next(-2) = 2.
Intended-behaviour devil's advocate realNo "intended" or "misuse" argument survives (shim class, JDK-8015110 deviation, trim-your-text, trimmed start as a boundary, reachability, upstream-asserted behaviour). A single leading newline or tab suffices; the JDK sentence iterator and a trailing-whitespace control walk correctly; the 9 upstream iterator tests pass with the fix.
Adjudication confirmed (high)Re-ran the reproducer's test (5 of 6 fail with assertions on, 4 of 6 off; 6 of 6 pass with the fix) and the finder's (3 of 3 fail).
Follow-up probes and audit affected 7.3.0 to 10.5.2; not security-sensitive; keepHistory probe on 10 releases and main; security probe (6 tests) showing indexing and ordinary highlighting unaffected; a credibility audit that re-ran the finder's test (keep).
This doc's fix validation fix A validatedNew test, the upstream suite and all 36 module tests through gradle on 645d4fe14a20 with assertions on and off, stock and with fix A; check -x test with fix A; plain-Java probes of every symptom and of the workaround.

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

Finding record

LFV-623, snapshot of 2026-11-06

Registry title
OpenNLPSentenceBreakIterator on text that starts with whitespace: a backward walk with previous() returns a non-boundary and then throws ArrayIndexOutOfBoundsException, and following() at an offset inside the leading whitespace throws StackOverflowError, because setText keeps the sentence detector's whitespace-trimmed first sentence start while first() and next() use the text's begin index
Area
analysis-opennlp / OpenNLPSentenceBreakIterator (public java.text.BreakIterator over OpenNLP sentence spans: setText, previous, following, moveToSentenceAt)
Priority
Low, rank 559: A backward walk or following() at an offset inside leading whitespace throws on a rarely used, direct-only iterator API, a loud crash rather than a silent wrong value.
Impact class
crash
Impact note
Hits only code that uses the opt-in analysis-opennlp module's public OpenNLPSentenceBreakIterator as a general java.text.BreakIterator, walking backward with previous() or calling following() or isBoundary() at an offset inside leading whitespace; for such code any text that starts with whitespace (one space, newline or tab is enough) makes a legal call throw ArrayIndexOutOfBoundsException (backward walk) or StackOverflowError (following(0) on a one-sentence text; an Error, so catch(Exception) does not stop it), deterministically, with -ea and -da, at any size; with two or more sentences following() in the whitespace only trips assertions under -ea and returns the right value under -da, and preceding() and next(-n) silently return the trimmed start, which is not a boundary; OpenNLPTokenizer (indexing and query analysis) never calls these methods and is not affected, and UnifiedHighlighter and FastVectorHighlighter with this iterator did not reach it on ordinarily tokenized fields (41 executed calls); every release from 7.3.0 to 10.6.0 (10.6.0 by reading) and main.
Summary
You create an OpenNLPSentenceBreakIterator, give it a text that starts with whitespace, for example ' Sentence number 1 has 6 words. Sentence number 2, 5 words.', and walk it backward with last()/previous() (the reverse loop shown in the BreakIterator javadoc), expecting the forward boundaries [0, 33, 60] in reverse order and then DONE. Instead previous() returns 2, which is not a boundary, and the next previous() throws ArrayIndexOutOfBoundsException (Index -1); following(0) on a one-sentence text with leading whitespace recurses until StackOverflowError. The OpenNLP sentence detector trims whitespace from its spans and setText stores those starts unchanged, so the first stored sentence start lies after the begin index that first() and next() treat as the first boundary.
Introduced
b720e1ee3a524034fb8a8a6188b0b23bf17ff1cb on branch_7x (first release 7.3.0) and 3e2f9e62d772218bf1fcae6d58542fad3ec43742 on master (first release 8.0.0), both 'LUCENE-2899: Add OpenNLP Analysis capabilities as a module' (committed by sarowe; patch-era JIRA issue LUCENE-2899, migrated as apache/lucene#3973, no GitHub PR), with the same file blob f69fbc6b: the class was added with this defect. [read: git blame -w attributes every code line of setText, previous(), following() and moveToSentenceAt() to 3e2f9e62d77, only two comment lines were re-wrapped by the LUCENE-9570 reformat 7f8b7ffbcad; the later commits 44fa35b65ff (#13170, preceding() start index) and b0ebaaed565 (#14216, instanceof pattern) do not touch the failing paths. exec: the history probe fails on 7.3.0, the first release with the class, and passes there with the one-line fix; no commit-level build was needed because 7.3.0 ships the introducing commit's blob] (2017-12-15 (both commits); first release 7.3.0, tagged 2018-04-04)
First affected version
7.3.0 (tagged 2018-04-04), the first release with the analysis/opennlp module [exec: the history probe fails with -ea and -da]. No release is unaffected: 7.2.1 and older do not have the class (API absent; executed as a setup error, no module artifact)
Affected versions
Every release from 7.3.0 through 10.6.0 (69 releases through 10.5.2, and 10.6.0 by reading) and main a9779ef3c193; never fixed. [exec] the follow-up's history probe (backward walk and following(0) on one and two sentences after two leading spaces, each release with its own test sentence model; the controls without leading whitespace pass) fails on 7.3.0 (-ea and -da), 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), with every bundled opennlp-tools version from 1.8.3 to 2.5.12 (the detector trims the leading whitespace in all of them); the finder's JUnit fails on 9.1.0, 9.4.2, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3 (on 9.12.3 its preceding() checks hit the separate #12210 crash first, which #13170 fixed in 10.0.0; the other parts reproduce). [read] at all 60 release tags from 7.3.0 to 10.5.2 in the local history clone, first, next, last, following, moveToSentenceAt, previous and setText match main once comments and whitespace are removed (only preceding() differs before 10.0.0, not on a failing path); 8.8.2-8.11.3 are inferred (tags not in the clone; bracketed by 8.8.1, read, and 8.11.4, executed); branch_10_5, branch_10_6, branch_10x and upstream main a95c754ee00 (2026-10-26) carry the same file blob af1ada42 (not executed); the 7 commits ea57360912ed..a9779ef3c193 do not touch lucene/analysis/opennlp. Not executed: x86 (pure Java logic). JDK 25.0.3, aarch64
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, read-only): 290 logged queries (GitHub 110, commits 10, downstream 23, lists 84, JIRA 33, web 30; 18 items read, none the same bug): a scripted search over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; manual GitHub GraphQL (38), gh search (9), JIRA REST (16) and mailing-list archive (15) queries; the file lists of all 407 open apache/lucene PRs; the LUCENE-2899 comments; the Solr, Elasticsearch, OpenSearch, Lucene.NET and OpenNLP repositories; GitHub-wide issue and code search. Closest items, not this bug: #12210 (another contributor's report of an ArrayIndexOutOfBoundsException in this class's preceding(), whose binary search started at sentenceStarts.length / 2) and its fix #13170 (merged 2024-03-11, first released in 10.0.0, changes only that line); #3973 / LUCENE-2899 (added the class and calls it a 'shim class' for OpenNLPTokenizer). Not fixed upstream: the file has the same blob (af1ada42) on a9779ef3c193, upstream main a95c754ee003 (2026-10-26), branch_10x and branch_10_6, and no open PR changes setText, previous() or following() (#12295 has only a stale instanceof hunk, #16911 deletes branch_10x). Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
Lucene commit tested
main 645d4fe14a20 (2026-10-28) through gradle in a scratch worktree: the new 3-test class (stock: 3 of 3 fail with assertions on, 2 of 3 off; with fix A: 0), the upstream TestOpenNLPSentenceBreakIterator (9 tests) and all 36 tests of :lucene:analysis:opennlp:test with assertions on and off (stock: only the new tests fail; fix A: 0), and :lucene:analysis:opennlp:check -x test with fix A; plain-Java probes of the same calls on stock and with fix A. The worktree was removed afterwards. The hunt's runs used main a9779ef3c193, whose OpenNLPSentenceBreakIterator.java is byte-identical (blob af1ada42), plus 12 releases from 7.3.0 to 10.5.2 (10 with the history probe, 9.1.0 and 9.4.2 with the finder's JUnit), all on OpenJDK 25.0.3, Linux aarch64, with the module's test sentence models.

Lucene and upstream sources

Finding record
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPSentenceBreakIterator.java:26-27 (at a9779ef3c193) (javadoc 'A {@link BreakIterator} that splits sentences using an OpenNLP sentence chunking model.'; public final class), :44-48 (first() returns the begin index), :62-72 (next() goes from sentence 0 to sentenceStarts[1]), :74-94 (following(): range check :76-77 accepts the begin index; the last-sentence DONE branch :81-87 citing JDK-8015110; the 'at least two sentences' binary-search branch :88-93), :96-115 (moveToSentenceAt: recursion :102, assertions :109-112), :117-133 (previous(): stops only at the begin index :119, reads sentenceStarts[--currentSentence] :129), :135-161 (preceding(): pos < sentenceStarts[0] branch :143-149), :194-205 (setText stores the span starts unchanged, :203)
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/tools/NLPSentenceDetectorOp.java:39-46 (at a9779ef3c193) (splitSentences calls SentenceDetectorME.sentPosDetect, which trims whitespace from the spans; the no-model detector returns one span 0..length)
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPTokenizer.java:52 (at a9779ef3c193) (the only in-tree user of the class) and lucene/analysis/common/src/java/org/apache/lucene/analysis/util/SegmentingTokenizerBase.java:114, :167, :194, :199 (setText, current() and next() only)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:182, (at a9779ef3c193) :186 (preceding()/following() at offsets inside a match) and lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/BreakIteratorBoundaryScanner.java:40-41 (last() then one previous())
  • lucene/analysis/opennlp/src/test/org/apache/lucene/analysis/opennlp/TestOpenNLPSentenceBreakIterator.java:160-166 (at a9779ef3c193) (testSliceStart: the padding lies before the slice's begin index), :190 (testWhitespaceOnly), :206 (testPrecedingWithTwoSentences, from #13170); no upstream test iterates a range that starts with whitespace followed by a sentence
The finding in 30 seconds
  • Lucene main 645d4fe14a20 lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPSentenceBreakIterator.java:26-27, 44-48, 54, 63-72, 75-94, 97-115, 118-133, 143-149, 150-160, 164-187, 195-205
  • Lucene main 645d4fe14a20 lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/tools/NLPSentenceDetectorOp.java:39-47
  • JDK 25 java.text.BreakIterator javadoc and source (first(), previous(), following(int), isBoundary(int) default implementation, BreakIterator.java:379-395)
Who is affected
  • Lucene main 645d4fe14a20 lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPTokenizer.java:52
  • Lucene main 645d4fe14a20 lucene/analysis/common/src/java/org/apache/lucene/analysis/util/SegmentingTokenizerBase.java:114, 167, 194, 199
  • Lucene main 645d4fe14a20 lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:182, 186; lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/BreakIteratorBoundaryScanner.java:40-41
Why it looks unintended
  • JDK 25 java.text.BreakIterator javadoc (class example printEachBackward; previous(); following(int))
  • Lucene main 645d4fe14a20 lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPSentenceBreakIterator.java:26-27, 44-48, 76-77, 82-84, 119, 143-149
  • Lucene main 645d4fe14a20 lucene/analysis/opennlp/src/java/module-info.java:25
  • Lucene main 645d4fe14a20 lucene/analysis/opennlp/src/test/org/apache/lucene/analysis/opennlp/TestOpenNLPSentenceBreakIterator.java:84-115, 126-149, 160-167, 190-196, 217-228
Suggested fix
  • Lucene main 645d4fe14a20 lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPSentenceBreakIterator.java:76-77, 88-112, 127, 129, 137-138, 143-149, 157, 175, 195-205
  • Lucene main 645d4fe14a20 lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/tools/NLPSentenceDetectorOp.java:39-47; lucene/analysis/opennlp/src/java/module-info.java:26
Prior reports
  • a Lucene clone git ls-tree origin/main (645d4fe14a2, 2026-10-28), origin/branch_10x, origin/branch_10_6, releases/lucene/10.5.2: OpenNLPSentenceBreakIterator.java blob af1ada42 (read 2026-10-29)

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.