All findings · Lucene finding · LFV-775 · uh-maxlength-truncation-splits-surrogate-pair

UnifiedHighlighter's maxLength cut can split an emoji, so the snippet ends in half a character

The UnifiedHighlighter keeps the first maxLength UTF-16 units of a stored value (10,000 by default) and never checks whether the cut splits a two-unit character such as an emoji. For Hello πŸ˜€ world at maxLength 7 the snippet is <b>Hello</b> \uD83D, half an emoji, which getBytes(UTF_8) turns into ?.

Summary

Priority
Low, rank 555The default UnifiedHighlighter maxLength cut can leave an unpaired high surrogate at the end of a snippet, which only affects display and needs a supplementary character exactly at the cut.
Impact
minor
Since
6.3.0 (2016), the first release with the UnifiedHighlighterintroduced 2016-10-04 · over 10 years
Affected
6.3.0 to 10.5.2, 10.6.0 (by reading) and main; 17 releases and main executed
Status
documented
Found by
bounded-exhaustive metamorphic testing of the real jars on supplementary-plane text (semi-formal)

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

LimitedStoredFieldVisitor.stringField keeps value.substring(0, Math.min(maxLength, value.length())) of the first value and Math.min(lengthBudget - 1, value.length()) units of each later value (UnifiedHighlighter.java:1446-1447, :1464). Neither checks whether the last kept unit is the first half of a surrogate pair, and all three executed offset sources (offsets in postings, term vectors, re-analysis) build the snippet from that text.

Background: two-unit characters and the highlighter's length budget

  1. Java strings count UTF-16 units. Most characters take one; a supplementary character (an emoji such as πŸ˜€ U+1F600, a CJK Extension B ideograph, a historic-script letter) takes two, a high half and a low half, called a surrogate pair. Either half alone is not text.
  2. The UnifiedHighlighter reads a stored value and keeps only its first maxLength units (default 10,000, set with withMaxLength), then picks passages in that prefix and marks the matches.
  3. It finds match offsets in the postings, in term vectors, or by re-analysing the stored text (the default for a plain stored text field). All three work on the truncated text.
Diagram in three panels. 1: the code that indexes Hello πŸ˜€ world with offsets in postings and StandardAnalyzer and highlights a term query for hello with withMaxLength(7); the value as 14 UTF-16 chars, the emoji U+1F600 at chars 6 and 7 (D83D and DE00). 2: the cut keeps chars 0 to 6 and drops DE00, because LimitedStoredFieldVisitor.stringField keeps value.substring(0, Math.min(maxLength, value.length())) with no check for a surrogate pair. 3: expected <b>Hello</b> and a space, actual the same followed by the lone D83D; getBytes(UTF_8) gives a question mark and a strict UTF-8 encoder throws MalformedInputException; the same happens with term vectors, with re-analysis, for a later value and at the default maxLength 10,000; with re-analysis and StandardAnalyzer the snippet Hello followed by the lone D83D also loses its highlight, through the tokenizer bug LFV-158; with the fix candidate (move a cut inside a pair back by one char) the snippet is <b>Hello</b> and a space, and the repro test and all 439 upstream UnifiedHighlighter tests pass.
The stored value Hello πŸ˜€ world as UTF-16 units, the maxLength 7 cut between the two halves of the emoji, and the snippet that ends in the lone first half. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index Hello πŸ˜€ world in a stored text field with offsets in postings, using StandardAnalyzer. In Java the emoji is two UTF-16 units, at indexes 6 and 7. You search hello and highlight the hit with UnifiedHighlighter.builder(searcher, analyzer).withMaxLength(7).
  2. You expect a snippet of whole characters: <b>Hello</b> , which is what maxLength 6 gives (maxLength 8 gives <b>Hello</b> πŸ˜€).
  3. The highlighter keeps exactly 7 units, Hello plus the first half of the emoji, and builds the snippet from that text.
  4. You get <b>Hello</b> \uD83D, with no error. getBytes(UTF_8) turns it into <b>Hello</b> ?, and a strict UTF-8 encoder throws MalformedInputException. Hits and scores are right.
  5. The same happens with the default maxLength 10,000 when unit 9,999 of a value is the first half of such a character, with offsets in postings, term vectors or re-analysis, and at the budget edge of a later value of a multi-valued field.

Who is affected

Applications that highlight stored text with the UnifiedHighlighter, defaults included (offsets in postings, term vectors or re-analysis; any analyzer), when a value longer than maxLength has the first half of a supplementary character exactly at the cut and the chosen passage reaches it. Hits and scores stay right; the snippet ends in one broken character.

Mechanism, terms and numbers618 words

The UnifiedHighlighter highlights only the first maxLength characters of a stored value (10,000 by default). LimitedStoredFieldVisitor makes that cut in UTF-16 units and never checks whether it falls between the two halves of a supplementary character, such as an emoji, a CJK Extension B ideograph or a historic-script letter. When it does, the text to highlight ends in a lone high surrogate, and each executed offset source (offsets in postings, term vectors, re-analysis) returns it at the end of the snippet once the chosen passage reaches the cut: Hello πŸ˜€ world at maxLength 7 gives <b>Hello</b> \uD83D, while 6 and 8 give whole characters. getBytes(UTF_8) turns the half into ?, and a strict UTF-8 encoder throws. Hits and scores are right; on the re-analysis path with StandardAnalyzer a short value also loses its highlight, through a separate tokenizer bug. Present since 6.3.0 (2016); moving the cut back by one unit fixes it, and the whole highlighter test task (728 tests) passes with the fix.

Terms

  • maxLength: "The maximum content size to process. Content will be truncated to this size before highlighting" (UnifiedHighlighter.java:596-603); DEFAULT_MAX_LENGTH = 10000 (:100); Builder.withMaxLength: "The text to be highlight is effectively truncated by this length" (:354). No unit is named; the cut counts Java chars.
  • LimitedStoredFieldVisitor: the stored-field visitor that loads the text to highlight. loadFieldValues creates it through newLimitedStoredFieldsVisitor with getMaxLength() (:1387, :1409-1411) and calls init() per document (:1395). Values of a multi-valued field are joined with the separator U+0000.
  • Offset source: POSTINGS when offsets are indexed (POSTINGS_WITH_TERM_VECTORS when term vectors are stored too), TERM_VECTORS when only term vectors are stored, else ANALYSIS, which re-analyzes the stored text (getOffsetSource, :696-709). For a multi-term or rewriting query a postings field is re-analyzed too (getOptimizedOffsetSource, :1268-1273). POSTINGS, TERM_VECTORS and ANALYSIS were executed; all of them highlight the same loaded content (:958).

The bug

  1. First value: values[currentField] = value.substring(0, Math.min(maxLength, value.length())) (:1446-1447).
  2. Later value: lengthBudget = maxLength - curValue.length() (:1450), then the separator and append(value, 0, Math.min(lengthBudget - 1, value.length())) (:1463-1464); needsField stops once curVal.length() >= maxLength (:1475).
  3. Neither cut looks at Character.isHighSurrogate; the word "surrogate" does not occur in the highlighter's main sources. When unit end - 1 is a high surrogate and unit end its low half, the content ends in the lone high half.
  4. Everything after the cut works on whole tokens and break-iterator boundaries: FieldHighlighter drops a token that straddles the cut (FieldHighlighter.java:164-166) and clamps passage ends to the content length (:186). A passage that ends at the content end copies the half character out (DefaultPassageFormatter.append, "Appends original text to the response", DefaultPassageFormatter.java:101). This is about a well-formed stored value and the stock passage machinery: append copies chars as they are (:108-138), so it neither makes nor repairs a split.

The numbers

case (WhitespaceAnalyzer, query hello unless noted)maxLengthsnippet on main
control, cut before the emoji6<b>hello</b>
control, cut after the emoji8<b>hello</b> πŸ˜€
offsets in postings7<b>hello</b> \uD83D
term vectors with offsets7<b>hello</b> \uD83D
plain stored text field (re-analysis)7<b>hello</b> \uD83D
second value world πŸ˜€ end after hello, query world13<b>world</b> \uD83D
default, emoji's first half at unit 999910000ends <b>hello</b> \uD83D

Main 645d4fe14a20, assertions on and off; the same on the 10.5.2 and 9.12.3 jars. With StandardAnalyzer and offsets in postings the snippet is <b>Hello</b> \uD83D; getBytes(UTF_8) gives <b>Hello</b> ?, and a strict UTF-8 encoder throws MalformedInputException: Input length = 1.

Exposure and likelihood369 words
  • Who is hit. Any UnifiedHighlighter use, defaults included: default maxLength 10,000, offsets in postings, term vectors or re-analysis (all three executed), any analyzer (the cut is made on the stored text, not on tokens), default writer and codec. One document is enough; no scale is needed.
  • Which text. A stored value longer than the budget that contains supplementary characters: emoji in user text, CJK Extension B and later ideographs, historic scripts, mathematical alphanumerics. The first half of such a character must be exactly the last kept unit (index maxLength - 1 of the first value, or the budget edge of a later value), and the chosen passage must reach the cut. Per truncated value the chance is roughly the share of units that start a supplementary character, so it is rare.
  • Effect. The snippet ends in one unpaired high surrogate. In the executed probe getBytes(UTF_8) replaced it with ? and a strict UTF-8 encoder threw MalformedInputException; other encoders may replace it differently (not run). The truncation itself does not change hits or scores. With offsets in postings or term vectors (and a term query) the matched word stays highlighted; on re-analysis with a JFlex analyzer a short truncated value can also lose its highlight, through a separate bug (next point).
  • Secondary symptom, through another bug. On the re-analysis path, StandardAnalyzer and the other JFlex tokenizers drop the tokens of a text that ends in a lone high surrogate (a separately registered bug), so a short truncated value comes back with no highlight: Hello \uD83D instead of <b>Hello</b> . In the hunt's runs the long re-analyzed value at the default maxLength kept its highlight. Either fix removes the symptom here (both executed). With the test framework's MockTokenizer the same path throws IllegalStateException (test-only).
  • Downstream (read, not executed). Solr's unified highlighting passes hl.maxAnalyzedChars (default 51,200) to setMaxLength, so it reaches the cut; Elasticsearch's unified highlighter loads values itself and calls FieldHighlighter directly, so it is likely not exposed; OpenSearch was not checked.
  • Why nobody noticed. One invisible or replaced character at the very end of a snippet, only when the cut lands inside a pair; the predecessor PostingsHighlighter had the same unchecked cut from 4.1.0 (2012) with no report.

Minimal repro

The test shows that with offsets in postings, UnifiedHighlighter.withMaxLength(7) cuts the stored value "Hello U+1F600 world" between the two UTF-16 halves of the emoji, so the snippet ends in an unpaired high surrogate.

TestUnifiedHighlighterMaxLengthSurrogatePair.java (JUnit test)65 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 org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.UnicodeUtil;

public class TestUnifiedHighlighterMaxLengthSurrogatePair extends LuceneTestCase {

  public void testFirstValueCutInsideSurrogatePair() throws Exception {
    Analyzer analyzer = new StandardAnalyzer();
    FieldType offsetsInPostings = new FieldType(TextField.TYPE_STORED);
    offsetsInPostings.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
        Document doc = new Document();
        doc.add(new Field("body", "Hello \uD83D\uDE00 world", offsetsInPostings));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query query = new TermQuery(new Term("body", "hello"));
        String snippet =
            UnifiedHighlighter.builder(searcher, analyzer)
                .withMaxLength(7)
                .build()
                .highlight("body", query, searcher.search(query, 1))[0];

        // expected whole characters only (maxLength 6 gives "<b>Hello</b> "); actual ends in U+D83D
        assertTrue(
            "expected a well-formed snippet, but was: " + snippet.replace("\uD83D", "\\uD83D"),
            UnicodeUtil.validUTF16String(snippet));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-775/, 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.uhighlight.TestUnifiedHighlighterMaxLengthSurrogatePair
Expected vs actual
Expected a well-formed snippet (whole characters only; maxLength 6 gives <b>Hello</b> ). Actual: <b>Hello</b> \uD83D.

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

More files, environment, full output37 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.AssertionError: expected a well-formed snippet, but was: <b>Hello</b> \uD83D
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.search.uhighlight.TestUnifiedHighlighterMaxLengthSurrogatePair.testFirstValueCutInsideSurrogatePair(TestUnifiedHighlighterMaxLengthSurrogatePair.java:59)

Why it looks unintended

  • A cut one unit earlier meets the same maxLength budget, as maxLength 6 shows; the formatter promises "original text".
  • Lucene fixed the same pattern as a bug in TruncateTokenFilter ("no longer produce half surrogates", 10.5.0) and in this module's SimpleHTMLEncoder (4.5.0).
  • No highlighter test accepts a split pair; the pinned mid-word cut passes with the fix.
The full argument223 words
  • The budget is an upper bound, and the fix keeps it. "The maximum content size to process" (UnifiedHighlighter.java:597) is met by a cut one unit earlier, which is what maxLength 6 already produces. The fix keeps the UTF-16 unit of maxLength, and every test of the executed highlighter test task still passes (728 tests, assertions on and off). No javadoc promises well-formed snippets; the argument rests on the bound, the formatter's wording and the precedents.
  • The formatter's contract is original text. DefaultPassageFormatter.append: "Appends original text to the response" (DefaultPassageFormatter.java:101). The stored value was well-formed; half a code point is not text in any encoding.
  • Precedents, fixed as bugs. CHANGES.txt:1215-1217, 10.5.0 Bug Fixes: "GITHUB#15899, GITHUB#15900: Improve TruncateTokenFilter to truncate on codepoints not chars and no longer produce half surrogates." In this module, CHANGES.txt:13362-13364, 4.5.0 Bug Fixes: "LUCENE-5191: SimpleHTMLEncoder in Highlighter module broke Unicode outside BMP because it encoded UTF-16 chars instead of codepoints." Also LUCENE-8325 (smartcn) and LUCENE-3905 (test-framework truncation).
  • No highlighter test accepts a split pair. A source scan finds no highlighter test that combines supplementary characters with maxLength. The pinned truncation tests (TestUnifiedHighlighter.java:194-223, LUCENE-5166) cut mid-word, which is still well-formed text, and they pass with the fix. Of the threads read, none accepts a cut inside a character for the highlighter.
The devil's-advocate check304 words

The strongest case for "intended": maxLength is documented as a plain size cut in chars, with no unit and no promise of code-point-safe output; upstream tests pin a cut inside a word; in LUCENE-5166 @mikemccand accepted truncated passages ("I'm not even sure it needs fixing. It ought to be rare-ish that a truncated passage is selected."); TruncateTokenFilter still ships a char mode documented with "This may split surrogate pairs" (TruncateTokenFilter.java:53-54, :61-62); the visitor is @lucene.internal and its factory method can be overridden. In #15900 @uschindler first called a half surrogate after a UTF-16 cut "expected", like String#substring. Later in that thread @uschindler agreed that "The old variant produces incorrect tokens", and added: "But when the search side also produces the same incorrect tokens it's not a big issue. Now we produce different tokens, so you won't find the broken tokens anymore in an existing index."

Why it fails: the accepted LUCENE-5166 behaviour is a cut mid-word, not mid-character, and nobody there discussed code points. TruncateTokenFilter's char mode remains as documented legacy behaviour, kept for backwards compatibility (TruncateTokenFilter.java:34-37, :61-67); the reason given in #15900 was existing indexes (Uwe Schindler's comment above), and the factory javadoc warns that moving to code points may require reindexing (TruncateTokenFilterFactory.java:32-37). The factory's default switched to code points from luceneMatchVersion 10.5.0 (:93-97), under Bug Fixes. The highlighter's snippet is computed at query time and not indexed, so that reason does not apply, and the highlighter documents no such caveat. An overridable internal hook does not make the default behaviour intended. The fix changes only the one case where the output is not text. The independent reproducer, the intended-lens cross-model review (it rated the intended argument "moderate"), the devil's advocate, the adjudication and the audit all concluded it is not intended.

Suggested fix

Recommended Move a split cut back by one unit and mark the field as full (+22/-3)

Move a cut that falls between the two halves of a pair back by one unit, at both cut sites in LimitedStoredFieldVisitor.stringField, and mark the field as full so no later value is appended. The repro then passes, and so does the whole highlighter test task (728 tests), assertions on and off.

// both cut sites in stringField: end = endBeforeSplitPair(value, end); needsField stops once truncated
private int endBeforeSplitPair(String value, int end) {
  if (end > 0
      && end < value.length()
      && Character.isHighSurrogate(value.charAt(end - 1))
      && Character.isLowSurrogate(value.charAt(end))) {
    truncated[currentField] = true; // the budget is spent even though one unit is left
    return end - 1;
  }
  if (end < value.length()) {
    truncated[currentField] = true;
  }
  return end;
}
All options and trade-offs4 options Β· 681 words

Two fixes were built and validated through gradle at 645d4fe14a20: the recommended one (the hunt's fix) and a smaller variant that a probe showed to be weaker. The other options are listed for the discussion with maintainers.

Move a split cut back by one unit and mark the field as full (+22/-3) (validated by the checks) (recommended)

A helper used at both cut sites returns end - 1 when unit end - 1 is a high and unit end a low surrogate, and records in a per-field boolean[] (allocated in init()) that the field's budget is spent, also for an ordinary cut; needsField then stops loading further values of that field. The budget stays in UTF-16 units.

// both cut sites in stringField: end = endBeforeSplitPair(value, end); needsField stops once truncated
private int endBeforeSplitPair(String value, int end) {
  if (end > 0
      && end < value.length()
      && Character.isHighSurrogate(value.charAt(end - 1))
      && Character.isLowSurrogate(value.charAt(end))) {
    truncated[currentField] = true; // the budget is spent even though one unit is left
    return end - 1;
  }
  if (end < value.length()) {
    truncated[currentField] = true;
  }
  return end;
}

Trade-offs

  • One small array per document. The helper both computes the cut and records state, which a reviewer may want split.
  • A moved-back cut leaves the content one unit short of maxLength; the flag is what keeps the visitor from appending a separator and an empty later value (see the smaller variant).
  • A PR could add the paste-ready test (or a shorter one) to TestUnifiedHighlighter.

What the checks validated

  • Scratch worktree at 645d4fe14a20, gradle: the paste-ready test fails 5 of 6 on stock main, assertions on and off, and 50 of 60 with -Ptests.iters=10; with the patch the whole :lucene:highlighter:test task passes, 35 classes, 728 tests, 0 failures, assertions on and off (counting the 6 paste-ready tests and the upstream truncation tests), and 60 of 60 with -Ptests.iters=10.
  • ecjLintMain, ecjLintTest, checkGoogleJavaFormat, forbiddenApisMain and forbiddenApisTest for the module pass.
  • Probes with the patch: a two-valued field cut inside the emoji gives <b>hello</b> and the no-match summary hello for 1 and 2 passages; with StandardAnalyzer on re-analysis the highlight comes back (<b>Hello</b> ).
  • Earlier, on a9779ef3c193: the finder's, the independent reproducer's and the devil's advocate's tests pass with it, the upstream mid-word cut control included, and the adjudication's re-run with a fresh seed (0 of 6 fail).

Only move a split cut back (+13/-2; validated, not recommended)

A static cutPoint(value, end) with the same pair check at both sites, and no new state.

Trade-offs

  • The whole highlighter test task passes with it (728 tests, 0 failures) and all 6 paste-ready tests pass.
  • After a moved-back cut the content is maxLength - 1 long, so the next value is still accepted and the U+0000 separator plus an empty value is appended (hello \u0000). Snippets stay right, but a two-passage no-match summary of that field returns hello \u0000, the separator NUL of a separately registered bug, instead of hello . Stock does the same when a value ends exactly one unit before the budget, so this adds one more way into that bug.

What the checks validated

  • Gradle at 645d4fe14a20: the whole highlighter test task with the paste-ready test, assertions on: 728 tests, 0 failures; the two probes above (runner); the encode probe, run by the fact check, gives the same clean <b>Hello</b> as the recommended fix for offsets in postings and for re-analysis with StandardAnalyzer.

Count maxLength in code points (not recommended)

Measure the budget in code points instead of chars.

Trade-offs

Would change the current UTF-16-unit behaviour, and with it the content size and output length for every text with supplementary characters; the javadoc does not define the unit, but existing snippet lengths would change. Nothing needs it. Not run.

Application-side workaround (not a fix)

Override newLimitedStoredFieldsVisitor (protected, @lucene.internal, UnifiedHighlighter.java:1409-1411) with a visitor that backs the cut off, or strip a trailing lone high surrogate from returned snippets.

Trade-offs

Every application has to know about it. Not run.

Downstream note

By code reading, Solr's unified highlighting passes hl.maxAnalyzedChars (default 51,200) as maxLength and so reaches this cut, while Elasticsearch loads values itself and likely does not; what one half character does downstream depends on the application's UTF-8 encoder (in the executed probe getBytes(UTF_8) gave ? and a strict encoder threw; other encoders were not run).

How it was found

Semi-formal. A bounded-exhaustive metamorphic test ran the real highlighter on small texts and on copies whose letters and digits were mapped to supplementary-plane characters, requiring that no snippet contain an unpaired surrogate. It failed on its first run, for all three offset sources it covered, whenever maxLength fell inside a pair; reading the visitor located the cut.

The method in full214 words
  • Harness. A translation-validation harness over the stock jars: 2,238 single-valued texts (all 1-3-token sequences over a, b, ab, Ab, B1, 1 with space or . separators, and all 4-token sequences) and 1,764 two-valued documents; StandardAnalyzer and KeywordAnalyzer; fields with offsets in postings, term vectors and none (re-analysis); 49 and 35 queries of 11 kinds (term, boolean, phrase, slop, prefix, wildcard, regexp, fuzzy, range, span, intervals); maxLength 10000 and 1-30; three mappings of letters and digits (Deseret and Adlam, Armenian and Adlam, and an all-BMP control, Georgian and Saurashtra).
  • Relations. "No snippet of a mapped text contains an unpaired surrogate" failed 2,202,831 times in 57,460,950 checks, always with the cut between the two halves of a character; the all-BMP control failed 0 times. Hits and scores never differed. With the cut on a code-point boundary every mapped snippet was the image of the original one, apart from 1,622 differences explained by two UTF-16-length heuristics that are not part of this report (the passage centre for phrase matches across a boundary, and PassageScorer's length weights for fuzzy matches).
  • Bridge. Reading LimitedStoredFieldVisitor while modelling the cut located the two lines; a public-API JUnit on the stock jars failed 5 of 5 on main, 10.5.2 and 9.12.3 and passed with the fix.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream trackers or the web (2026-10-27, re-checked 2026-10-29), nor in the changed files of the 408 open PRs. Nearest: a 2019 downstream report of the same symptom from another highlighter. Not fixed upstream.

Searches4 searches

No report of this defect was found, for the UnifiedHighlighter or its predecessor PostingsHighlighter. The nearest items show the same pattern in other components, fixed as bugs, or other defects of the same cut. Upstream main 645d4fe14a20 (2026-10-28), branch_10x and branch_10_6 keep both cut lines (read).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), JIRA archive (LUCENE, SOLR), mailing lists (dev@, java-user@, issues@, commits@, Solr lists), downstream trackers, web2026-10-27a 61-query scripted prior-report search run, 38 direct GitHub searches over Lucene, Solr, Elasticsearch and OpenSearch, 13 JIRA and 70 list queries, git history; plus the finder's 51 and a verifier's 27 queries263 tool hits, 0 high and 17 medium overlap, all read: none is this bug; precedents only: #15899/#15900 (TruncateTokenFilter), #6255 (LUCENE-5191), #9372 (LUCENE-8325), LUCENE-3905; other defects of the predecessor's cut: #6096 (LUCENE-5032), #6230 (LUCENE-5166)
Open PRs2026-10-27the changed files of all 408 open apache/lucene PRs4 touch uhighlight; #12295 edits stringField only for an instanceof cast; none touches the cut lines
Downstream2026-10-27issue searches and code readingelastic/elasticsearch#45771 (2019, closed): the same symptom from the FastVectorHighlighter on a short value; a maintainer said the unified highlighter handled that emoji
GitHub issues and PRs (apache/lucene), re-check2026-10-29UnifiedHighlighter surrogate, highlighter maxLength surrogate, highlight unpaired surrogate, UnifiedHighlighter emoji, LimitedStoredFieldVisitor; four more queries hit the search rate limitnothing new (#8819 and #6096 are known non-matches); both cut lines unchanged on main, branch_10x and branch_10_6

History

Present since the UnifiedHighlighter was added (LUCENE-7438, released in 6.3.0, 2016); later commits kept the cut. Executed on 17 releases from 6.3.0 to 10.5.2 and on main: all affected; never fixed. The predecessor PostingsHighlighter had the same unchecked cut from 4.1.0 until its removal in 7.0.0.

Versions tested8 rows

Introduced with the class: LUCENE-7438 "New UnifiedHighlighter" (migrated as #8490; pull request apache/lucene-solr#79 by @Timothy055, closed without a GitHub merge; committed by @dsmiley on 2016-10-04 as 722e8271243 on master and the same-day branch_6x cherry-pick 4b6794368df, first released in 6.3.0). Not a regression. Later commits kept the arithmetic: LUCENE-8805 5a694ea26ff (signature), LUCENE-9570 6faa4f98e04 (reformat), #13085 8f759d5bf65 (append(value, 0, n) instead of substring, same characters), #15842 b59848c7049 (instanceof pattern). Lineage: PostingsHighlighter's append(value, 0, maxLength - builder.length()) (LUCENE-4290, 4.1.0, 2012, @rmuir; removed in 7.0.0 by LUCENE-7815).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthis doc's test fails 5 of 6 (gradle and runner, assertions on and off)
10.5.2, 9.12.3 affectedthe same (this doc's test, runner); the finder's JUnit fails 5 of 5
9.1.0, 10.0.0 affectedthe finder's JUnit fails 5 of 5; the probe 5 of 5
7.7.0 to 9.0.0 probehalf surrogate in all 5 scenarios, controls clean (7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0); with StandardAnalyzer the short re-analyzed value also loses its highlight
6.3.0 to 7.6.0 probehalf surrogate in all 5 scenarios, controls clean (6.3.0 with assertions on and off, 6.6.6, 7.0.0, 7.2.1-7.6.0); the re-analyzed highlight is kept
6.2.1 no APIno UnifiedHighlighter (the probe does not compile)
4.1.0, 6.2.1, 6.3.0 (PostingsHighlighter) predecessorthe same unchecked cut, 3 of 3 scenarios; the class was removed in 7.0.0, so this is lineage
main + the fix not affectedall 6 pass; the whole highlighter test task passes (728 tests)

Independent checks

The finder, an independent reproducer, a devil's advocate, the adjudication and a credibility audit each ran tests on the stock jars and said real; the audit lowered the impact to minor. Reading-only reviews by another model family (OpenAI GPT-5.6): real (medium), and not real only because it could not run code. This doc re-ran everything through gradle.

Checks and verdicts358 words

Every check except the cross-model reviews executed code (the hunt's checks on main a9779ef3c193, this doc's on 645d4fe14a20).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate5-test public-API JUnit with StandardAnalyzer: 5 of 5 fail on main (on and off), 10.5.2 and 9.12.3, pass with the fix; the exhaustive harness's 2,202,831 failures all have the cut inside a pair, the all-BMP control none.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real (low), proceduralCould not run code, so it could not say real; it agreed with the root cause, found the inputs in contract and called the static evidence strongly in favour of a defect. A separate run re-ran the repro: 5 of 5 fail.
independent reproducer realOwn 6-test JUnit with controls at maxLength 6 and 8 and a WhitespaceAnalyzer case: 6 of 6 fail on main (on and off), 10.5.2 and 9.12.3; the MockTokenizer path throws; 0 of 6 with the fix.
cross-model review, intended lens (OpenAI GPT-5.6, reading only) real (medium)Rated the "exact UTF-16 budget" argument moderate and found that it fails; separate runs fail on main and 10.5.2 and pass with the fix.
intended-behaviour devil's advocate real; not intendedBuilt the strongest intended case and showed why it fails; its intent test fails exactly the 3 cut-inside-pair cases of 6, a strict encoder throws, and the pinned mid-word cut stays well-formed with and without the fix.
adjudication confirmed (high)Re-ran the independent reproducer's test with a new seed: 6 of 6 fail, on and off; 0 of 6 with the fix. Low priority.
credibility audit real; impact lowered to minorRe-ran the finder's test: 5 of 5 fail, on and off. Graded display text only, like the sibling bug in the matchhighlight module.
this doc repro and fix confirmedPaste-ready test fails 5 of 6 through gradle (on and off; 50 of 60 with -Ptests.iters=10) and the runner (main, 10.5.2, 9.12.3); with the fix the whole highlighter test task passes (728 tests), on and off; probes showed why the smaller variant is not recommended.

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

Finding record

LFV-775, snapshot of 2026-11-06

Registry title
UnifiedHighlighter cuts a stored value at maxLength UTF-16 units even between the two halves of a supplementary character (an emoji, a CJK Extension B ideograph), so the snippet can end in an unpaired high surrogate, for example '<b>Hello</b> \uD83D'
Area
highlighter / UnifiedHighlighter stored-value loading (LimitedStoredFieldVisitor maxLength cut)
Priority
Low, rank 555: The default UnifiedHighlighter maxLength cut can leave an unpaired high surrogate at the end of a snippet, which only affects display and needs a supplementary character exactly at the cut.
Impact class
minor
Impact note
Applications that highlight stored text with the UnifiedHighlighter in its default configuration (default maxLength 10,000; offsets in postings, term vectors and re-analysis (POSTINGS, TERM_VECTORS and ANALYSIS, all three executed); any analyzer; by code reading also Solr's unified highlighting, which passes hl.maxAnalyzedChars, default 51,200) get a snippet that ends in one unpaired high surrogate whenever a stored value is longer than the budget, the first half of a supplementary character sits exactly at the last kept unit (UTF-16 index maxLength-1 of the first value, or the per-value budget edge of a later value of a multi-valued field) and the chosen passage runs to the cut; encoding the snippet with getBytes(UTF_8) gives '?' and a strict UTF-8 encoder throws (other encoders not run), while hits and scores are never wrong and nothing throws inside Lucene's production code; on the ANALYSIS path with StandardAnalyzer a short truncated content also loses its highlight (through the registered LFV-158); no scale is needed; released since 6.3.0.
Summary
You store the value 'Hello U+1F600 world' (the emoji takes UTF-16 indexes 6 and 7), search for hello and highlight the hit with UnifiedHighlighter.builder(searcher, analyzer).withMaxLength(7), and expect a snippet made of whole characters, '<b>Hello</b> ', just as maxLength 6 gives '<b>Hello</b> ' and maxLength 8 gives the whole emoji. Instead LimitedStoredFieldVisitor keeps exactly 7 UTF-16 units, so the content and the snippet end with the first half of the emoji, '<b>Hello</b> \uD83D', which String.getBytes(UTF_8) turns into '<b>Hello</b> ?' and a strict UTF-8 encoder rejects. The same happens at the default maxLength 10,000 when a value's 10,000th unit is the first half of a supplementary character, and for a later value of a multi-valued field at its per-value budget.
Introduced
722e82712435ecf46c9868137d885484152f749b (LUCENE-7438 'New UnifiedHighlighter', migrated as apache/lucene#8490, fix version 6.3; pull request apache/lucene-solr#79 by Timothy055, closed without a GitHub merge; committed by dsmiley on master, first released in 7.0.0) and its same-day branch_6x cherry-pick 4b6794368df373df1f68ccf27f7556914efeb95e, which first ships in 6.3.0: it added LimitedStoredFieldVisitor with both cut lines [read: git log -L on stringField, git blame of UnifiedHighlighter.java:1446-1447, 1450-1464 and 1474-1476, git log -S on both cut expressions; the later commits LUCENE-8805 5a694ea26ff (signature only), LUCENE-9570 6faa4f98e04 (formatting), #13085 8f759d5bf65 (append(value, 0, n) instead of substring, same characters) and #15842 b59848c7049 (instanceof pattern) kept the arithmetic; exec: 6.2.1 has no UnifiedHighlighter, 6.3.0 is affected]. Not a regression: the class had the defect in its first version. The same unchecked cut is older than the class: its predecessor PostingsHighlighter (LUCENE-4290, 6ac21f776c7 on branch_4x / f2d1557c5d1 on trunk, rmuir, 2012-12-11, first released in 4.1.0; removed in 7.0.0 by LUCENE-7815) cut with append(value, 0, maxLength - builder.length()) [exec on 4.1.0, 6.2.1 and 6.3.0]; that class is gone, so it is lineage, not part of this row. (2016-10-04 (LUCENE-7438); the predecessor PostingsHighlighter cut dates from 2012-12-11 (LUCENE-4290))
First affected version
6.3.0 [exec, -ea and -da]: the first release with the UnifiedHighlighter (branch_6x cherry-pick 4b6794368df); 6.2.1 has no UnifiedHighlighter [exec: compile error, package org.apache.lucene.search.uhighlight does not exist]. Lineage only: PostingsHighlighter had the same cut from 4.1.0 until its removal in 7.0.0 [exec on 4.1.0, 6.2.1, 6.3.0; 4.0.0 has no postingshighlight package].
Affected versions
every release from 6.3.0 to 10.5.2 (all 87 releases: 6.3.0-6.6.6, 7.0.0-7.7.3, 8.0.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2), 10.6.0 (by reading) and main a9779ef3c193 (unreleased 11.0); never fixed upstream. [exec] the follow-up's version-portable probe UhMaxLengthSurrogateHistoryProbe (constructor plus setMaxLength, NIOFSDirectory; the candidate's 5 scenarios plus 2 controls with the cut next to the emoji) is affected 5 of 5 on 6.3.0 (-ea and -da), 6.6.6, 7.0.0, 7.2.1, 7.3.0, 7.4.0, 7.5.0, 7.6.0, 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), with both controls well-formed and highlighted on every version; the candidate JUnit (builder API, so 9.1.0 and later) fails 5 of 5 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails 6 of 6 on main (-ea and -da), 10.5.2 and 9.12.3. [read] both cut lines and no surrogate handling at all 78 tagged releases from 6.3.0 to 10.5.2 in the history clone (8.8.2-8.11.3 are not tagged there; they lie between 8.8.1 [read] and 8.11.4 [exec]); upstream main a95c754ee003 (2026-10-26, the latest local fetch), branch_10x, branch_10_6 and branch_10_5 keep both lines (UnifiedHighlighter.java:1447 and :1464). The secondary lost-highlight symptom (a short re-analyzed value with StandardAnalyzer) appears only from 7.7.0 (7.6.0 keeps the highlight), which is LFV-158's boundary; the unpaired surrogate itself is present from 6.3.0 on.
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-27, the prior-report record: a scripted prior-report search of 61 queries (20 GitHub issue/PR queries on apache/lucene, 3 commit, 2 downstream-repository, 24 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 7 of 8 web), 263 hits with 0 high and 17 medium overlap, every medium hit read; 38 direct GitHub searches over apache/lucene, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; all 408 open apache/lucene PRs listed with their changed files (4 touch uhighlight, none the cut lines: #12295 changes only the instanceof cast in stringField, #13165, #16881 and #16851 are elsewhere); 13 LUCENE and SOLR JQL queries; 70 lists.apache.org queries over 7 lists; git history searches; plus the finder's run (51 queries) and the intended-behaviour check's run (27 queries); re-checked 2026-10-27 with 4 GitHub searches: nothing new); not fixed upstream (main a95c754ee003, branch_10x, branch_10_6 and branch_10_5 keep UnifiedHighlighter.java:1447 and :1464 [read]); nearest items, not reports of this bug: elastic/elasticsearch#45771 (2019, the same symptom, a lone high surrogate at the end of a highlight, from the FastVectorHighlighter's fragment boundary on a short value; a maintainer replied that the unified highlighter handled that emoji), #15899/#15900 (TruncateTokenFilter half surrogates, fixed in 10.5.0 under Bug Fixes), #6255 (LUCENE-5191, SimpleHTMLEncoder broke non-BMP characters in this module, fixed in 4.5), #9372 (LUCENE-8325, smartcn) and LUCENE-3905 (test-framework truncation), the same pattern in other components; #6096 (LUCENE-5032) and #6230 (LUCENE-5166), other defects of the predecessor PostingsHighlighter's maxLength cut, with no mention of surrogates; downstream reach [read, not executed]: Solr's UnifiedSolrHighlighter calls setMaxLength(hl.maxAnalyzedChars, default 51200), so Solr's unified highlighting goes through this cut; Elasticsearch's CustomUnifiedHighlighter loads the value itself and calls FieldHighlighter.highlightFieldForDoc directly, bypassing LimitedStoredFieldVisitor, so Elasticsearch is likely not exposed; OpenSearch was not checked
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree: the paste-ready test with assertions on and off and with -Ptests.iters=10, the whole highlighter test task with each of the two fixes, the module's lint, format and forbidden-API checks; the standalone JUnit runner and four probes); the 10.5.2 and 9.12.3 release jars (this doc's test); 6.3.0 to 10.5.2 (the hunt's version-portable probe). The hunt and its checks ran on main a9779ef3c193, whose highlighter module is identical to 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:1446-1447 (first value: value.substring(0, Math.min(maxLength, value.length()))), :1464 (later values: append(value, 0, Math.min(lengthBudget - 1, value.length()))), :1475 (stop condition), :354 (withMaxLength javadoc), :596-603 (getMaxLength javadoc), :100 (DEFAULT_MAX_LENGTH = 10000); a Lucene clone origin/main a95c754ee003, origin/branch_10x, origin/branch_10_6, origin/branch_10_5: the same two cut lines at :1447 and :1464
The finding in 30 seconds
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:100, (at 645d4fe14a20) :354, :596-603, :696-709, :958, :1253-1273 (:1268-1273), :1382-1411 (:1387, :1395, :1409-1411), :1438-1466 (:1446-1447, :1450, :1463-1464), :1468-1479 (:1475)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:164-166, (at 645d4fe14a20) :186; DefaultPassageFormatter.java:101, :108-138
Why it looks unintended
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:354, (at 645d4fe14a20) :596-603; DefaultPassageFormatter.java:101
  • lucene/CHANGES.txt:1175 (at 645d4fe14a20) (10.5.0 Bug Fixes), :1215-1217 (GITHUB#15899, GITHUB#15900); :13320 (4.5.0 Bug Fixes), :13362-13364 (LUCENE-5191)
  • lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:194-223 (at 645d4fe14a20) (testFormatWithMatchExceedingContentLength*)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/TruncateTokenFilter.java:34-37, (at 645d4fe14a20) :53-54, :61-67; TruncateTokenFilterFactory.java:32-37, :93-97
  • github.com/apache/lucene #15900, comment by uschindler, 2026-04-02T22:29:57Z (read with the GitHub API on 2026-10-29)
Suggested fix
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:1409-1411 (at 645d4fe14a20) (newLimitedStoredFieldsVisitor)
History
  • a Lucene clone: git show origin/main (645d4fe14a20), origin/branch_10x (657014b3c579), origin/branch_10_6 (4ae946e5a9b9), releases/lucene/10.5.2: UnifiedHighlighter.java:1447, :1464

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 main 645d4fe14a20 and the executed runs; every value drawn should come from the executed runs and the source at that commit.

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.