All findings · Lucene finding · LFV-545 · simplepattern-false-alarm-retry-splits-surrogate-pair

SimplePatternTokenizer and SimplePatternSplitTokenizer retry a failed match in the middle of an emoji, so 🔥hot is indexed as U+FFFD + hot and so🔥hot is cut in two

With the pattern 🔥[0-9]+|[^🔥 ]+, SimplePatternTokenizer turns 🔥hot deal into \uDD25hot, deal, so a search for hot misses the document. After a failed match at an emoji, it retries one Java char later, inside the emoji.

Summary

Priority
Low, rank 524A surrogate pair is split only with a narrow pattern shape in the opt-in simple-pattern tokenizers, a rare-input surrogate-cut analysis row like LFV-459.
Impact
silent wrong results
Since
6.5.0 (LUCENE-7465, committed 2017-02-13)introduced 2017-02-13 · about 9 years
Affected
6.5.0 to 10.5.2 and main
Status
documented
Found by
bounded-exhaustive translation validation

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

SimplePatternTokenizer tries its pattern at each character and keeps the longest match. An emoji is one character to the pattern but two chars in Java. When a try that started at an emoji fails, the tokenizer should move on to the next character; it moves on by one char and lands inside the emoji.

The text 🔥hot deal shown as ten UTF-16 units, with the emoji as two halves at offsets 0 and 1. A failed try starts at the emoji and dies at h. An arrow shows where the retry should start (offset 2, h) and where it does start (offset 1, the second half of the emoji), giving the token \uDD25hot instead of hot.
🔥hot deal as UTF-16 units: the try at 🔥 (units 0-1) dies at h. The retry should start at h (offset 2) but starts at the emoji's second half (offset 1), so the token is \uDD25hot [1,5) instead of hot [2,5), and the index holds \uFFFDhot. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 🔥hot deal with SimplePatternTokenizer (factory name simplePattern) and the pattern 🔥[0-9]+|[^🔥 ]+: 🔥 followed by digits (like 🔥5), or a run of characters other than 🔥 and space. In Java, 🔥 (U+1F525) is two chars, a surrogate pair, at offsets 0 and 1.
  2. You expect hot [2,5) and deal [6,10): no match starts at 🔥 (no digit follows), so the next try starts at h. The same tokenizer gives exactly that with [^🔥 ]+ alone, and so does PatternTokenizer with the same regex.
  3. You get \uDD25hot [1,5) and deal. After the failed try at 🔥 the tokenizer steps back to one char past the start, which is the emoji's second half, and [^🔥 ] accepts that half. The index stores \uFFFDhot, so a TermQuery for hot finds 0 documents instead of 1.
  4. 🔥 x gives an extra token \uDD25 [1,2) before x. SimplePatternSplitTokenizer with the separators [^a-z🔥]+|🔥! cuts so🔥hot now into so\uD83D, hot, now instead of so🔥hot, now. The tokenizers do not throw; they emit these tokens with assertions on or off.

Who is affected

Users of the opt-in, experimental simplePattern or simplePatternSplit tokenizers whose pattern can start a longer alternative at an emoji or other supplementary character and also accepts its second half (a negated class that excludes it, or a range reaching into the surrogate block). Text then hits it whenever the character is not followed by the rest of its alternative.

Mechanism, terms and numbers1,002 words

SimplePatternTokenizer and SimplePatternSplitTokenizer read their input one code point at a time, so an emoji is one step of the automaton even though it is two Java chars ("The runDFA operates in Unicode space, not UTF16", SimplePatternTokenizer.java:114). When a match attempt that started at such a character fails after reading further, the false-alarm branch pushes back all but one char of what it read (pushBack(tokenUpto - 1), :155; pushBack(sepUpto - 1), SimplePatternSplitTokenizer.java:166), so the next attempt starts at the emoji's low surrogate, which the reader returns as a code point of its own.

If another part of the pattern accepts that half, typically a negated class that excludes the emoji or a range that reaches into the surrogate block (such as [\u0080-\uFFFF]; the pattern need not name a supplementary character), the tokenizer emits a token that starts inside the pair (\uDD25hot [1,5) instead of hot [2,5)), or an extra token made of half the emoji; the split tokenizer ends a token after the first half (so\uD83D) and loses the word so🔥hot. The index stores the lone halves as U+FFFD, so exact-term searches for the affected words miss. The line is in the classes' first commit (6.5.0) and fails on every release run, up to 10.5.2 and main. The fix changes two lines per class: push back all but the first code point.

How the tokenizer scans

SimplePatternTokenizer.incrementToken() (SimplePatternTokenizer.java:104-162 at a9779ef3c193) on 🔥hot deal, whose UTF-16 units are 0:D83D 1:DD25 2:h 3:o 4:t 5:space 6:d 7:e 8:a 9:l:

  1. Read one code point and start the automaton (:116, :121). nextCodePoint() (:242-253) sees the high surrogate and joins it with the next unit (:248-249), so ch = U+1F525 and two units are in the term buffer. step(0, U+1F525) is live (it begins 🔥[0-9]+) but not accepting.
  2. Look ahead (:126-139). h is not a digit, so the automaton dies. Nothing was accepted and the input did not end: the false-alarm branch (:153-156).
  3. The false alarm rewinds by chars. pushBack(tokenUpto - 1) (:155, tokenUpto = 3 units) pushes back U+DD25 and h; the next line, tokenUpto = 0 (:156), discards the high surrogate already read, so the next try starts at offset 1, inside the pair. The comment says "push back all but the first character we scanned", but the first thing scanned was a two-unit code point.
  4. The retry starts inside the pair. The next nextCodePoint() reads U+DD25, which is not a high surrogate, so it is returned as the code point U+DD25 (:248-252). Lucene RegExp negated classes are any code point from 0 to 0x10FFFF minus the listed ones (RegExp.java:1302, Automata.java:105-106), so [^🔥 ] accepts it, and the token is \uDD25hot [1,5) (:141-150).
  5. The other no-match branch does it right. When the first step already fails (state == -1, :158-160), the whole code point is dropped: with [^🔥 ]+ alone both units of 🔥 are skipped and the next try starts at h. So whether 🔥 is stepped over correctly depends on whether some alternative can start with it.

The split tokenizer

SimplePatternSplitTokenizer (SimplePatternSplitTokenizer.java:99-170) looks for separators the same way, and every code point that does not start one stays in the pending token (:213-220). On so🔥hot now the try at 🔥 starts 🔥!, dies at h, and pushBack(sepUpto - 1) (:163-167) leaves so\uD83D in the pending token (here the high surrogate is kept). The retry reads U+DD25, which [^a-z🔥]+ accepts as a one-unit separator, so so\uD83D [0,3) is emitted and hot [4,7) starts a new token.

Exact trigger

All three are needed; naming a supplementary character is not. (a) An attempt that starts at a supplementary character goes live and then dies after reading further, before the end of the input: some alternative starts with the character, by naming it (🔥[0-9]+, 🔥[a-z]+, 🔥!, 🔥🔥) or through a class that contains it followed by more (.[0-9]+, [^a-z ][0-9]+, [^ ]+!). (b) Nothing was accepted in that attempt: a top-level . or [^ ]+ accepts the whole character, so the longest match is the token and no retry happens (😀ab|. on 😀x and 🔥[0-9]+|[^ ]+ are correct). (c) Another part of the pattern accepts a match that starts with the exposed low surrogate: a negated class that excludes the character ([^🔥 ]), any range that reaches into the surrogate block U+D800-U+DFFF (the common "non-ASCII" [\u0080-\uFFFF], or [\u3000-\uFFEF]), or a hand-built automaton that accepts surrogate code points. (\uXXXX here stands for the character itself, as a Java string literal gives it; Lucene RegExp rejects \u as an escape.)

Executed on main without naming any supplementary character (the doc's fact and diagram checks): .[0-9]+|[\u0080-\uFFFF]+|[a-z]+ on 🔥hot deal gives an extra \uDD25 [1,2) before hot, deal; [^ ]+!|[\u3000-\uFFEF]+ on 🔥中 b gives \uDD25中 [1,3) where PatternTokenizer gives 中 [2,3); the split tokenizer with [\u0080-\uFFFF]+|.! cuts so🔥hot now into so\uD83D, hot now. With the fix the diagram check's cases are correct.

If any of the three fails, the tokens are correct: a retry at the low surrogate that accepts nothing ends at the next code point, as a code-point retry would (:153-160). Executed correct: [^🔥 ]+ alone, 🔥[0-9]+|[^ ]+ and .[0-9]+|[a-z]+. The hunt's 39-pattern pool had no explicit range reaching into the surrogate block (its negated classes contain the block), so its counts say nothing about the range shapes: its 13,741 lone-surrogate mismatches per tokenizer were recorded (one example per class and pattern) only for the 2 pool patterns that meet all three conditions, while 3 more patterns that name the emoji were correct. When the try reaches the end of the input instead, another branch runs (:151-152): that is the sibling bug LFV-525.

Exposure and likelihood309 words
  • Who is exposed. Users of either tokenizer, directly or through the factories (SPI names simplePattern and simplePatternSplit, for example in CustomAnalyzer or a Solr schema), or through downstream tokenizers built on them, whose pattern has the shape above. A typical intent: keep an emoji-plus-something token such as 🔥5, and otherwise strip the emoji from words.
  • Opt-in. Both classes and factories are @lucene.experimental, and no Lucene analyzer uses them by default.
  • What it takes. With such a pattern, ordinary text triggers it every time the character is not followed by the rest of a live alternative and the try does not reach the end of the input: the emoji right before a word (🔥hot) or a space (🔥 x), or inside a word for the split tokenizer (so🔥hot). 🔥5, where the alternative matches, is tokenized correctly.
  • Effect. Silent wrong tokens with offsets inside a surrogate pair, at any index size, with assertions on or off: an extra junk term (U+FFFD), the next word indexed under a U+FFFD-prefixed term (a search for the word misses the document), or a word cut into two terms (a search for the whole word misses, one for its tail matches). IndexWriter replaces lone surrogates with U+FFFD as documented, CheckIndex stays clean, and highlight snippets can contain unpaired surrogates of the document's own text. In the runs only the tokens of the value being tokenized changed. No hang was seen: in the hunt's cost probe (up to 2.4M chars, one aarch64 host) the per-char cost of stock and fixed code did not grow with input size.
  • How likely. Rare, by judgement: it needs all three pattern conditions (the wider shapes found by the doc's checks were not counted), and no report since 6.5.0 (2017) was found in the public sources searched. Real deployments were not surveyed.

Minimal repro

The test shows that SimplePatternTokenizer and SimplePatternSplitTokenizer restart a failed match one UTF-16 unit after a supplementary character E (U+1F525), i.e. inside its surrogate pair.

TestSimplePatternSurrogateRetry.java (JUnit test)48 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.analysis.pattern;

import java.io.StringReader;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
import org.apache.lucene.util.automaton.Automaton;
import org.apache.lucene.util.automaton.Operations;
import org.apache.lucene.util.automaton.RegExp;

public class TestSimplePatternSurrogateRetry extends BaseTokenStreamTestCase {

  private static final String E = new String(Character.toChars(0x1F525));

  private static Automaton dfa(String regexp) {
    return Operations.determinize(
        new RegExp(regexp).toAutomaton(), Operations.DEFAULT_DETERMINIZE_WORK_LIMIT);
  }

  public void testTokenizer() throws Exception {
    Tokenizer t = new SimplePatternTokenizer(dfa(E + "[0-9]+|[^" + E + " ]+"));
    t.setReader(new StringReader(E + " x"));
    // expected [x] [3,4); actual [U+DD25 [1,2), x]: the retry after E starts at its low surrogate
    assertTokenStreamContents(t, new String[] {"x"}, new int[] {3}, new int[] {4}, 4);
  }

  public void testSplitTokenizer() throws Exception {
    Tokenizer t = new SimplePatternSplitTokenizer(dfa(E + "[0-9]+|[^" + E + "a-z]+"));
    t.setReader(new StringReader(E + "b"));
    // expected [Eb] [0,3); actual [U+D83D [0,1), b]: the retry after E starts at its low surrogate
    assertTokenStreamContents(t, new String[] {E + "b"}, new int[] {0}, new int[] {3}, 3);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-545/, 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: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.pattern.TestSimplePatternSurrogateRetry
Expected vs actual
Expected [x] at [3,4) for E[0-9]+|[^E ]+ on E x, and [Eb] at [0,3) for the separator pattern E[0-9]+|[^Ea-z]+ on Eb; actual [U+DD25, x] and [U+D83D, b] (lone surrogates), with -ea and -da.

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

More files, environment, full output60 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 2 test(s) run, 2 failed
    => org.junit.ComparisonFailure: term 0 expected:<[x]> but was:<[?]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:262)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:598)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:622)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:710)
       org.apache.lucene.analysis.pattern.TestSimplePatternSurrogateRetry.testTokenizer(TestSimplePatternSurrogateRetry.java:39)
  JUnit Vintage:TestSimplePatternSurrogateRetry:testSplitTokenizer
    MethodSource [className = 'org.apache.lucene.analysis.pattern.TestSimplePatternSurrogateRetry', methodName = 'testSplitTokenizer', methodParameterTypes = '']
    => org.junit.ComparisonFailure: term 0 expected:<?[?b]> but was:<?[]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:262)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)

Why it looks unintended

  • The code says the automaton runs "in Unicode space, not UTF16", reads 🔥 as one code point, and skips it whole when the first step fails.
  • An alternative that matches nowhere changes the output: [^🔥 ]+ gives hot, 🔥[0-9]+|[^🔥 ]+ gives \uDD25hot.
  • PatternTokenizer and String.split keep the pair intact; Lucene fixed the same output as bugs in other analysis components.
The full argument350 words
  • The code works in code points. SimplePatternTokenizer.java:114 and SimplePatternSplitTokenizer.java:111: "The runDFA operates in Unicode space, not UTF16 (java's char)". nextCodePoint() joins surrogate pairs, and the first-step-fails branch (:158-160) skips the whole code point. On well-formed text a lone surrogate exists only because the false alarm rewinds into a code point that was read whole.
  • Contract (no javadoc sentence mentions surrogates, defines where a retry starts or promises code-point-aligned boundaries: that this is not intended is inferred from the code and the comparisons, not from a documented contract). Class javadoc (SimplePatternTokenizer.java:34-35): "The matching is greedy: the longest match at a given start point will be the next token." Factory javadoc (SimplePatternTokenizerFactory.java:41-43): "the longest token matching at a given point is created." The split tokenizer is "just like SimplePatternTokenizer except that the pattern should make valid token separator characters, like String.split" (SimplePatternSplitTokenizer.java:33-34).
  • An unused alternative changes the tokens. Within 🔥hot deal, [^🔥 ]+ and 🔥[0-9]+|[^🔥 ]+ match the same substrings, yet the first gives hot, deal and the second \uDD25hot, deal. The same holds for [^a-z🔥]+ vs [^a-z🔥]+|🔥! on so🔥hot now. No start-point rule allows that.
  • The documented comparisons keep the pair. PatternTokenizer (group 0) gives hot, deal and x; in split mode (group -1) and with String.split, so🔥hot now gives so🔥hot, now.
  • Supplementary input is in scope. Upstream testOneToken and testNoTokens feed TestUtil.randomUnicodeString through both tokenizers (with .*, which never fails a try); no dedicated test uses a failing alternative that starts with a supplementary character. TestRandomChains can build these tokenizers from random regexps that may contain surrogate pairs, but it checks consistency and offsets, not surrogate integrity.
  • Precedent. lucene/CHANGES.txt lists as bug fixes "LUCENE-8325: Fixed the smartcn tokenizer to not split UTF-16 surrogate pairs" and "GITHUB#15899, GITHUB#15900: Improve TruncateTokenFilter to truncate on codepoints not chars and no longer produce half surrogates".
  • History. The false-alarm line is in the first commit (93fa72f77bd, LUCENE-7465) next to the Unicode-space comment; the issue (#8517) never discusses surrogates or where a retry starts.
The devil's-advocate check133 words

The strongest case for "intended": the pattern itself accepts lone surrogate code points ([^🔥 ] does), so the tokenizer only does what the automaton allows; both classes are @lucene.experimental; buffers and offsets count chars; and the comment says "push back all but the first character".

Why it fails: on well-formed text the automaton would never see a lone surrogate if the tokenizer kept the code points it read whole; java.util.regex gives [^🔥 ] the same meaning yet keeps the pair intact on these inputs; the same loop skips whole code points on its other no-match path; and the unused-alternative example shows the output is not a function of the pattern's language. @lucene.experimental marks API instability, not permission for malformed terms. Cross-model reviews (reading only) rated the works-as-designed case weak.

Suggested fix

Recommended Push back all but the first code point (validated)

Push back all but the first code point on a false alarm: record Character.charCount(ch) of the attempt's first code point and call pushBack(tokenUpto - firstLen) (sepUpto - firstLen in the split tokenizer). Two lines per class; the repro, the pattern package and the whole analysis-common module (1,914 tests) pass with assertions on and off.

All options and trade-offs5 options · 938 words

The recommended change was built at a9779ef3c193 and validated with gradle in a scratch worktree (the repro, TestSimplePattern*, the pattern package, the whole analysis-common module, assertions on and off, the module's static checks and TestRandomChains), and against the stock jars with the patched classes shadowed (this doc's repro, the finder's and a verifier's). It is code-identical to the hunt's fix, which the bounded-exhaustive check showed removes exactly this mismatch class. A verifier's independent fix is the unit-count variant below, also run. Ship it together with the sibling LFV-525 fix in the same function.

Push back all but the first code point (validated) (recommended)

surrogate-retry-fix.diff, +9/-5 over both classes: after the first step, record how many UTF-16 units the attempt's first code point has, and on a false alarm push back all but that many. The retry then starts at the next code point, as the first-step-fails branch already does.

diff --git a/lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java b/lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java
index 1914875560d..13704d4ce64 100644
--- a/lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java
+++ b/lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java
@@ -119,6 +119,8 @@ public final class SimplePatternSplitTokenizer extends Tokenizer {
         }
       }
       int state = runDFA.step(0, ch);
+      // UTF-16 length of the code point this attempt starts at
+      final int firstLen = Character.charCount(ch);
 
       if (state != -1) {
         // a token separator just possibly started; keep scanning to see if the token is accepted:
@@ -161,9 +163,9 @@ public final class SimplePatternSplitTokenizer extends Tokenizer {
             return false;
           }
         } else {
-          // false alarm: there was no token separator here; push back all but the first character
-          // we scanned
-          pushBack(sepUpto - 1);
+          // false alarm: there was no token separator here; push back all but the first code
+          // point we scanned
+          pushBack(sepUpto - firstLen);
         }
       }
     }
diff --git a/lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java b/lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java
index 61812a21ece..b67b219a8b4 100644
--- a/lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java
+++ b/lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java
@@ -119,6 +119,8 @@ public final class SimplePatternTokenizer extends Tokenizer {
       }
 
       int state = runDFA.step(0, ch);
+      // UTF-16 length of the code point this attempt starts at
+      final int firstLen = Character.charCount(ch);
 
       if (state != -1) {
         // a token just possibly started; keep scanning to see if the token is accepted:
@@ -151,8 +153,8 @@ public final class SimplePatternTokenizer extends Tokenizer {
         } else if (ch == -1) {
           return false;
         } else {
-          // false alarm: there was no token here; push back all but the first character we scanned
-          pushBack(tokenUpto - 1);
+          // false alarm: there was no token here; push back all but the first code point we scanned
+          pushBack(tokenUpto - firstLen);
           tokenUpto = 0;
         }
       } else {

Trade-offs

  • None found: one local per method, no new state.
  • Cost: in the hunt's cost probe (-da, one aarch64 host, 300k-2.4M chars where every emoji takes the retry) the per-char cost did not grow with input size; at 1.2M and 2.4M chars 6.2-13.9 ns/char with the fix vs 13.0-19.3 on stock.

What the checks validated

  • gradle at a9779ef3c193: the repro 5 of 5; TestSimplePattern* 34 tests; org.apache.lucene.analysis.pattern.* 87 tests (2 skipped); the whole analysis-common module 1,914 tests, 18 skipped, 0 failures; each with assertions on and off. check -x test (lint, forbidden APIs, formatting) passes; TestRandomChains passes with 2 seeds.
  • The new upstream-suite methods pass; on stock both fail.
  • Shadowed on the stock jars: this doc's repro 5 of 5 (-ea, -da), the finder's 2 of 2, a verifier's 8 of 8. The hunt's bounded-exhaustive check (4,140,572 checks): this mismatch class goes from 13,741 runs per tokenizer to 0, the sibling's end-of-input class stays; with both fixes, 0 mismatches. The same fix applied to the 6.5.0 sources passes there.

Land it with the end-of-input fix of LFV-525 (validated)

The sibling bug LFV-525 (a lookahead that reaches the end of the input without a match ends tokenization) is in the same function; both of its fix options already contain this change, and this doc's repro passes 5 of 5 with its recommended patch shadowed.

Trade-offs

If the end-of-input fix landed without this one, this bug would fire more often (15,432 instead of 13,741 runs per tokenizer in the exhaustive check), because more attempts reach the false-alarm retry.

Variant: count the units the first read consumed (a verifier's fix, run)

final int firstLen = tokenUpto; (sepUpto in the split tokenizer) right after the first nextCodePoint(), then the same pushBack(... - firstLen). This is the independent fix of verifier: its own 8-test JUnit passes 8 of 8 (-ea, -da) and the upstream suites 29 of 29 on main ea57360912ed, and this doc's repro passes 5 of 5 with it shadowed on main a9779ef3c193 (-ea, -da).

Trade-offs

The same as Character.charCount(ch) on well-formed text. On malformed text (an unpaired high surrogate, which nextCodePoint() joins with the next unit unchecked) it still counts exactly the units read; that is reasoned from the code, not tested.

Workaround without a Lucene change: exclude the surrogate range (validated on stock)

For patterns built in Java code, add \uD800-\uDFFF to every negated class, so a retry at a lone half dies at its first step and is skipped whole: 🔥[0-9]+|[^🔥 \uD800-\uDFFF]+ and [^a-z🔥\uD800-\uDFFF]+|🔥! give the expected tokens on all five inputs of this doc on stock main.

Trade-offs

Needs lone surrogate characters in the pattern string, which text configuration formats may not carry (not tried there); every user must know about it.

Document the current behaviour (not recommended)

State that a failed try at a supplementary character retries at its second char.

Trade-offs

Keeps malformed terms and the unused-alternative inconsistency, when the fix is two lines per class.

Downstream note

Elasticsearch and OpenSearch build their simple_pattern and simple_pattern_split tokenizers on these classes, so the same patterns give the same wrong tokens there (read in their sources, not run there).

How it was found

A bounded-exhaustive check ran both real tokenizers on every string of up to 6 code points over {a, b, space, -, 😀} with 39 patterns and compared them with the javadoc's longest-match rule. 13,741 runs per tokenizer produced a lone surrogate, all from 2 patterns; reading the code then located the false-alarm push-back.

The method in full183 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier). The checker ran the real SimplePatternTokenizer and SimplePatternSplitTokenizer on all 19,531 strings of up to 6 code points over {a, b, space, -, U+1F600}, for 39 RegExp patterns (4,140,572 checks, including PatternTokenizer controls), against a reference written from the class javadoc: the greedy longest match per start point, with membership decided by java.util.regex. The mismatch class "lone surrogate in token" (13,741 runs per tokenizer) came only from the 2 pool patterns that start an alternative with the emoji and exclude it from a negated class (😀a|[^😀a ]+, 😀😀|[^😀 ]\-). Reading the code then located pushBack(tokenUpto - 1) / pushBack(sepUpto - 1). The pool had no explicit range reaching into the surrogate block, so the check did not cover the range shapes of the trigger that the doc's checks found later.

The finder then wrote a JUnit through the public constructors (2 of 2 fail on main with assertions on and off, 10.5.2 and 9.12.3) and the one-line fix, which removes exactly this class from the check. Independent checks followed (below).

Prior reports, history and checks

Prior reports

No prior report: 102 tool queries over GitHub issues, PRs and commits, downstream trackers, the mailing lists, JIRA and the web, 43 manual GitHub, JIRA and list searches, and the files of all 409 open PRs (2026-10-26). Closest: the same defect class fixed in other components (TruncateTokenFilter, smartcn).

Searches5 searches

No report of this bug by anyone was found; the nearest items are the issue that added the classes, the 2017 end-of-input fix in the same branch, and surrogate-splitting fixes in other components. Limits: GitHub search is token based and only first result pages were read; GitHub's search rate limit slowed a verifier; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories (Elasticsearch, OpenSearch, Solr), mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, web2026-10-26the hunt's prior-report tool in two runs, 102 queriesno report
GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr)2026-10-2621 manual searches: class and factory names with surrogate, emoji, supplementary; simple_pattern, simple_pattern_split; LUCENE-7465no report
JIRA (LUCENE, SOLR) and mailing lists (including the Solr lists)2026-10-2613 JIRA and 9 list queriesno report
Open PRs and upstream branches2026-10-26the changed files of all 409 open apache/lucene PRs; both files read on main, branch_10x and branch_10_6two PRs touch analysis/pattern, neither this code (#12613, #16911); the line is unchanged on all three branches
Related items (read)2026-10-26issue threads and commits read in full#8517 (LUCENE-7465, added the classes; nothing on surrogates); commit 2d03aa21a2b (end-of-input case); #15899 / PR #15900 (TruncateTokenFilter), #9372 (LUCENE-8325, smartcn), LUCENE-7363 (DecimalDigitFilter): the same defect class elsewhere

History

Introduced with both classes by LUCENE-7465 (93fa72f77bd, 2017-02-13), first released in 6.5.0. Fails the same way on all 10 releases run from 6.5.0 to 10.5.2 and on main; the line has never changed.

Versions tested7 rows

Introduced by 93fa72f77bd (2017-02-13, LUCENE-7465 "add SimplePatternTokenizer and SimpleSplitPatternTokenizer", @mikemccand; a JIRA patch, no PR; issue migrated as #8517) and the branch_6x backport c24e03e6bf4 (byte-identical tokenizer files). They already have the "Unicode space, not UTF16" comment, nextCodePoint() and pushBack(tokenUpto-1) / pushBack(sepUpto-1). git blame credits 9a1702a8f5b (a byte-identical restore after a merge revert) and 8ef6a0da568 (reformatting); git log -L shows no other change. The 2017 follow-up 2d03aa21a2b (end-of-input lookahead) did not touch the push-back count. Not a regression.

By source the line is at every release tag 6.5.0-10.5.2 of the history clone (8.8.2-8.11.4 are not tagged there; 8.11.4 was run) and on upstream main, branch_10x and branch_10_6 (read 2026-10-26). Never fixed.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 3 of 5 fail (gradle with assertions on and off; standalone -ea and -da); TermQuery body:hot finds 0 documents
main ea57360912ed affectedthe finder's test 2 of 2 and a verifier's 6 of 8 fail (-ea and -da); the portable probe's 2 bug cases fail, its controls pass
10.5.2, 9.12.3 affectedthis doc's test 3 of 5 (10.5.2 -ea and -da, 9.12.3 -da); the finder's 2 of 2; the probe
6.5.0 to 10.0.0 (8 releases: 6.5.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0) affectedthe finder's test 2 of 2 and the probe's 2 bug cases fail with byte-identical wrong tokens; controls pass (-da too on 6.5.0)
6.5.0 + the fix fix checkthe fix applied to the 6.5.0 sources: the test and the probe pass
6.4.2 not affectedno SimplePattern* classes (the repro does not compile)
main + fix not affectedthe repro passes; the analysis-common module, 1,914 tests, passes with assertions on and off

Independent checks

An independent reproducer, a second executed probe, a devil's advocate and an adjudication said real and re-ran it with assertions on and off. Reviews by another model family (reading only) agreed on the root cause; one said "not real" only because it could not run code. An audit kept it.

Checks and verdicts457 words

Each check ran or re-ran a repro on the stock jars, except the reading-only reviews, for which a separate run re-ran the finder's repro; the verdicts below are each check's own.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit through the public constructors fails 2 of 2 on main (-ea and -da), 10.5.2 and 9.12.3; with the fix 2 of 2 pass and the upstream TestSimplePatternTokenizer and TestSimplePatternSplitTokenizer pass 29 of 29.
cross-model review, refute lens not real (low), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6, reading only) could not run code, so it voted not real on procedure only; it agreed with the root cause, judged the inputs in contract and rated the intended-behaviour case weak. A re-run of the repro failed (2 of 2).
cross-model review, intended lens real (medium)The same reviewer family, reading only: the best works-as-designed case (char-unit buffers and offsets, the 'all but the first character' comment, @lucene.experimental) is weak.
The cross-model review, executed probe for the reading-only lens realThrough the RegExp String constructors and the factory: 3 of 3 fail on main (-ea, -da) and 10.5.2, pass with the fix.
Independent reproducer realOwn 8-test JUnit with U+1F600: 6 of 8 fail on main (-ea, -da), 10.5.2 and 9.12.3, including the unused-alternative check and a random invariant (1,371 of 5,324 tokens bad); its own fix passes 8 of 8 and keeps the upstream tests at 29 of 29.
Devil's advocate real (high)Built the strongest intended-behaviour case and showed why it fails; PatternTokenizer, Matcher.find and String.split keep the pair intact on the same inputs.
adjudication confirmed (high)Re-ran the finder's JUnit (-ea and -da: 2 of 2 fail), the independent reproduction's (6 of 8 fail) and the finder's with the fix (2 of 2 pass).
audit keepRe-ran the finder's JUnit on main (-ea and -da: 2 of 2 fail) and an own probe through the factory: 😀[a-z]+|[^😀 ]+ on hi 😀 there gives an extra \uDE00 token, java.util.regex gives only hi, there. Committer view: a small PR with a test would be easy to accept.
this doc's runs real; fix validatedA new public-factory repro fails 3 of 5 on main a9779ef3c193 (gradle and standalone, assertions on and off), 10.5.2 and 9.12.3; the fix passes it and the whole analysis-common module.
doc fact checks real; wording correctedRe-ran this doc's gradle command at a9779ef3c193 (5 tests, 3 failed, assertions on and off) and the fix's suites, and found executed counterexamples that widened the trigger: patterns that name no supplementary character are affected too. A cross-model check (reading only) and a diagram check agreed; the doc was corrected.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,443 words

Finding record

LFV-545, snapshot of 2026-11-06

Registry title
SimplePatternTokenizer and SimplePatternSplitTokenizer restart a failed match one UTF-16 unit after a supplementary start character, so they can emit tokens that start or end inside a surrogate pair
Area
analysis / pattern tokenizers (analysis-common: SimplePatternTokenizer, SimplePatternSplitTokenizer and their simplePattern / simplePatternSplit factories)
Priority
Low, rank 524: A surrogate pair is split only with a narrow pattern shape in the opt-in simple-pattern tokenizers, a rare-input surrogate-cut analysis row like LFV-459.
Impact class
silent wrong results
Impact note
Hits only applications that choose SimplePatternTokenizer or SimplePatternSplitTokenizer (directly or through the simplePattern / simplePatternSplit factories) with a pattern that starts a longer alternative with a supplementary character (an emoji or other non-BMP literal or range) and elsewhere accepts that character's low surrogate (typically a negated class that excludes the character, '.', or a hand-built automaton): then each attempt that starts at that character and dies before the end of the input restarts inside the surrogate pair, so SimplePatternTokenizer emits an extra lone-surrogate term or glues the low surrogate onto the next word (stored as U+FFFD, offsets inside the pair) and SimplePatternSplitTokenizer cuts a term after the high surrogate and loses the original term, at any scale, with -ea and -da and no exception; patterns that do not name supplementary characters, or whose classes treat the character and its surrogates alike (for example [^ ]+), are not affected.
Summary
You tokenize 'E x', where E is an emoji such as U+1F525, with SimplePatternTokenizer and the pattern E[a-z]+|[^E ]+ (E followed by letters, or a run of characters other than E and space) and expect the single token 'x' [3,4): the attempt at E dies at the space, and the tokenizer, which steps its automaton over code points, should move on to the next code point (PatternTokenizer and java.util.regex give exactly [x]). Instead it also emits a token made of E's lone low surrogate U+DD25 at offsets [1,2), which the index stores as U+FFFD, because the failed-attempt branch pushes back all but one UTF-16 unit and so restarts in the middle of the pair. SimplePatternSplitTokenizer has the same step: separator Ea|[^Ea ]+ on 'Eb' yields the lone high surrogate [0,1) instead of the token 'E' [0,2), and with separator [^a-zE]+|E! the text 'abEcd' becomes 'ab' plus a lone high surrogate, then 'cd', instead of the one token 'abEcd'; nothing throws.
Introduced
93fa72f77bd024aa09eef043c65c64a6524613dc (LUCENE-7465 'add SimplePatternTokenizer and SimpleSplitPatternTokenizer, for tokenization using Lucene's regexp/automaton implementation', by GitHub mikemccand; JIRA patch, no pull request; issue migrated as apache/lucene#8517; branch_6x backport c24e03e6bf4d09e6f31eee8192bb6c0c4b2b6d27 with byte-identical tokenizer files) [read: the commit already has the 'Unicode space, not UTF16' comment, nextCodePoint() and the false-alarm pushBack(tokenUpto-1) / pushBack(sepUpto-1); git blame on main credits 9a1702a8f5b, a byte-identical restore after the merge revert 325cbf00185, and 8ef6a0da568 (LUCENE-9570 reformatting only); git log -L shows no other change to these lines; exec: the one-line fix applied to the 6.5.0 sources makes the JUnit and the probe pass on 6.5.0]. Not a regression: the line is as old as the classes (2017-02-13 (master 93fa72f and branch_6x c24e03e6bf4))
First affected version
6.5.0 [exec] (6.4.2 has no SimplePattern* classes [exec: compile error])
Affected versions
6.5.0 through 10.5.2 (the last release before 10.6.0; latest 9.x 9.12.3, latest 8.x 8.11.4) and main ea57360912ed; never fixed. [exec] the candidate JUnit (fails 2 of 2) and a portable main() probe (both bug cases wrong with byte-identical output, both controls correct) on 10 releases, 6.5.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 and 10.5.2, and on main, with -da also on 6.5.0, 9.12.3, 10.5.2 and main (JUnit import adapted for 6.5.0-9.0.0, JUnitCore runner for 6.5.0-7.0.0); the independent reproduction's JUnit fails 6 of 8 on main (-ea, -da), 10.5.2 and 9.12.3. [read] the false-alarm line is in both files at every release tag 6.5.0-10.5.2 of the history clone (8.8.2-8.11.4 are untagged there; 8.8.1 read, 8.11.4 executed), and upstream main, branch_10x and branch_10_6 still have it (contents API, 2026-10-26).
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; the prior-report record, verdict new; plus the finder's and the independent reproduction's and the intended-behaviour check's searches): a scripted prior-report search in two runs (102 queries) over apache/lucene GitHub issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 21 manual GitHub searches (class and factory names with surrogate, emoji and supplementary, simple_pattern / simple_pattern_split, LUCENE-7465), 13 JIRA and 9 mailing-list queries (Solr lists included); contents-API reads showing the lines unchanged on upstream main, branch_10x and branch_10_6; the changed files of all 409 open apache/lucene PRs (two touch analysis/pattern, neither this code: #12613 PatternReplaceFilterFactory, #16911 branch_10x decommission). Related, not this bug: apache/lucene#8517 (LUCENE-7465, closed: added both classes; body and all comments read, nothing on surrogates or the retry start), commit 2d03aa21a2b (2017, end-of-input lookahead fix; does not touch the push-back count), and precedents for the same defect class in other components: apache/lucene#15899 and PR #15900 (TruncateTokenFilter half surrogates, merged 2026-04), apache/lucene#9372 (LUCENE-8325, smartcn tokenizer) and LUCENE-7363 (DecimalDigitFilter)
Lucene commit tested
main a9779ef3c193 (2026-10-28): this doc's test through gradle in a scratch worktree with assertions on and off, stock and with the fix (also the pattern package, the whole analysis-common module and its static checks), and through the standalone JUnit runner with -ea and -da; the 10.5.2 (-ea, -da) and 9.12.3 (-da) release jars. Earlier pipeline runs (2026-10-26): main ea57360912ed, whose two tokenizer files are byte-identical, and 10 releases from 6.5.0 to 10.5.2 (the finder's test and a portable probe).

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:30-39 (at ea57360912ed) (class javadoc: 'The matching is greedy: the longest match at a given start point will be the next token', 'like String.split'; @lucene.experimental), :41-45 (TODO: 'on failing to match a token, we skip one character forward and try again'), :114 ('The runDFA operates in Unicode space, not UTF16 (java's char)'), :116 and :134 (nextCodePoint), :121 and :158-160 (state == -1 at the first step: the whole code point is consumed, no push-back), :153-156 (false alarm: 'push back all but the first character we scanned', pushBack(tokenUpto - 1) at :155), :242-253 (nextCodePoint joins a surrogate pair)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:30-38 (at ea57360912ed) (javadoc: 'just like SimplePatternTokenizer except that the pattern should make valid token separator characters, like String.split'; @lucene.experimental), :111 (Unicode-space comment), :112 and :134 (nextCodePoint), :163-167 (false alarm, pushBack(sepUpto - 1) at :166)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizerFactory.java:54, (at ea57360912ed) :62, :71-75 and SimplePatternSplitTokenizerFactory.java:56, :64, :73-77 (@lucene.experimental, SPI names simplePattern and simplePatternSplit, the factories determinize the RegExp)
The finding in 30 seconds
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:104-162 (incrementToken; 114 Unicode-space comment, 116 and 134 nextCodePoint, 121 first step, 126-139 lookahead, 141-150 accept, 151-152 end-of-input branch, 153-156 false alarm with pushBack(tokenUpto - 1) at 155, 158-160 first step fails), 182-202 (pushBack), 242-253 (nextCodePoint)
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:99-170 (incrementToken; 111 comment, 121 first step, 141-155 separator found, 156-162 end of input, 163-167 false alarm with pushBack(sepUpto - 1) at 166), 213-220 (appendToToken)
  • a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:1302 (negated class = makeAnyChar minus the class); lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:105-106 (makeAnyChar = MIN_CODE_POINT..MAX_CODE_POINT)
Who is affected
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizerFactory.java:54, 62 and SimplePatternSplitTokenizerFactory.java:56, 64 (@lucene.experimental, SPI names)
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:38 and lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:37 (@lucene.experimental)
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/resources/META-INF/services/org.apache.lucene.analysis.TokenizerFactory:25-26 (SPI registration only)
  • a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1483-1485 (malformed surrogate pairs are replaced with U+FFFD)
Why it looks unintended
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:30-39 (javadoc), 114, 153-160
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:30-38 (javadoc), 111, 163-167
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizerFactory.java:41-43 (factory javadoc)
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/test/org/apache/lucene/analysis/pattern/TestSimplePatternTokenizer.java:58-63 and TestSimplePatternSplitTokenizer.java:69-74 (testOneToken, testNoTokens with randomUnicodeString and .*)
  • a Lucene main checkout (a9779ef3c193) lucene/analysis.tests/src/test/org/apache/lucene/analysis/tests/TestRandomChains.java:448-452 (Automaton arguments from AutomatonTestUtil.randomRegexp) and lucene/test-framework/src/java/org/apache/lucene/tests/util/automaton/AutomatonTestUtil.java:100-105 (random regexp strings with surrogate pairs)
  • a Lucene main checkout (a9779ef3c193) lucene/CHANGES.txt:1198-1199 (GITHUB#15899, GITHUB#15900), 7479 (LUCENE-8325), 8421 (LUCENE-7465)

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

Repro files

About the diagrams

A small diagram, made in parallel with this doc from the code at a9779ef3c193 and a probe executed on main. This caption describes the bug they show; see the diagram notes for exactly what is on screen.

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.