All findings · Lucene finding · LFV-861 · simplespanfragmenter-start-does-not-reset-waitforpos

A reused classic Highlighter with SimpleSpanFragmenter cuts the next text differently, because start() does not reset waitForPos

SimpleSpanFragmenter.start() resets every per-text field except waitForPos, its "do not cut yet" position, so after a text that ends with a phrase match a reused Highlighter makes the next text's first 12 words one 70-character fragment, where a new one cuts about every 20 characters. Hits and scores are unaffected.

Summary

Priority
Low, rank 545This sibling of LFV-859 in the opt-in SimpleSpanFragmenter only changes snippet layout, and it fires whenever a reused Highlighter's previous text ended in a phrase match, which is more ordinary than LFV-858's overlapping-phrase case.
Impact
minor
Since
2.4.0 (2008, LUCENE-794)introduced 2008-04-29 · over 18 years
Affected
2.4.0 to 10.6.0 and main
Status
documented
Found by
code reading while modelling the fragment loop, confirmed by a bounded-exhaustive differential check

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 classic Highlighter keeps one fragmenter and calls its start() before every text. SimpleSpanFragmenter remembers in waitForPos a position before which it must not cut, so a phrase match stays whole; start() resets everything except that.

How the classic Highlighter cuts a text into fragments

  1. The Highlighter walks the text token by token and, from the second token on, asks its fragmenter: start a new fragment here?
  2. SimpleSpanFragmenter says yes when the text so far passes the next multiple of the fragment size (20, 40, 60 characters, ...), unless a query match is in progress: when a match starts, it sets waitForPos to the position after the match and says no until its counter gets there.
  3. Before each text the Highlighter calls the fragmenter's start(), documented as "Initializes the Fragmenter".
Diagram of the classic Highlighter with SimpleSpanFragmenter, fragment size 20, phrase "search library". Text 1, "Lucene is an open source project that ships a search library", ends with the match, which sets waitForPos to 11; the text ends before the counter gets there. start() for text 2 resets position to -1 and currentNumFrags to 1 but keeps waitForPos 11. Text 2 with a new Highlighter: "Highlighting picks" | " short fragments of" | " text around each" | " match so that a reader" | ... With the reused Highlighter: one 70-character fragment "Highlighting picks short fragments of text around each match so that a", then " reader" | " of" | " the". The fix adds waitForPos = -1 to start().
Hit 1 ends with the phrase and leaves waitForPos = 11; start() for hit 2 keeps it, so the reused Highlighter makes no cut until its counter reaches 11: one 70-character fragment, then one-word fragments, where a new Highlighter cuts about every 20 characters. The fix is one line in start(). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build one classic Highlighter with a QueryScorer and SimpleSpanFragmenter(scorer, 20) for the phrase "search library" (StandardAnalyzer), and reuse it for every hit, as Highlighter is designed for.
  2. Highlight hit 1, Lucene is an open source project that ships a search library. At the phrase the fragmenter sets waitForPos = 11: no new fragment until its position counter reaches 11. The text ends first, so waitForPos stays 11.
  3. Highlight hit 2, Highlighting picks short fragments of text around each match so that a reader of the search library manual .... You expect what a new Highlighter gives: Highlighting picks | short fragments of | text around each | match so that a reader | ....
  4. You get Highlighting picks short fragments of text around each match so that a (70 characters), then reader | of | the, one word each: start() reset the counter for hit 2 but not waitForPos, so no cut came before the counter reached 11, and then the fragment count caught up word by word.
  5. No error, and only snippets change, never hits or scores; they depend on what the same Highlighter saw before. In the test's longer case at the default fragment size 100, the best fragment grows from 114 to 491 characters.

Who is affected

Code that reuses one classic Highlighter with the opt-in SimpleSpanFragmenter for several hits, fields or values, and highlights a phrase, multi-phrase or span query; term queries never trigger it and the default SimpleFragmenter is safe. Snippet layout only: hits and scores are unchanged. Every release since 2.4.0; rare.

Mechanism, terms and numbers763 words

The classic Highlighter keeps one Fragmenter and calls its start(text, tokenStream), documented as "Initializes the Fragmenter", before every text. SimpleSpanFragmenter, the span-aware fragmenter that keeps phrase matches whole, stores in waitForPos the position just after a match that has started and refuses to start a new fragment until its position counter gets there. Its start() resets the counter, the fragment count, the text size and the attributes, but not waitForPos.

So when a phrase, multi-phrase or span match ends a text, the next text highlighted by the same Highlighter inherits the pending position: no fragment boundary until the counter reaches it, then one-word fragments while the fragment count catches up, a different best fragment, and sometimes a phrase split in two. With the phrase "search library" at size 20 the next text's first fragment is 70 characters; at the default size 100 the best fragment is 491 characters instead of 114. Search hits and scores are unaffected. The omission dates from the class's creation in 2008 (2.4.0) and reproduces on every release to 10.6.0 and on main; the fix is one line.

Terms

  • Fragmenter: the policy that cuts a text into fragments. Highlighter holds one (Highlighter.java:44, default SimpleFragmenter; setTextFragmenter, :423-425) and calls start(text, tokenStream) at the start of every getBestTextFragments call (:194), which every getBestFragment(s) method reaches. Fragmenter#start: "Initializes the Fragmenter. You can grab references to the Attributes you are interested in from tokenStream and then access the values in isNewFragment()" (Fragmenter.java:28-35).
  • SimpleSpanFragmenter: "breaks text up into same-size fragments but does not split up {@link Spans}. This is a simple sample class." (SimpleSpanFragmenter.java:26-29). It gets the query's matches in the current text from QueryScorer, which re-extracts them for every text in init (QueryScorer.java:170-181, called at Highlighter.java:175) and stores each as PositionSpan(start, end) with an inclusive end (WeightedSpanTermExtractor.java:332).

The bug

  1. isNewFragment() adds the token's position increment to position (SimpleSpanFragmenter.java:63). While waitForPos > position it returns false at once, before the span lookup and the size test (:65-69).
  2. When a span of the current token starts at position, it sets waitForPos = positionSpan.end + 1 (:76-81).
  3. Otherwise a new fragment starts when the token's end offset reaches fragmentSize * currentNumFrags and at least half a fragment of text is left (:84-90).
  4. The Highlighter calls isNewFragment() only when a new token arrives (Highlighter.java:209-224) and flushes the last token group without another call (:241-254). A span that ends the text therefore leaves waitForPos set.
  5. start() resets position = -1, currentNumFrags = 1, textSize and the three attributes (:98-106), not waitForPos (:36). The next text gets no boundary until its counter reaches the old value; then the size test, still at the first fragment's limit, fires at every token until currentNumFrags catches up; and while the stale wait holds, a match that starts is never registered, so it can be cut in two.

The numbers (stock main 645d4fe14a20, assertions on and off)

casenew Highlighterreused Highlighter
hit 2 after hit 1 (size 20)Highlighting picks | short fragments of | text around each | match so that a reader | of the <B>search</B> <B>library</B> manual | sees why a document matchedHighlighting picks short fragments of text around each match so that a (70 characters) | reader | of | the | <B>search</B> <B>library</B> manual | sees why a document matched
hit 2 without the phraseHighlighting picks | short fragments of | text around each | match so that a reader | sees why a document matchedHighlighting picks short fragments of text around each match so that a | reader | sees | why | a document matched
hit 1 highlighted twice1st call: Lucene is an open | source project that | ships a <B>search</B> <B>library</B>2nd call: the whole 60-character text as one fragment
default size 100, getBestFragment, phrase "quick fox"114 characters: x52 ... x59 <B>quick</B> <B>fox</B> y0 ... y16491 characters: x0 x1 ... x59 <B>quick</B> <B>fox</B> y0 ... y61
control: SimpleFragmenter(20) reusedsame as newsame as new

In hit 1, "search library" is at token positions 9-10. The fragmenter's counter runs one token behind (the sibling LFV-859), so it sees the match start at library and sets waitForPos = 10 + 1 = 11. In hit 2 the counter reaches 11 at the 13th word, reader: the first 12 words are one fragment.

Exposure and likelihood263 words
  • Who is exposed. Code that reuses one classic Highlighter with SimpleSpanFragmenter (opt-in; the default is SimpleFragmenter, Highlighter.java:44) for several texts: hits, fields, or the values of a multi-valued field. Reuse is the Highlighter's design (one fragmenter field, start() per text) and the package example's pattern (package-info.java:36-59, one Highlighter for 10 hits and two fields, with the default fragmenter).
  • What it takes. A positional query (phrase, MultiPhraseQuery or span query, also inside a BooleanQuery) whose match ends the previous text (its last token, or with the stock counter also the second-to-last), and a previous text long enough that the stale position lies beyond the next text's first fragment boundary (at size 100 roughly more than one fragment of previous text, by reading). A plain TermQuery or SynonymQuery has no position spans and never sets waitForPos.
  • What the damage looks like. One oversized fragment (70 characters at size 20; 491 instead of 114 at the default size 100), then one-word fragments, a different best fragment, and sometimes a phrase split across fragments (1,632 such splits in the finder's bounded check of tiny texts). Repeating getBestFragments on the same text gives a different answer the second time. Silent: no error.
  • What is not affected. Search hits, counts and scores; SimpleFragmenter and NullFragmenter reused (same output as new ones).
  • How likely. Rare: opt-in fragmenter, reuse, a positional query and a previous text that ends with a match. No user report has been attributed to it in 18 years.
  • Scale. Nothing depends on index size or concurrency; two short strings reproduce it.

Minimal repro

The test shows that a classic Highlighter with SimpleSpanFragmenter that is reused after a text ending in a phrase match cuts the next text differently from a fresh Highlighter, because SimpleSpanFragmenter.start() does not reset waitForPos.

TestSimpleSpanFragmenterReuse.java (JUnit test)55 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.highlight;

import java.util.Arrays;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSimpleSpanFragmenterReuse extends LuceneTestCase {

  private static Highlighter newHighlighter() {
    QueryScorer scorer = new QueryScorer(new PhraseQuery("body", "search", "library"), "body");
    Highlighter highlighter = new Highlighter(scorer);
    highlighter.setTextFragmenter(new SimpleSpanFragmenter(scorer, 20));
    return highlighter;
  }

  private static String fragments(Highlighter highlighter, Analyzer analyzer, String text)
      throws Exception {
    TextFragment[] frags =
        highlighter.getBestTextFragments(analyzer.tokenStream("body", text), text, false, 100);
    Arrays.sort(frags, (a, b) -> Integer.compare(a.getFragNum(), b.getFragNum()));
    return Arrays.toString(frags);
  }

  public void testReusedHighlighterFragmentsLikeAFreshOne() throws Exception {
    String first = "Lucene is an open source project that ships a search library";
    String second =
        "Highlighting picks short fragments of text around each match so that a reader of the"
            + " search library manual sees why a document matched";
    try (Analyzer analyzer = new StandardAnalyzer()) {
      Highlighter reused = newHighlighter();
      fragments(reused, analyzer, first);
      // expected: the fragments of a fresh Highlighter; actual: one 70-char fragment, then 1-word ones
      assertEquals(
          fragments(newHighlighter(), analyzer, second), fragments(reused, analyzer, second));
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-861/, 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/highlighter/build/libs/lucene-highlighter-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$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.search.highlight.TestSimpleSpanFragmenterReuse
Expected vs actual
Expected the fresh Highlighter's fragments [Highlighting picks, short fragments of, text around each, ...]; actual [Highlighting picks short fragments of text around each match so that a, reader, of, the, ...] (one 70-char fragment, then one-word ones), with -ea and -da.

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

More files, environment, full output65 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
    => org.junit.ComparisonFailure: expected:<[Highlighting picks[,  short fragments of,  text around each,  match so that a reader,  of the] <B>search</B> <B>li...> but was:<[Highlighting picks[ short fragments of text around each match so that a,  reader,  of,  the, ] <B>search</B> <B>li...>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.highlight.TestSimpleSpanFragmenterReuse.testReusedHighlighterFragmentsLikeAFreshOne(TestSimpleSpanFragmenterReuse.java:51)

Why it looks unintended

  • Fragmenter#start "Initializes the Fragmenter"; Highlighter calls it on its one fragmenter before every text (Highlighter.java:44, :194).
  • start() resets every other per-text field; an upstream test reuses one SimpleSpanFragmenter for two documents; its synonym query has no phrase, so it never sees the bug.
  • A "fragment way too long" waitForPos bug was fixed before (LUCENE-2229, 5.4.1).
The full argument261 words
  • Contract. Fragmenter#start: "Initializes the Fragmenter" (Fragmenter.java:28-35). Highlighter keeps one fragmenter (:44, :423-425) and calls start for every text (:194). The contract is implicit: no javadoc says in so many words that a reused fragmenter must cut a text as a new one does; the case rests on start() being the per-text initializer, the one-fragmenter design and the points below.
  • The class itself. start() resets position, currentNumFrags, textSize and the attributes (SimpleSpanFragmenter.java:100-105). waitForPos holds a position of the previous text, which means nothing in the next one: QueryScorer.init re-extracts the spans for every text (QueryScorer.java:170-181). SimpleFragmenter.start resets its own state (SimpleFragmenter.java:47-50).
  • Tests. TestHighlighter#testHighlightingSynonymQuery reuses one Highlighter and one SimpleSpanFragmenter for documents 2 and 3 (TestHighlighter.java:238-258, reuse at :248-256); a SynonymQuery has no position spans, so it never sees the bug. No test or javadoc says a fragmenter is single-use.
  • Intent. Michael McCandless asked on LUCENE-1685 (#2759, 2009-06-12) for a way "to not have to make a SpanScorer (and, by extension, fragmenter) per query, but instead make it up-front", and later "Should we also default the fragmenter to SimpleSpanFragmenter?" LUCENE-1685 made the span-aware QueryScorer reusable in 2.9.0; the fragmenter's own state was not discussed.
  • Precedent. LUCENE-2229 (#3305): "Fix Highlighter's SimpleSpanFragmenter when multiple adjacent stop words following a span can unduly make the fragment way too long" (lucene/CHANGES.txt:9891-9893, 5.4.1), with the test assertion "Fragment must be less than 60 characters long" (TestHighlighter.java:435). It changed the same waitForPos check, within one text.
The devil's-advocate check134 words

The intended-behaviour checker's strongest case: the javadoc calls the class "a simple sample class", and most upstream tests build a new SimpleSpanFragmenter per document, which could suggest single use.

Why it fails: the per-document line in those tests is a leftover of the 2008 design, when the SpanScorer took one text's token stream in its constructor and had to be built per document, together with its fragmenter; in the LUCENE-1685 commit 10b41d2dce2 the HighlighterTest diff moves the scorer and the Highlighter out of the loop and keeps the setTextFragmenter(new SimpleSpanFragmenter(...)) line inside it, changing only its argument. The class is maintained public API (LUCENE-1389 changed it in 2.9.0, LUCENE-2229 fixed it in 5.4.1), "sample" does not license output that depends on an unrelated earlier text, and the cross-model reviewer rated the intended-behaviour argument weak.

Suggested fix

Recommended Reset waitForPos in start() (one line)

Add waitForPos = -1; to SimpleSpanFragmenter.start(). All 5 tests then pass, and so does the whole highlighter test task (722 upstream tests), with assertions on and off; the module's lint and format checks pass. A fragmenter's first text behaves as before (the field already starts at -1).

   public void start(String originalText, TokenStream tokenStream) {
     position = -1;
+    waitForPos = -1;
     currentNumFrags = 1;
     textSize = originalText.length();
All options and trade-offs3 options · 322 words

One fix, validated with gradle on 645d4fe14a20; a workaround for released versions; and one alternative that is not recommended.

Reset waitForPos in start() (one line) (validated by the checks) (recommended)

one added line after position = -1; in SimpleSpanFragmenter.start(). It makes start() initialize the last per-text field it missed.

   public void start(String originalText, TokenStream tokenStream) {
     position = -1;
+    waitForPos = -1;
     currentNumFrags = 1;
     textSize = originalText.length();

Trade-offs

None found. The first text of every fragmenter is unchanged; later texts get what a new fragmenter gives; no cost. Independent of the sibling fixes (LFV-858, LFV-859): the finder's test and 2 of the independent reproduction's tests still fail with both sibling fixes applied, and pass with this one.

What the checks validated

  • Gradle, scratch worktree at 645d4fe14a20: the fix plus the paste-ready test plus the finder's and the independent reproduction's tests, the whole :lucene:highlighter:test task with assertions on and off: 37 classes, 733 tests, 0 failures (34 upstream classes with 722 tests, TestHighlighter 59; the 3 reproducing classes 5 + 1 + 5 tests).
  • ecjLintMain, ecjLintTest and checkGoogleJavaFormat of the highlighter module pass (eclint and error-prone were not configured in that run and did not apply).
  • Before the fix the paste-ready test fails 4 of 5 (the control passes). The same reset shadowed into 2.4.0, 2.9.0 and main makes the history repro pass.

Workaround for released versions: a new fragmenter per text (validated by the checks)

Call highlighter.setTextFragmenter(new SimpleSpanFragmenter(scorer, size)) before each text.

Trade-offs

One allocation per text; every caller has to know about it.

What the checks validated

The history repro's control does exactly this and matched a new Highlighter in every run.

Document SimpleSpanFragmenter as single-use (not recommended)

A javadoc note instead of a code change.

Trade-offs

Contradicts Fragmenter#start ("Initializes the Fragmenter"), the one-fragmenter design of Highlighter and the upstream test that reuses one; a one-line reset is simpler than a new usage rule.

Downstream note

Downstream servers whose opt-in plain highlighter keeps one Highlighter with a SimpleSpanFragmenter per field for all hits of a fetch phase, as Elasticsearch's and OpenSearch's do by a reading of their code (not executed), would show it across hits and the values of multi-valued fields.

How it was found

Found while modelling the classic Highlighter's fragment loop: writing a corrected copy of SimpleSpanFragmenter showed that start() resets every per-text field except waitForPos. A bounded-exhaustive check on the stock jar then found 160,425 of 1,048,320 small text pairs where a reused Highlighter cuts the second text differently from a new one.

The method in full153 words

Method: model-based, bounded-exhaustive differential check on the stock jar (the study's "semi-formal" tier). A hunt unit, a transducer model of Highlighter.getBestTextFragments composed with SimpleSpanFragmenter, 2026-10-28, main a9779ef3c193.

  • While writing the fix-sketch shadow class for the model, the finder read that start() resets position and currentNumFrags but not waitForPos.
  • ReuseProbe then ran every pair (first text of at most 4 tokens, second text of at most 6 tokens, over {a, c, x}) with PhraseQuery(a c) and fragment sizes 1 to 8 through a reused and a new Highlighter: 160,425 of 1,048,320 second-text results differ, 1,632 of them split the phrase only when reused. Smallest example: size 4, first text a c, second text a a a a: reused |a a a a|, new |a a| a a|.
  • An AI finder then wrote the public-API JUnit; independent AI tools reproduced, reviewed, judged and audited it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-28, re-checked 2026-10-29): GitHub issues, PRs and commits, the JIRA archive, the dev@ and java-user@ lists, downstream trackers and the web. The nearest item, LUCENE-2229 (#3305), is a different waitForPos bug, fixed in 5.4.1.

Searches4 searches

Nothing reports that SimpleSpanFragmenter keeps waitForPos from one text to the next, or that a reused Highlighter fragments a text differently from a new one. The last commit to SimpleSpanFragmenter.java on main is 54fba99cb13 (2022-09-24, formatting); no open PR touches it.

Related, not the same bug: #3305 / LUCENE-2229 (fixed 5.4.1: the same field, a stop-word gap within one text); #6083 / LUCENE-5019 (open: long fragments after a stop word within one text); #2463 / LUCENE-1389 (2008: changed start() without resetting waitForPos); #2759 / LUCENE-1685 (made QueryScorer reusable); #1869 / LUCENE-794 (introduced the class); #15333 and open PR #15346 (zero-score fragment merging); #6444 / LUCENE-5381; #3661 / LUCENE-2587; java-user threads of 2015-08, 2019-05, 2011-07, 2009-11, 2016-10 and 2010-05 (other causes); elastic/elasticsearch #7944, #11820 and #1072 (LUCENE-2229 era). Lucene.NET's port has the same omission, with no issue there.

Possibly related, unverified: later comments on elastic/elasticsearch#9442 (downstream releases 5.4.1, 5.5.0, 6.2.3) report oversized plain-highlighter fragments that go away with the simple fragmenter, with no repro or cause; they fit this bug and equally other causes.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-28three runs of a scripted prior-report search (identifiers, symptoms, downstream; 16 issue/PR and 3 commit queries each) and 27 manual searches, plus the changed-file lists of all 410 open PRsno report; nearest #3305 (LUCENE-2229), a different mechanism
Mailing lists and JIRA archive2026-10-2820 list queries per tool run over dev@, java-user@, issues@ and commits@; 4 JIRA queries per run; threads read in fullno report; six java-user threads with other causes
Downstream trackers and the web2026-10-28elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and apache/lucenenet on GitHub; 18 web queriesno report naming the cause; elastic/elasticsearch#9442 possibly related, unverified
Re-check (GitHub REST listing; the search API was rate-limited)2026-10-29the 25 issues and PRs updated since 2026-10-28 and the files of their PRs; commits to the file and the packagenothing about this bug; #16936 edits the package example only

History

Present since the class was written (35c7eb36dfb, LUCENE-794, 2008-04-29) and first released in 2.4.0. A history repro fails on all 149 releases from 2.4.0 to 10.5.2, on 10.6.0 (branch_10_6 4ae946e5a9b9) and on main; never fixed, not a regression.

Versions tested7 rows

Introduced by 35c7eb36dfb "Commit of LUCENE-794 patch - adding phrase/span query support to highlighter" (svn r652164, 2008-04-29; LUCENE-794 is #1869; no CHANGES entry). Git records Mark Harwood (@markharwood) as author; the patch is Mark Miller's (@markrmiller), who opened #1869 with it, and Mark Harwood closed the issue with "Committed as part of r652164". It created SimpleSpanFragmenter with private int waitForPos = -1; and a start() that reset only position and currentNumFrags; git log -S waitForPos finds no later commit that adds a reset.

In 2.4.x the span scorer was bound to one text, so the leak showed when the same text was highlighted again after SpanScorer.reset(). Since 2.9.0 (LUCENE-1695 f73a4f43242, LUCENE-1685 10b41d2dce2 / #2759) one Highlighter is reused across texts without any reset call. LUCENE-1389 (c2f7d554a3e, 2.9.0) and LUCENE-2229 (5.4.1) changed the logic around the field without fixing it.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthis doc's test fails 4 of 5 (gradle and runner, assertions on and off)
main a9779ef3c193, branch_10_6 4ae946e5a9b9 affectedthe finder's test fails 1 of 1 (assertions on and off) and the independent reproduction's 3 of 5 (main on and off; branch_10_6 with assertions on)
10.5.2, 9.12.3 affectedthis doc's test fails 4 of 5 (10.5.2 also with assertions off); the finder's and the independent reproduction's tests fail too
8.0.0, 8.11.4, 9.0.0, 9.1.0, 10.0.0 affectedthe finder's test fails 1 of 1 (assertions on; 8.11.4 also off; on 8.x and 9.0.0 as a port that changes only the LuceneTestCase import); this doc's test fails 4 of 5 on 9.1.0
2.4.0 to 10.5.2 (149 releases) history reproa version-independent main() repro fails on every release; from 2.9.0 on with byte-identical fragments
2.3.2 API absentno SimpleSpanFragmenter; the repro does not compile
2.4.0, 2.9.0, main + fix fixedthe repros pass with waitForPos = -1 in start()

Independent checks

An independent reproducer and a credibility audit wrote and ran their own tests, a devil's advocate and the adjudication re-ran them, and all found it real; the adjudication confirmed it with high confidence. Reviews by another model family could not run code; the one with a verdict read the code and agreed.

Checks and verdicts312 words

Every check except the cross-model reviews ran its own test against the stock jars; "(on and off)" marks runs with assertions both on and off.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnit fails 1 of 1 on main (on and off), branch_10_6, 10.5.2 and 9.12.3, and still with both sibling fixes applied; passes with the one-line fix.
Independent reproducer realOwn JUnit fails 3 of 5 on main (on and off), 10.5.2, 9.12.3 and branch_10_6; its 2 controls pass; the fix passes 5 of 5; 2 of 5 still fail with only the sibling fixes.
cross-model review, refute lens no review (procedural)An independent reviewer from another model family (OpenAI GPT-5.6): No command ran, so its "not real" is procedural; its own notes call it "a procedural not real, not a substantive refutation" and ask for a rerun when command execution works.
cross-model review, intended lens real (medium), read onlyOpenAI GPT-5.6 reading only: root cause agrees, inputs in contract, intended-behaviour argument weak; it ran no code and had seen the independent reproduction and the intended-behaviour check verdicts.
Devil's advocate real (high)Rebutted "simple sample class", per-document tests, misuse and unreachability; the finder's test fails on main (on and off), 10.5.2 and 9.12.3.
adjudication confirmed (high)Re-ran the independent reproduction's test with seed C0FFEE31: 3 of 5 fail on and off; the fix passes 5 of 5. Classed minor (layout only).
registry re-run and credibility audit keepRegistry re-runs fail as before (seed 5EED0329); the audit's own default-size main() gives 495 vs 114 characters on main and 10.5.2 and passes with the reset.
this doc's runs reproduced; fix validatedNew test fails 4 of 5 on main 645d4fe14a20 (gradle and runner, on and off), 10.5.2 and 9.12.3; with the fix the whole highlighter test task passes.

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

Finding record

LFV-861, snapshot of 2026-11-06

Registry title
Classic Highlighter's SimpleSpanFragmenter.start() does not reset waitForPos, so a Highlighter reused for the next text (or for the same text again) keeps suppressing fragment boundaries after a phrase match that ended the previous text: one oversized fragment, then one-word fragments and a different best fragment, depending on what was highlighted before
Area
highlighter / classic Highlighter fragmenting (SimpleSpanFragmenter.start, state carried across texts)
Priority
Low, rank 545: This sibling of LFV-859 in the opt-in SimpleSpanFragmenter only changes snippet layout, and it fires whenever a reused Highlighter's previous text ended in a phrase match, which is more ordinary than LFV-858's overlapping-phrase case.
Impact class
minor
Impact note
Applications that reuse one classic Highlighter with QueryScorer and SimpleSpanFragmenter for several texts (opt-in fragmenter; reusing one Highlighter is the pattern of the package example, and a downstream plain highlighter caches one per field, read not executed) and run a phrase, span or multi-phrase query get different fragment boundaries in a text whenever the previous text ended with a match and was long enough for the stale position to pass the next text's first boundary; highlighting output only (hits and scores are unaffected), no error, no scale needed, in every release since 2.4.0.
Summary
You reuse one classic Highlighter with a QueryScorer and a SimpleSpanFragmenter for several texts (Highlighter keeps one fragmenter and calls its start(), documented as 'Initializes the Fragmenter', for every text) and expect each text to be cut into the same fragments as with a fresh Highlighter. Instead start() resets the position and fragment count but not waitForPos, the position the fragmenter is still waiting for, so when a phrase match ended the previous text no boundary is allowed in the next text until it reaches that old position: with the phrase 'search library' at the end of the first text and fragment size 20, the next text starts with one 70-character fragment 'Highlighting picks short fragments of text around each match so that a', then one-word fragments ', reader', ', of', ', the', where a fresh Highlighter gives 'Highlighting picks', ' short fragments of', ' text around each', and getBestFragment also differs. Hits and scores are unaffected; only the snippet layout changes, and it depends on what the same Highlighter highlighted before.
Introduced
35c7eb36dfb07ff919147d7570158433e48cd092 (svn r652164, 'Commit of LUCENE-794 patch - adding phrase/span query support to highlighter'; LUCENE-794 = apache/lucene#1869, patch by markrmiller, committed by markharwood; no CHANGES entry) [read: git blame -w -M -C and git log -S/-G waitForPos in the history clone: the commit creates SimpleSpanFragmenter with 'private int waitForPos = -1;' and a start() that resets only position and currentNumFrags, and no later commit on any ref adds a reset; plus a shadow run of the commit's 24 highlighter classes over the 2.4.0 jars, which fails]. Not a regression. Cross-text reuse became the normal usage in 2.9.0 (f73a4f43242 LUCENE-1695, Scorer.init per text; 10b41d2dce2 LUCENE-1685 / #2759, reusable QueryScorer); before that the leak shows on the documented repeat call on the same text after SpanScorer.reset() (2008-04-29 (35c7eb36dfb, author date = commit date 2008-04-29T22:07:18Z))
First affected version
2.4.0 [exec] (tagged 2008-10-08; 2.3.2, the last earlier release, has no SimpleSpanFragmenter: the history repro does not compile there)
Affected versions
Every release from 2.4.0 to 10.5.2, the branch_10_6 head 4ae946e5a9b9 (10.6.0, released 2026-10-29) and main a9779ef3c193 [exec: the history repro SpanFragmenterReuseHistory (main(), WhitespaceAnalyzer, PhraseQuery 'search library', fragment size 20; five API-era source variants with the same logic) fails on all 149 releases 2.4.0-10.5.2, on branch_10_6 and on main with -ea (151 runs) and on the 12 versions also run with -da; from 2.9.0 on every run prints byte-identical fragments; in 2.4.x only the same-text-twice leg applies; the finder's JUnit fails on 8.0.0-main (14 runs, -ea and -da), the independent reproduction's JUnit fails 3 of 5 on main (-ea, -da), branch_10_6, 10.5.2 and 9.12.3; the one-line fix shadowed in passes on 2.4.0, 2.9.0 and main]. Not fixed: no upstream commit to SimpleSpanFragmenter.java since 54fba99cb13 (author and commit date 2022-09-24, formatting) on main (head c5ab3e57b9b) or branch_10_6 [read, GitHub API, 2026-10-28].
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: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-28, read-only; the prior-report record, verdict new: three scripted prior-report searches (identifiers, symptoms, downstream), each with 16 GitHub issue/PR queries on apache/lucene, 3 commit queries, 2 downstream-repository queries, 20 mailing-list queries over dev@, java-user@, issues@ and commits@ and 4 JIRA-archive queries, plus 18 web queries; 27 distinct manual GitHub searches over apache/lucene, apache/lucenenet, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; the file lists of all 410 open apache/lucene PRs. Re-check through the REST API (the search API was rate-limited): the 15 apache/lucene PRs created since 2026-10-26 (#16907 to #16926) touch no highlighter file; no title among the 76 issues and PRs updated since 2026-10-26 mentions highlighting, fragments or spans; the last commit to SimpleSpanFragmenter.java is 54fba99cb13 (author and commit date 2022-09-24, formatting), the last to the highlight package bfe5c847d3c (2026-07-27, unrelated) on main and 056feb02df6 (2026-05-14, unrelated) on branch_10_6. No issue, PR, review comment, JIRA entry, list thread or downstream report says that SimpleSpanFragmenter keeps waitForPos from one text to the next, or that a reused Highlighter fragments a text differently from a fresh one. Related, different: #3305 / LUCENE-2229 (fixed 2015, the same field, a within-text stop-word gap, '==' -> '<='), #6083 / LUCENE-5019 (open, long fragments after a stop word within one text), #2463 / LUCENE-1389 (2008, changed start() without resetting waitForPos), #2759 / LUCENE-1685 (made QueryScorer reusable; supports that reuse is intended), #1869 / LUCENE-794 (introduced the class), #15333 and open PR #15346 (zero-score fragment merging), #6444 / LUCENE-5381, #3661 / LUCENE-2587; java-user threads of 2015-08 (end-of-text half-fragment rule), 2019-05, 2011-07, 2009-11, 2016-10 and 2010-05 (other causes); elastic/elasticsearch #7944, #11820 and #1072 (LUCENE-2229 era). Possibly related, unverified: later comments on elastic/elasticsearch#9442 (downstream releases 5.4.1, 5.5.0 and 6.2.3) report oversized plain-highlighter fragments that go away with the simple fragmenter, with no repro or cause; they are symptom-compatible with this bug and equally with other causes. Lucene.NET's port has the same omission, with no issue.
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28; tested 2026-10-29) through gradle in a scratch worktree and through the standalone JUnit runner, assertions on and off; the 10.5.2 (on and off) and 9.12.3 release jars. The hunt's own runs: main a9779ef3c193 (identical highlighter sources), branch_10_6 4ae946e5a9b9, seven releases from 8.0.0 to 10.5.2 (JUnit), and a history main() repro on all 149 releases 2.4.0-10.5.2. The fix was validated with gradle on 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/SimpleSpanFragmenter.java:26-29 (at a9779ef3c193) (class javadoc 'breaks text up into same-size fragments but does not split up {@link Spans}. This is a simple sample class.'), :36 ('private int waitForPos = -1;'), :65-69 (while waitForPos > position, return false before the span lookup and the size test), :76-80 (waitForPos = positionSpan.end + 1), :98-106 (start() resets position, currentNumFrags, textSize and the attributes, not waitForPos)
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/Fragmenter.java:28-35 (at a9779ef3c193) (start: 'Initializes the Fragmenter.'); Highlighter.java:44 (one textFragmenter field), :194 (textFragmenter.start(text, tokenStream) on every getBestTextFragments call), :423-425 (setTextFragmenter); package-info.java:37-52 (example reuses one Highlighter for 10 hits and two fields); lucene/highlighter/src/test/org/apache/lucene/search/highlight/TestHighlighter.java testHighlightingSynonymQuery (one SimpleSpanFragmenter reused for two documents, lines 248-256)
The finding in 30 seconds
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/SimpleSpanFragmenter.java:26-29, (at 645d4fe14a20) :36, :62-93 (:63, :65-69, :76-81, :84-90), :98-106
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/Fragmenter.java:28-35; (at 645d4fe14a20) Highlighter.java:44, :175, :194, :209-224, :241-254, :423-425; QueryScorer.java:170-181; WeightedSpanTermExtractor.java:332
Who is affected
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/Highlighter.java:44, (at 645d4fe14a20) :194, :423-425; package-info.java:36-59
Why it looks unintended
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/Fragmenter.java:28-35; (at 645d4fe14a20) Highlighter.java:44, :194, :423-425; SimpleSpanFragmenter.java:26-29, :100-105; QueryScorer.java:170-181; SimpleFragmenter.java:47-50
  • lucene/highlighter/src/test/org/apache/lucene/search/highlight/TestHighlighter.java:238-258 (at 645d4fe14a20) (testHighlightingSynonymQuery), :415-437 (LUCENE-2229 test, :435)
  • lucene/CHANGES.txt:9859 (at 645d4fe14a20) (5.4.1 section), :9891-9893 (LUCENE-2229)
  • https://github.com/apache/lucene/issues/2759 (LUCENE-1685 comments of 2009-06-12 and 2009-08-06 by Michael McCandless; read via the GitHub API 2026-10-29)
History
  • https://github.com/apache/lucene/issues/1869 (LUCENE-794, migrated from JIRA, by Mark Miller; comment of 2008-04-29 by Mark Harwood "Committed as part of r652164"; read via the GitHub API 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

A light, step-by-step picture of the size-20 example, made in parallel with this doc from executed runs on main 645d4fe14a20 and the code.

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.